Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50427edc8f | ||
|
|
5e4bcdb134 | ||
|
|
cd32df6eb3 |
@@ -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
|
||||
@@ -1,29 +0,0 @@
|
||||
# VoiceStudio compatibility entry
|
||||
|
||||
The current cross-agent package is [voicestudio](../../../../skills/voicestudio/SKILL.md).
|
||||
For new installations use `npx skills add debpalash/VoiceStudio --skill voicestudio`.
|
||||
|
||||
Use the running backend at the user's configured address (default
|
||||
`http://localhost:3900`). Check `/health`, discover `/openapi.json` and
|
||||
`/v1/audio/voices`, then use the installed schema for speech, transcription,
|
||||
profiles, and jobs. The HTTP MCP endpoint is `/mcp`; discover tools from the
|
||||
connected server instead of assuming this older package's tool inventory.
|
||||
|
||||
Launch the installed Electron app if the backend is unavailable. For source
|
||||
development follow the checkout's Electron README. Existing helpers in
|
||||
`scripts/` support legacy source installations; inspect their environment
|
||||
and dependency assumptions before running them.
|
||||
|
||||
Model downloads and remote services require the user's choice. Never silently
|
||||
install models, promise fixed latency, or treat compatibility voice names as
|
||||
real provider voices. Validate saved audio and asynchronous job completion
|
||||
before reporting success. Protected backends require configured credentials;
|
||||
never disable authentication to make an example work.
|
||||
|
||||
Source and current setup documentation:
|
||||
https://github.com/debpalash/VoiceStudio
|
||||
|
||||
This archived entry is not an installable skill. Existing installations should
|
||||
remove the old `omnivoice` / `oss-maintainer` entries and install `voicestudio` /
|
||||
`voicestudio-maintainer` from the canonical repository. Legacy helpers remain
|
||||
for existing users; the Electron supervisor is the preferred launcher.
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: omnivoice
|
||||
description: "Local TTS, voice cloning, voice design, and video dubbing via the VoiceStudio MCP server (open-source ElevenLabs alternative; nothing leaves the machine, runs on MPS/CUDA/CPU). Use when: (1) generating speech from text in any of 646 languages, (2) cloning a voice from a 3-second reference clip, (3) designing a voice by gender/age/accent/pitch/style, (4) dubbing a video into another language, (5) listing voice profiles or personality presets, (6) producing narration where privacy, cost, or absent API keys matter, (7) non-English narration where Edge TTS/kokoro fall short, (8) batch audio for blog posts or content pipelines. Triggers: 'omnivoice', 'voice clone', 'clone this voice', 'tts', 'narrate', 'generate speech', 'voice synthesis', 'dub video', 'voice design', 'local tts', 'multilingual voice', 'narrate this post', 'elevenlabs alternative'."
|
||||
---
|
||||
|
||||
# VoiceStudio
|
||||
|
||||
## 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`.
|
||||
|
||||
## Prerequisites — Backend Must Be Running
|
||||
|
||||
The MCP tools all hit `$OMNIVOICE_API_URL` (default `http://localhost:3900`). If the backend is down, every tool returns a connection error. Install + boot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git "$OMNIVOICE_HOME"
|
||||
cd "$OMNIVOICE_HOME"
|
||||
uv sync
|
||||
VIRTUAL_ENV="$(pwd)/.venv" uv pip install 'mcp[cli]'
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
scripts/check-health.sh # exit 0 if up
|
||||
scripts/start-backend.sh # boot in background (MPS/CUDA auto-detected)
|
||||
```
|
||||
|
||||
First synthesis call lazy-downloads the `k2-fsa/OmniVoice` model (~2.4 GB) from HuggingFace — cached on subsequent boots.
|
||||
|
||||
## Task Index — Pick the Right Tool
|
||||
|
||||
| Task | Tool | Notes |
|
||||
|---|---|---|
|
||||
| Verify backend is up | `check_health` | Returns `{"status":"ok","device":"mps|cuda|cpu"}` |
|
||||
| Text → audio with a saved voice | `generate_speech(text, profile_id)` | Returns base64 WAV. `profile_id="demo0001"` is the bundled demo voice |
|
||||
| Text → audio without a clone (voice design) | `generate_speech(text, instruct="…")` | Omit `profile_id`; pass an `instruct` like `"warm middle-aged female narrator, calm pace"` |
|
||||
| Multilingual narration | `generate_speech(text, language="es")` | Any ISO 639 code or `"Auto"` |
|
||||
| List existing voices | `list_voices` | Returns id, name, type, personality |
|
||||
| List personality presets | `list_personalities` | Returns narrator / casual / news-anchor / etc. with their `instruct` strings |
|
||||
| List supported languages | `list_languages` | 646 total; returns 20 popular + the full count |
|
||||
|
||||
For non-trivial decisions (which engine to use, when to pick VoiceStudio over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
|
||||
|
||||
For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown, see [references/mcp-setup.md](references/mcp-setup.md).
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### 1. One-shot narration with the demo voice
|
||||
|
||||
```python
|
||||
# As called through the MCP client (your agent will do this for you):
|
||||
result = generate_speech(
|
||||
text="Hello — this is VoiceStudio generating speech locally.",
|
||||
profile_id="demo0001",
|
||||
language="English",
|
||||
steps=16, # 8 = fast/draft · 16 = balanced · 32 = quality
|
||||
)
|
||||
# result is JSON with audio_id, generation_time_s, audio_duration_s, format, wav_base64
|
||||
```
|
||||
|
||||
Benchmark: 4.2 s of audio in ~24 s server-side on Apple Silicon MPS at 16 diffusion steps.
|
||||
|
||||
### 2. Save the WAV to disk and play
|
||||
|
||||
Tool returns base64 PCM WAV (16-bit, mono, 24 kHz). Decode + write:
|
||||
|
||||
```python
|
||||
import base64, json
|
||||
payload = json.loads(result_text) # parse JSON the tool returns
|
||||
open("out.wav","wb").write(base64.b64decode(payload["wav_base64"]))
|
||||
```
|
||||
|
||||
On macOS: `afplay out.wav`. Convert to MP3 with `ffmpeg -i out.wav -codec:a libmp3lame -b:a 128k out.mp3`.
|
||||
|
||||
### 3. Voice clone — end-to-end recipe
|
||||
|
||||
Cloning needs a 3-10 second reference clip the model will use as a speaker embedding. The MCP server does NOT expose profile creation — it only reads existing profiles. Two paths to create one:
|
||||
|
||||
**Path A — bundled helper (macOS, recommended for fresh clones):**
|
||||
|
||||
```bash
|
||||
scripts/record-reference.sh ~/Downloads/my-ref.wav 12 1
|
||||
# args: output_path raw_duration_sec mic_index
|
||||
# Default mic_index=1 (MacBook built-in); list devices via:
|
||||
# ffmpeg -f avfoundation -list_devices true -i ""
|
||||
```
|
||||
|
||||
The script gives **audible** countdown + start/stop cues via macOS `say` + `/System/Library/Sounds/Ping.aiff` so the user knows when to speak (terminal stdout is buffered — text "speak now" prompts arrive too late). It records a longer raw window, then trims to ~10 seconds of speech via `silenceremove + atrim`, plays back for verification, and prints the next-step `curl` command.
|
||||
|
||||
**Path B — manual:**
|
||||
|
||||
```bash
|
||||
# 1. Record (mono, 24 kHz native — matches model's internal rate)
|
||||
ffmpeg -f avfoundation -i ":1" -t 12 -ac 1 -ar 24000 raw.wav
|
||||
|
||||
# 2. Trim leading silence + take first 10 sec of speech
|
||||
ffmpeg -i raw.wav \
|
||||
-af "silenceremove=start_periods=1:start_silence=0.05:start_threshold=-40dB,atrim=end=10" \
|
||||
-ac 1 -ar 24000 ref.wav
|
||||
|
||||
# 3. Verify
|
||||
ffmpeg -i ref.wav -af volumedetect -f null - 2>&1 | grep volume # max should be > -20 dB
|
||||
afplay ref.wav
|
||||
```
|
||||
|
||||
**POST to /profiles** (multipart/form-data — required fields: `name`, `ref_audio`):
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3900/profiles \
|
||||
-F "name=carlos-clone" \
|
||||
-F "ref_audio=@ref.wav" \
|
||||
-F "ref_text=The exact text spoken in the clip" \
|
||||
-F "language=English" \
|
||||
| python3 -m json.tool
|
||||
# returns { "id": "abc12345", "name": "carlos-clone" }
|
||||
```
|
||||
|
||||
Once created, pass `profile_id` to `generate_speech` (via MCP) or directly via `POST /generate`. Profiles persist in SQLite + reference-audio files at `~/Library/Application Support/OmniVoice/voices/<id>.<ext>` (the backend preserves the uploaded extension — `.wav` if you uploaded a WAV, `.mp3` if MP3, etc.). State persists across backend restarts.
|
||||
|
||||
**Reference clip tips that materially affect quality:**
|
||||
|
||||
| Factor | Why it matters |
|
||||
|---|---|
|
||||
| Single speaker | Mixed speakers blur the embedding |
|
||||
| Clean speech, no music/noise | Model embeds the noise too |
|
||||
| Natural prosody (avoid pangrams) | Diffusion samples replicate prosody, not just timbre |
|
||||
| 3-10 sec is the sweet spot | < 3 s lacks information; > 10 s adds compute without quality gain |
|
||||
| Match `ref_text` to what's spoken | Improves alignment, especially on noisy refs |
|
||||
| `language` correct | Wrong language → cross-lingual transfer artifacts |
|
||||
| Loudness peak ≥ -15 dB | Quiet refs work but normalize poorly |
|
||||
|
||||
### 4. Voice design (no reference clip)
|
||||
|
||||
Skip `profile_id`; provide an `instruct` string describing the desired voice:
|
||||
|
||||
```python
|
||||
generate_speech(
|
||||
text="Welcome to the future of agentic systems.",
|
||||
instruct="warm middle-aged female narrator, calm authoritative pace, documentary style",
|
||||
)
|
||||
```
|
||||
|
||||
Get pre-made instructs via `list_personalities` and copy the one matching the brief (narrator, casual, news-anchor, etc.).
|
||||
|
||||
### 5. Video dubbing (web UI only)
|
||||
|
||||
The MCP server does not expose the dubbing endpoint. The full transcribe → translate → re-voice → mux pipeline lives behind the desktop UI (`bun run desktop` in `$OMNIVOICE_HOME`) and the `/dub/*` REST routes. When the user asks to dub a video, point them to the UI; surface this skill only for the synthesis primitives above.
|
||||
|
||||
## When NOT to use VoiceStudio
|
||||
|
||||
- **Fast English-only narration on weak hardware** → `kokoro-tts` is ~10× smaller and 2× realtime on CPU (see [references/engines-comparison.md](references/engines-comparison.md))
|
||||
- **Lowest-friction one-off TTS** → Edge TTS needs no install or backend
|
||||
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; VoiceStudio ties or wins on multilingual + cloning
|
||||
- **Real-time streaming dictation** → use the VoiceStudio desktop widget (`⌘+⇧+Space`), not the MCP server
|
||||
|
||||
## Resources
|
||||
|
||||
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across VoiceStudio / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
|
||||
- [references/mcp-setup.md](references/mcp-setup.md) — MCP wiring, backend lifecycle, env vars, troubleshooting
|
||||
- [scripts/check-health.sh](scripts/check-health.sh) — `curl /health`, exit 0/1
|
||||
- [scripts/start-backend.sh](scripts/start-backend.sh) — Start uvicorn on 127.0.0.1:3900 with health probe
|
||||
- [scripts/stop-backend.sh](scripts/stop-backend.sh) — Clean shutdown via `kill -TERM` on the bound PID
|
||||
- [scripts/record-reference.sh](scripts/record-reference.sh) — macOS-only: record + trim + verify a reference clip for cloning, with audible cues (`say` + system beeps) that bypass terminal output buffering
|
||||
|
||||
Backend Swagger / OpenAPI: `http://127.0.0.1:3900/docs` (when backend is up).
|
||||
|
||||
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"
|
||||
|
||||
|
||||
+7
-53
@@ -28,25 +28,8 @@ Read it before opening a proposal; the licence check in particular ends most of
|
||||
- [Bun](https://bun.sh/) (frontend package manager)
|
||||
- [uv](https://docs.astral.sh/uv/) (Python environment manager)
|
||||
- [ffmpeg](https://ffmpeg.org/) (audio/video processing)
|
||||
- [Rust / Cargo](https://rustup.rs/) (desktop shell only)
|
||||
- Python 3.10+ (managed automatically by `uv`)
|
||||
|
||||
Linux desktop development also needs WebKitGTK/GTK development libraries. On
|
||||
Debian or Ubuntu, install the same packages used by CI:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev libpango1.0-dev libcairo2-dev \
|
||||
libsoup-3.0-dev libgdk-pixbuf-2.0-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev \
|
||||
gstreamer1.0-plugins-good \
|
||||
libasound2-dev build-essential curl wget file
|
||||
```
|
||||
|
||||
See the [Linux source-build guide](../docs/install/linux.md#building-from-source)
|
||||
for Fedora and Arch packages.
|
||||
|
||||
### Clone & Run
|
||||
|
||||
```bash
|
||||
@@ -56,17 +39,7 @@ bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This launches Electron with hot reload. Its runtime supervisor manages backend setup
|
||||
and startup; do not launch a second backend. See [Electron setup](../electron/README.md).
|
||||
|
||||
```bash
|
||||
bun run build # build Electron
|
||||
bun run start # launch the built Electron app
|
||||
bun run dist # package locally without publishing
|
||||
bun run dev:web # legacy browser UI + backend
|
||||
```
|
||||
|
||||
The legacy browser command starts both services:
|
||||
This starts both services:
|
||||
|
||||
| Service | URL | What it does |
|
||||
|---------|-----|---|
|
||||
@@ -81,38 +54,20 @@ cause doesn't scroll away with the terminal. The same death is also reported
|
||||
as a crash notice in the UI the next time the backend starts (see
|
||||
[docs/install/troubleshooting.md §14c](docs/install/troubleshooting.md)).
|
||||
|
||||
### Legacy Desktop App (Tauri)
|
||||
### Desktop App (Tauri)
|
||||
|
||||
```bash
|
||||
bun run tauri # legacy dev: hot-reload Tauri shell + backend
|
||||
bun run tauri:desktop-prod # legacy production: builds, bundles the backend, then launches
|
||||
bun run desktop # dev: hot-reload Tauri shell + backend
|
||||
bun run desktop-prod # production: builds, bundles the backend, then launches
|
||||
```
|
||||
|
||||
Both run `uv sync` first (so the Python backend env is set up) and start the
|
||||
backend automatically — you do **not** start it separately. Use the exact script
|
||||
names: there is no `desktop=prod` (note the **hyphen** in `tauri:desktop-prod`).
|
||||
`tauri:desktop-prod` is Windows-aware (auto-detects bash/git; see `scripts/desktop-prod.mjs`).
|
||||
names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
|
||||
`desktop-prod` is Windows-aware (auto-detects bash/git; see `scripts/desktop-prod.mjs`).
|
||||
|
||||
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
After installing Rust with rustup (or `uv` with its installer), a terminal that
|
||||
was already open still has the old `PATH`. The desktop launchers (`bun tauri`,
|
||||
`bun tauri:desktop-prod`, `bun tauri:desktop-fresh`) detect this and add `~/.cargo/bin` /
|
||||
`~/.local/bin` for that run, printing a one-line note; to make it permanent,
|
||||
open a new terminal, or on macOS/Linux load Cargo into the current one:
|
||||
|
||||
```bash
|
||||
source "$HOME/.cargo/env"
|
||||
bun run tauri
|
||||
```
|
||||
|
||||
If Rust is genuinely not installed, the launchers stop up front with the
|
||||
install command instead of failing later inside `cargo metadata`.
|
||||
|
||||
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
|
||||
or `javascriptcoregtk-4.1` missing mean the native packages above were not
|
||||
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
|
||||
|
||||
If the app opens but stays on the **setup splash with no buttons**, the Python
|
||||
backend didn't finish starting — the splash surfaces the stall reason, a log
|
||||
panel, and a **Retry** button (and Settings → Logs → Backend has the full trace).
|
||||
@@ -219,7 +174,6 @@ class MyEngineBackend(TTSBackend):
|
||||
|
||||
- **Components**: Functional components with hooks
|
||||
- **State**: Zustand stores in `src/stores/`, organized by slice
|
||||
- **Brand assets**: Reuse the canonical mark, palette, naming, and compatibility rules in [`docs/branding.md`](../docs/branding.md); do not redraw or rename runtime identifiers ad hoc
|
||||
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the VoiceStudio palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
|
||||
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
|
||||
|
||||
@@ -320,7 +274,7 @@ that — the agent recalls the architecture, conventions, and your past findings
|
||||
instead of re-reading the tree each time. [**memxt**](https://github.com/debpalash/memxt)
|
||||
(100% local, MCP-based, built by this project's maintainer) exists for exactly
|
||||
this; any MCP memory server works. Pair it with the repo's agent skill —
|
||||
`npx skills add debpalash/VoiceStudio` — so your agent knows the project's
|
||||
`npx skills add debpalash/omnivoice-studio` — so your agent knows the project's
|
||||
hard rules from the first prompt.
|
||||
|
||||
## Quality gates your PR must pass
|
||||
|
||||
+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
|
||||
|
||||
+5
-139
@@ -11,11 +11,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
windows_wix_diagnostic:
|
||||
description: Run only the tiny nonpublishing Windows MSI authoring diagnostic
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -26,15 +21,8 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{ !inputs.windows_wix_diagnostic }}
|
||||
name: Tests (backend + frontend)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
# Same restricted-network resilience the smoke matrix already sets. This
|
||||
# job resolves the same direct-URL dependency and had none of it, which
|
||||
# is why it was the one that kept dying (see scripts/uv-sync-retry.sh).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -73,14 +61,7 @@ jobs:
|
||||
# so their tests can exercise the real import path, not the
|
||||
# "package not installed" fallback. Smoke job below stays on bare
|
||||
# `uv sync` because smoke only hits /health + fixture profiles.
|
||||
#
|
||||
# Retried because one dependency — en-core-web-sm — resolves to a
|
||||
# direct GitHub release URL, and github.com intermittently answers
|
||||
# `http2 error: refused stream before processing any application
|
||||
# logic`. uv's own 3 retries all land inside the same few seconds and
|
||||
# fail together, which has cost otherwise-green runs (#1517, #1518).
|
||||
# Backing off between whole attempts is what actually clears it.
|
||||
run: bash scripts/uv-sync-retry.sh --all-extras
|
||||
run: uv sync --all-extras
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
@@ -90,7 +71,7 @@ jobs:
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
@@ -111,14 +92,13 @@ jobs:
|
||||
run: |
|
||||
bash frontend/src-tauri/appimage/AppRun.test.sh
|
||||
bash scripts/inject-apprun.test.sh
|
||||
bash scripts/verify-apprun-bundle.test.sh
|
||||
|
||||
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
|
||||
# import chain) with a hermetic data dir from its conftest.py. It no
|
||||
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
run: uv run pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
|
||||
@@ -172,12 +152,6 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||||
|
||||
# Electron used to be built only after a release started, so renderer,
|
||||
# preload and packaging regressions could pass the required PR gate.
|
||||
# Keep this command shared with release.yml through the root script.
|
||||
- name: Electron typecheck, tests and production contract
|
||||
run: bun run check:electron
|
||||
|
||||
# Production-bundle blank-screen gate. Everything above runs UN-minified
|
||||
# (dev server + Vitest/jsdom), so a crash that exists ONLY in the minified
|
||||
# release bundle — a TDZ reorder that throws before React mounts — passes
|
||||
@@ -192,28 +166,6 @@ jobs:
|
||||
- name: Production-bundle smoke — no blank screen
|
||||
working-directory: frontend
|
||||
run: bun run test:prod-bundle
|
||||
- name: Electron renderer workflow smokes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OMNIVOICE_PORT=3999
|
||||
export VOICESTUDIO_UI_URL=http://localhost:3912
|
||||
export PLAYWRIGHT_CHANNEL=chromium
|
||||
bun run --cwd electron smoke:server > /tmp/voicestudio-electron-smoke.log 2>&1 &
|
||||
server_pid=$!
|
||||
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
|
||||
for _ in {1..60}; do
|
||||
if curl --fail --silent --show-error "$VOICESTUDIO_UI_URL" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.25
|
||||
done
|
||||
curl --fail --silent --show-error "$VOICESTUDIO_UI_URL" >/dev/null || {
|
||||
cat /tmp/voicestudio-electron-smoke.log
|
||||
exit 1
|
||||
}
|
||||
node electron/tests/playback-smoke.mjs
|
||||
node electron/tests/dub-smoke.mjs
|
||||
|
||||
# ── Cross-platform Tauri shell check ────────────────────────────────────
|
||||
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
|
||||
@@ -223,7 +175,6 @@ jobs:
|
||||
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
|
||||
# Full bundling stays in release.yml on tag push.
|
||||
tauri-cross-platform:
|
||||
if: ${{ !inputs.windows_wix_diagnostic }}
|
||||
name: Tauri shell check (${{ matrix.label }})
|
||||
needs: test
|
||||
strategy:
|
||||
@@ -306,17 +257,6 @@ jobs:
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
|
||||
|
||||
# Backend-lifecycle fault-injection harness: real child processes die
|
||||
# scripted deaths through the OMNIVOICE_BACKEND_CMD seam, and each
|
||||
# scenario asserts the user-visible diagnosis names the actual cause
|
||||
# (port conflict / traceback root cause / spawn failure / timeout /
|
||||
# crash-loop exhaustion / signal 9 / deliberate replace / deferred-
|
||||
# startup step). Serial: the scenarios share process-global state
|
||||
# (env vars, crash store, kill-intended flag) by design.
|
||||
- name: Cargo test (backend lifecycle harness)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test --test backend_lifecycle --target ${{ matrix.rust_target }} --message-format=short -- --test-threads=1
|
||||
|
||||
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
|
||||
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
|
||||
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
|
||||
@@ -324,7 +264,6 @@ jobs:
|
||||
# job above misses. Narrow scope (tests/smoke/ only) — full pytest stays
|
||||
# on Linux until Phase 1's INST-01 lands setuptools for WhisperX.
|
||||
smoke-matrix:
|
||||
if: ${{ !inputs.windows_wix_diagnostic }}
|
||||
name: Smoke (${{ matrix.label }})
|
||||
needs: test
|
||||
strategy:
|
||||
@@ -412,19 +351,6 @@ jobs:
|
||||
echo "choco attempt $i did not produce ffmpeg — retrying in $((i * 30))s"
|
||||
sleep $((i * 30))
|
||||
done
|
||||
# Chocolatey is one distribution channel, not the dependency. When
|
||||
# its feed is down across every retry (2026-08-13: three attempts,
|
||||
# three 'installed 0/1'), fall back to the static gyan.dev release
|
||||
# build GitHub mirror — the same binary, no feed in the path.
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "::warning::choco feed down — falling back to static ffmpeg build"
|
||||
curl -fsSL --retry 3 -o /tmp/ffmpeg.zip \
|
||||
https://github.com/GyanD/codexffmpeg/releases/download/7.1/ffmpeg-7.1-essentials_build.zip
|
||||
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
|
||||
bindir=$(dirname "$(find /tmp/ffmpeg -name ffmpeg.exe | head -1)")
|
||||
echo "$bindir" >> "$GITHUB_PATH"
|
||||
export PATH="$bindir:$PATH"
|
||||
fi
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
@@ -440,7 +366,7 @@ jobs:
|
||||
# backend host. The Intel-Mac leg separately pins the documented
|
||||
# unsupported contract: its UI is a remote-backend client only (#889).
|
||||
if: matrix.backend_supported
|
||||
run: bash scripts/uv-sync-retry.sh --extra pockettts
|
||||
run: uv sync --extra pockettts
|
||||
|
||||
- name: Verify the documented Intel Mac contract
|
||||
if: ${{ !matrix.backend_supported }}
|
||||
@@ -464,68 +390,8 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Run smoke tests
|
||||
# Exercise credential paths on native Windows as well as POSIX hosts.
|
||||
if: matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/smoke/ tests/test_hf_token_cache_paths.py -q --tb=short
|
||||
run: uv run pytest tests/smoke/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
|
||||
|
||||
# The isolated backend session, on Windows. The `test` job runs it on
|
||||
# Linux only, which is how four tests that CANNOT pass on Windows shipped
|
||||
# unnoticed: two reach for os.WNOHANG and os.waitid (POSIX-only, an
|
||||
# AttributeError before the first assertion), one asserts a RuntimeError
|
||||
# that `backend_drain_fd` returns None instead of raising off POSIX, and
|
||||
# one raced the OS reaping a crashed child — a race Linux won and Windows
|
||||
# lost every time. All four were invisible to CI and hit every Windows
|
||||
# contributor on their first `pytest` run. Forty seconds closes the class.
|
||||
- name: Isolated backend session (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
# Artifact commits depend on native Windows rename/replace semantics;
|
||||
# Linux emulation cannot exercise sharing rules or path parsing.
|
||||
# test_worker_task_store and test_worker_inbound_transport joined this
|
||||
# step after a Windows run found a real portability bug the Linux-only
|
||||
# `test` job could not see: a staged input's artifact id was built with
|
||||
# os.path.join, so a Windows control plane persisted and shipped
|
||||
# `inputs\<sha>.wav` — which a Linux worker cannot resolve. These suites
|
||||
# need no ffmpeg, so they cost seconds here.
|
||||
- name: Remote-worker artifact paths (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
run: >-
|
||||
uv run --no-sync pytest
|
||||
tests/test_worker_upload_server.py
|
||||
tests/test_worker_server_integrity.py
|
||||
tests/test_worker_task_store.py
|
||||
tests/test_worker_inbound_transport.py
|
||||
-q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
|
||||
|
||||
windows-wix-diagnostic:
|
||||
name: Windows MSI authoring (no publishing)
|
||||
needs: test
|
||||
if: ${{ !cancelled() && (inputs.windows_wix_diagnostic || needs.test.result == 'success') }}
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- name: Bundle canonical system and per-user templates with a tiny payload
|
||||
shell: pwsh
|
||||
run: ./scripts/diagnose-windows-wix.ps1
|
||||
- name: Restore hosted Installer policy after failed standard-user installation
|
||||
shell: powershell
|
||||
run: ./scripts/test-msi-policy-cleanup.ps1
|
||||
- name: Preserve verbose linker output and rendered authoring
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-wix-diagnostic
|
||||
path: wix-diagnostic-artifacts/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
name: Electron packaging rehearsal
|
||||
|
||||
# Explicitly artifact-only: no tag, schedule, release, or publishing permission.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: electron-rehearsal-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux
|
||||
arch: x64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
flags: --linux --x64
|
||||
- runner: windows-2022
|
||||
platform: win32
|
||||
arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
flags: --win --x64
|
||||
- runner: macos-15
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
target: aarch64-apple-darwin
|
||||
flags: --mac --arm64
|
||||
- runner: macos-15-intel
|
||||
platform: darwin
|
||||
arch: x64
|
||||
target: x86_64-apple-darwin
|
||||
flags: --mac --x64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
VOICESTUDIO_RUST_TARGET: ${{ matrix.target }}
|
||||
VOICESTUDIO_UPDATE_CHANNEL: electron-preview-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.4.2'
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: native/desktop-bridge -> target
|
||||
key: electron-${{ matrix.target }}
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '0.12.13'
|
||||
enable-cache: false
|
||||
- name: Linux native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libasound2-dev libxdo-dev libxtst-dev libx11-dev libxkbcommon-dev libwayland-dev libssl-dev pkg-config xvfb
|
||||
- name: Bundle pinned uv for the host architecture
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const expected = process.env.VOICESTUDIO_RUST_TARGET;
|
||||
const targets = { 'linux-x64': 'x86_64-unknown-linux-gnu', 'win32-x64': 'x86_64-pc-windows-msvc', 'darwin-arm64': 'aarch64-apple-darwin', 'darwin-x64': 'x86_64-apple-darwin' };
|
||||
if (targets[`${process.platform}-${process.arch}`] !== expected) throw new Error('Runner architecture does not match package target');
|
||||
const source = execFileSync(process.platform === 'win32' ? 'where.exe' : 'which', ['uv'], { encoding: 'utf8' }).trim().split(/\r?\n/)[0];
|
||||
const dir = 'frontend/src-tauri/binaries';
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const destination = join(dir, `uv-${expected}${process.platform === 'win32' ? '.exe' : ''}`);
|
||||
copyFileSync(source, destination);
|
||||
if (process.platform !== 'win32') chmodSync(destination, 0o755);
|
||||
NODE
|
||||
- name: Install locked dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Validate and build Electron
|
||||
run: bun run check:electron
|
||||
- name: Package without publishing
|
||||
working-directory: electron
|
||||
run: |
|
||||
bun x electron-builder --config electron-builder.config.mjs ${{ matrix.flags }} --publish never
|
||||
node tests/packaging-contract.mjs --artifact
|
||||
node tests/update-package-contract.mjs --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
|
||||
- name: Packaged startup smoke test
|
||||
working-directory: electron
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
xvfb-run -a node tests/packaged-smoke.mjs --setup
|
||||
else
|
||||
node tests/packaged-smoke.mjs --setup
|
||||
fi
|
||||
- name: Save installers and updater metadata for review
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: electron-rehearsal-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
electron/release/VoiceStudio-Electron-*
|
||||
electron/release/electron-*.yml
|
||||
@@ -1,218 +0,0 @@
|
||||
name: Electron desktop release
|
||||
|
||||
# Builds are safe by default. Only an explicit publish dispatch exposes a release.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: "Publish the tagged Electron release after all platforms pass"
|
||||
type: boolean
|
||||
default: false
|
||||
allow_unsigned:
|
||||
description: "Explicitly accept unsigned/unnotarized Electron installers and documented updater limitations"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: electron-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
# The transition tag is assembled after the manual Tauri draft succeeds.
|
||||
if: github.event_name == 'workflow_dispatch' || github.ref_name != vars.TAURI_SUNSET_TAG
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Require an exact version tag
|
||||
env:
|
||||
REF: ${{ github.ref }}
|
||||
ALLOW_UNSIGNED: ${{ inputs.allow_unsigned }}
|
||||
DISPATCH_ACTOR: ${{ github.actor }}
|
||||
RERUN_ACTOR: ${{ github.triggering_actor }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
run: |
|
||||
if [ "$ALLOW_UNSIGNED" = true ]; then
|
||||
test "$DISPATCH_ACTOR" = "$OWNER" && test "$RERUN_ACTOR" = "$OWNER" || {
|
||||
echo "Only the repository owner may accept unsigned installers"; exit 1;
|
||||
}
|
||||
fi
|
||||
VERSION=$(node -p "require('./frontend/package.json').version")
|
||||
test "$REF" = "refs/tags/v$VERSION" || { echo "Dispatch on the exact version tag"; exit 1; }
|
||||
package:
|
||||
needs: validate
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
platform: linux
|
||||
arch: x64
|
||||
target: x86_64-unknown-linux-gnu
|
||||
flags: --linux --x64
|
||||
- runner: windows-2022
|
||||
platform: win32
|
||||
arch: x64
|
||||
target: x86_64-pc-windows-msvc
|
||||
flags: --win --x64
|
||||
- runner: macos-15
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
target: aarch64-apple-darwin
|
||||
flags: --mac --arm64
|
||||
- runner: macos-15-intel
|
||||
platform: darwin
|
||||
arch: x64
|
||||
target: x86_64-apple-darwin
|
||||
flags: --mac --x64
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
VOICESTUDIO_RUST_TARGET: ${{ matrix.target }}
|
||||
VOICESTUDIO_UPDATE_CHANNEL: electron-stable-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.4.2'
|
||||
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: native/desktop-bridge -> target
|
||||
key: electron-${{ matrix.target }}
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
version: '0.12.13'
|
||||
enable-cache: false
|
||||
- name: Linux native dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libasound2-dev libxdo-dev libxtst-dev libx11-dev libxkbcommon-dev libwayland-dev libssl-dev pkg-config xvfb
|
||||
- name: Bundle pinned uv for the host architecture
|
||||
run: |
|
||||
node --input-type=module <<'NODE'
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, copyFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const expected = process.env.VOICESTUDIO_RUST_TARGET;
|
||||
const targets = { 'linux-x64': 'x86_64-unknown-linux-gnu', 'win32-x64': 'x86_64-pc-windows-msvc', 'darwin-arm64': 'aarch64-apple-darwin', 'darwin-x64': 'x86_64-apple-darwin' };
|
||||
if (targets[`${process.platform}-${process.arch}`] !== expected) throw new Error('Runner architecture does not match package target');
|
||||
const source = execFileSync(process.platform === 'win32' ? 'where.exe' : 'which', ['uv'], { encoding: 'utf8' }).trim().split(/\r?\n/)[0];
|
||||
const dir = 'frontend/src-tauri/binaries';
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const destination = join(dir, `uv-${expected}${process.platform === 'win32' ? '.exe' : ''}`);
|
||||
copyFileSync(source, destination);
|
||||
if (process.platform !== 'win32') chmodSync(destination, 0o755);
|
||||
NODE
|
||||
- name: Install locked dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Validate and build Electron
|
||||
run: bun run check:electron
|
||||
- name: Package without publishing
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.ELECTRON_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.ELECTRON_CSC_KEY_PASSWORD }}
|
||||
working-directory: electron
|
||||
run: |
|
||||
bun x electron-builder --config electron-builder.config.mjs ${{ matrix.flags }} --publish never
|
||||
node tests/packaging-contract.mjs --artifact
|
||||
node tests/update-package-contract.mjs --platform ${{ matrix.platform }} --arch ${{ matrix.arch }}
|
||||
- name: Verify macOS signing and notarization before publication
|
||||
if: inputs.publish == true && inputs.allow_unsigned != true && matrix.platform == 'darwin'
|
||||
run: |
|
||||
APP=$(find electron/release -maxdepth 2 -name VoiceStudio.app -type d -print -quit)
|
||||
test -n "$APP"
|
||||
codesign --verify --deep --strict "$APP"
|
||||
spctl --assess --type execute --verbose=2 "$APP"
|
||||
- name: Verify Windows installer signature before publication
|
||||
if: inputs.publish == true && inputs.allow_unsigned != true && matrix.platform == 'win32'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installers = @(Get-ChildItem electron/release/VoiceStudio-Electron-*.exe)
|
||||
if ($installers.Count -eq 0) { throw "No installer to verify" }
|
||||
foreach ($installer in $installers) {
|
||||
$signature = Get-AuthenticodeSignature $installer.FullName
|
||||
if ($signature.Status -ne 'Valid') { throw "Installer signature is not trusted: $($installer.Name)" }
|
||||
}
|
||||
- name: Packaged startup smoke test
|
||||
working-directory: electron
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = Linux ]; then
|
||||
xvfb-run -a node tests/packaged-smoke.mjs --setup
|
||||
else
|
||||
node tests/packaged-smoke.mjs --setup
|
||||
fi
|
||||
- name: Save installers and updater metadata for review
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: electron-release-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: |
|
||||
electron/release/VoiceStudio-Electron-*
|
||||
electron/release/electron-*.yml
|
||||
|
||||
release:
|
||||
needs: package
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
SUNSET_TAG: ${{ vars.TAURI_SUNSET_TAG }}
|
||||
PUBLISH: ${{ inputs.publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: electron-release-*
|
||||
merge-multiple: true
|
||||
path: release-assets
|
||||
- name: Validate all platforms before creating a release
|
||||
run: |
|
||||
python3 scripts/prepare_electron_release.py --assets release-assets --tag "$TAG"
|
||||
- name: Preserve the final Tauri updater feeds
|
||||
run: |
|
||||
test -n "$SUNSET_TAG" || { echo "Set TAURI_SUNSET_TAG before releasing"; exit 1; }
|
||||
# The transition tag already holds its own final Tauri feeds.
|
||||
# Later releases carry copies pointing to the immutable sunset payloads.
|
||||
gh release download "$SUNSET_TAG" --pattern latest.json --dir release-assets
|
||||
gh release download "$SUNSET_TAG" --pattern latest-user.json --dir release-assets
|
||||
python3 scripts/prepare_electron_release.py --assets release-assets --tag "$TAG" --sunset-tag "$SUNSET_TAG"
|
||||
- name: Disclose explicitly accepted unsigned artifacts
|
||||
if: inputs.allow_unsigned == true
|
||||
run: |
|
||||
cat >> release-assets/RELEASE_NOTES.md <<'EOF'
|
||||
|
||||
### Electron installer trust
|
||||
These Electron installers are unsigned or ad-hoc signed and are not Apple-notarized.
|
||||
Windows/macOS may show trust warnings. macOS automatic updates are unverified;
|
||||
use manual installer updates. Tauri updater signatures remain independently verified.
|
||||
EOF
|
||||
- name: Create or update draft
|
||||
run: |
|
||||
if ! gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --verify-tag --draft --title "$TAG — VoiceStudio" --notes-file release-assets/RELEASE_NOTES.md
|
||||
fi
|
||||
test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = true || { echo "Refusing to replace a published release"; exit 1; }
|
||||
gh release edit "$TAG" --notes-file release-assets/RELEASE_NOTES.md
|
||||
find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print0 | xargs -0 gh release upload "$TAG" --clobber
|
||||
- name: Publish only when explicitly requested
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish == true
|
||||
run: gh release edit "$TAG" --draft=false --latest
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
run: uv sync
|
||||
|
||||
- name: Run eval suites (non-gating)
|
||||
continue-on-error: true
|
||||
env:
|
||||
TRANSLATE_BASE_URL: ${{ secrets.EVALS_LLM_BASE_URL }}
|
||||
TRANSLATE_API_KEY: ${{ secrets.EVALS_LLM_API_KEY }}
|
||||
run: uv run --no-sync python tests/evals/run_evals.py --output eval-report.json
|
||||
run: uv run python tests/evals/run_evals.py --output eval-report.json
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -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
|
||||
+66
-427
@@ -27,22 +27,27 @@
|
||||
# each to surface PyInstaller/Tauri issues that never showed up locally on
|
||||
# macOS — iterate on CI.
|
||||
|
||||
name: Tauri sunset (manual only)
|
||||
name: Desktop Release
|
||||
|
||||
# Legacy workflow: run once on the final Tauri version tag.
|
||||
# Electron releases are owned by electron-release.yml.
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
schedule:
|
||||
# 07:00 UTC daily — rolling `preview` prerelease from `main`. The
|
||||
# preview-gate job no-ops the matrix when main hasn't moved in a day.
|
||||
- cron: '0 7 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: "Keep the final Tauri release draft until Electron artifacts are ready"
|
||||
description: "Create as draft release (tag push only)"
|
||||
required: false
|
||||
default: "true"
|
||||
publish_preview:
|
||||
description: "Legacy compatibility input; previews are retired"
|
||||
description: "Publish a rolling 'preview' prerelease (updater Preview channel). Previews ALWAYS build from main — dispatching from any other branch fails the preview-gate."
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
|
||||
permissions:
|
||||
contents: write # needed to attach artifacts + updater manifest to GH Release
|
||||
|
||||
@@ -71,16 +76,6 @@ jobs:
|
||||
name: Tests (backend + frontend)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Require the designated final Tauri tag
|
||||
env:
|
||||
SUNSET_TAG: ${{ vars.TAURI_SUNSET_TAG }}
|
||||
REF: ${{ github.ref }}
|
||||
PREVIEW: ${{ inputs.publish_preview }}
|
||||
run: |
|
||||
test -n "$SUNSET_TAG" || { echo "Set TAURI_SUNSET_TAG to the final v* tag first"; exit 1; }
|
||||
test "$REF" = "refs/tags/$SUNSET_TAG"
|
||||
test "$PREVIEW" != "true"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python 3.11
|
||||
@@ -114,10 +109,10 @@ jobs:
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
run: uv sync
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
@@ -147,29 +142,21 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||||
|
||||
- name: Electron typecheck, tests and production contract
|
||||
run: bun run check:electron
|
||||
|
||||
# Decide preview-vs-stable, and for nightly runs whether `main` actually
|
||||
# moved in the last day. Outputs gate the expensive matrix (`build`) and the
|
||||
# `preview-notes` job, so a no-commit night costs only this ~30s job.
|
||||
preview-gate:
|
||||
name: Preview gate
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
is_preview: ${{ steps.decide.outputs.is_preview }}
|
||||
proceed: ${{ steps.decide.outputs.proceed }}
|
||||
stable_tag: ${{ steps.decide.outputs.stable_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- id: decide
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
event="${{ github.event_name }}"
|
||||
@@ -184,13 +171,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "is_preview=true" >> "$GITHUB_OUTPUT"
|
||||
# Resolve once before the matrix starts so every platform stamps
|
||||
# against the same immutable Stable-channel snapshot.
|
||||
STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
|
||||
[[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "::error::latest stable release has an invalid tag"; exit 1;
|
||||
}
|
||||
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_preview=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -319,7 +299,7 @@ jobs:
|
||||
libwebkit2gtk-4.1-dev \
|
||||
build-essential curl wget file libxdo-dev libssl-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev \
|
||||
libasound2-dev ffmpeg xvfb
|
||||
libasound2-dev ffmpeg
|
||||
|
||||
# ── Frontend build ─────────────────────────────────────────────────
|
||||
- name: Cache bun deps
|
||||
@@ -352,7 +332,7 @@ jobs:
|
||||
- name: Bundle uv (${{ matrix.rust_target }})
|
||||
shell: bash
|
||||
env:
|
||||
UV_VERSION: "0.12.13"
|
||||
UV_VERSION: "0.11.7"
|
||||
TRIPLE: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -481,9 +461,6 @@ jobs:
|
||||
fi
|
||||
{
|
||||
echo 'body<<RELEASE_BODY_EOF'
|
||||
echo '## Final Tauri update'
|
||||
echo 'VoiceStudio desktop is moving to Electron. This is the last Tauri release. Back up your data and install Electron separately: https://github.com/debpalash/VoiceStudio/blob/main/docs/electron-migration.md'
|
||||
echo
|
||||
echo "$BODY"
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
@@ -520,22 +497,35 @@ jobs:
|
||||
echo "APPLE_TEAM_ID=$TID"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# Stamp each preview with a numeric prerelease that is strictly above the
|
||||
# latest stable release. Main may intentionally retain the released
|
||||
# version while AUTO_VERSION_BUMP is disabled; in that case the helper
|
||||
# advances the preview base by one patch so stable users can still opt in
|
||||
# and receive it. The edit is ephemeral and never committed.
|
||||
# Stamp each preview build with a unique, monotonically increasing semver
|
||||
# PRERELEASE so the updater actually offers it (a rolling preview that
|
||||
# always reported the static 0.3.0 never looked "newer", so no update was
|
||||
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
|
||||
# bundle + updater version from tauri.conf.json, so rewriting it here
|
||||
# stamps the artifacts + latest.json. Under the versioning hard rule
|
||||
# (owner-set 2026-06-11) main is always last-release + 1, so BASE-N is a
|
||||
# prerelease of the NEXT version and semver-sorts ABOVE the last stable
|
||||
# (0.3.6-N > 0.3.5) — preview users naturally upgrade past stable, and
|
||||
# the Windows MSI ProductVersion (which strips the prerelease → 0.3.6)
|
||||
# is also correctly above the last stable.
|
||||
- name: Stamp preview version
|
||||
if: needs.preview-gate.outputs.is_preview == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \
|
||||
--package-json frontend/package.json \
|
||||
--stable-tag "$STABLE_TAG" \
|
||||
--run-number "${{ github.run_number }}")
|
||||
# package.json is the single source of truth; tauri.conf.json reads its
|
||||
# version from it ("version": "../package.json"), so stamping
|
||||
# package.json restamps the whole bundle.
|
||||
CONF=frontend/package.json
|
||||
BASE=$(jq -r .version "$CONF")
|
||||
# MSI/WiX requires the semver pre-release identifier to be numeric-only
|
||||
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
|
||||
# preview stamp is BASE-N — still sorts below the stable BASE for the
|
||||
# updater, still unique per run.
|
||||
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
|
||||
tmp=$(mktemp)
|
||||
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
|
||||
mv "$tmp" "$CONF"
|
||||
echo "Stamped preview version: $PREVIEW_VERSION"
|
||||
|
||||
# The rolling `preview` release is REUSED every night, and macOS updater
|
||||
@@ -615,21 +605,6 @@ jobs:
|
||||
fi
|
||||
done < /tmp/stale.txt
|
||||
|
||||
# A retried job reuses its version and can collide with installers it
|
||||
# uploaded before a later step failed. Keep other versions/arches intact;
|
||||
# macOS versionless updater archives are scoped by release tag and arch.
|
||||
- name: Clear this target's installer assets on retry
|
||||
if: github.run_attempt > 1
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
RELEASE_TARGET: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
VERSION=$(python -c 'import json; print(json.load(open("frontend/package.json"))["version"])')
|
||||
python scripts/clear-release-rerun-assets.py \
|
||||
--tag "$RELEASE_TAG" --version "$VERSION" --target "$RELEASE_TARGET"
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -674,125 +649,6 @@ jobs:
|
||||
updaterJsonPreferNsis: false
|
||||
includeUpdaterJson: true
|
||||
|
||||
- name: Build + publish Electron desktop
|
||||
if: false # Electron is released independently by electron-release.yml.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
IS_PREVIEW: ${{ needs.preview-gate.outputs.is_preview }}
|
||||
VOICESTUDIO_RUST_TARGET: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.rust_target }}" in
|
||||
aarch64-apple-darwin) ELECTRON_OS=darwin; ELECTRON_ARCH=arm64; FLAGS="--mac --arm64" ;;
|
||||
x86_64-apple-darwin) ELECTRON_OS=darwin; ELECTRON_ARCH=x64; FLAGS="--mac --x64" ;;
|
||||
x86_64-pc-windows-msvc) ELECTRON_OS=win32; ELECTRON_ARCH=x64; FLAGS="--win --x64" ;;
|
||||
x86_64-unknown-linux-gnu) ELECTRON_OS=linux; ELECTRON_ARCH=x64; FLAGS="--linux --x64" ;;
|
||||
*) echo "Unsupported Electron target: ${{ matrix.rust_target }}"; exit 1 ;;
|
||||
esac
|
||||
if [ "$IS_PREVIEW" = "true" ]; then
|
||||
export VOICESTUDIO_UPDATE_CHANNEL="electron-preview-${ELECTRON_OS}-${ELECTRON_ARCH}"
|
||||
else
|
||||
export VOICESTUDIO_UPDATE_CHANNEL="electron-stable-${ELECTRON_OS}-${ELECTRON_ARCH}"
|
||||
fi
|
||||
if [ -n "${APPLE_CERTIFICATE:-}" ]; then
|
||||
export CSC_LINK="$APPLE_CERTIFICATE"
|
||||
export CSC_KEY_PASSWORD="${APPLE_CERTIFICATE_PASSWORD:-}"
|
||||
fi
|
||||
|
||||
bun install --frozen-lockfile
|
||||
(
|
||||
cd electron
|
||||
bun run build
|
||||
node tests/packaging-contract.mjs
|
||||
bun x electron-builder \
|
||||
--config electron-builder.config.mjs $FLAGS --publish never
|
||||
node tests/packaging-contract.mjs --artifact
|
||||
if [ "$RUNNER_OS" = "Linux" ]; then
|
||||
xvfb-run -a node tests/packaged-smoke.mjs --setup
|
||||
else
|
||||
node tests/packaged-smoke.mjs --setup
|
||||
fi
|
||||
node tests/update-package-contract.mjs \
|
||||
--channel "$VOICESTUDIO_UPDATE_CHANNEL" \
|
||||
--platform "$ELECTRON_OS" \
|
||||
--arch "$ELECTRON_ARCH"
|
||||
)
|
||||
|
||||
# A rolling preview reuses one release. Remove only this platform /
|
||||
# architecture's older Electron artifacts before publishing the new
|
||||
# version; sibling matrix legs own different names and metadata.
|
||||
if [ "$IS_PREVIEW" = "true" ]; then
|
||||
gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets \
|
||||
--jq '.assets[].name' > electron-assets.txt
|
||||
case "${{ runner.os }}" in
|
||||
Windows) OS_TOKEN=win ;;
|
||||
macOS) OS_TOKEN=mac ;;
|
||||
Linux) OS_TOKEN=linux ;;
|
||||
esac
|
||||
while IFS= read -r asset; do
|
||||
case "$asset" in
|
||||
VoiceStudio-Electron-*-${OS_TOKEN}-${ELECTRON_ARCH}.*|${VOICESTUDIO_UPDATE_CHANNEL}*.yml)
|
||||
gh release delete-asset "$RELEASE_TAG" "$asset" --yes --repo "$GITHUB_REPOSITORY"
|
||||
;;
|
||||
esac
|
||||
done < electron-assets.txt
|
||||
fi
|
||||
|
||||
electron_artifact_count=0
|
||||
while IFS= read -r artifact; do
|
||||
gh release upload "$RELEASE_TAG" "$artifact" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
electron_artifact_count=$((electron_artifact_count + 1))
|
||||
done < <(find electron/release -maxdepth 1 -type f \
|
||||
\( -name 'VoiceStudio-Electron-*' -o -name "${VOICESTUDIO_UPDATE_CHANNEL}*.yml" \) | sort)
|
||||
if [ "$electron_artifact_count" -eq 0 ]; then
|
||||
echo "FAIL — Electron build produced no publishable artifacts"
|
||||
find electron/release -maxdepth 1 -type f -print || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build per-user Windows MSI
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
working-directory: frontend
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python ../scripts/render-per-user-wix.py \
|
||||
--source src-tauri/wix/main.wxs \
|
||||
--system-wxs src-tauri/target/${{ matrix.rust_target }}/release/wix/x64/main.wxs \
|
||||
--output src-tauri/target/wix-per-user/main.wxs
|
||||
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
|
||||
--config src-tauri/tauri.per-user.conf.json
|
||||
DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
|
||||
while IFS= read -r artifact; do
|
||||
safe=${artifact// (Current User)/_Current_User}
|
||||
[ "$safe" = "$artifact" ] || mv "$artifact" "$safe"
|
||||
done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*')
|
||||
|
||||
- name: Publish per-user Windows updater channel
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
|
||||
MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1)
|
||||
[ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; }
|
||||
[ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; }
|
||||
VERSION=$(jq -r .version frontend/package.json)
|
||||
python scripts/build_windows_user_manifest.py \
|
||||
--repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \
|
||||
--asset "$(basename "$MSI")" --signature-file "$MSI.sig" \
|
||||
--output latest-user.json
|
||||
gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \
|
||||
--clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
|
||||
# Structural verification of the installed/extracted bundle. The thin
|
||||
# uv-venv installer ships NO frozen backend binary (the venv is built on
|
||||
@@ -861,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"
|
||||
@@ -876,72 +731,6 @@ jobs:
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
- name: Per-user installer smoke (Windows, non-admin account)
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 8
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass \
|
||||
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")" -PrepareHostedRunner
|
||||
|
||||
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
|
||||
# machine AFTER tauri's files-map has placed the real icon bytes — the
|
||||
# exact bug #1518 guarded against, resurfacing on the first real tag
|
||||
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
|
||||
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
|
||||
# signature covered the old bytes), and clobber the draft release's
|
||||
# asset + the linux signature inside latest.json. The smoke below then
|
||||
# validates the repaired artifact, not the broken one.
|
||||
- name: Repair AppImage .DirIcon, re-sign, re-upload
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# Data, not shell source (zizmor template-injection): a crafted ref
|
||||
# must never expand inside a script that holds the signing key.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
WORK="$(mktemp -d)"; cd "$WORK"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$WORK/squashfs-root"
|
||||
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
|
||||
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
|
||||
echo ".DirIcon already resolves inside the bundle — no repair needed"
|
||||
exit 0
|
||||
fi
|
||||
# The real bytes are at the AppDir root (linuxdeploy put them there
|
||||
# before mislinking). Ship a regular file: nothing left to dangle.
|
||||
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
|
||||
rm -f "$ROOT/.DirIcon"
|
||||
cp "$SRC" "$ROOT/.DirIcon"
|
||||
# Pinned immutable release + checksum: this binary runs with the
|
||||
# updater signing key and a release-write token in its environment,
|
||||
# so a mutable 'continuous' asset is not acceptable supply chain.
|
||||
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
|
||||
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
|
||||
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
|
||||
chmod +x "$WORK/appimagetool"
|
||||
# Same FUSE-less trick the build itself uses.
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
|
||||
cd "$GITHUB_WORKSPACE/frontend"
|
||||
bunx tauri signer sign "$APPIMAGE"
|
||||
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
# latest.json is NOT patched here: every tauri-action leg re-uploads
|
||||
# the shared manifest, so an in-leg patch races the other platforms —
|
||||
# the repair-updater-manifest job below is the single final writer.
|
||||
echo "repacked, re-signed, re-uploaded"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
@@ -962,13 +751,17 @@ jobs:
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||||
# linuxdeploy's GTK/GStreamer hooks wrap the seeded launcher as
|
||||
# AppRun.wrapped. Verify the complete launcher chain, not only the
|
||||
# small hook runner installed at the AppImage root.
|
||||
bash "$GITHUB_WORKSPACE/scripts/verify-apprun-bundle.sh" \
|
||||
"$ROOT" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version"
|
||||
# Regression gate: beforeBundleCommand runs before Tauri creates the
|
||||
# AppDir. The v0.4.2 artifact therefore silently shipped Tauri's
|
||||
# stock AppRun and bypassed every WebKit/Mesa compatibility fix.
|
||||
cmp -s "$ROOT/AppRun" "$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|
||||
|| fail "custom AppRun missing from final AppImage"
|
||||
[ -s "$ROOT/usr/lib/.bundled-webkitgtk-version" ] \
|
||||
|| fail "bundled WebKitGTK version marker missing"
|
||||
cmp -s \
|
||||
"$ROOT/usr/lib/.bundled-webkitgtk-version" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version" \
|
||||
|| fail "bundled WebKitGTK version marker is stale or mismatched"
|
||||
# Thin uv-venv installer: verify the AppImage carries the shell binary,
|
||||
# the bundled uv sidecar, and the backend source resources.
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
@@ -979,10 +772,10 @@ jobs:
|
||||
|
||||
# ── Compute SHA-256 checksums (Phase 0 GATE-05) ───────────────────
|
||||
# Native OS tools: shasum -a 256 (POSIX) / Get-FileHash (Windows).
|
||||
# Writes SHA256SUMS-<label>.txt, attached to the release below. The
|
||||
# release-notes-checksums job puts every leg's file into the notes.
|
||||
# Writes SHA256SUMS-<label>.txt for the user-verifiable path AND
|
||||
# captures the content into $GITHUB_OUTPUT for body append.
|
||||
- name: Compute SHA-256 checksums
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
id: checksums
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -1003,10 +796,6 @@ jobs:
|
||||
-o -name "*.msi" -o -name "*.msi.sig" \
|
||||
-o -name "*.AppImage" -o -name "*.AppImage.sig" \
|
||||
-o -name "*.deb" \) 2>/dev/null | sort)
|
||||
while IFS= read -r artifact; do
|
||||
ARTIFACTS+=("$artifact")
|
||||
done < <(find electron/release -maxdepth 1 -type f \
|
||||
\( -name 'VoiceStudio-Electron-*' -o -name 'electron-*.yml' \) 2>/dev/null | sort)
|
||||
|
||||
if [ ${#ARTIFACTS[@]} -eq 0 ]; then
|
||||
echo "FAIL — no artifacts found under $BUNDLE_DIR"
|
||||
@@ -1037,77 +826,15 @@ jobs:
|
||||
|
||||
echo "checksums_file=$OUT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Attach only. The notes are one shared text and the publish is one
|
||||
# decision, so both belong to the single release-notes-checksums job
|
||||
# that runs after the whole matrix (see there for why).
|
||||
- name: Attach SHA256SUMS file
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
FILE: ${{ steps.checksums.outputs.checksums_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$TAG" "$FILE" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# ── Checksums into the notes, then publish (the single writer) ───────────
|
||||
# Every build leg used to append its checksums to the shared release notes
|
||||
# with softprops/action-gh-release. Two things went wrong:
|
||||
# - The appends were concurrent read-modify-writes, so a leg that read the
|
||||
# notes before another wrote them lost its section. v0.5.1 and v0.5.2
|
||||
# both shipped without the macOS Apple Silicon checksums in the notes.
|
||||
# - softprops defaults to draft: false, so the FIRST leg to finish
|
||||
# published tauri-action's draft while the other installers and the
|
||||
# complete latest.json were still being built (v0.5.2 went public at
|
||||
# 17:27; its latest.json was finished at 17:38).
|
||||
# This job is the only writer of the notes and the only publisher. It runs
|
||||
# once every leg, the manifest repair and the uninstall scripts are done,
|
||||
# writes the four platforms' checksums in a fixed order, and fails if one is
|
||||
# missing, so a failed platform leaves the release a draft.
|
||||
release-notes-checksums:
|
||||
needs: [build, repair-updater-manifest, uninstall-scripts]
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
KEEP_DRAFT: ${{ inputs.draft }}
|
||||
steps:
|
||||
- name: Write every platform's checksums into the notes, then publish
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
gh release download "$TAG" --repo "$REPO" --pattern 'SHA256SUMS-*.txt' --dir "$WORK"
|
||||
# The checksum sections and the Contributors strip are always the
|
||||
# tail of the notes; drop them so a re-run rebuilds rather than
|
||||
# stacks (contributors-strip re-appends its strip after this job).
|
||||
gh release view "$TAG" --repo "$REPO" --json body --jq .body \
|
||||
| awk '/^### .* artifacts$/ || /^## Contributors$/ {exit} {print}' > "$WORK/notes.md"
|
||||
missing=0
|
||||
for label in "macOS Apple Silicon" "macOS Intel" "Windows x64" "Linux x64"; do
|
||||
file="$WORK/SHA256SUMS-${label// /.}.txt"
|
||||
if [ -f "$file" ]; then
|
||||
cat "$file" >> "$WORK/notes.md"
|
||||
else
|
||||
echo "::error::The release has no checksums for $label"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
[ "$missing" = 0 ] || exit 1
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file "$WORK/notes.md"
|
||||
if [[ "$KEEP_DRAFT" == "true" ]]; then
|
||||
echo "Final Tauri draft verified; Electron publication owns the transition."
|
||||
elif [[ "$TAG" == *-* ]]; then
|
||||
gh release edit "$TAG" --repo "$REPO" --draft=false --prerelease
|
||||
else
|
||||
gh release edit "$TAG" --repo "$REPO" --draft=false --latest
|
||||
fi
|
||||
- name: Append checksums to release + attach SHA256SUMS file
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
append_body: true
|
||||
body_path: ${{ steps.checksums.outputs.checksums_file }}
|
||||
files: ${{ steps.checksums.outputs.checksums_file }}
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
# ── Uninstall scripts as release assets (#1089) ───────────────────────────
|
||||
# The in-app uninstaller (Settings → Storage → Remove all data) is the primary
|
||||
@@ -1121,96 +848,9 @@ jobs:
|
||||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||||
# idempotent. This can never create a second release.
|
||||
# The Linux leg may repack + re-sign its AppImage (see the repair step in
|
||||
# the build matrix); every tauri-action leg also re-uploads the SHARED
|
||||
# latest.json, so patching the manifest inside any leg races the others.
|
||||
# This job runs once after the whole matrix as the single final writer:
|
||||
# it makes the manifest's linux signature agree with the .sig asset that
|
||||
# actually shipped, and refuses to leave a mismatch behind.
|
||||
# The matrix validates each Electron package before upload. This final read-only
|
||||
# check validates the other half of the contract: GitHub must actually serve
|
||||
# all four manifests and every payload they name. Without it a green release
|
||||
# can leave the in-app updater with four 404 feeds.
|
||||
electron-publish-contract:
|
||||
needs: [build, preview-gate]
|
||||
if: false # Electron release workflow owns this contract.
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
CHANNEL: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || 'stable' }}
|
||||
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Verify published Electron updater assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
mkdir -p "$WORK/manifests"
|
||||
gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isPrerelease,assets > "$WORK/release.json"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "electron-${CHANNEL}-*.yml" --dir "$WORK/manifests"
|
||||
if [ "$CHANNEL" = "preview" ]; then
|
||||
VERSION=$(python3 scripts/stamp-preview-version.py \
|
||||
--package-json frontend/package.json \
|
||||
--stable-tag "$STABLE_TAG" \
|
||||
--run-number "${{ github.run_number }}")
|
||||
else
|
||||
VERSION=$(python3 -c 'import json; print(json.load(open("frontend/package.json"))["version"])')
|
||||
fi
|
||||
python3 scripts/check_electron_release_assets.py \
|
||||
--release-json "$WORK/release.json" \
|
||||
--manifest-dir "$WORK/manifests" \
|
||||
--channel "$CHANNEL" \
|
||||
--version "$VERSION"
|
||||
|
||||
repair-updater-manifest:
|
||||
needs: [build, preview-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Data, not shell source — same zizmor rule as the leg step.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
steps:
|
||||
- name: Align latest.json's linux signature with the shipped .sig asset
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
|
||||
if [ "$HAS_MANIFEST" != "true" ]; then
|
||||
echo "no latest.json on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
|
||||
# Same fail-closed rule as the manifest: absence is checked against
|
||||
# the asset LIST; an actual download failure must fail the job, or
|
||||
# the manifest keeps a signature nobody shipped.
|
||||
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
|
||||
if [ "$HAS_SIG" != "true" ]; then
|
||||
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
|
||||
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
|
||||
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
|
||||
NEW_SIG=$(cat "$SIG_FILE")
|
||||
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
|
||||
if [ "$CHANGED" -ge 1 ]; then
|
||||
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
|
||||
else
|
||||
echo "manifest already agrees with the shipped .sig — no write"
|
||||
fi
|
||||
|
||||
uninstall-scripts:
|
||||
needs: [build]
|
||||
if: startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -1247,12 +887,11 @@ jobs:
|
||||
# MUST append via `gh release edit` on the EXISTING release (never a second
|
||||
# softprops publish — that races tauri-action's per-matrix draft and splits
|
||||
# installers across two releases; see uninstall-scripts). `needs: [build]`
|
||||
# guarantees the release exists and release-notes-checksums has written the
|
||||
# notes, and this job
|
||||
# guarantees the release + all checksum appends already landed, and this job
|
||||
# is single (no matrix) so there is no write race. Idempotent: it strips any
|
||||
# prior "## Contributors" block before re-appending, so re-runs don't stack.
|
||||
contributors-strip:
|
||||
needs: [build, release-notes-checksums]
|
||||
needs: [build]
|
||||
if: >-
|
||||
github.event_name == 'push'
|
||||
&& startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: pip-audit (Python)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
bash scripts/uv-sync-retry.sh
|
||||
uv sync
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
# Pin a floor: `bun audit` was added in bun 1.2.x, so guarantee it exists.
|
||||
|
||||
-29
@@ -22,8 +22,6 @@ node_modules
|
||||
.turbo/
|
||||
bun.lockb
|
||||
frontend/src-tauri/target/
|
||||
electron/.tmp-native-target/
|
||||
native/**/target*/
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Secrets & env
|
||||
@@ -57,7 +55,6 @@ memxt.db-wal
|
||||
!.claude/agents/**
|
||||
/.cache*
|
||||
/.tmp/
|
||||
/.tmp-*
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Research clones — upstream repos used as reference, not shipped
|
||||
@@ -157,29 +154,3 @@ playwright-report/
|
||||
|
||||
# probe — generated HTML reports
|
||||
tests/probe/reports/
|
||||
|
||||
# Local architecture/planning scratch (goal docs, review briefs, council
|
||||
# reports). Working notes for whoever is driving a change, not a repo artifact.
|
||||
/remote/
|
||||
|
||||
# OmniVoice GGUF runtime build artifacts (scripts/build-omnivoice-tts.sh).
|
||||
# Only 0-byte placeholders of omnivoice-tts-* are tracked; real binaries,
|
||||
# the checksums manifest and the copied libggml shared libs ship via CI.
|
||||
bin/libggml*
|
||||
bin/checksums.sha256
|
||||
bin/omnivoice-tts-linux-aarch64
|
||||
|
||||
# Dubbing-demo intermediates. The .mp4/.srt/manifest.json in this directory ARE
|
||||
# committed (they ship with the app); the per-language source WAVs are just the
|
||||
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
|
||||
backend/assets/samples/demo/dubbing/*.src.wav
|
||||
|
||||
# Stray sqlite session artifacts (`<db-path>.ses`). An in-memory DB yields the
|
||||
# literal name `:memory:.ses`, and a path containing `:` cannot be checked out
|
||||
# on Windows at all — committing one fails every Windows CI job at the git
|
||||
# checkout step, before a single test runs. Guarded by
|
||||
# tests/test_no_windows_hostile_paths.py.
|
||||
*.ses
|
||||
|
||||
# Generated Windows MSI diagnostic logs and installer payloads
|
||||
/wix-diagnostic-artifacts/
|
||||
|
||||
@@ -25,8 +25,4 @@ regexes = [
|
||||
'''^hf_QWERTYUIOPasdfghjklZXCVBNM0123456789xyzAB$''',
|
||||
# NLLB generation length argument, not the value of a credential.
|
||||
'''^max_length=400$''',
|
||||
# Dubbing pane split-position localStorage key, not a credential.
|
||||
'''^omnivoice\.dubSplit\.v1$''',
|
||||
# cryptography's Ed25519 private-key type name, not key material.
|
||||
'''^Ed25519PrivateKey$''',
|
||||
]
|
||||
|
||||
@@ -33,17 +33,8 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
|
||||
- `frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
|
||||
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
|
||||
|
||||
## Shared select controls
|
||||
|
||||
- Use `frontend/src/components/SearchableSelect.jsx` for all new or redesigned select boxes. Reuse `VoiceSelector` for voice choices. Do not introduce native `<select>` controls.
|
||||
- Provide a localized `ariaLabel`; use `menuPortal` inside scrolling or clipping containers. Preserve keyboard selection and disabled states.
|
||||
|
||||
## Agent skills
|
||||
|
||||
Project development skills are pinned in `skills-lock.json` and installed under
|
||||
`.agents/skills/`: Vite and FastAPI.
|
||||
Repository rules and tracker mappings override generic skill guidance.
|
||||
|
||||
### Issue tracker
|
||||
|
||||
GitHub Issues on `debpalash/VoiceStudio`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
+25
-493
@@ -3,446 +3,30 @@
|
||||
All notable changes to VoiceStudio.
|
||||
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
|
||||
the frozen-backend fallback mirror it for their toolchains.
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.3] — 2026-09-17
|
||||
## [0.5.0] — 2026-08-10
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The README is shorter, with a new Electron UI tour and refreshed screenshots (#2129)
|
||||
|
||||
- Support pages feature cleaner donation cards, with a workspace support shortcut and sponsor footer with hover cards and email inquiries (#2129)
|
||||
|
||||
- Integrations has a dedicated sidebar workspace with featured sponsors, searchable AI providers, and smooth sponsor-strip scrolling (#2129)
|
||||
|
||||
- Integrations now covers 100+ automation, communications, MCP, agent, developer, data, and productivity tools with config-driven detail pages (#2129)
|
||||
|
||||
- Electron now ships as a complete cross-platform VoiceStudio desktop app with local-first cloning, production workspaces, model packs, repair agents, native integrations, updates, parity checks, and the shared backend contracts required by those workflows (#1823)
|
||||
|
||||
- The Model Catalogue is one page: what you use now on top, then each family's engines and weights (#2013)
|
||||
- VoxCPM2 installs in one click into its own environment, with the CUDA build of PyTorch on NVIDIA GPUs (#2021)
|
||||
- MOSS-TTS-Nano installs in one click into its own environment, pinned to a reviewed upstream commit it works with (#2022)
|
||||
- CosyVoice 3 installs in one click into its own environment, with a trimmed dependency set that needs no TensorRT, DeepSpeed or third-party package feed (#2025)
|
||||
- VoiceStudio 0.5.0 is the release where the name, desktop chrome, documentation, and package metadata finally tell one clear story
|
||||
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
|
||||
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
|
||||
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU
|
||||
- A warning before a slow generation, rather than after a five-minute wait
|
||||
- The watermark can be turned off in Settings, as the docs always said
|
||||
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
|
||||
- macOS support now matches what the app actually delivers
|
||||
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing
|
||||
|
||||
### Changed
|
||||
|
||||
- Electron becomes the default source desktop, with artifact-only packaging rehearsals and a separate final Tauri update path (#2157)
|
||||
- Installable agent skills use current VoiceStudio names and Electron workflows (#2157)
|
||||
- README clarifies the Electron transition while keeping desktop contributions welcome (#2153) — thanks @cyberspace-cs!
|
||||
|
||||
- Electron first run uses four simple steps with model packs, optional advanced controls and skippable dictation setup (#2129)
|
||||
|
||||
- Model Catalogue is one page: a setup summary (speech, transcription, dictation, language model) on top, one TTS / ASR / LLM switch, and each family's downloadable weights listed under its engines; the separate Models pane and the Settings → Voice → Engines / Models signposts are gone, the models directory and voice previews moved to Settings → Storage and the HF mirror to Network (#2013)
|
||||
- The engine list is one line per engine (engine, device it runs on, status, one action) with a detail panel for everything else; each engine's weights install from its panel, so the separate weights list and recommendation card are gone (#2020)
|
||||
- CosyVoice 3 installs patched protobuf and transformers releases, clearing five security advisories (#2030, #2031)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep demo playback aligned across languages, preserve worker GPU metrics, and restrict unsigned releases to owner dispatches (#2157)
|
||||
|
||||
- Desktop integration checks cover current dubbing safeguards, navigation, and the linked engine catalog (#2157)
|
||||
|
||||
- Tauri and Electron now share native dictation, watch-folder, and Wayland shortcut contracts; focused paste stays ordered and first-run uv stays pinned at 0.12.13 (#2122)
|
||||
- Dubbing demos synchronize playheads without simultaneous playback and let you open a sample in the editor (#2131)
|
||||
- macOS desktop sidebar clears the traffic lights, uses a narrower collapsed rail, and places notifications and device controls with more space (#2126)
|
||||
- Dubbing timelines keep short segments proportional, support zoom, and remove timestamp-confirmed duplicate ASR context (#2129)
|
||||
|
||||
- Dubbing translation shares the agent footer with live logs, validated output, cancellation and contextual retries (#2129)
|
||||
|
||||
- Agent dubbing translation saves a custom tone and adaptation prompt and preserves it during timing rewrites (#2129)
|
||||
|
||||
- Dubbing preserves original sound outside dialogue and mixes separated background only beneath replacement speech (#2129)
|
||||
|
||||
- Dubbing repairs missing speech caches, rejects incomplete output, avoids oversized speaker references, and fits full speech without early clipping (#2129)
|
||||
- Workspace sidebars have a working right-edge resize handle, allow 40% more width, remember their size, and keep video controls inside the preview (#2129)
|
||||
- Pressing Play while a video is loading starts playback when it is ready instead of reporting playback unavailable (#2129)
|
||||
- Video previews show their thumbnail before playback, including the source video in Dub (#2129)
|
||||
- Linux and Windows workspace headers consistently expand and collapse the sidebar, with the app logo at the top of the collapsed rail (#2129)
|
||||
- Stopping a process on macOS no longer fails with "Operation not permitted" when it was already exiting (#2032)
|
||||
- A YouTube link blocked by its "not a bot" check now says how to attach signed-in cookies in Dub, instead of quoting yt-dlp's command-line flags (#2036, #2034)
|
||||
- An engine that fails to start now says whether it timed out, crashed (with its exit code and last output) or answered wrongly, instead of "did not signal ready: None" (#2037, #2026)
|
||||
- Transcribing an M4A file with PyTorch Whisper works, instead of failing with "Format not recognised" (#2042, #2039)
|
||||
- PyTorch Whisper runs on 6 GB NVIDIA cards instead of falling back to CPU, because its memory check now fits the model it loads (#2044, #2041)
|
||||
- MCP tools wait as long as the backend does, so a long transcription no longer fails at 120 s with an empty error (#2043, #2040)
|
||||
- Generating on an older NVIDIA GPU (Tesla T4, and other pre-Ampere cards) no longer kills the backend on the first request — CUDA graphs are not captured below sm_80 (#2135)
|
||||
- "Disable torch.compile" in Settings → Performance now works on macOS and Linux, not only Windows; it was greyed out on the platforms that needed it (#2135)
|
||||
- Setting `TORCH_COMPILE_DISABLE=1` in the environment now actually disables torch.compile, for the in-process engine and engine subprocesses alike (#2135)
|
||||
- A backend killed by a native crash now leaves the faulting thread's stack in `backend_err.log` instead of exiting silently (#2135)
|
||||
|
||||
### CI
|
||||
|
||||
- A tagged release is published only after every platform's installers and checksums are attached, and its notes list all four platforms' checksums (#2029)
|
||||
- A worker-transport test no longer fails when a slow Windows runner takes over 2 seconds to tear down (#2038)
|
||||
|
||||
|
||||
|
||||
## [0.5.2] — 2026-09-10
|
||||
|
||||
**Highlights**
|
||||
|
||||
- Supertonic-3 and PocketTTS show their license Accept button again, so they can be enabled (#2017)
|
||||
- An engine that can't run on your platform says so, instead of telling you to install it (#2018)
|
||||
- MOSS-TTS-v1.5, Confucius4-TTS, dots.tts, Supertonic-3 and PocketTTS install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015, #2016)
|
||||
- A pronunciation entry that is stored but not applied yet says so, instead of looking like it did not match (#1949)
|
||||
- A bare 500 report now names the backend error class, so two unrelated faults stop filing the same issue (#1773)
|
||||
- A rejected dubbing source language now names the code it rejected (#1960)
|
||||
- The first-run install log is kept on disk instead of vanishing with the setup screen (#1847)
|
||||
- `bun run desktop` reclaims port 3900 from a backend the app itself left running, instead of refusing to start (#1974)
|
||||
- A dictation shortcut another app already owns now says so, instead of silently doing nothing (#1858)
|
||||
- Quitting on Windows is no longer reported as a crash on the next launch (#1898)
|
||||
- A Reduce motion switch in Settings, for calm without changing your whole system (#1857)
|
||||
- A light theme, and System Auto now follows a light-mode OS instead of staying dark (#1973) — thanks @CoDe-ReDz!
|
||||
- Generating from a one-character input now says the input was too short, instead of quoting a convolution error (#1826)
|
||||
- First run asks about text size before the install, not after it (#1849)
|
||||
- Cloning without a reference clip now says so, instead of naming library parameters you cannot set (#1879)
|
||||
- Upgrading torch for an RTX 50-series card no longer trades one startup crash for another, and the upgrade is documented (#1931)
|
||||
- A generation timeout now points at the compute-time budget in Settings rather than an environment variable (#1808)
|
||||
- An engine you have not installed now says so, instead of reporting a failed check (#1866)
|
||||
- The Accessibility prompt no longer floats over first-run setup and every other app until you grant it (#1845, #1886)
|
||||
- The last onboarding step offers to install a speech-to-text model instead of failing three times when none is installed (#1856)
|
||||
- A download that fails because the folder sits behind a mount point Windows will not cross now says so, and where to move it (#1957)
|
||||
- A GPU that is merely short on free memory is no longer told to reinstall its drivers (#1812) — thanks @michaelhuamanflores!
|
||||
- An error thrown by a browser extension is filtered on Safari and the macOS app too, not only on Chromium (#1901) — thanks @Chang-Jin-Lee!
|
||||
- Choosing the China mirror no longer re-races the network on every dependency step, which cost seconds per step on blocked connections (#1892) — thanks @yuezheng2006!
|
||||
- The backend log panel reports a log it cannot read instead of quietly showing less (#1847) — thanks @Chang-Jin-Lee!
|
||||
- The floating dictation bubble adds pause, resume, stop, close, and a multiline preview (#1952)
|
||||
- Transcriptions checks model readiness and offers an inline download and shortcut hints (#1952)
|
||||
- Transcriptions' missing-model prompt lists every dictation model by accuracy vs latency, languages and size, so you install the one that fits — or switch to one already on disk (#1952)
|
||||
- The Engines menu's Transcription tab picks the dictation model under Sherpa-ONNX, and that choice now also drives Sherpa transcription (#1952)
|
||||
- A failure with no stage attached no longer borrows another stage's advice, so a text-to-speech error stops telling you the video server dropped the download (#1943)
|
||||
- A generation failure that the app cannot classify now names the backend error class, so two unrelated faults stop arriving as the same untriageable report (#1800)
|
||||
- Transcriptions dictation wakes the desktop recorder, presents one contextual start action, and centers its microphone icon with the label (#1902)
|
||||
- Colab transcription and dubbing now include an explicit ASR model setup step (#1922) — thanks @nidhi-singh02!
|
||||
- Apple Silicon now shows one canonical OmniVoice choice in the engine picker while retaining its automatic crash-isolated sidecar runtime (#1913)
|
||||
- Validate current-user Windows installers under a standard account on hosted runners (#1883)
|
||||
- Model downloads survive a flaky connection instead of restarting from zero (#1940)
|
||||
- `bun run dev` recovers on Windows instead of demanding Task Manager (#1941)
|
||||
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
|
||||
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
|
||||
- Gallery voice previews play again — the quality guard was rejecting good renders as silent (#1819) — thanks @flutterkage2k!
|
||||
- Tilde-separated number ranges are spoken clearly without running their endpoints together (#1821) — thanks @flutterkage2k!
|
||||
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
|
||||
- Fix current-user Windows installer validation and nested resource cleanup (#1873)
|
||||
- Keep generated frontend assets available while building the current-user Windows installer (#1881)
|
||||
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
- audio.cpp joins the engine lineup as an opt-in CPU backend for Breeze-TTS-2 (English + Chinese, clone + voice design, explicit Model Catalogue install, no Python venv) (#1891)
|
||||
- audio.cpp uses installed native CUDA, HIP, Metal, and Vulkan providers and preserves device routing across remote workers (#1926)
|
||||
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
|
||||
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
|
||||
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
|
||||
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
|
||||
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
|
||||
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
|
||||
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
|
||||
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
|
||||
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
|
||||
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
|
||||
|
||||
### Changed
|
||||
|
||||
- Tauri 2.11.5 with refreshed plugins (dialog, updater, log, opener, positioner, single-instance), React 19.3, TanStack Query 5.102, lucide 1.43, posthog-js 1.428, and the rest of the npm workspace on current minors; jsdom 30, jest-dom 7, concurrently 10, taze 21 (#1952)
|
||||
- eslint ignores `src-tauri/`, so a local Tauri build no longer floods `lint:hooks` with parse errors from generated assets (#1952)
|
||||
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
|
||||
- Dubbing aligns output settings, brings review status forward, and simplifies transcript and glossary editing; Launchpad files and voices reflow into responsive grids (#1823)
|
||||
- Transcript segments use three readable rows for text, timing/status and voice controls, with heights that adapt to wrapping (#1823)
|
||||
- Dragging the waveform pans horizontally while a click still seeks, keeping the timed transcript aligned (#1823)
|
||||
- Bulk segment editing uses searchable voice and language menus, readable language names and a responsive selection toolbar (#1823)
|
||||
- Dubbing overlays playback controls on video, combines waveform and transcript in a compact timeline, and removes header/action background fills (#1823)
|
||||
- Dubbing uses compact casting, translation and output controls with responsive rows to leave more room for editing (#1823)
|
||||
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
|
||||
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
|
||||
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
|
||||
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
|
||||
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
|
||||
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
|
||||
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
|
||||
- The expandable sidebar reveals workspace labels with restrained active states; language menus adapt to multiple columns on wider screens (#1823)
|
||||
- Voice design and recording use themed, keyboard-accessible selectors with clearer spacing and labels (#1823)
|
||||
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
|
||||
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
|
||||
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
|
||||
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
|
||||
|
||||
### Added
|
||||
|
||||
- Remote-worker metrics distinguish unavailable readings from zero and keep probes off the control loop (#2155)
|
||||
|
||||
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
|
||||
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
|
||||
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
|
||||
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
|
||||
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
|
||||
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
|
||||
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
|
||||
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
|
||||
|
||||
### Docs
|
||||
|
||||
- PowerShell Docker setup now generates the administrator key without requiring Python on the host (#1993) — thanks @yangfan-yf-yf!
|
||||
- The torch upgrade an RTX 50-series card needs is written down, with the second pin file the resolver checks and the command that proves the kernels are there (#1931)
|
||||
- Docker quick starts now explain the AMD64-only images and direct Apple Silicon users to the native macOS app (#1921) — thanks @yangfan-yf-yf!
|
||||
- audio.cpp (Breeze-TTS-2) is now a documented opt-in engine: prebuilt binary install, explicit GGUF download, voice modes, and the weights' research/non-commercial terms (#1891)
|
||||
- `docs/STRUCTURE.md` describes the tree as it is today, and a test now keeps its counts honest (#1981) — thanks @Dawcraft!
|
||||
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
|
||||
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
|
||||
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
|
||||
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
|
||||
|
||||
### Fixed
|
||||
|
||||
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#2024)
|
||||
- Uninstalling a translation engine no longer removes a package VoiceStudio or another engine still needs (#2019)
|
||||
- Closing the dictation pill on Windows removes it from the screen: an empty dark rectangle used to stay there, always on top, until the app was quit (#2009)
|
||||
- The dictation pill on Windows no longer sits inside a bordered card wider than the pill itself (#2009)
|
||||
- Dictation uses the model you picked instead of one remembered from before the backend started, so it stops reporting no speech-to-text model while one is installed — and when none is, the main window offers the download (#2012)
|
||||
- The remote-worker loop-responsiveness tests no longer turn a build red over milliseconds of scheduling noise on shared CI hardware (#1990)
|
||||
- Remote GPU workers work when the machine running VoiceStudio is on Windows: a staged input is now identified the same way on every operating system, instead of with a path only Windows can read (#2005)
|
||||
- The pronunciation list badges an IPA or CMU entry as not applied yet, so you can see it without running a test (#1949) — thanks @utkarsha741!
|
||||
- A remote-worker test no longer fails at random on Windows CI: it waited for a background thread by spinning the event loop that thread's work needed (#1990)
|
||||
- The isolated backend test session passes on a stock Windows checkout, and CI now runs it there so it stays that way (#1990)
|
||||
- Windows contributors can run the test suite without Developer Mode: tests that create a symlink now skip instead of failing with `WinError 1314` (#1990)
|
||||
- The crash details dialog now says what the exit code means and what to try, instead of showing a raw number and a log (#1927)
|
||||
- A crash report now carries the backend's actual last words: the log tail is captured after the dying process's final output lands, not the instant it exits (#1850)
|
||||
- The first-run setup screen no longer mislabels a step when the bootstrap restarts itself: Rust now says which attempt each stage and log line belongs to, instead of the screen guessing from a once-a-second poll (#1900)
|
||||
- A port-3900 conflict now names who is actually holding it, and gives the command that ends an orphaned backend, instead of telling you to quit an app that has no window (#1933) — thanks @Chang-Jin-Lee!
|
||||
- Windows desktop launches no longer freeze at "Loading ML runtime (PyTorch)": the parent-liveness watchdog polls the stdin pipe instead of leaving a read pending, which deadlocked numpy's OpenBLAS initializer (#1952, #1955)
|
||||
- `bun desktop-prod` and `bun desktop-fresh` find Rust and uv from a terminal opened before they were installed, as `bun desktop` already did; a missing Rust toolchain fails up front with the install steps (#1952)
|
||||
- Voice synthesis progress no longer races to a fabricated 95%; it stays indeterminate until the active generation path reports real progress (#1907) — thanks @psiberfunk!
|
||||
- The Backend log tab keeps showing history across a log rollover, instead of going nearly empty until new lines arrive (#1920)
|
||||
- Clearing the logs now empties the rotated log files too, so it frees the space it appears to (#1920)
|
||||
- An error thrown by a browser extension no longer offers to file itself as a VoiceStudio bug (#1901)
|
||||
- Clearing the desktop logs no longer wipes the backend's stderr, which is the only record a native crash leaves behind and is meant to survive a respawn (#1510)
|
||||
- Long audiobook chapters now use the same device- and text-length-aware synthesis timeout as other TTS routes (#1910) — thanks @psiberfunk!
|
||||
- Interrupted audiobook renders can resume cached chapters after tab navigation, and their chapter cache is available from the recovery card (#1911) — thanks @psiberfunk!
|
||||
- System-check details and storage paths beginning with a number or a slash no longer render with their leading text moved to the end of the line (#1848) — thanks @psiberfunk!
|
||||
- An unavailable engine's row now links to that engine's guide, so the generic "check installation and configuration" message has somewhere to send you (#1866) — thanks @psiberfunk!
|
||||
- The backend log now records which engine failed a health check and whether its probe raised, instead of a line that identified neither (#1866) — thanks @psiberfunk!
|
||||
- The first-run Activity log counts every line instead of freezing at 200 while the install is still running, and Copy now hands back the whole run rather than the last 200 lines (#1847) — thanks @psiberfunk!
|
||||
- A first-run failure that happened early in a long install keeps its specific advice, instead of falling back to the generic retry hint once the log scrolled past 200 lines (#1847) — thanks @psiberfunk!
|
||||
- Opening the log panel no longer clips the Launchpad's heading and slides the feature cards up over it — the page scrolls instead of squashing itself (#1859) — thanks @psiberfunk!
|
||||
- Segmented model downloads split files into 16 MB ranges instead of one range per connection, so a dropped connection refetches one range rather than restarting the file (#1940)
|
||||
- The download accelerator is kept across retries after a transient network failure and resumes from its manifest, instead of falling back to a from-zero `snapshot_download` (#1940)
|
||||
- `dev-backend.mjs` stops the backend by process tree on Windows, so an orphaned uvicorn no longer holds port 3900 and turns a source reload into three phantom crashes (#1941)
|
||||
- `clear-dev-ports.mjs` can free a stuck development port on Windows again, bound to the inspected process instance so a recycled pid is never terminated (#1941)
|
||||
- Checkout-ownership matching no longer resolves POSIX paths with the host's separator, which made the guard's own test fail on Windows (#1941)
|
||||
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
|
||||
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
|
||||
- Transcribing with an engine that reports no segment end no longer fails with a server error; the null timing is passed through the way the segment list already expects (#1904) — thanks @aeroglu!
|
||||
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
|
||||
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
|
||||
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
|
||||
- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809)
|
||||
- Backend connection errors report crashes only when recorded evidence exists, and diagnostic waits honor cancellation (#1810)
|
||||
- A CUDA or ROCm GPU with less VRAM than the engine needs now gets the CPU compute-time budget instead of the shorter accelerated one, since it pages to system RAM and renders slower than the CPU would — applied to local generation, voice conversion, and remote worker deadlines alike (#1806) — thanks @VishvakR!
|
||||
- Gallery previews no longer fail with "the voice engine returned no audible audio" on perfectly good renders: the degenerate-buzz guard measured spectral flatness over the whole clip (so the value tracked clip length) against a threshold calibrated on a synthetic signal, and rejected real speech in every language tested (#1819) — thanks @flutterkage2k!
|
||||
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
|
||||
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
|
||||
- Audiobook is now a Write → Cast → Produce tab workspace matching the voice workspace, with the warnings/progress/result rail pinned below (#1841)
|
||||
- Gallery uses a workspace header with zone tabs, hairline section dividers, theme-token cards, and borderless import rows (#1841)
|
||||
- Gallery cards reset native button faces, cluster icon actions in the header so Use voice never wraps, and use a roomier grid floor (#1841)
|
||||
- Gallery filters gain name search, removable iconified pills with clear-all, and dimension icons on every facet (#1841)
|
||||
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
|
||||
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
|
||||
- Long dubbing segment errors wrap in a bounded scrollable notice instead of widening the editor (#1823)
|
||||
- Voice dropdowns match their field width, use theme accents, and show recent voices only once (#1823)
|
||||
- Language menus no longer show a pale frame around their search header (#1823)
|
||||
- The notification count stays inside the title bar instead of clipping above the bell (#1823)
|
||||
- The workspace engine menu opens beside its button instead of at the opposite edge of the page (#1823)
|
||||
- Cloning reuses the dubbing language picker with flags, search, and single selection, opening above the pinned synthesis controls (#1823)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
- The header status dot now honors OS Reduce Motion instead of pulsing regardless (#1862) — thanks @psiberfunk!
|
||||
- Onboarding reads Hugging Face tokens locally, preserves Windows CLI logins, and requires successful discovery before replacing saved credentials (#1852) — thanks @psiberfunk!
|
||||
- The logs panel no longer reports “All clear” before log retrieval succeeds or while logs contain warnings or errors (#1870) — thanks @motodriver!
|
||||
- MOSS accelerator routing and status match runtime selection, with CPU fallback when device probing fails (#1830) — thanks @li-lizhe!
|
||||
- Confucius accelerator routing tolerates failed device probes, and dots.tts keeps safe default precision on non-CUDA hosts (#1831) — thanks @li-lizhe!
|
||||
- On macOS, the header status dot and kicker no longer render underneath the overlaid traffic lights (#1863) — thanks @psiberfunk!
|
||||
- The capture widget can hide after recording and recover from being left visible while idle (#1865) — thanks @psiberfunk!
|
||||
- macOS retains the shared desktop window sizing, resize limits, and file-drop behavior when native chrome is applied (#1865) — thanks @psiberfunk!
|
||||
- On macOS, the header no longer shows Windows-style minimize/maximize/close buttons alongside the native traffic lights (#1865) — thanks @psiberfunk!
|
||||
- Release retries replace their own partially uploaded installers without colliding with existing assets (#1871)
|
||||
- Timed-out voice engines finish process cleanup before retrying, and old timeout callbacks cannot kill replacement engines (#1872)
|
||||
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
|
||||
- The bootstrap splash no longer shows fabricated first-run install steps on a warm start or repair sync — a step now renders done only once it was actually observed (#1894)
|
||||
- A deliberate, clean quit killed by the desktop shell's short shutdown grace no longer gets reported as a crash on next launch — the run sentinel now clears before the slower shutdown steps instead of after (#1895)
|
||||
- Model Catalogue engine rows stack into one column on narrow shells instead of clipping actions off-screen (#1891)
|
||||
- Simplified Chinese locale completed: all 486 missing keys translated and the parity ratchet tightened to zero (#1877) — thanks @yearth!
|
||||
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
|
||||
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
|
||||
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
|
||||
- Voice Design no longer lets you pick a Chinese dialect and an English accent together — the picker keeps them mutually exclusive instead of round-tripping a 400 (#1771)
|
||||
- The desktop app no longer attaches to an already-running backend on version string alone: it now verifies the backend's actual code fingerprint too, so an orphaned or manually started backend reporting the current version but running older code (e.g. a stale `destination_path` export 422) gets replaced instead of adopted (#1770)
|
||||
- Korean locale overhauled: 231 mistranslations corrected and all 493 missing keys translated (#1776) — thanks @j30231!
|
||||
- Japanese "Cleaning…" clone status now reads as denoising instead of housekeeping (#1775) — thanks @j30231!
|
||||
- The batch dubbing queue now has a UI entry point — a quiet link on the Dub landing (it was previously unreachable: the app switched on a mode nothing ever set) (#1768) — thanks @mvanhorn!
|
||||
- OpenAI-compatible ASR now requires HTTPS outside loopback and refuses redirects so audio stays on the configured origin (#1736)
|
||||
- Windows isolated engines now retain direct Job ownership without an extra Python supervisor process that can deadlock the child loader (#1734)
|
||||
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
|
||||
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
|
||||
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
|
||||
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
|
||||
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
|
||||
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
|
||||
- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710)
|
||||
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
|
||||
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
|
||||
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
|
||||
- Dictation capture now queues native events whenever its webview listener unmounts or reloads instead of emitting them to nobody (#1707)
|
||||
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
|
||||
|
||||
## [0.5.1] — 2026-08-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
- OmniVoice generation on Apple Silicon now runs in a crash-isolated child, so fatal MPS memory exits no longer take down the local backend (#1697, #1698) — thanks @ndntran14!
|
||||
- Model-load GPU exhaustion now returns a sanitized, actionable dubbing error, and readiness correctly attributes the shared model status to TTS (#1695)
|
||||
- Source-mode development now restarts an isolated backend crash without tearing down the UI, while repeated crash loops still stop loudly with diagnostics (#1690)
|
||||
- Dubbing playback now keeps an audible companion source when a WebView can render the preview picture but cannot decode its audio (#1692)
|
||||
- Model Catalogue engine rows now use the available desktop width and keep identity, runtime state, and actions from crowding one another (#1689)
|
||||
- VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646)
|
||||
- A timed-out in-process dub transcription no longer starts a second WhisperX/CTranslate2 call over the abandoned native worker, preventing the overlapping access that preceded Windows `0xC0000005` exits (#1669)
|
||||
- Windows debugger termination code `0x40010004` is no longer misreported as a backend crash or charged against automatic restart recovery (#1663)
|
||||
- Studio now keeps one generation reservation across page changes, preventing a remount from stacking native jobs until the backend reports capacity busy or is killed under memory pressure (#1670)
|
||||
- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644)
|
||||
- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter!
|
||||
- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y!
|
||||
- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd!
|
||||
- Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui!
|
||||
- WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui!
|
||||
- Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd!
|
||||
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
|
||||
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
|
||||
- Reporting a bug from an outdated build now offers the latest release first (#1547)
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
|
||||
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
|
||||
- Dub subtitles can be retimed, inserted, and merged in either direction from the segment table (#1612) — thanks @invio-a11y!
|
||||
|
||||
### Changed
|
||||
- Model Catalogue now uses one breathable workspace canvas with simpler pane and engine-family navigation instead of nested cards and scroll regions (#1685)
|
||||
- Linux source launchers now catch missing libxdo and GStreamer audio plugins before they can cause a linker error or an aborted, blank WebKit renderer (#1680, #1682)
|
||||
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
|
||||
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
|
||||
|
||||
### Added
|
||||
- A bundled Rust loopback sidecar exposes dictation start/stop/toggle, focused-output sessions, discovery, and JSON-RPC; the backend adds versioned streaming events and a dependency-free CLI bridge for Herdr, coding agents, editors, desktop apps, and TUIs (#1646)
|
||||
- Headless NVIDIA and ROCm machines can now join as worker-only Docker Compose services with no published UI and durable protocol-v2 enrollment; update both machines together before reconnecting (#1638) — thanks @jkrogers9862!
|
||||
- Linux ARM64 (Asahi Apple Silicon) support for the OmniVoice GGUF engine — a `linux-aarch64` binary built with GGML Vulkan where the toolchain allows it, so Apple GPUs accelerate generation through the open-source Honeykrisp driver instead of falling back to CPU-only (#1641)
|
||||
- One-command install on every desktop OS: `curl -fsSL https://voicestudio.sh/install | sh` (macOS/Linux/WSL) or `irm https://voicestudio.sh/install | iex` (Windows) — the URL serves the right script per platform, and Windows gains a source installer (`scripts/install.ps1`) with a 3-OS CI smoke (#1626)
|
||||
- Per-line subtitle management in the dub table: a line's end time is editable alongside its start (typing a time and dragging its timeline edge now take the same path), lines merge with the previous row as well as the next (`Ctrl/Cmd+Shift+M`), and a new line can be inserted into the gap after any row (#1612) — thanks @invio-a11y!
|
||||
- CI now enforces performance regression budgets on the hot paths — operation-count tests pin streaming TTS to one synthesis per sentence and cached dub re-mixes to zero re-synthesis; fast-path guards cover zero re-decoding and ⌈N/W⌉ native batch calls when enabled (#1594)
|
||||
- Default-engine dubbing now synthesizes several segments per forward pass instead of one call per line — the width follows the host's device headroom (1 on CPU and low-VRAM cards, up to 8), `OMNIVOICE_DUB_BATCH_WIDTH` overrides it, and engines without native batching keep the single-segment path (#1594)
|
||||
- `/ws/tts` now reports real time-to-first-audio, and its RTF measures synthesis alone so a slow client can't inflate it (#1594)
|
||||
- The locally cached AudioSeal watermark generator warms on a background thread ~35s after boot (`OMNIVOICE_PRELOAD_WATERMARK=0` opts out; explicitly setting `=1` may download it), so the first synthesis no longer serializes the audioseal import + model load inline — measured at ~42s on a cold filesystem, 3s short of a 90s client timeout (#1576) — thanks @paoloantinori!
|
||||
- Voices you've cloned stay "warm" across restarts — encoded references now persist to disk (~10 KB each), so the first generation of a session skips the re-encode and any transcription pass; `OMNIVOICE_PROMPT_DISK_CACHE=0` opts out (#1565)
|
||||
- Optional FlashInfer acceleration for the default engine on CUDA (`OMNIVOICE_FLASHINFER=1`, ~2.2x measured) — needs the optional `flashinfer-python` package; missing package or kernel failure logs why and falls back to the standard path (#1565)
|
||||
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
|
||||
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
|
||||
- Opt-in 24-layer PocketTTS checkpoints via `OMNIVOICE_POCKETTTS_24L` — better prosody for it/de/es/pt at roughly 2x render time (still faster than real-time); the fast 6-layer model stays the default (#1613) — thanks @paoloantinori!
|
||||
|
||||
### Docs
|
||||
- Supported-version and install guidance now identifies 0.5.1 as the stable desktop and container release (#1687)
|
||||
- The Docker Hub overview now shows the current engine-switching demo, Model Catalogue, and gallery voice workflow (#1593)
|
||||
- The Docker Hub overview and install guide now show the v0.5 tags and the built-in API-key/share-PIN security model instead of obsolete v0.4 and no-authentication guidance (#1592)
|
||||
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
|
||||
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
|
||||
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
|
||||
|
||||
### Fixed
|
||||
- Workspaces now measure their responsive width when the post-bootstrap shell actually mounts, so native UI scaling reflows Projects and History instead of crushing the Dubbing demo into unreadable columns (#1683)
|
||||
- Dubbing keeps the source-language selector visible after a local file is chosen, so ASR can be pinned before transcription starts (#1678) — thanks @Lonki-lomki-cloud!
|
||||
- First-run media-engine downloads become available to TTS immediately without a restart, and missing media-process failures now point to repair controls (#1677) — thanks @farhataligpt-dev!
|
||||
- Source installs on AMD GPUs honour `OMNIVOICE_TORCH_VARIANT=rocm`: `bun run desktop` now swaps in the ROCm torch wheel after `uv sync` and launches the backend without re-syncing, instead of silently reverting to the CPU-only CUDA build on every start (#1665) — thanks @uberclokr!
|
||||
- `bun run desktop` on a fresh clone no longer fails with "resource path `../../frontend/dist` doesn't exist" — the dev launcher creates the placeholder Tauri resource directory before compiling (#1664) — thanks @uberclokr!
|
||||
- macOS no longer loses TTS after the first request when Python lacks `os.waitid`; subprocess ownership now uses a safe `waitpid` fallback without risking reused process groups (#1656) — thanks @paoloantinori!
|
||||
- Desktop startup, Retry, reset, uninstall, shutdown, and crash recovery now share one backend lifecycle owner; quitting interrupts first-run installers and gracefully drains then force-cleans the full backend process tree, so overlaps cannot duplicate or orphan it (#1635) — thanks @Xohaibxobi!
|
||||
- Large Stories and Audiobook projects now persist in IndexedDB instead of overflowing the `omnivoice.app` localStorage envelope, with quota-safe migration and orderly exit/reload flushing (#1636) — thanks @leodzai!
|
||||
- OmniVoice and its crash-isolated subprocess now route to AMD ROCm GPUs instead of warning and falling back to CPU (#1629) — thanks @j4r3kb!
|
||||
- Dictation now cancels pending startup work, capture resources, sockets, and timers when the capture widget closes, preventing late work against a destroyed webview (#1645)
|
||||
- Streaming generation failures now show recognized recovery guidance and appear in Diagnostics instead of only returning a generic error (#1607)
|
||||
- The worker-capacity transport test no longer races its own setup: the 1-slot limit now goes through the enrollment handshake instead of mutating client config after connect, where the server's stream-open ConfigUpdate (carrying the registered capacity of 2) could overwrite it and fake an over-accept; failed CI twice on 2026-08-21 (#1630)
|
||||
- Moving words across a speaker boundary in a dub — merging two lines and splitting them again — no longer dubs the second half in the first speaker's voice; each half now keeps the speaker, voice, direction, gain, and language of whoever actually says it (#1612) — thanks @invio-a11y!
|
||||
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
|
||||
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
|
||||
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
|
||||
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
|
||||
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
|
||||
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
|
||||
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
|
||||
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
|
||||
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
|
||||
- IndexTTS 2.5 no longer has long-text generation killed at 60 seconds — the sidecar now proves it is alive every 5 seconds while `infer()` runs, and its deadline rises to 900s (`OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S`) (#1611) — thanks @zuiaiyutu!
|
||||
- The OpenAI-compatible `/v1/audio/speech` route now reuses the shared cached engine for explicit `model` ids instead of constructing a fresh engine — and its sidecar/model load, a ~28s floor per call for subprocess engines — on every request, with the same single-engine-resident discipline `/generate` applies (#1614) — thanks @paoloantinori!
|
||||
- The setup wizard's RAM check no longer blocks 8 GB machines whose OS reports ~7.8 GB usable — the thresholds now tolerate reserved memory, and `OMNIVOICE_RAM_PREFLIGHT=0` turns a genuine block into a warning for those who accept the OOM risk (#1618)
|
||||
- Invisible watermarking now runs eagerly instead of through `torch.compile` — AudioSeal's lazy compile sent the first embed of every session into Inductor's C++ codegen, which failed outright on macOS hosts whose toolchain couldn't serve it and shipped the audio unmarked after a 30-40s wait; first embed drops from 9.70s to 0.26s (#1615) — thanks @paoloantinori!
|
||||
- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609)
|
||||
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
|
||||
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
|
||||
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
|
||||
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
|
||||
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
|
||||
- `OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
|
||||
- Network Sharing from Windows MSI/portable installs now serves the bundled web interface to LAN devices instead of redirecting them to their own `localhost` (#1589) — thanks @TWIISTED-STUDIOS!
|
||||
- Exported dubbed videos now mark the dubbed language as the default audio stream while keeping Original available as an explicit choice (#1575) — thanks @invio-a11y!
|
||||
- Cloning references can no longer exhaust system memory: transcript-free clips up to 75 seconds are searched in five bounded passages, longer clips ask to be trimmed, and supplied transcripts remain capped at 20 seconds to preserve alignment (#1578) — thanks @ACKAPOB!
|
||||
- Stored artifact subpaths now resolve after moving a data directory between Windows, macOS, Linux, and Docker, while traversal and symlink escapes remain blocked (#1559) — thanks @Eman-Yousaf!
|
||||
- A remote browser hitting an API-key-configured server's admin 403 now gets the API-key login form instead of endless console 403s, while desktop and PIN-only/no-key servers keep the plain loopback error so guests are never offered a login no key can satisfy (#1568) — thanks @paoloantinori!
|
||||
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
|
||||
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
|
||||
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
|
||||
- The Linux desktop cleanup regression test now isolates build artifacts, so an existing developer build can no longer change its result (#1566)
|
||||
|
||||
- Renaming, deleting, or revoking consent on a voice (and starring/clearing history, recording exports) now live-updates every open tab again — the sync routes' WebSocket events were silently dropped, which could look like "all my voices are gone" (#1561) — thanks @paoloantinori!
|
||||
|
||||
### CI
|
||||
- Project agents now share pinned Vite and FastAPI skills from skills.sh (#1594)
|
||||
- Weekly full-history secret scans no longer mistake the Ed25519 private-key type name for committed key material (#1591)
|
||||
|
||||
## [0.5.0] — 2026-08-13
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The app is now **VoiceStudio** (previously OmniVoice-Studio) — one waveform-and-spark identity across the app, docs and installers. Your data folder, settings and Docker image paths stay put.
|
||||
- **Model Catalogue** — engines and models in one workspace: every TTS, transcription and LLM engine with its device routing and install state, defaults picked there.
|
||||
- Switch TTS, ASR and LLM engines from the status bar or any workspace — ready-only choices, memory status, environment-pin protection, `Ctrl/Cmd+E`. (#1530)
|
||||
- Lend another machine's GPU with a join code and a QR scan — a Compute control in the status bar picks where jobs run, and several people can share one GPU box with revocable, certificate-pinned connections. (#1516, #1496)
|
||||
- Server mode is locked down: admin actions require an API key (#1525), and the remote UI exchanges it for short-lived sessions that never sit in browser storage or WebSocket URLs (#1528) — thanks @bultodepapas!
|
||||
- A faster, cleaner Dub workspace for multilingual production, with a production command bar and per-language cards. (#1489)
|
||||
- The demo audio and video the app always advertised now actually ship, rendered by VoiceStudio's own engine. (#1517)
|
||||
- Dictation works on Wayland now — the portal shortcut actually fires (#1490, #1526) — and the recording pill is back on every desktop.
|
||||
- The Launchpad wears the project's signal-field waveform artwork over a quieter, borderless layout. (#1533)
|
||||
- The catalogue reads as headroom, not breakage: available engines sort first, uninstalled ones say what they need (#1531), and the LLM row names the provider that actually answers (#1538).
|
||||
- Gallery voices can be saved as local profiles — audio lands in your profile store with validated, content-addressed references. (#1542)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the status bar" width="820" />
|
||||
|
||||
| The Model Catalogue | The Voice Gallery |
|
||||
| --- | --- |
|
||||
| <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="420" /> | <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/gallery-save.png" alt="Voice Gallery — save a voice as a profile" width="420" /> |
|
||||
|
||||
### Changed
|
||||
|
||||
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
|
||||
- Typing and large workspace edits no longer serialize and rewrite persisted documents on every input; writes are coalesced off the interaction path — thanks @bultodepapas! (#1541)
|
||||
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
|
||||
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
|
||||
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
|
||||
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
|
||||
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
|
||||
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
|
||||
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
|
||||
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
|
||||
- Linux release smoke now validates linuxdeploy's wrapped custom launcher instead of rejecting a healthy AppImage. (#1506)
|
||||
- Remote GPU workers render audiobooks chapter by chapter, with automatic per-chapter local fallback and one combined notice if the worker drops out. (#1478)
|
||||
- Remote GPU workers can now run a job to completion: long renders no longer die at two minutes, a worker that drops and reconnects mid-render keeps its work, and a timed-out job no longer takes the worker offline for good. Placing a job still needs the development-only `POST /workers/tasks`; wiring the app's own Synthesize button to it comes next.
|
||||
- Voice, Stories, Audiobook, Gallery, Settings, profiles, and Launchpad now use compact, responsive layouts with accessible controls. (#1491)
|
||||
- Dubbing's Generate Dub, Verify, and Export actions now use a compact hierarchy with visible labels, responsive reflow, and motion-safe feedback. (#1493)
|
||||
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail. (#1489)
|
||||
- VoiceStudio now uses one waveform-and-spark mark across the title bar, About screen, README, browser favicon, and every desktop/platform icon. (#1487)
|
||||
- PocketTTS now asks you to review its code license, model license and gated-access conditions before first use, and explains how to unlock the model instead of showing a raw download failure — thanks @paoloantinori! (#1442)
|
||||
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
|
||||
- The app is now **VoiceStudio** (previously OmniVoice-Studio). Only the name you see changes — your data folder, settings and the Docker image paths stay put, so upgrading needs nothing from you. On Linux the .deb is now `voicestudio`; remove the old `omnivoice-studio` package once.
|
||||
@@ -452,72 +36,18 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Added
|
||||
|
||||
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
|
||||
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
|
||||
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
|
||||
- Settings → System → **Remote workers** sends individual jobs to GPUs on your other machines while everything else stays here. Off by default; each machine is added with a single-use token and approved before any audio reaches it. See [docs/remote-workers.md](docs/remote-workers.md).
|
||||
- First-run setup now recommends a screen-aware interface scale, with compact controls available throughout setup. (#1502)
|
||||
- OrcaRouter is now available as a named OpenAI-compatible LLM provider — thanks @Marc-oss-hub! (#1499)
|
||||
- IndexTTS 2.5 is available as a pinned one-click sidecar with five-language dubbing, expressive cloning, and backward-compatible IndexTTS-2 support. (#1482) — thanks @marwanlhabti5-coder!
|
||||
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
|
||||
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
|
||||
- Portable mode lets you choose the folder — press **Change…** on the first-run setup screen and put the whole install on an external drive. It also stops being greyed out after a default Program Files install. (#766)
|
||||
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
|
||||
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
|
||||
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
|
||||
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
|
||||
|
||||
### Docs
|
||||
### CI
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
|
||||
### Fixed
|
||||
|
||||
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
|
||||
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
|
||||
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
|
||||
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
|
||||
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
|
||||
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
|
||||
- Transcription now moves to the next working engine when the auto-picked one passes its availability check but breaks on first real use, instead of returning an internal error — the recovery dubbing already had. Affected accurate-mode transcription, the OpenAI-compatible API, batch, dub verify, and voice-clone reference text. (#1512)
|
||||
- A malformed request now gets a clear 422 instead of an internal error, and uploading a file to an endpoint that expects JSON no longer copies the whole upload into the app log — a 145 KB clip wrote roughly 500 KB of log, recording your audio in the file people paste into bug reports. (#1513)
|
||||
- The Simplified Chinese (zh-CN) translation no longer mistranslates brand names and technical terms — Discord, Tailscale, Hugging Face, IPA, and LLM (Cinematic) were rendered as nonsensical literal translations, and ~250 more awkward machine-translation strings are now natural Chinese. (#1508) — thanks @anyingiit!
|
||||
- Worker restart coverage now waits for the registration response to persist its identity instead of racing the client callback in CI. (#1505)
|
||||
- Dub language and export selections now restore without false schema warnings, and remote-worker port 7443 is identified instead of reported as a generic timeout. (#1504)
|
||||
- A configured remote backend now bypasses local first-run setup, verifies itself before app requests begin, and shows recovery instead of leaving the desktop stuck on Setup. (#1503)
|
||||
- An idle voice model now actually hands its memory back. The unload emptied the GPU cache a moment before releasing the model, so it freed nothing while reporting success — a GPU machine lending its card sat on 3.6 GB indefinitely. (#1495)
|
||||
- Unloading a model on an NVIDIA GPU now returns the last ~770 MB too. A single 8.5 MB cuBLAS workspace sat inside the model's memory block and kept the whole block reserved, so an idle machine held 1.2 GB instead of 470 MB no matter how often you pressed Flush Memory. (#1495)
|
||||
- Flush Memory reports reserved GPU memory alongside allocated. Allocated alone reads near zero right after an unload while the GPU still shows gigabytes, which is exactly the case people were reporting. (#1495)
|
||||
- The AudioSeal watermark models are released after the same idle period as everything else, instead of staying in memory for the life of the app once anything was watermarked. (#1495)
|
||||
- Remote GPU workers now synthesize a dub's fresh segments as one coarse job with live progress and cancellation; fitting, assembly and RVC remain local. (#1478)
|
||||
- Gallery voice previews now fall back to a local render when a downloaded clip cannot be decoded, instead of failing silently. (#1478)
|
||||
- A second VoiceStudio instance can no longer silently share the remote-worker port; it keeps running locally and explains how to resolve the conflict. (#1478)
|
||||
- Remote GPU jobs stay pinned to the selected worker across retries and restarts, stop when their caller leaves, and cannot return from cancellation as completed. (#1478)
|
||||
- Remote GPU model labels now survive registration, legacy blank model IDs share one capacity slot, long jobs retain bounded leases, and idle cleanup cannot evict a live local render. (#1478)
|
||||
- Remote GPU jobs now stop before dispatch when that worker lacks the model, offer the download there, and refresh scheduling as soon as it finishes. (#1478)
|
||||
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
|
||||
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
|
||||
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
|
||||
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
|
||||
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
|
||||
- Dictation shortcuts now register and rebind through the desktop portal on Wayland, honor custom keys in focused app views, and show the effective platform keys. (#1490)
|
||||
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
|
||||
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
|
||||
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
|
||||
- Linux microphone recording now falls back to WAV when WebKit cannot encode MediaRecorder audio, and desktop scaling/titlebar controls remain responsive at every UI scale. (#1481)
|
||||
- Dubbing can install a missing ASR model and retry the same job, navigate back through completed stages, and finish transcription under low GPU memory without producing an empty transcript. (#1481)
|
||||
- Filenames and other outside data can no longer forge extra lines or terminal commands in backend and frontend diagnostic logs. (#1457)
|
||||
- Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459)
|
||||
- Backend failures keep raw tracebacks, local paths and credentials in the local log instead of returning them in API responses. (#1454)
|
||||
@@ -601,17 +131,19 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
|
||||
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
|
||||
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
|
||||
|
||||
### Docs
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
|
||||
### CI
|
||||
|
||||
- Windows CI falls back to a static ffmpeg build when the Chocolatey feed is down, instead of failing the run. (#1542)
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
- Windows smoke tests stopped silently passing a broken ffmpeg install, and every smoke leg is now budgeted for a cold dependency install. (#1290)
|
||||
- Test suites no longer leak config paths or model-manager shutdown state into one another, which had been failing unrelated pull requests. (#1269)
|
||||
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
|
||||
- The nightly preview build stopped refusing to publish its own healthy updater manifest when the macOS legs finished a few minutes ahead of the slowest one — Preview-channel users were silently left without new builds.
|
||||
|
||||
## [0.4.2] — 2026-07-28
|
||||
|
||||
|
||||
@@ -66,10 +66,7 @@ Architecture not yet mapped. Follow existing patterns found in the codebase.
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
|
||||
- `vite` — Vite configuration, assets, HMR, builds, and Vitest guidance.
|
||||
- `fastapi-python` — FastAPI and Pydantic implementation patterns.
|
||||
|
||||
Canonical copies live under `.agents/skills/`; `skills-lock.json` pins their sources and hashes. Claude should follow these paths directly, avoiding cross-platform symlinks.
|
||||
No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.
|
||||
<!-- GSD:skills-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
|
||||
+4
-10
@@ -10,10 +10,10 @@ Copyright 2024-present Palash Debnath and VoiceStudio contributors.
|
||||
|
||||
VoiceStudio is **free and open-source software, licensed under the GNU
|
||||
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
|
||||
copy, modify, and redistribute it. That **includes commercial and internal
|
||||
business use** of the application itself. Model weights, tokenizers, and other
|
||||
third-party assets retain their own terms; this application license does not
|
||||
grant or summarize rights under those separate terms.
|
||||
copy, modify, and redistribute it — and that **includes commercial and internal
|
||||
business use**: run the app, use its outputs commercially, sell the audio you
|
||||
produce with it, provide professional/client services with it, and deploy it
|
||||
within your organization.
|
||||
|
||||
Because this is the **Affero** GPL, one additional obligation applies: if you
|
||||
modify VoiceStudio and make that modified version available to others over
|
||||
@@ -41,12 +41,6 @@ is **separately licensed under Apache License 2.0** by its upstream authors and
|
||||
is not relicensed here. Apache License 2.0 is compatible with, and may be
|
||||
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
|
||||
|
||||
Downloaded model weights are not relicensed by VoiceStudio. The default
|
||||
`k2-fsa/OmniVoice` model card identifies its code as Apache-2.0 and pretrained
|
||||
weights as CC-BY-NC. Its `audio_tokenizer/LICENSE` contains separate Boson
|
||||
Higgs Audio 2 and Meta Llama community terms. A commercial license for
|
||||
VoiceStudio-owned code does not replace any of those terms.
|
||||
|
||||
Third-party dependencies retain their own licenses. See `Cargo.lock`,
|
||||
`bun.lock`, and `uv.lock` for the resolved set.
|
||||
|
||||
|
||||
@@ -1,101 +1,647 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio" width="88" />
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub><em>previously OmniVoice-Studio</em></sub></p>
|
||||
<h3>Make voices. Tell stories. Keep the files. ♡</h3>
|
||||
<p>Clone, design, dub, dictate, and build audiobooks in one open-source desktop studio.<br/><b>Local-first by default.</b> No subscription or usage meter. Optional online services stay opt-in.</p>
|
||||
|
||||
<p>
|
||||
<a href="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="VoiceStudio ranking on Trendshift" width="220" height="48" /></a>
|
||||
</p>
|
||||
<p><strong>Open source voice cloning and workflow engine. Build local.</strong></p>
|
||||
<p>
|
||||
<a href="https://voicestudio.sh/?utm_source=github&utm_medium=readme&utm_campaign=project">Website</a> ·
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest">Download</a> ·
|
||||
<a href="#get-started">Get started</a> ·
|
||||
<a href="#documentation">Docs</a> ·
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#why-voicestudio">Why VoiceStudio</a> ·
|
||||
<a href="#tts-engines">Engines</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">Donate</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="https://voicestudio.sh">Website</a> ·
|
||||
<a href="https://voicestudio.sh/docs">Docs</a> ·
|
||||
<a href="https://status.voicestudio.sh">Status</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
<a href="README_CN.md">简体中文</a>
|
||||
<a href="https://x.com/idebpalash">X</a> ·
|
||||
<a href="README_CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/debpalash/VoiceStudio/ci.yml?branch=main" alt="CI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio" alt="Latest release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue" alt="AGPL-3.0" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases"><img src="https://img.shields.io/github/downloads/debpalash/VoiceStudio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/X-Follow_for_updates-000000?style=flat-square&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download the latest release" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://trendshift.io/repositories/28176?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/28176/daily?language=Python" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||

|
||||
<br/>
|
||||
|
||||
## Your voice. Your workflow.
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — Launchpad" width="100%"/>
|
||||
</div>
|
||||
|
||||
| Create | Produce | Connect |
|
||||
| :--- | :--- | :--- |
|
||||
| Clone a voice or design your own | Dub videos with timed speech | Local API & MCP for agents |
|
||||
| Dictate with a floating widget | Stories, audiobooks & batch jobs | Optional remote workers |
|
||||
> **Your voice is personal. Your studio should feel personal too.** VoiceStudio keeps its core workflow on your hardware: clone, design, dub, dictate, and publish in 646 languages without a subscription or usage meter. Network-backed engines and services are optional, visible choices—not hidden requirements.
|
||||
|
||||
Start with **VoiceStudio** (default, powered by k2-fsa/OmniVoice), or choose another engine. [Features & engine catalog](docs/feature-catalog.md).
|
||||
> [!WARNING]
|
||||
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
Local workflows run on your hardware. Remote services are optional; usage analytics requires consent.
|
||||
<a id="features"></a>
|
||||
|
||||
<details>
|
||||
<summary><strong>Explore the workspaces</strong> · Clone, dub, design & models</summary>
|
||||
## ✨ Features
|
||||
|
||||
Three flagships, five more headliners, and a dozen under the fold.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/media/electron/voice-cloning.png" alt="Electron voice cloning workspace with the bundled demo voice" width="100%" /></td>
|
||||
<td><img src="docs/media/electron/dubbing.png" alt="Electron video dubbing workspace" width="100%" /></td>
|
||||
</tr>
|
||||
<tr><td align="center">Voice cloning</td><td align="center">Video dubbing</td></tr>
|
||||
<tr>
|
||||
<td><img src="docs/media/electron/voice-design.png" alt="Describe a voice in the Electron voice design workspace" width="100%" /></td>
|
||||
<td><img src="docs/media/electron/models.png" alt="Install and manage local speech models" width="100%" /></td>
|
||||
</tr>
|
||||
<tr><td align="center">Voice design</td><td align="center">Local models</td></tr>
|
||||
<tr>
|
||||
<td width="33%"><img src="docs/features/clone.png" alt="Voice Cloning" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/design.png" alt="Voice Design" width="100%"/></td>
|
||||
<td width="33%"><img src="docs/features/dub.png" alt="Video Dubbing" width="100%"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🎙️ <b>Voice Cloning</b><br/><sub>3-sec clip → any voice · 646 languages · zero-shot</sub></td>
|
||||
<td align="center">🎨 <b>Voice Design</b><br/><sub>Describe it — gender, age, accent, emotion</sub></td>
|
||||
<td align="center">🎬 <b>Video Dubbing</b><br/><sub>Transcribe → translate → re-voice → MP4</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<img width="2628" height="1950" alt="VoiceStudio desktop workspace" src="https://github.com/user-attachments/assets/b474497d-a453-49a3-a2dd-f023ec6b7659" />
|
||||
|
||||
</details>
|
||||
|
||||
## Get started
|
||||
|
||||
Download from [Releases](https://github.com/debpalash/VoiceStudio/releases/latest), then follow your platform guide:
|
||||
|
||||
**[macOS](docs/install/macos.md) · [Windows](docs/install/windows.md) · [Linux](docs/install/linux.md) · [Docker](docs/install/docker.md)**
|
||||
|
||||
Open **Voice cloning**, choose a voice or add a clean reference recording, enter your text, and generate. Install the required model when prompted. Hardware needs vary by engine; see [performance](docs/performance.md).
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
|
||||
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
|
||||
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
|
||||
<td align="center" width="20%">🔐<br/><b>Local-first</b><br/><sub>Core creation stays on your machine</sub></td>
|
||||
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary><strong>Run the Electron preview from source</strong></summary>
|
||||
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
<br/>
|
||||
|
||||
See [Electron setup](electron/README.md) for prerequisites and backend configuration.
|
||||
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
|
||||
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
|
||||
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
|
||||
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
|
||||
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
|
||||
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
|
||||
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
|
||||
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
|
||||
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
|
||||
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
|
||||
|
||||
</details>
|
||||
|
||||
> **Electron is the primary desktop app.** The next desktop release ships Electron, with one final Tauri sunset update. Bug reports and contributions remain welcome; include the app version and whether you use Electron or Tauri.
|
||||
---
|
||||
|
||||
## Documentation
|
||||
<a id="quickstart"></a>
|
||||
|
||||
| Need | Start here |
|
||||
## ⚡ Quickstart
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
</div>
|
||||
|
||||
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
|
||||
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
|
||||
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
|
||||
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
|
||||
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## ⚖️ Why VoiceStudio
|
||||
|
||||
Cloud voice tools are convenient, but they put your workflow behind an account, a meter, and somebody else's infrastructure. VoiceStudio gives you a capable studio that runs on your hardware, with optional integrations when you choose them.
|
||||
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
|---|---|---|
|
||||
| **Pricing** | Subscription and usage limits | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Audiobook / Stories** | ❌ | ✅ Full audiobook editor + multi-voice stories (EPUB/PDF import, .m4b export) |
|
||||
| **Languages** | Plan/model dependent | **646** |
|
||||
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
|
||||
| **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins |
|
||||
| **API Keys** | Account required | Not needed for the local workflow |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
|
||||
| **ASR Engines** | 1 | **11** — [full lineup](#asr-engines) |
|
||||
| **MCP Server** | ❌ | ✅ Use from Claude, Cursor, any MCP client |
|
||||
| **Self-check** | ❌ | ✅ Diagnostics suite, error journal, scrubbed debug bundles |
|
||||
| **Customizable** | ❌ Closed | ✅ Fork it, extend it, ship it |
|
||||
|
||||
Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<b>Convinced? Come build with us.</b><br/>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<br/><br/>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ System Requirements
|
||||
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
|
||||
| **RAM** | 8 GB | 16 GB+ |
|
||||
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
|
||||
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
|
||||
| **Python** | 3.10+ (managed by `uv`) | 3.11–3.12 |
|
||||
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
|
||||
|
||||
> [!NOTE]
|
||||
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/VoiceStudio/issues/889) · [macOS](docs/install/macos.md)).
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### 🗣️ TTS Engines
|
||||
|
||||
**14 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | ✅ | — | ✅ CUDA/CPU | — | ✅ CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | English | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
|
||||
| **MLX-Audio** (Kokoro, Qwen3-TTS, CSM, Dia, …) | Multi | Varies | Varies | ❌ | ✅ Native | ❌ | Varies |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2** ⚡ | Multi | ✅ | — | ✅ CUDA | — | ✅ CUDA | Apache-2.0 |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|
||||
GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on
|
||||
another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
|
||||
`http://` or `https://` origin and add that machine's CIDR to
|
||||
`OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected.
|
||||
|
||||
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
|
||||
>
|
||||
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio.
|
||||
>
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB), **dots.tts** (2B, ~9 GB), and **Confucius4-TTS** are heavyweight opt-ins that run in their own isolated venv from a local clone. None claims Apple-Silicon MPS (CPU on Macs); dots.tts has no Windows path; Confucius4 wants CUDA (CPU works, ~17× realtime). Details: [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md).
|
||||
|
||||
</details>
|
||||
|
||||
<a id="asr-engines"></a>
|
||||
|
||||
### 🎧 ASR Engines
|
||||
|
||||
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing & subtitles — word-level timing via wav2vec2 forced alignment |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Settings → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Settings → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
|
||||
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 shell — Rust │
|
||||
│ window state · global dictation hotkey · system tray · │
|
||||
│ signed auto-updater (stable/preview) · single-instance · │
|
||||
│ first-run bootstrap (installs uv + Python venv) · blank guard │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ Frontend — React + Vite │
|
||||
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
|
||||
│ Batch · Diagnostics · MCP client — Zustand store · WS bus │
|
||||
│ ▲ IPC / HTTP + WS │
|
||||
├──────────────────────────┼─────────────────────────────────────────┤
|
||||
│ Backend — FastAPI sidecar @ localhost:3900 │
|
||||
│ 100+ REST endpoints · SSE + WebSocket streaming · │
|
||||
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
|
||||
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
|
||||
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
|
||||
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
|
||||
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
|
||||
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
|
||||
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
|
||||
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Shell (Rust)** — native OS integration: the system-wide dictation hotkey, tray, signed auto-updater (stable + preview channels), single-instance lock, and the first-run bootstrap that installs `uv` and a Python 3.11 venv.
|
||||
- **Frontend (React)** — every workspace tab over a Zustand store, with a WebSocket event bus that live-refreshes the UI when backend data changes.
|
||||
- **Backend (FastAPI)** — the bundled Python sidecar: 100+ endpoints, SSE/WSS streaming, a SQLite DB migrated by Alembic, and the OpenAI-compatible API surface.
|
||||
- **Engines** — 14 TTS + 11 ASR, plus Demucs (isolation), Pyannote (diarization), and AudioSeal (watermark), all behind routing that GPU-preflights each engine and refuses to silently fall back to CPU.
|
||||
|
||||
<a id="openai-api"></a>
|
||||
|
||||
## 🔌 OpenAI-compatible API
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
|
||||
|
||||
```diff
|
||||
- base_url="https://api.openai.com/v1"
|
||||
+ base_url="http://localhost:3900/v1"
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally** on whatever engine you have active. What the cloud can't do: `voice` takes **your own cloned-voice profile IDs**, and `model` can pin a **specific engine** per request.
|
||||
|
||||
| Endpoint | What it does |
|
||||
|---|---|
|
||||
| Setup help | [Troubleshooting](docs/install/troubleshooting.md) · [Model downloads](docs/downloading-models.md) |
|
||||
| Models & audio quality | [Engine guides](docs/engines/README.md) · [Benchmarks](docs/benchmarks.md) |
|
||||
| Integrations | [Local API](docs/speech-platform.md) · [MCP](docs/mcp.md) · [Examples](examples/README.md) |
|
||||
| Development | [Contributing](.github/CONTRIBUTING.md) · [Electron](electron/README.md) · [Changelog](CHANGELOG.md) |
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
Agent skills: `npx skills add debpalash/VoiceStudio` — choose **voicestudio** for audio workflows or **voicestudio-maintainer** for repository maintenance.
|
||||
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
|
||||
|
||||
## Sponsors
|
||||
```sh
|
||||
# 1 — find a cloned voice's profile ID
|
||||
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
|
||||
|
||||
<a href="https://forms.gle/2PYCvd39hbwijzX37"><img src="docs/media/sponsor-slot.svg" alt="Your brand — apply for a featured VoiceStudio sponsor slot" width="640" /></a>
|
||||
# 2 — synthesize with it
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
**Become a featured partner.** [Apply for a paid placement](https://forms.gle/2PYCvd39hbwijzX37) · [Email us](mailto:partner@voicestudio.sh)
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
|
||||
|
||||
Support development: [Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
|
||||
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
|
||||
with client.audio.speech.with_streaming_response.create(
|
||||
model="tts-1", voice="<profile-id>", input="Made on my own hardware.") as r:
|
||||
r.stream_to_file("speech.wav")
|
||||
|
||||
## License & responsible use
|
||||
# STT
|
||||
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
|
||||
```
|
||||
|
||||
[AGPL-3.0](LICENSE). Models have their own licenses; review them before commercial use. Clone voices only with permission. See [license details](LICENSE-NOTICE.md).
|
||||
Want the whole surface (100+ endpoints)? The full REST API reference is embedded in the app — **Settings → OpenAPI Reference** (Scalar-powered), or the `{}` button in the footer.
|
||||
|
||||
Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? It's loopback-only and unauthenticated by default; to reach it remotely you set a share PIN or an API key. [docs/api-auth.md](docs/api-auth.md) covers the exact headers, query params, `401`/`403`/`429` meanings, and the `OMNIVOICE_TRUSTED_NETWORKS` exemption.
|
||||
|
||||
### 📓 Run on Google Colab
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
No local GPU? The [official notebook](notebooks/VoiceStudio_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
|
||||
|
||||
### 🤝 Agent Skills
|
||||
|
||||
Teach your coding agent to speak and listen through your local VoiceStudio — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
|
||||
|
||||
```sh
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
Ships two [skills](https://skills.sh):
|
||||
|
||||
- **`omnivoice`** — generate speech (including your cloned voices) and transcribe audio from any agent, free and fully offline via your local install.
|
||||
- **`oss-maintainer`** — the maintainer methodology this project is run with, for anyone running their own OSS project with an agent.
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
### 🔜 Up Next
|
||||
|
||||
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
|
||||
- 🌐 **Hosted Demo** — try VoiceStudio without installing anything
|
||||
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
|
||||
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
|
||||
|
||||
<details>
|
||||
<summary><b>✅ Everything shipped so far</b> — the receipts, by category</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Category | Features |
|
||||
|----------|----------|
|
||||
| **Longform** | Audiobook editor (text/EPUB/PDF → chaptered .m4b) with multi-voice cast, expressive controls, live per-chapter progress + Stop, and a one-click sample; Stories multi-voice editor, two-pass loudnorm mastering, crash-resume for interrupted renders, pronunciation control + SSML-lite prosody |
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system, UI scale fix for Linux/WebKitGTK |
|
||||
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
|
||||
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
|
||||
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
|
||||
| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
|
||||
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="sponsor--donate"></a>
|
||||
|
||||
## 💜 Sponsor / Donate
|
||||
|
||||
One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="This month's agent-bill fund: $10 / $200" />
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_❤️-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<sub>Also from the maker: <a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a> · <a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a> — a ⭐ helps too.</sub>
|
||||
|
||||
</div>
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
### 🌟 Sponsors
|
||||
|
||||
VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
|
||||
|
||||
**Your logo here** — [become a sponsor](SPONSORS.md)
|
||||
|
||||
<!-- SPONSORS:END -->
|
||||
|
||||
</div>
|
||||
|
||||
<sub>💡 GitHub also shows a **Sponsor** button at the top of this repo, wired to the same links via <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a>.</sub>
|
||||
|
||||
---
|
||||
|
||||
## 💬 Community
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<br/>
|
||||
<sub>We respond to setup questions within hours, not days.</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>What happens in there</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| Channel | What happens there |
|
||||
|---------|--------------------|
|
||||
| `#announcements` | Release news and the big moments — new versions land here first |
|
||||
| `#releases` + `#changelog` | Every build and exactly what's inside it |
|
||||
| `#issues` | Bug reports as forum posts — triaged straight into GitHub issues |
|
||||
| `#ideas` | Feature requests, discussed and voted on |
|
||||
| `#discuss-ideas` | Design talk before things get built |
|
||||
| `#general` | Setup help, GPU troubleshooting, and showing off your dubs |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="contributing"></a>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
|
||||
|
||||
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
|
||||
- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next
|
||||
|
||||
---
|
||||
|
||||
## ❓ FAQ
|
||||
|
||||
<details>
|
||||
<summary><b>Is this really as good as ElevenLabs?</b></summary>
|
||||
<br/>
|
||||
Honest answer: <b>it depends on what you're doing.</b>
|
||||
|
||||
<b>Where VoiceStudio is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
|
||||
|
||||
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
|
||||
|
||||
<b>For dubbing specifically:</b> a dub is a chain — transcription → translation → cloning → synthesis — only as good as its weakest link on <i>your</i> source material. If parts come out incoherent, check the segment table's <i>original</i> text first: when the transcription is already wrong, switch the ASR engine or use cleaner source audio — that's usually the fix, not the voice.
|
||||
|
||||
Try it on your real material — it's free and takes one download. Many users replace ElevenLabs outright; some keep both. Both outcomes are fine with us.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
|
||||
<br/>
|
||||
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
|
||||
|
||||
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 5–15 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage.
|
||||
|
||||
<b>Want audiobook-grade, trained-on-your-voice fidelity?</b> That path exists, but it's offline fine-tuning, not an in-app button: prepare a dataset of your recordings (<a href="docs/data_preparation.md">docs/data_preparation.md</a>) and fine-tune the bundled checkpoint via <code>init_from_checkpoint</code> (<a href="docs/training.md">docs/training.md</a>). Fair warning — it's a technical, command-line workflow that needs a capable GPU and hours of transcribed audio. In-app fine-tuning / long-reference "professional" cloning is on the <a href="docs/ROADMAP.md">roadmap</a> as research only; no promised date.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
|
||||
<br/>
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How much VRAM do I need?</b></summary>
|
||||
<br/>
|
||||
<b>4 GB minimum.</b> With ≤8 GB, the TTS model is automatically offloaded to CPU during transcription. With 8+ GB, everything runs on GPU simultaneously. No GPU at all? CPU mode works — just slower (~3× for TTS).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What languages are supported?</b></summary>
|
||||
<br/>
|
||||
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Can I add my own TTS engine?</b></summary>
|
||||
<br/>
|
||||
Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</code> and add it to the <code>_REGISTRY</code> dictionary — ~50 lines. The fourteen built-in engines all work this way; see <a href="#tts-engines">TTS Engines</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does VoiceStudio collect any data about me?</b></summary>
|
||||
<br/>
|
||||
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
|
||||
|
||||
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How do I uninstall it / remove all its data?</b></summary>
|
||||
<br/>
|
||||
VoiceStudio is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="license"></a>
|
||||
|
||||
## 📜 License
|
||||
|
||||
VoiceStudio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** VoiceStudio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
|
||||
A **commercial license** is available for organizations that want to embed VoiceStudio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **VoiceStudio@palash.dev**.
|
||||
|
||||
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms, and [`LICENSE-NOTICE.md`](LICENSE-NOTICE.md) for the plain-language summary and scope.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
VoiceStudio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
|
||||
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | Optimized Transformer inference on CPU and GPU |
|
||||
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | Invisible neural audio watermarking for AI provenance |
|
||||
| [**Tauri**](https://tauri.app) | Native desktop app framework |
|
||||
| [**Supertone / Supertonic 3**](https://huggingface.co/Supertone/supertonic-3) | ONNX TTS engine — 31 languages, CPU-efficient |
|
||||
| [**Sherpa-ONNX**](https://github.com/k2-fsa/sherpa-onnx) | WASM-ready universal TTS/ASR runtime |
|
||||
| [**GPT-SoVITS**](https://github.com/RVC-Boss/GPT-SoVITS) | Zero-shot TTS engine — 5 languages, RTF 0.014 |
|
||||
|
||||
---
|
||||
|
||||
<a id="more-from-the-maker"></a>
|
||||
|
||||
## 🧰 More local open-source from the maker
|
||||
|
||||
Like the local-first philosophy? It runs in the family — same maker, same rule: **your data stays on your machine.**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/Opal"><img src="https://raw.githubusercontent.com/debpalash/Opal/main/assets/opal_logo.png" width="96" alt="Opal logo"/></a>
|
||||
<h3><a href="https://github.com/debpalash/Opal">Opal 💠</a></h3>
|
||||
<p><b>Play everything.</b> The media player for the AI era.</p>
|
||||
<p><sub>Video, anime, comics, torrents, Jellyfin & Plex — one player for all of it, with local AI memory and context built in. Written in Zig, runs on macOS & Windows.</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/Opal/stargazers"><img src="https://img.shields.io/github/stars/debpalash/Opal?style=flat-square&color=f59e0b" alt="Opal stars"/></a>
|
||||
<a href="https://palash.dev/opal"><img src="https://img.shields.io/badge/site-palash.dev%2Fopal-8b5cf6?style=flat-square" alt="Opal website"/></a>
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/memxt"><img src="https://raw.githubusercontent.com/debpalash/memxt/main/assets/logo-mark.svg" width="96" alt="memxt logo"/></a>
|
||||
<h3><a href="https://github.com/debpalash/memxt">memxt 🧠</a></h3>
|
||||
<p><b>The fastest benchmarked open-source AI memory system.</b></p>
|
||||
<p><sub>Local long-term memory for Claude Code and coding agents — an MCP server on SQLite + embeddings, 100% on your machine. Your agent finally remembers yesterday.</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/memxt/stargazers"><img src="https://img.shields.io/github/stars/debpalash/memxt?style=flat-square&color=f59e0b" alt="memxt stars"/></a>
|
||||
<a href="https://github.com/debpalash/memxt#readme"><img src="https://img.shields.io/badge/docs-README-10b981?style=flat-square" alt="memxt docs"/></a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br/>
|
||||
|
||||
If you read this far, you're our kind of person.<br/>
|
||||
**[⭐ Star this repo](https://github.com/debpalash/VoiceStudio)** so others can find it too.<br/>
|
||||
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.<br/>
|
||||
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep VoiceStudio shipping.
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+591
-50
@@ -1,81 +1,622 @@
|
||||
*本文档是 [README.md](README.md) 的简体中文翻译;若与英文版有出入,以英文版为准。*
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio" width="88" />
|
||||
<img src="docs/logo.png" alt="VoiceStudio 徽标" width="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><strong>开源声音克隆与工作流引擎。在本地构建。</strong></p>
|
||||
<p>使用本地 AI 克隆声音、翻译配音、语音听写和制作有声书。</p>
|
||||
<p><sub><em>原名 OmniVoice-Studio</em></sub></p>
|
||||
<h3>创造声音,讲述故事,文件始终属于你。♡</h3>
|
||||
<p>在一个开源桌面工作室里完成克隆、设计、配音、听写和有声书制作。<br/><b>默认本地优先。</b>没有订阅,也没有用量计费;联网服务始终由你主动选择。</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest">下载</a> ·
|
||||
<a href="#开始使用">开始使用</a> ·
|
||||
<a href="#文档">文档</a> ·
|
||||
<a href="#quickstart">快速开始</a> ·
|
||||
<a href="#features">功能</a> ·
|
||||
<a href="#why-voicestudio">为什么选择 VoiceStudio</a> ·
|
||||
<a href="#tts-engines">引擎</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">捐赠</a> ·
|
||||
<a href="#contributing">参与贡献</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
<a href="README.md">English</a>
|
||||
<a href="README.md"><strong>English</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Star 数" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="版本" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="许可证" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="下载最新版本" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||

|
||||
<br/>
|
||||
|
||||
<p align="center"><sub>新 Electron 桌面界面,使用此分支及内置演示声音录制。正式发布版本的界面可能有所不同。</sub></p>
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — 启动台" width="100%"/>
|
||||
</div>
|
||||
|
||||
## 用 VoiceStudio 创作
|
||||
> **声音很私人,创作空间也应该真正属于你。** VoiceStudio 的核心流程运行在你的硬件上:克隆、设计、配音、听写,并以 646 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
|
||||
|
||||
- **声音克隆与设计**:上传参考录音,或用文字描述你想要的声音。
|
||||
- **视频配音**:转录、翻译、分配说话人,并编辑语音时间轴。
|
||||
- **语音听写**:通过悬浮录音组件录制、转录和复制文字。
|
||||
- **长篇创作**:制作多角色脚本、有声书和批量任务。
|
||||
- **模型管理**:选择语音合成与转录引擎、语言及计算设备。
|
||||
> [!WARNING]
|
||||
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR:[提交 Issue](https://github.com/debpalash/VoiceStudio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
|
||||
|
||||
本地工作流在你的硬件上运行。远程服务为可选功能;使用情况分析须经同意才会启用。
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ 功能
|
||||
|
||||
八大主打功能——折叠区里还有十二项等你展开。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/media/electron/voice-cloning.png" alt="Electron 声音克隆工作区与内置演示声音" width="100%" /></td>
|
||||
<td><img src="docs/media/electron/dubbing.png" alt="Electron 视频配音工作区" width="100%" /></td>
|
||||
</tr>
|
||||
<tr><td align="center">声音克隆</td><td align="center">视频配音</td></tr>
|
||||
<tr>
|
||||
<td><img src="docs/media/electron/voice-design.png" alt="Electron 声音设计工作区" width="100%" /></td>
|
||||
<td><img src="docs/media/electron/models.png" alt="本地语音模型管理" width="100%" /></td>
|
||||
</tr>
|
||||
<tr><td align="center">声音设计</td><td align="center">本地模型</td></tr>
|
||||
<tr>
|
||||
<td align="center" width="25%">
|
||||
<h3>🎙️ 语音克隆</h3>
|
||||
<p>3 秒音频 → 复刻任何声音。<br/><b>646 种语言</b>,零样本。</p>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<h3>🎨 声音设计</h3>
|
||||
<p>性别、年龄、口音、音高、语速、<br/>情感、方言——<b>随心调节</b>。</p>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<h3>🎬 视频配音</h3>
|
||||
<p>YouTube 链接或文件 → 转录 →<br/>翻译 → 重新配音 → <b>MP4</b>。</p>
|
||||
</td>
|
||||
<td align="center" width="25%">
|
||||
<h3>📖 有声书编辑器</h3>
|
||||
<p>导入文本、EPUB 或 PDF。自动分章、<br/>响度归一、元数据。导出 <b>.m4b</b>。</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<h3>🎭 故事模式</h3>
|
||||
<p>多声音编辑器。逐行分配声音、<br/>预览、<b>导出完整配音阵容</b>。</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>⌨️ 听写工具</h3>
|
||||
<p>在<b>任何应用</b>中按 <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd>。<br/>转录、自动粘贴、随即消失。</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🔐 本地优先</h3>
|
||||
<p>核心创作流程<br/><b>留在你的设备上</b>。</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🤖 MCP 服务器</h3>
|
||||
<p>从 <b>Claude</b>、Cursor 或<br/>任何 MCP 客户端使用 VoiceStudio。</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 开始使用
|
||||
<details>
|
||||
<summary><b>……还有 12 项</b>——人声分离、说话人分离、批量处理、水印、诊断等等</summary>
|
||||
|
||||
从 [Releases](https://github.com/debpalash/VoiceStudio/releases/latest) 下载,然后阅读对应平台的安装指南:
|
||||
<br/>
|
||||
|
||||
**[macOS](docs/install/macos.md) · [Windows](docs/install/windows.md) · [Linux](docs/install/linux.md) · [Docker](docs/install/docker.md)**
|
||||
- 🔊 **人声分离** — 基于 Demucs:把语音从音乐中分离出来,同时保留背景音床。
|
||||
- 👥 **说话人分离** — Pyannote + WhisperX 自动识别谁说了什么。
|
||||
- 📦 **批量队列** — 拖入 50 个视频就可以走开;每个任务都有独立进度条。
|
||||
- 🛡️ **AI 水印** — AudioSeal(Meta):不可见,且能在压缩后留存。
|
||||
- 🔬 **诊断** — 自检套件、错误日志、脱敏诊断包。
|
||||
- ⚡ **GPU 自动检测** — CUDA · MPS · ROCm(Linux,需手动开启)· CPU;显存 ≤8 GB 时自动卸载。
|
||||
- 🧭 **引擎路由** — 逐引擎 GPU 预检;绝不静默回退到 CPU。
|
||||
- 🧩 **可扩展** — 继承 `TTSBackend`,约 50 行代码即可接入任意引擎。
|
||||
- 🎒 **便携声音角色** — 将声音导出为 `.ovsvoice` 包:身份 + 水印。
|
||||
- ♾️ **无限长 TTS** — 按句分块生成,没有长度上限,可经 WebSocket 流式输出。
|
||||
- 🌐 **远程后端** — 让 UI 指向远程服务器;对 Tailscale 友好,支持 Bearer 认证。
|
||||
- 🧠 **听写 + LLM** — 用本地 LLM 润色转录文本,可选回声消除。
|
||||
|
||||
打开声音克隆页面,选择已有声音或添加清晰的参考录音,输入文字并生成。按提示安装所需模型。硬件要求因引擎而异,详见[性能指南](docs/performance.md)。
|
||||
</details>
|
||||
|
||||
**从源码运行 Electron 预览版:**
|
||||
---
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
bun install
|
||||
cd electron
|
||||
bun run dev
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ 快速开始
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b>首次启动需要一次性批准——右键点击 → <b>打开</b>(macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac:</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
|
||||
</div>
|
||||
|
||||
选择你的操作系统,按指南从头到尾操作:
|
||||
|
||||
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
|
||||
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。
|
||||
|
||||
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
|
||||
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
|
||||
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
|
||||
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
|
||||
|
||||
Hugging Face Token 的配置见
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
|
||||
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
|
||||
[docs/downloading-models.md](docs/downloading-models.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## 💡 为什么选择 VoiceStudio?
|
||||
|
||||
云端语音工具很方便,但工作流会依赖账号、用量计费和他人的基础设施。VoiceStudio 在你的硬件上提供完整工作室;只有你主动选择时,才会使用联网集成。
|
||||
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
|---|---|---|
|
||||
| **价格** | 订阅与用量限制 | 免费且开源(AGPL-3.0)· 专有用途可选 [商业许可证](#license) |
|
||||
| **语音克隆** | ✅ 3 秒音频 | ✅ 3 秒音频,零样本 |
|
||||
| **声音设计** | ✅ 性别、年龄 | ✅ 性别、年龄、口音、音高、风格、方言 |
|
||||
| **有声书 / 故事** | ❌ | ✅ 完整有声书编辑器 + 多声音故事(EPUB/PDF 导入,.m4b 导出) |
|
||||
| **语言** | 取决于套餐和模型 | **646** |
|
||||
| **视频配音** | ✅ 仅云端 | ✅ 完全本地 |
|
||||
| **数据隐私** | 音频在远端处理 | 核心流程在本地运行;联网服务必须主动选择 |
|
||||
| **API 密钥** | 需要账号 | 本地流程不需要 |
|
||||
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCm(Linux)· CPU |
|
||||
| **桌面应用** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS 引擎** | 1 | **14** — [完整矩阵](#tts-engines) |
|
||||
| **ASR 引擎** | 1 | **10** — [完整阵容](#asr-engines) |
|
||||
| **MCP 服务器** | ❌ | ✅ 可从 Claude、Cursor 及任何 MCP 客户端使用 |
|
||||
| **自检** | ❌ | ✅ 诊断套件、错误日志、脱敏调试包 |
|
||||
| **可定制** | ❌ 闭源 | ✅ 随你 Fork、扩展、发布 |
|
||||
|
||||
专业级语音 AI,去掉订阅,也去掉云端。
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<b>心动了?来和我们一起构建吧。</b><br/>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord" /></a>
|
||||
<br/><br/>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 系统要求
|
||||
|
||||
| | **最低配置** | **推荐配置** |
|
||||
|---|---|---|
|
||||
| **操作系统** | Windows 10、macOS 12+(Apple Silicon)、Ubuntu 24.04+(glibc 2.39+) | 任意现代 64 位操作系统 |
|
||||
| **内存** | 8 GB | 16 GB+ |
|
||||
| **显存(GPU)** | 4 GB(自动将 TTS 卸载到 CPU) | 8 GB+(NVIDIA RTX 3060+) |
|
||||
| **硬盘** | 10 GB 可用空间(模型 + 缓存) | 20 GB+ SSD |
|
||||
| **Python** | 3.10+(由 `uv` 管理) | 3.11–3.12 |
|
||||
| **GPU** | 可选——CPU 也能跑 | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm(仅 Linux) |
|
||||
|
||||
> [!TIP]
|
||||
> 对于显存 **≤8 GB** 的 GPU,VoiceStudio 会在转录期间自动将 TTS 卸载到 CPU——无需配置。不需要专用 GPU;整条流水线都可以在 CPU 上运行(只是慢一些)。
|
||||
|
||||
> [!NOTE]
|
||||
> **AMD GPU:** ROCm 加速**仅限 Linux 且需手动开启**——在首次运行的设置界面选择 **“AMD GPU (ROCm)”**,或设置 `OMNIVOICE_TORCH_VARIANT=rocm`([docs/install/linux.md](docs/install/linux.md#amd-gpu-rocm))。在 **Docker/Podman** 中请改用专门的 ROCm 镜像:`ghcr.io/debpalash/omnivoice-studio:rocm`([docs/install/docker.md](docs/install/docker.md#pull-and-run-amd-gpu--rocm))。**在 Windows 上,AMD GPU(含 Ryzen AI 核显)只能以 CPU 运行**:PyTorch 没有 Windows 版 ROCm 轮子,因此 Windows 上的 GPU 加速仅限 NVIDIA/CUDA([docs/install/windows.md](docs/install/windows.md#gpu-support))。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **macOS Intel(x86_64)不支持本地后端:** 应用 UI 可以安装,但 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子([#889](https://github.com/debpalash/VoiceStudio/issues/889))。Intel Mac 用户仍可让 UI 指向另一台机器上的远程后端——参见 [docs/install/macos.md](docs/install/macos.md)。
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### 🗣️ TTS 引擎
|
||||
|
||||
**14 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加六个按需延迟安装的重量级引擎(IndexTTS 2、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整矩阵</b>——14 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 引擎 | 语言 | 克隆 | 指令 | Linux | macOS ARM | Windows | 许可证 |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
| **VoiceStudio**(默认,由 k2-fsa/OmniVoice 驱动) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | 内置 |
|
||||
| **CosyVoice 3** | 9 + 18 种方言 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | ✅ | — | ✅ CUDA/CPU | — | ✅ CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | 英语 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
|
||||
| **MLX-Audio**(Kokoro、Qwen3-TTS、CSM、Dia 等) | 多语言 | 因模型而异 | 因模型而异 | ❌ | ✅ 原生 | ❌ | 因模型而异 |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2** ⚡ | 多语言 | ✅ | — | ✅ CUDA | — | ✅ CUDA | Apache-2.0 |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | 内置 |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡(8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡(2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|
||||
> **CUDA** = GPU 加速 · **MPS** = Apple Silicon Metal · **CPU** = 随处可运行,大模型较慢 · KittenTTS 和 MOSS-TTS-Nano 可在 CPU 上实时运行 · MLX-Audio 仅限 Apple Silicon · ⚡ = 延迟注册(首次使用时安装)
|
||||
>
|
||||
> **克隆**能力的意义不止于单段生成:视频配音(以及任何固定了声音的批量任务)需要参考音频克隆来保持说话人身份,因此把不支持克隆的引擎(KittenTTS、Sherpa-ONNX、Supertonic 3)设为当前引擎时,这些任务会在开始前就给出可操作的失败提示,而不是静默回退到 VoiceStudio。
|
||||
>
|
||||
> **MOSS-TTS-v1.5**(8B,约 16 GB)、**dots.tts**(2B,约 9 GB)和 **Confucius4-TTS** 是重量级可选引擎,从本地克隆在各自独立的 venv 中运行。三者均不支持 Apple Silicon MPS(在 Mac 上以 CPU 运行);dots.tts 没有 Windows 路径;Confucius4 建议使用 CUDA(CPU 可用,约为实时时长的 17 倍)。详情:[MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md)。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="asr-engines"></a>
|
||||
|
||||
### 🎧 ASR 引擎
|
||||
|
||||
**10 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。九个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整阵容</b>——10 个引擎、各自的强项与计算类型说明</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 引擎 | `OMNIVOICE_ASR_BACKEND` | 语言 | 最适合 |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
| **WhisperX**(默认) | `whisperx` | ~100 | 配音与字幕——通过 wav2vec2 强制对齐实现词级时间对齐 |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | Linux / macOS / Windows 上的快速转录(CTranslate2) |
|
||||
| **Faster-Whisper(隔离)** | `faster-whisper-isolated` | ~100 | 与 Faster-Whisper 相同,但在子进程中崩溃隔离——ASR 崩溃不会拖垮整个应用 |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Apple Silicon 原生速度(Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | 经 🤗 Transformers 的 CUDA / CPU 兜底方案(无需 cuDNN 8) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | 英语 + 25 种欧洲语言 | 即使在 CPU 上也能以约 10 倍实时速度达到 SOTA 精度,自动语言检测(NVIDIA NeMo,CUDA/CPU) |
|
||||
| **Moonshine** | `moonshine` | 英语 | 边缘设备 / 低延迟,ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | 多语言一体化——内置 VAD + 行内说话人分离(SenseVoice) |
|
||||
| **sherpa-onnx**(实时听写) | `sherpa-onnx-asr` | 25 种欧洲语言 + 90+ | 实时、快于实时的听写——小体积流式/离线 ONNX 模型(Parakeet TDT v3/v2、流式 Zipformer 与 Paraformer、Whisper Tiny),CPU 运行,macOS / Windows / Linux 表现完全一致。在 **设置 → 语音** 中按模型选择。 |
|
||||
| **OpenAI 兼容** ⚠️ 远程 | `openai-compat-asr` | 取决于服务器 | 当下通往 **Qwen3-ASR** 的路径(自托管服务器,无需等 transformers 支持)、任何 OpenAI 兼容的转录端点,或 OpenAI 官方 API——无需安装,在 **设置 → 引擎**(ASR 标签页)中配置并测试连接。音频会离开你的设备,发送到你指定的任何服务器;参见 [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md)。 |
|
||||
|
||||
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。每个引擎都在本地设备上运行——无需 API 密钥,无需云端。
|
||||
|
||||
> **GPU 不支持高效 float16?** 在较老的 NVIDIA GPU(Maxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`,VoiceStudio 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`(CPU 用 `float32`)。将其设为 `int8` 并重启后端。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ DubTab · VoiceConsole · Stories · Audiobook · Gallery │
|
||||
│ Dictation · BatchQueue · Diagnostics · MCP Client │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Backend (FastAPI) │
|
||||
│ 100+ API endpoints · SSE+WSS streaming · SQLite │
|
||||
├──────────┬──────────┬──────────┬──────────┬────────────────┤
|
||||
│ WhisperX │ Demucs │VoiceStudio │ Pyannote │ Engine Routing │
|
||||
│ (+7 ASR │ Source │ (+10 │ Diariz- │ ↳ GPU preflight │
|
||||
│ engines) │ Sep. │ TTS) │ ation │ ↳ No silent CPU │
|
||||
└──────────┴──────────┴──────────┴──────────┴────────────────┘
|
||||
CUDA / MPS / ROCm / CPU (auto-detected + routed)
|
||||
```
|
||||
|
||||
环境要求和后端配置见 [Electron 开发指南](electron/README.md)。项目仍在积极开发中,可通过 [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) 反馈问题。
|
||||
<a id="openai-api"></a>
|
||||
|
||||
## 文档
|
||||
## 🔌 OpenAI 兼容 API
|
||||
|
||||
| 需求 | 链接 |
|
||||
已经有会说 OpenAI 音频 API 的脚本、智能体或工具?把它指向 `http://localhost:3900/v1` 即可——不需要密钥,也不用改代码。后端为音频端点内置了即插即用的兼容接口,直接接到你当前启用的 TTS/ASR 引擎(没错,`voice` 参数接受你克隆的声音配置 ID)。
|
||||
|
||||
| 端点 | 作用 |
|
||||
|---|---|
|
||||
| 安装帮助 | [故障排查](docs/install/troubleshooting.md) · [模型下载](docs/downloading-models.md) |
|
||||
| 模型与音质 | [引擎指南](docs/engines/README.md) · [基准测试](docs/benchmarks.md) |
|
||||
| 集成 | [本地 API](docs/speech-platform.md) · [MCP](docs/mcp.md) · [示例](examples/README.md) |
|
||||
| 参与开发 | [贡献指南](.github/CONTRIBUTING.md) · [Electron](electron/README.md) · [更新日志](CHANGELOG.md) |
|
||||
| `POST /v1/audio/speech` | TTS——输入文本;输出 `mp3` / `wav` / `flac` / `opus` / `pcm`。`tts-1` / `tts-1-hd` 映射到你当前启用的引擎;也接受 OpenAI 的声音名称(`alloy` 等)。 |
|
||||
| `POST /v1/audio/transcriptions` | STT——输入音频文件;输出 `json`、`text`、`verbose_json`、`srt` 或 `vtt`。`whisper-1` 映射到你当前启用的 ASR 引擎。 |
|
||||
| `GET /v1/audio/voices` | VoiceStudio 扩展——列出所有声音配置和引擎,客户端可据此发现你的克隆声音。 |
|
||||
|
||||
安装智能体技能:`npx skills add debpalash/VoiceStudio`
|
||||
```sh
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model": "tts-1", "voice": "alloy", "input": "Generated on my own hardware.", "response_format": "wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
|
||||
## 支持 VoiceStudio
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string works — nothing checks it
|
||||
|
||||
[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [赞助项目](SPONSORS.md) · [商务合作](mailto:partner@voicestudio.sh)
|
||||
result = client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb"))
|
||||
print(result.text)
|
||||
```
|
||||
|
||||
**让语音应用开发者看到你的品牌。** 了解应用底部栏、集成目录、文档和 README 的付费展示合作。[申请合作](https://forms.gle/2PYCvd39hbwijzX37)或[发送邮件](mailto:partner@voicestudio.sh)。
|
||||
想要完整的接口(100+ 端点)?完整的 REST API 参考已内嵌在应用中——**设置 → OpenAPI 参考**(由 Scalar 驱动),或点击页脚的 `{}` 按钮。
|
||||
|
||||
## 许可与负责任使用
|
||||
### 📓 在 Google Colab 上运行
|
||||
|
||||
应用采用 [AGPL-3.0](LICENSE) 许可。模型遵循各自的许可,商用前请确认其条款。克隆声音前须取得本人许可。详见[许可说明](LICENSE-NOTICE.md)。
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
没有本地 GPU?官方笔记本([notebooks/VoiceStudio_Studio_Colab.ipynb](notebooks/VoiceStudio_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
|
||||
|
||||
### 🤝 智能体技能(Agent Skills)
|
||||
|
||||
用一条命令教会你的 AI 智能体(Claude Code、Cursor、Codex 等)使用 VoiceStudio:
|
||||
|
||||
```sh
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
内含两个 [skills](https://skills.sh):**`omnivoice`**——让任何智能体通过你的本地安装进行语音合成与转录(包括你克隆的声音),免费且离线;以及 **`oss-maintainer`**——本项目所遵循的维护者方法论,适合任何用智能体运营自己开源项目的人。
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ 路线图
|
||||
|
||||
### 🔜 即将推出
|
||||
|
||||
- 🎬 **唇形同步 v2** — 使用 wav2lip 进行视觉语音时间对齐
|
||||
- 🌐 **在线演示** — 无需安装即可体验 VoiceStudio
|
||||
- 🔌 **插件市场** — 社区贡献的 TTS 引擎与特效
|
||||
- 🎵 **实时变声器** — 通话中的麦克风实时变声
|
||||
|
||||
<details>
|
||||
<summary><b>✅ 已经发布的一切</b>——按类别列出的“成绩单”</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 分类 | 功能 |
|
||||
|----------|----------|
|
||||
| **长内容** | 有声书编辑器(文本/EPUB/PDF → 分章 .m4b)、Stories 多声音编辑器、两遍响度归一母带处理、渲染中断后的崩溃续渲、发音控制 + SSML-lite 韵律 |
|
||||
| **配音** | 完整流水线(转录→翻译→合成→封装)、场景感知分割、唇形同步评分、流式 TTS、逐说话人声音分配、Smart Fit 时长匹配 + 二次 QC、独立的配音主页 |
|
||||
| **声音** | 零样本克隆、声音设计、A/B 对比、声音预览控件、支持收藏/标签的声音库、便携声音角色包(`.ovsvoice`)、声音控制台工作区 |
|
||||
| **音频** | Demucs 人声分离、逐段增益、选择性音轨导出、分轨/SRT/VTT/MP3 导出、按句分块实现的无限长 TTS |
|
||||
| **多语言** | 多语言批量选择器、顺序 GPU 执行的批量配音队列 |
|
||||
| **说话人分离** | Pyannote 机器学习分离、自动说话人克隆提取、逐说话人声音分配 |
|
||||
| **ASR** | 9 个引擎(WhisperX、Faster-Whisper、隔离版 Faster-Whisper、MLX Whisper、PyTorch Whisper、Parakeet TDT、Moonshine、FunASR/SenseVoice、sherpa-onnx 实时听写)、崩溃隔离的子进程后端 |
|
||||
| **TTS** | 14 个引擎(VoiceStudio、CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX,+ 延迟安装:IndexTTS 2、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)、带 GPU 预检的引擎路由 |
|
||||
| **基础设施** | Docker 部署、CUDA/MPS/ROCm 自动检测、cuDNN 8 兼容、显存感知模型卸载、引擎路由(绝不静默回退 CPU)、诊断套件与错误日志、受限网络镜像支持 |
|
||||
| **AI 溯源** | AudioSeal 不可见水印(类似 SynthID)、视频徽标叠加、水印检测 API |
|
||||
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、毛玻璃设计系统、Linux/WebKitGTK 的 UI 缩放修复 |
|
||||
| **实时事件** | WebSocket 事件总线——数据变更时即时刷新侧边栏、指数退避重连 |
|
||||
| **状态管理** | Zustand 状态迁移——`uiSlice`、`pillSlice`、`dubSlice`、`generateSlice`、`prefsSlice`、`glossarySlice` |
|
||||
| **桌面** | 跨平台 Tauri 安装程序(macOS DMG——Apple Silicon;Intel 不支持本地后端,#889——Windows MSI、Linux deb/AppImage)、自动更新基础设施、单实例约束、关闭最小化到托盘、macOS Gatekeeper 修复 |
|
||||
| **听写** | 全局系统级热键(`⌘+⇧+Space`)、无边框浮动控件、WebSocket 流式 ASR、自动粘贴、可自定义热键、本地 LLM 转录润色 |
|
||||
| **批量流水线** | 完整批量 TTS:提取 → 转录 → 翻译 → 生成 → 混音 → 导出,带实时进度追踪 |
|
||||
| **MCP 服务器** | 让 VoiceStudio 成为 Claude、Cursor 及任何 MCP 客户端的本地 TTS/STT 提供方 |
|
||||
| **远程后端** | 让桌面 UI 指向远程后端 URL,支持 Bearer 认证(附 Tailscale 文档) |
|
||||
| **可靠性** | 启动开屏的卡死看门狗、逐引擎 GPU 兼容矩阵、引擎二进制不可执行时的可操作报错、setuptools 自动修复 |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="sponsor--donate"></a>
|
||||
|
||||
## 💜 赞助 / 捐赠
|
||||
|
||||
VoiceStudio 由一位开发者使用 Claude Code 和 AI 智能体独立打造——而智能体账单是实打实的(过去三个月花了数千美元)。如果 VoiceStudio 为你创造了价值,帮忙分担一小部分账单,就能让开发保持全职推进。
|
||||
|
||||
<div align="center">
|
||||
|
||||
**本月智能体账单基金**
|
||||
|
||||
<img src="https://img.shields.io/badge/raised_%2410_of_%24200-5%25-EAB308?style=for-the-badge" alt="已筹 $10 / $200" />
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_❤️-FF5E5B?style=for-the-badge&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
|
||||
<br/>
|
||||
<sub>每一美元都直接用于支付智能体账单——让 VoiceStudio 的开发持续不断。</sub>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<sub><b>来自 VoiceStudio 作者的更多应用</b>——同样的本地优先理念:
|
||||
<a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a>(播放一切——AI 时代的媒体播放器)·
|
||||
<a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a>(Claude Code 与编码智能体的本地记忆)。
|
||||
给它们点个 ⭐ 也是一种支持 → <a href="#more-from-the-maker">详见下文</a>。</sub>
|
||||
|
||||
</div>
|
||||
|
||||
<a id="sponsors"></a>
|
||||
|
||||
### 🌟 赞助商
|
||||
|
||||
VoiceStudio **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有 SaaS 收入。赞助商让开发得以持续,作为回报,可以在这里、在应用内(顶级档位还包括项目官网)获得一个徽标位。这是一份感谢,绝不是付费墙。**[查看档位并成为赞助商 →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
<!-- SPONSORS:START — logo slots are filled here as sponsors come aboard; see SPONSORS.md -->
|
||||
|
||||
**这里可以是你的徽标** — [成为赞助商](SPONSORS.md)
|
||||
|
||||
<!-- SPONSORS:END -->
|
||||
|
||||
</div>
|
||||
|
||||
<sub>💡 GitHub 也会在本仓库顶部显示一个 **Sponsor** 按钮,经由 <a href=".github/FUNDING.yml"><code>.github/FUNDING.yml</code></a> 指向相同的链接。</sub>
|
||||
|
||||
---
|
||||
|
||||
## 💬 社区
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="加入 Discord" /></a>
|
||||
<br/>
|
||||
<sub>设置类问题我们几小时内就会回复,而不是几天。</sub>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><b>里面都在聊什么</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
| 频道 | 那里发生什么 |
|
||||
|---------|--------------------|
|
||||
| `#announcements` | 发布消息与重大时刻——新版本最先在这里公布 |
|
||||
| `#releases` + `#changelog` | 每一个构建,以及里面究竟有什么 |
|
||||
| `#issues` | 以论坛帖子形式提交的 Bug 报告——直接分诊进 GitHub Issues |
|
||||
| `#ideas` | 功能请求,供讨论与投票 |
|
||||
| `#discuss-ideas` | 动手之前的设计讨论 |
|
||||
| `#general` | 安装帮助、GPU 疑难排查,以及晒你的配音成果 |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="contributing"></a>
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
非常欢迎——Bug 修复、新的 TTS 引擎适配器、UI 改进、文档、翻译。统统欢迎。
|
||||
|
||||
- 📖 阅读 **[贡献指南](.github/CONTRIBUTING.md)** 了解环境搭建、代码风格和 PR 工作流
|
||||
- 🐛 浏览 [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- 💬 加入我们的 [Discord](https://discord.gg/bzQavDfVV9) 讨论想法或寻求帮助
|
||||
|
||||
---
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
<details>
|
||||
<summary><b>真的能和 ElevenLabs 一样好吗?</b></summary>
|
||||
<br/>
|
||||
诚实的回答:<b>取决于你要做什么。</b>
|
||||
|
||||
<b>VoiceStudio 真正有竞争力的地方:</b>从干净的参考音频进行语音克隆(最先进的开源扩散 TTS)、语言覆盖(646 种语言对他们的 32 种),以及所有结构性优势——没有按字符计费、没有用量上限、音频不离开你的设备、完整的流水线可定制性(14 个 TTS 引擎、10 个 ASR 引擎、翻译方案随你选)。
|
||||
|
||||
<b>ElevenLabs 仍然领先的地方:</b>开箱即用的稳定性与打磨程度,尤其是英语 TTS。他们的单一模型经过深度调优;我们的质量取决于你选择的引擎、你的硬件,以及(对克隆而言)参考音频——干燥、近麦的音频比嘈杂或有回声的音频克隆效果好得多。
|
||||
|
||||
<b>具体到配音:</b>配音是一条链——转录 → 翻译 → 克隆 → 合成——在<i>你的</i>素材上,它只取决于最薄弱的一环。如果部分输出语无伦次,先检查片段表里的<i>原文</i>:当转录本身就错了,换一个 ASR 引擎或使用更干净的源音频——修复点通常在这里,而不是声音。
|
||||
|
||||
拿你的真实素材试试——免费,下载一次即可。许多用户直接用它替换了 ElevenLabs;也有人两个都留着。这两种结果我们都乐见。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>能在 Apple Silicon(M1/M2/M3/M4)上运行吗?</b></summary>
|
||||
<br/>
|
||||
可以。MPS 加速会被自动检测。在 Apple 硬件上,MLX 优化的 Whisper 模型可提供更快的转录速度。<b>不支持 Intel Mac</b>:应用 UI 可以安装,但本地 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——Intel Mac 只能配合远程后端使用。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>需要多少显存?</b></summary>
|
||||
<br/>
|
||||
<b>最低 4 GB。</b> 显存 ≤8 GB 时,TTS 模型会在转录期间自动卸载到 CPU。8 GB 以上时,所有组件同时在 GPU 上运行。完全没有 GPU?CPU 模式也能用——只是慢一些(TTS 约慢 3 倍)。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>可以用于商业用途吗?</b></summary>
|
||||
<br/>
|
||||
<b>可以——商业使用免费</b>,基于 <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>:运行它、出售用它生成的音频、为客户的视频配音、在团队中部署。只有一项义务:如果你<b>修改</b>了 VoiceStudio 并通过网络向他人提供该修改版本,你必须依据相同条款分享修改后的源代码。想把它嵌入闭源产品?可获取商业许可证——参见<a href="#license">许可证</a>。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>支持哪些语言?</b></summary>
|
||||
<br/>
|
||||
通过 VoiceStudio 模型的 TTS 支持 646 种语言。转录(WhisperX)支持 99 种语言。翻译覆盖范围取决于目标语言对。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>可以添加自己的 TTS 引擎吗?</b></summary>
|
||||
<br/>
|
||||
可以。在 <code>backend/services/tts_backend.py</code> 中继承 <code>TTSBackend</code>,并将其添加到 <code>_REGISTRY</code> 字典中——约 50 行代码。十四个内置引擎均以此方式实现;参见 <a href="#tts-engines">TTS 引擎</a>。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>VoiceStudio 会收集我的任何数据吗?</b></summary>
|
||||
<br/>
|
||||
<b>除非你明确同意,否则不会。</b>首次运行时应用会<i>询问</i>你——一个页面、两个同等分量的按钮,没有预先勾选。在你回答“是”之前,VoiceStudio 什么都不发送:没有分析、没有遥测、没有账号、没有“回传”。跳过提问就等于“否”。无论如何,你的文本、音频、声音和项目永远不会离开你的设备。
|
||||
|
||||
如果你选择同意(也可随时在 <b>设置 → 隐私 → “帮助改进 VoiceStudio”</b> 中开关),发送的只是匿名、不含内容的使用统计:生成信息(引擎、语言、生成耗时、字符<i>数量</i>、错误<i>类型</i>),以及应用生命周期——一次安装信号、版本更新(版本号之间)、崩溃(错误类别和<i>分桶后的</i>运行时长,绝不含日志)、错误<i>类型</i>(有上限、去重),以及卸载时的一次告别信号。绝不包含你的文本、音频、文件名或任何可识别信息——这由代码中的属性白名单强制保证(<code>backend/core/analytics.py</code>),而不只是一句承诺。源码构建根本没有分析数据的接收端,因此根本不会询问。你自己的统计数字在 <b>设置 → 用量</b> 中查看,本地计算,不发送到任何地方。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>如何卸载它 / 删除它的所有数据?</b></summary>
|
||||
<br/>
|
||||
VoiceStudio 完全本地运行——卸载就是删除应用及其写入的文件夹(模型缓存、Python 环境、你的声音/项目、配置)。运行 <code>scripts/uninstall.sh</code>(macOS/Linux)或 <code>scripts\uninstall.ps1</code>(Windows)——它会先以干跑方式列出每个文件夹及其大小,加 <code>--yes</code> 才会真正删除。完整的各平台路径列表和应用移除步骤见 <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>。
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="license"></a>
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
VoiceStudio 是基于 [**GNU Affero 通用公共许可证 v3.0(AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html) 的自由开源软件。
|
||||
|
||||
**可免费用于任何用途——包括商业和企业内部用途。** 运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中推广——全部免费,无需许可证。作为一份**网络著佐权(copyleft)**许可证,AGPL 增加了一项义务:如果你**修改**了 VoiceStudio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL-3.0 条款向他们提供该修改版本的完整对应源代码。
|
||||
|
||||
希望将 VoiceStudio 嵌入**闭源或专有**产品或服务、又不受 AGPL-3.0 著佐权义务约束的组织,可获取**商业许可证**。**定价方案即将推出。** 咨询:**VoiceStudio@palash.dev**。
|
||||
|
||||
捆绑的 `omnivoice/` TTS 模型(作者 Han Zhu)在上游仍为 Apache-2.0 许可。完整且具约束力的条款请参见 [`LICENSE`](LICENSE)。
|
||||
|
||||
---
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
VoiceStudio 站在这些杰出开源工作的肩膀上:
|
||||
|
||||
| 项目 | 作用 |
|
||||
|---------|------|
|
||||
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | 零样本扩散 TTS 引擎——核心语音合成模型 |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | 词级别语音识别与时间对齐 |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | 音乐源分离,用于人声分离 |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | 说话人分离——谁说了什么 |
|
||||
| [**CTranslate2**](https://github.com/OpenNMT/CTranslate2) | CPU 和 GPU 上的优化 Transformer 推理 |
|
||||
| [**AudioSeal (Meta)**](https://github.com/facebookresearch/audioseal) | 用于 AI 溯源的不可见神经音频水印 |
|
||||
| [**Tauri**](https://tauri.app) | 原生桌面应用框架 |
|
||||
| [**Supertone / Supertonic 3**](https://huggingface.co/Supertone/supertonic-3) | ONNX TTS 引擎——31 种语言,CPU 高效 |
|
||||
| [**Sherpa-ONNX**](https://github.com/k2-fsa/sherpa-onnx) | 支持 WASM 的通用 TTS/ASR 运行时 |
|
||||
| [**GPT-SoVITS**](https://github.com/RVC-Boss/GPT-SoVITS) | 零样本 TTS 引擎——5 种语言,RTF 0.014 |
|
||||
|
||||
---
|
||||
|
||||
<a id="more-from-the-maker"></a>
|
||||
|
||||
## 🧰 来自同一作者的更多本地开源项目
|
||||
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。**
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/Opal"><img src="https://raw.githubusercontent.com/debpalash/Opal/main/assets/opal_logo.png" width="96" alt="Opal 徽标"/></a>
|
||||
<h3><a href="https://github.com/debpalash/Opal">Opal 💠</a></h3>
|
||||
<p><b>播放一切。</b>AI 时代的媒体播放器。</p>
|
||||
<p><sub>视频、动漫、漫画、种子、Jellyfin 和 Plex——一个播放器全部搞定,并内置本地 AI 记忆与上下文。使用 Zig 编写,支持 macOS 和 Windows。</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/Opal/stargazers"><img src="https://img.shields.io/github/stars/debpalash/Opal?style=flat-square&color=f59e0b" alt="Opal Star 数"/></a>
|
||||
<a href="https://palash.dev/opal"><img src="https://img.shields.io/badge/site-palash.dev%2Fopal-8b5cf6?style=flat-square" alt="Opal 官网"/></a>
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" width="50%" valign="top">
|
||||
<br/>
|
||||
<a href="https://github.com/debpalash/memxt"><img src="https://raw.githubusercontent.com/debpalash/memxt/main/assets/logo-mark.svg" width="96" alt="memxt 徽标"/></a>
|
||||
<h3><a href="https://github.com/debpalash/memxt">memxt 🧠</a></h3>
|
||||
<p><b>经基准测试验证的最快开源 AI 记忆系统。</b></p>
|
||||
<p><sub>为 Claude Code 和编码智能体提供本地长期记忆——基于 SQLite + 嵌入向量的 MCP 服务器,100% 在你的设备上运行。你的智能体终于能记住昨天了。</sub></p>
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/memxt/stargazers"><img src="https://img.shields.io/github/stars/debpalash/memxt?style=flat-square&color=f59e0b" alt="memxt Star 数"/></a>
|
||||
<a href="https://github.com/debpalash/memxt#readme"><img src="https://img.shields.io/badge/docs-README-10b981?style=flat-square" alt="memxt 文档"/></a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br/>
|
||||
|
||||
如果你读到了这里,你就是我们的同路人。<br/>
|
||||
**[⭐ 给这个仓库点个 Star](https://github.com/debpalash/VoiceStudio)**,让更多人能找到它。<br/>
|
||||
**[💬 加入 Discord](https://discord.gg/bzQavDfVV9)**,分享你的作品。<br/>
|
||||
**[❤️ 支持开发](https://ko-fi.com/debpalash)**——资助让 VoiceStudio 持续发布的 AI 智能体账单。
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
|
||||
<img alt="Star 历史" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="96" height="96" />
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="96" />
|
||||
<h1>Sponsor VoiceStudio</h1>
|
||||
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
|
||||
</div>
|
||||
|
||||
@@ -41,16 +41,6 @@ hiddenimports = [
|
||||
# even though pyproject.toml ships the package. Guarded by
|
||||
# tests/test_socks_proxy.py.
|
||||
'socksio',
|
||||
# Remote GPU workers (backend/worker/). The feature is opt-in, so every
|
||||
# import of it is deliberately deferred to the moment it is switched on —
|
||||
# inside `lifespan` and inside `ControlPlane.start()`. That keeps the cost
|
||||
# off users who never enable it, but it also means a frozen build has no
|
||||
# static import chain to follow, so the modules must be named here or the
|
||||
# feature raises ModuleNotFoundError only in the installers.
|
||||
'grpc', 'grpc.aio',
|
||||
'worker.service', 'worker.agent',
|
||||
'worker.transport.server', 'worker.transport.client',
|
||||
'worker.protocol.gen.worker_v1_pb2', 'worker.protocol.gen.worker_v1_pb2_grpc',
|
||||
|
||||
# Core
|
||||
'uuid', 'asyncio',
|
||||
|
||||
+119
-121
@@ -6,10 +6,7 @@ composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(read-only bootstrap is allowed in explicit server mode; mutations still
|
||||
require the admin API key — see `_server_mode`).
|
||||
- `require_admin`: method-aware admin gate for privileged routers.
|
||||
- `require_admin_action`: strict admin gate for side-effectful GET actions.
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
- `require_native_access`: true-loopback-only access to the host filesystem;
|
||||
unlike `require_loopback`, it is never bypassed by server mode.
|
||||
- `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback
|
||||
@@ -17,19 +14,64 @@ Currently exposed:
|
||||
keep their own inline loopback guards.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
is_local_host,
|
||||
is_loopback,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed
|
||||
|
||||
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
|
||||
# `request.client.host` carries an address, not a hostname, so the literal
|
||||
# "localhost" entry is defensive — some upstream wrappers (TestClient with
|
||||
# a custom client tuple, certain reverse-proxy headers) may pass strings
|
||||
# rather than parsed addresses. We accept the broader set without weakening
|
||||
# the guard: nothing here matches a non-loopback origin.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
|
||||
def _trusted_networks():
|
||||
"""CIDR networks from OMNIVOICE_TRUSTED_NETWORKS (comma-separated) treated as
|
||||
loopback-trusted — e.g. a reverse proxy or self-hosted LAN, so the API-key /
|
||||
PIN gates don't block LAN clients that can't present the credential (a proxy
|
||||
that strips the Authorization header). Read at call time (matching
|
||||
`_server_mode` / `remote_api_key`) so tests can monkeypatch the env; restart
|
||||
to apply changes in production."""
|
||||
nets = []
|
||||
for cidr in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
|
||||
cidr = cidr.strip()
|
||||
if cidr:
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||
except ValueError:
|
||||
pass # malformed entry ignored — never wedge the auth gate
|
||||
return nets
|
||||
|
||||
|
||||
def is_loopback(host):
|
||||
"""True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted
|
||||
network. Admin gates (``require_loopback`` → ``/system/set-env``,
|
||||
``/api/settings/*``) use this so a trusted-network CIDR exempts consumption
|
||||
(TTS / dictation) but never the RCE-class admin surface."""
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_local_host(host):
|
||||
"""Loopback address, OR on a configured trusted network. The consumption
|
||||
gates (PIN/API-key middleware, WS guard) call this so a trusted LAN/proxy is
|
||||
exempted. Admin gates use :func:`is_loopback` — NOT this — to preserve the
|
||||
two-tier privilege model: consumption trust ≠ admin trust."""
|
||||
if is_loopback(host):
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
# Unwrap IPv4-mapped IPv6 (::ffff:192.168.1.5) so it matches IPv4 CIDRs —
|
||||
# dual-stack proxies (Caddy, Node.js) frequently pass the mapped form.
|
||||
if getattr(ip, "ipv4_mapped", None):
|
||||
ip = ip.ipv4_mapped
|
||||
return any(ip in net for net in _trusted_networks())
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
@@ -54,15 +96,6 @@ def _server_mode() -> bool:
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def validate_server_admin_key() -> None:
|
||||
"""Reject an explicitly blank key before a server-mode app starts."""
|
||||
raw_key = os.environ.get("OMNIVOICE_API_KEY")
|
||||
if _server_mode() and raw_key is not None and not raw_key.strip():
|
||||
raise RuntimeError(
|
||||
"OMNIVOICE_API_KEY is blank; configure a non-whitespace administrator key"
|
||||
)
|
||||
|
||||
|
||||
def _configured_pin(request) -> str | None:
|
||||
"""The active share PIN (``app.state.network_share.pin``) or None. Read via
|
||||
getattr so a bare Request stub (or a request that hit before lifespan set
|
||||
@@ -74,45 +107,40 @@ def _configured_pin(request) -> str | None:
|
||||
|
||||
|
||||
def _admin_credential_configured(request) -> bool:
|
||||
"""Whether an API key or share PIN is configured.
|
||||
|
||||
The PIN cannot authorize admin access, but its presence means the operator
|
||||
opted out of bare-server discovery. Remote admin then remains closed until
|
||||
they configure and present the long API key.
|
||||
"""
|
||||
if remote_api_key():
|
||||
"""Whether the operator has set ANY credential gate — the remote API key or
|
||||
a share PIN. When neither is set, server mode leaves admin open (the Docker
|
||||
issue #261 flow the image depends on)."""
|
||||
if os.environ.get("OMNIVOICE_API_KEY"):
|
||||
return True
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
|
||||
def _request_presents_admin_credential(
|
||||
request,
|
||||
*,
|
||||
side_effectful_get: bool = False,
|
||||
) -> bool:
|
||||
"""Whether the canonical principal carries remote admin capability.
|
||||
def _request_presents_admin_credential(request) -> bool:
|
||||
"""Whether the request carries a valid **API key** via the channels the
|
||||
middleware accepts (``Authorization: Bearer`` / ``?api_key`` / ``ov_key``
|
||||
cookie).
|
||||
|
||||
API-key and short-lived session principals may unlock server-mode admin.
|
||||
PIN and trusted-network principals remain consumption-only.
|
||||
"""
|
||||
principal = principal_for(request)
|
||||
if principal.kind not in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}:
|
||||
Admin is RCE-class (``/system/set-env`` + ``/api/settings/*``), so only the
|
||||
API key — a long operator-chosen secret — unlocks it. The 6-digit share PIN
|
||||
is deliberately NOT accepted here: it is a *consumption* credential for LAN
|
||||
playback and is short enough to brute-force (10^6, no lockout), so it must
|
||||
never gate the admin surface (CodeRabbit #1213). A trusted-network CIDR
|
||||
(``is_local_host`` — also a consumption exemption) likewise never unlocks
|
||||
admin. Net: remote admin in server mode requires the API key; a PIN-only
|
||||
deployment keeps admin loopback-only. getattr-defensive so a minimal Request
|
||||
stub never raises."""
|
||||
api_key = os.environ.get("OMNIVOICE_API_KEY") or ""
|
||||
if not api_key:
|
||||
return False
|
||||
if principal.transport not in {
|
||||
CredentialTransport.COOKIE,
|
||||
CredentialTransport.LEGACY_COOKIE,
|
||||
}:
|
||||
return True
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if side_effectful_get or method not in SAFE_HTTP_METHODS:
|
||||
return cookie_csrf_allowed(
|
||||
request,
|
||||
side_effectful_get=side_effectful_get,
|
||||
)
|
||||
return True
|
||||
headers = getattr(request, "headers", None) or {}
|
||||
query = getattr(request, "query_params", None) or {}
|
||||
cookies = getattr(request, "cookies", None) or {}
|
||||
|
||||
auth = headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if not supplied:
|
||||
supplied = query.get("api_key") or cookies.get("ov_key") or ""
|
||||
return bool(supplied and secrets.compare_digest(supplied, api_key))
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
@@ -135,9 +163,9 @@ def require_loopback(request: Request) -> None:
|
||||
unenforceable, so the gate can't require true loopback. It then applies the
|
||||
admin-credential rule instead:
|
||||
|
||||
- No credential configured (no API key, no PIN) → read-only requests are
|
||||
open, matching the #261 Docker bootstrap flow. State-changing requests
|
||||
fail closed even if a route accidentally kept this legacy dependency.
|
||||
- No credential configured (no API key, no PIN) → open, matching the #261
|
||||
Docker flow where the operator reaches ``/system/*`` off the bridge
|
||||
gateway with nothing set.
|
||||
- A credential IS configured → the request must present the **API key**.
|
||||
This keeps the two-tier privilege model intact under server mode:
|
||||
``OMNIVOICE_TRUSTED_NETWORKS`` is a *consumption* exemption
|
||||
@@ -145,20 +173,14 @@ def require_loopback(request: Request) -> None:
|
||||
NEVER by itself unlock the admin surface (``/system/set-env`` — RCE-class —
|
||||
and ``/api/settings/*``). The 6-digit share PIN is a consumption credential
|
||||
too and does not gate admin, so a PIN-only deployment keeps admin
|
||||
loopback-only; remote admin requires the long API key. See
|
||||
docs/api-auth.md (#1213).
|
||||
loopback-only; remote admin requires the (long) API key. A LAN client in a
|
||||
trusted CIDR — or one holding only the PIN — gets 403 here even though it
|
||||
sails through the consumption gates. See docs/api-auth.md (#1213).
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if method not in SAFE_HTTP_METHODS:
|
||||
# Defense in depth. Privileged routers should declare
|
||||
# ``require_admin`` directly, but a missed migration must not turn
|
||||
# into an unauthenticated Docker write primitive.
|
||||
require_admin(request)
|
||||
return
|
||||
if not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
@@ -166,31 +188,6 @@ def require_loopback(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
|
||||
|
||||
def _admin_gate_403() -> None:
|
||||
"""Raise the admin-gate 403 with a detail that states what would ACTUALLY
|
||||
satisfy the gate. The bundled UI routes any 403 whose detail mentions
|
||||
"admin api key" to the API-key login form (frontend ``client.ts``; the
|
||||
literal contract is locked by ``tests/test_auth_gate_detail_lockstep.py``),
|
||||
so the wording must not name a key where presenting one cannot help.
|
||||
|
||||
The detail names the key only when the gate would accept one: server mode
|
||||
WITH an API key configured. Every other rejection — desktop mode (the
|
||||
credential checks in the callers only run under server mode) and a
|
||||
server-mode deployment with only a share PIN or nothing configured — keeps
|
||||
the plain loopback detail, because only loopback can use admin there.
|
||||
Naming the key in those cases would trap a LAN-share guest in a login
|
||||
form that can never succeed (#1213, #1525; PR #1569 review).
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"loopback origin or admin API key required"
|
||||
if _server_mode() and remote_api_key()
|
||||
else "loopback origin required"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def require_admin(request: Request) -> None:
|
||||
"""Gate RCE/filesystem-capable admin routers.
|
||||
|
||||
@@ -209,30 +206,12 @@ def require_admin(request: Request) -> None:
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
read_only = method in SAFE_HTTP_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
read_only = method in {"GET", "HEAD", "OPTIONS"}
|
||||
if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip():
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
return
|
||||
_admin_gate_403()
|
||||
|
||||
|
||||
def require_admin_action(request: Request) -> None:
|
||||
"""Gate an administrative action even when its HTTP method is read-only.
|
||||
|
||||
A small number of legacy GET endpoints have real side effects. For example,
|
||||
an engine health check may spawn a sidecar process. Such routes cannot use
|
||||
:func:`require_admin`'s bare-server discovery exception.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode() and _request_presents_admin_credential(
|
||||
request,
|
||||
side_effectful_get=True,
|
||||
):
|
||||
return
|
||||
_admin_gate_403()
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
def require_desktop(request: Request) -> None:
|
||||
@@ -253,10 +232,9 @@ def require_local(request: Request) -> None:
|
||||
trusted network. The consumption-tier companion to :func:`require_loopback`:
|
||||
use on routes a trusted-network client (LAN/proxy) should reach without a PIN
|
||||
or API key — e.g. the dictation model/prefs endpoints that pair with the
|
||||
dictation WebSocket. Admin routes stay on :func:`require_admin`.
|
||||
dictation WebSocket. Admin routes stay on :func:`require_loopback`.
|
||||
|
||||
In server mode this consumption gate is a no-op. Admin dependencies remain
|
||||
method-aware and independent from this exemption."""
|
||||
In server mode the gate is a no-op (same as :func:`require_loopback`)."""
|
||||
host = request.client.host if request.client else None
|
||||
if is_local_host(host):
|
||||
return
|
||||
@@ -278,9 +256,29 @@ def require_native_access(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="native filesystem access requires loopback origin")
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""The remote-backend bearer key (Wave 2.3), or None when remote mode is
|
||||
off. Read at call time so tests can monkeypatch the env."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY") or None
|
||||
|
||||
|
||||
def ws_remote_authorized(websocket) -> bool:
|
||||
"""Whether the canonical WS principal has a remote admin credential."""
|
||||
return principal_for(websocket).kind in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}
|
||||
"""Whether a WebSocket handshake presents the remote API key.
|
||||
|
||||
Browser WebSockets cannot set an Authorization header, so the key may
|
||||
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
|
||||
middleware sets on the first authenticated HTTP request. Returns False
|
||||
when remote mode is off — callers keep their loopback-only behavior.
|
||||
"""
|
||||
key = remote_api_key()
|
||||
if not key:
|
||||
return False
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if not supplied:
|
||||
supplied = (
|
||||
websocket.query_params.get("api_key")
|
||||
or websocket.cookies.get("ov_key")
|
||||
or ""
|
||||
)
|
||||
return secrets.compare_digest(supplied, key)
|
||||
|
||||
@@ -32,142 +32,23 @@ def _public_routing_reason(status: object, diagnostic: object) -> str:
|
||||
return _ROUTING_BY_STATUS.get(status, _ROUTING_UNAVAILABLE)
|
||||
|
||||
|
||||
# Categories for WHY an engine is unavailable. The probe's own sentence cannot
|
||||
# cross the boundary — it carries exception text, local paths and sometimes
|
||||
# credentials — but "Engine unavailable. Check installation and configuration."
|
||||
# told the user nothing at all, and "Last error: A previous engine check
|
||||
# failed." reads like a crash rather than "you have not installed this yet"
|
||||
# (#1866). Classifying the private diagnostic into an owned sentence keeps the
|
||||
# boundary intact and still names the kind of problem and the place to fix it.
|
||||
_UNAVAILABLE_NOT_INSTALLED = (
|
||||
"This engine's package isn't installed yet. Install it from "
|
||||
"Model Catalogue."
|
||||
)
|
||||
# An engine gated behind an in-app license review (Supertonic-3, PocketTTS).
|
||||
# The Model Catalogue shows its Accept button only when the reason matches
|
||||
# /license not accepted/i (EngineCompatibilityMatrix.reasonMentionsLicense), so
|
||||
# this sentence must keep those words: collapsing it into the generic line hid
|
||||
# the only way to enable those engines.
|
||||
_UNAVAILABLE_LICENSE = (
|
||||
"License not accepted yet. Review and accept it in "
|
||||
"Model Catalogue to enable this engine."
|
||||
)
|
||||
# An engine that cannot run on this machine at all: Apple-Silicon-only MLX,
|
||||
# PyTorch with no Intel Mac build. "Isn't installed yet" or "check
|
||||
# installation" sent people after an install that could never work.
|
||||
_UNAVAILABLE_PLATFORM = (
|
||||
"This engine doesn't run on this computer's platform. Its guide lists "
|
||||
"the platforms it supports."
|
||||
)
|
||||
# Apple Silicon whose PyTorch cannot use the GPU (MPS): the platform is
|
||||
# right, the installation is not. MLX-Audio / MLX-Whisper need MPS (#390).
|
||||
_UNAVAILABLE_NO_MPS = (
|
||||
"This engine needs Apple's GPU (MPS), and this installation's PyTorch "
|
||||
"can't use it. Updating macOS or reinstalling VoiceStudio usually "
|
||||
"restores it."
|
||||
)
|
||||
_UNAVAILABLE_NEEDS_CONFIG = (
|
||||
"This engine needs to be configured before it can run. Open "
|
||||
"Model Catalogue to finish setting it up."
|
||||
)
|
||||
_UNAVAILABLE_FILE_MISSING = (
|
||||
"A file this engine needs is missing or unreadable. Reinstall it from "
|
||||
"Model Catalogue."
|
||||
)
|
||||
|
||||
# The same two cases for an engine the app cannot install for you. "Install it
|
||||
# from Model Catalogue" sent people to a page with no Install button
|
||||
# for that engine — most of the catalogue — which reads as the app being
|
||||
# broken. The row's own guide link (``docs_url``) is the real next step.
|
||||
_UNAVAILABLE_NOT_INSTALLED_MANUAL = (
|
||||
"This engine isn't installed yet, and it has no one-click install. "
|
||||
"Its guide lists the install steps."
|
||||
)
|
||||
_UNAVAILABLE_FILE_MISSING_MANUAL = (
|
||||
"A file this engine needs is missing or unreadable. Its guide lists the "
|
||||
"install steps."
|
||||
)
|
||||
_MANUAL_INSTALL_VARIANT = {
|
||||
_UNAVAILABLE_NOT_INSTALLED: _UNAVAILABLE_NOT_INSTALLED_MANUAL,
|
||||
_UNAVAILABLE_FILE_MISSING: _UNAVAILABLE_FILE_MISSING_MANUAL,
|
||||
}
|
||||
|
||||
# Matched against the lowered probe text. Ordered most specific first: a
|
||||
# missing file often also says "not installed", and the file case has the more
|
||||
# useful remedy of the two.
|
||||
_UNAVAILABLE_SIGNATURES = (
|
||||
# First: its probe text also says "Open Model Catalogue", and the
|
||||
# license is the one gap only the user can close.
|
||||
(_UNAVAILABLE_LICENSE, ("license not accepted",)),
|
||||
# Before the install and file checks: a platform reason often also says
|
||||
# "unavailable" or names a missing wheel, and no install can fix it. Not
|
||||
# "apple silicon only": mlx-audio says that on an M-series Mac too, when
|
||||
# the package is merely missing and installing does help.
|
||||
(_UNAVAILABLE_PLATFORM, (
|
||||
"requires apple silicon", "not supported on this platform",
|
||||
"unavailable on intel macs", "no macos x86_64 wheel",
|
||||
"no windows install", "not supported on windows",
|
||||
)),
|
||||
(_UNAVAILABLE_NO_MPS, ("torch mps unavailable",)),
|
||||
(_UNAVAILABLE_FILE_MISSING, (
|
||||
"file is missing", "file is empty", "file is unreadable",
|
||||
"script missing", "binary", "not found at",
|
||||
)),
|
||||
(_UNAVAILABLE_NEEDS_CONFIG, (
|
||||
"environment variable", "configure a server endpoint", "api key",
|
||||
"unconfigured", "set the", "base url",
|
||||
)),
|
||||
(_UNAVAILABLE_NOT_INSTALLED, (
|
||||
"not installed", "package missing", "not available", "no module named",
|
||||
"import ", "unavailable:", "failed to load",
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
def _public_unavailable_reason(diagnostic: object) -> str:
|
||||
"""Map a private availability probe to an accurate stable category."""
|
||||
private = diagnostic.lower() if isinstance(diagnostic, str) else ""
|
||||
for public, markers in _UNAVAILABLE_SIGNATURES:
|
||||
if any(marker in private for marker in markers):
|
||||
return public
|
||||
return _UNAVAILABLE
|
||||
|
||||
|
||||
def public_backends(entries: list[dict]) -> list[dict]:
|
||||
"""Copy registry entries while replacing service diagnostics.
|
||||
|
||||
Availability probes may contain exception text, local paths, tracebacks, or
|
||||
credentials. Registry-authored fields are not probe output and remain
|
||||
intact: ``install_hint``, ``setup_snippet`` and ``docs_url`` are all
|
||||
VoiceStudio-owned constants keyed on the engine id, so an unavailable row
|
||||
still has something actionable to show and somewhere to send the user
|
||||
(#1866) even though ``reason``/``last_error`` are replaced here.
|
||||
credentials. Installation hints are registry-authored and remain intact.
|
||||
"""
|
||||
safe: list[dict] = []
|
||||
for entry in entries:
|
||||
item = dict(entry)
|
||||
if item.get("reason") is not None:
|
||||
reason = _public_unavailable_reason(item["reason"])
|
||||
# Only a row that explicitly says it has NO one-click install gets
|
||||
# the manual wording. Rows without the field (ASR, LLM,
|
||||
# translation — some of which have installers of their own) keep
|
||||
# the line that points at Model Catalogue.
|
||||
if item.get("one_click_install") is False:
|
||||
reason = _MANUAL_INSTALL_VARIANT.get(reason, reason)
|
||||
item["reason"] = reason
|
||||
item["reason"] = _UNAVAILABLE
|
||||
if item.get("last_error") is not None:
|
||||
item["last_error"] = _PREVIOUS_FAILURE
|
||||
if item.get("routing_reason") is not None:
|
||||
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
|
||||
|
||||
|
||||
@@ -15,33 +15,22 @@ Design notes
|
||||
* Previews are cached on disk keyed by a hash of (instruct, language), so two
|
||||
archetypes that resolve to the same voice share a cache file and the cold
|
||||
render only happens once per distinct voice.
|
||||
* That same key names the pre-rendered clips in the opt-in voice gallery
|
||||
(``services.gallery``), which is consulted BEFORE the engine so a fresh
|
||||
install can hear voices before the 2.4 GB checkpoint finishes downloading.
|
||||
Gallery files win over a local render of the same key — but only for
|
||||
``/preview``. ``/use`` always renders locally: the WAV it keeps in
|
||||
``VOICES_DIR`` is the reference audio a cloned voice is built from, and a
|
||||
downloaded MP3 must never become that.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services import gallery
|
||||
|
||||
logger = logging.getLogger("omnivoice.archetypes")
|
||||
|
||||
@@ -57,12 +46,11 @@ _PREVIEW_SEED = 42
|
||||
# 32 reliably converges to speech across the gallery's instruct/script space
|
||||
# at a one-time (cached) render cost.
|
||||
_PREVIEW_NUM_STEP = 32
|
||||
# Reject near-pure tonal artifacts using mean framed spectral flatness.
|
||||
# Calibrated against the tracked speech demos exercised by
|
||||
# test_archetype_preview_quality.py: the quietest (Mandarin dubbing, 44.1 kHz)
|
||||
# measures ~7.7e-6, while the worst tested tonal buzz measures ~3.3e-9.
|
||||
# 1e-7 leaves >10x margin on both sides without rejecting low-flatness speech.
|
||||
_DEGENERATE_FLATNESS = 1e-7
|
||||
# Spectral-flatness floor below which a render is a degenerate tonal artifact
|
||||
# rather than speech. Real, mastered speech sits ~0.04–0.07; a tonal buzz
|
||||
# collapses to <0.005. 0.015 separates the two with wide margin and sits well
|
||||
# below even breathy/whisper voices (which are broadband → high flatness).
|
||||
_DEGENERATE_FLATNESS = 0.015
|
||||
|
||||
|
||||
def _preview_key(a: dict) -> str:
|
||||
@@ -73,153 +61,6 @@ def _preview_key(a: dict) -> str:
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _design_profile_values(a: dict) -> tuple[str, str]:
|
||||
"""Canonical instruct + complete picker state for a designed archetype."""
|
||||
return a["instruct"], json.dumps(a["attrs"], sort_keys=True)
|
||||
|
||||
|
||||
def _profile_audio_path(ref_audio_path: object) -> Optional[Path]:
|
||||
"""Resolve only a regular, non-symlinked file inside ``VOICES_DIR``."""
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _materialized_audio_is_current(row, a: dict) -> bool:
|
||||
"""Whether an existing row still has the sample described by its metadata."""
|
||||
expected_filename = _profile_audio_filename(row["id"])
|
||||
path = _profile_audio_path(row["ref_audio_path"])
|
||||
return bool(
|
||||
row["ref_audio_path"] == expected_filename
|
||||
and is_playable_wav(path)
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
)
|
||||
|
||||
|
||||
def _profile_audio_filename(profile_id: str) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _archetype_personality(a: dict) -> str:
|
||||
return f"archetype:{a['id']}"
|
||||
|
||||
|
||||
def _legacy_archetype_profile(conn, a: dict):
|
||||
"""Adopt only a row that an older archetype materializer could have made."""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
expected_audio = _profile_audio_filename(row["id"])
|
||||
try:
|
||||
states_match = (
|
||||
not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"]
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
states_match = False
|
||||
if (
|
||||
row["ref_audio_path"] == expected_audio
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["kind"] in (None, "", "clone", "design")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and states_match
|
||||
):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _is_materialized_archetype_row(row, a: dict) -> bool:
|
||||
"""Recognize rows owned by this materializer without trusting identity text alone."""
|
||||
try:
|
||||
states_match = json.loads(row["vd_states"]) == a["attrs"]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["personality"] == _archetype_personality(a)
|
||||
and row["kind"] == "design"
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["ref_audio_path"] == _profile_audio_filename(row["id"])
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and states_match
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_archetype_profile(conn, a: dict):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(_archetype_personality(a),),
|
||||
).fetchall()
|
||||
owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None)
|
||||
return owned if owned is not None else _legacy_archetype_profile(conn, a)
|
||||
|
||||
|
||||
async def _render_profile_audio(
|
||||
a: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Render one validated sample, optionally staging it for a later CAS."""
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
safe_id = Path(audio_filename).stem
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
if publish:
|
||||
await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-")
|
||||
else:
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
if not is_playable_wav(audio_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
except BaseException:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
audio_path.unlink()
|
||||
raise
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path:
|
||||
"""Render and validate a WAV before atomically replacing *out_path*."""
|
||||
audio_path = Path(out_path)
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, tmp_path)
|
||||
if not is_playable_wav(tmp_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
os.replace(tmp_path, audio_path)
|
||||
finally:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
tmp_path.unlink()
|
||||
return audio_path
|
||||
|
||||
|
||||
def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None:
|
||||
"""Repair profiles created before archetype `/use` persisted design kind."""
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?",
|
||||
(
|
||||
instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED,
|
||||
audio_filename, _archetype_personality(a), row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# A non-empty script is always required — synthesizing empty text yields
|
||||
# silence. Every archetype carries a use-case script, but guard the render path
|
||||
# too so a malformed archetype can never drive a blank render.
|
||||
@@ -249,46 +90,24 @@ def _is_blank_audio(audio_tensor) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_FLATNESS_FRAME = 1024
|
||||
_FLATNESS_HOP = 512
|
||||
#: Frames quieter than this fraction of the loudest frame's energy are the gaps
|
||||
#: between words, not speech; their spectrum is the noise floor and averaging it
|
||||
#: in drags the measurement toward the value of whatever silence sounds like.
|
||||
_FLATNESS_FRAME_FLOOR = 1e-4
|
||||
|
||||
|
||||
def _spectral_flatness(audio_tensor) -> Optional[float]:
|
||||
"""Mean per-frame geometric-mean / arithmetic-mean of the power spectrum.
|
||||
"""Geometric-mean / arithmetic-mean of the power spectrum.
|
||||
|
||||
~1.0 for broadband noise, →0 for a pure tone. The degenerate diffusion
|
||||
renders this guards against are near-pure tonal buzzes, distinct from both
|
||||
silence (caught by ``_is_blank_audio``) and real speech. Returns ``None``
|
||||
if it can't be computed so callers don't act on a bad measurement.
|
||||
|
||||
Measured over short frames and averaged — the standard definition. A single
|
||||
FFT of the whole clip (what this used to do) is not the same quantity: its
|
||||
frequency resolution grows with clip length, so speech harmonics carve
|
||||
ever-deeper nulls into the spectrum and the geometric mean collapses. That
|
||||
made the result depend on how long the clip was rather than on what it
|
||||
sounded like, and put real speech below the rejection threshold.
|
||||
renders this guards against are near-pure tonal buzzes (flatness <0.005),
|
||||
distinct from both silence (caught by ``_is_blank_audio``) and real speech
|
||||
(~0.04+). Returns ``None`` if it can't be computed so callers don't act on
|
||||
a bad measurement.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
|
||||
t = t.detach().to("cpu", dtype=torch.float32)
|
||||
if t.ndim > 1:
|
||||
t = t.mean(dim=0)
|
||||
t = t.flatten()
|
||||
if t.numel() < _FLATNESS_FRAME or not torch.isfinite(t).all():
|
||||
t = t.detach().to("cpu", dtype=torch.float32).flatten()
|
||||
if t.numel() < 1024 or not torch.isfinite(t).all():
|
||||
return None
|
||||
frames = t.unfold(0, _FLATNESS_FRAME, _FLATNESS_HOP)
|
||||
spec = torch.fft.rfft(frames * torch.hann_window(_FLATNESS_FRAME)).abs().pow(2) + 1e-12
|
||||
energy = spec.sum(dim=1)
|
||||
spec = spec[energy > energy.max() * _FLATNESS_FRAME_FLOOR]
|
||||
if spec.shape[0] == 0:
|
||||
return None
|
||||
return float((torch.exp(spec.log().mean(dim=1)) / spec.mean(dim=1)).mean())
|
||||
spec = torch.fft.rfft(t * torch.hann_window(t.numel())).abs().pow(2) + 1e-12
|
||||
return float(torch.exp(torch.mean(torch.log(spec))) / torch.mean(spec))
|
||||
except Exception: # never let the checker itself block a render
|
||||
return None
|
||||
|
||||
@@ -353,7 +172,7 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
# GPU pool and brick the backend (#730 class). Budget comes from the shared
|
||||
# length-scaled helper (#1190) instead of the flat 300s default.
|
||||
from services.model_manager import generate_timeout_s
|
||||
_budget = generate_timeout_s(text, engine=model)
|
||||
_budget = generate_timeout_s(text)
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _infer(_PREVIEW_SEED), what="Archetype preview generate",
|
||||
timeout=_budget)
|
||||
@@ -380,60 +199,21 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
# Runs on the dedicated watermark pool (#1190): AudioSeal embedding is CPU
|
||||
# work that holds no VRAM, so it must not occupy a GPU worker ahead of the
|
||||
# next generate on 1-worker hosts.
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, model.sampling_rate,
|
||||
context="archetypes.render",
|
||||
from services.watermark import mark_synthetic
|
||||
from services.model_manager import get_watermark_pool
|
||||
import functools
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
functools.partial(mark_synthetic, audio_tensor, model.sampling_rate,
|
||||
context="archetypes.render"),
|
||||
what="Archetype watermark",
|
||||
timeout=generate_timeout_s(""),
|
||||
executor=get_watermark_pool(),
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_safe_torchaudio_save(str(out_path), audio_tensor, model.sampling_rate)
|
||||
|
||||
|
||||
def _no_voice_model_downloaded() -> bool:
|
||||
"""True only on a *positive* "no TTS weights on this machine" answer.
|
||||
|
||||
Fails open on purpose: the cache probes are best-effort (a user-managed
|
||||
clone outside the HF layout is invisible to them), and telling someone with
|
||||
a working engine to go download a model is worse than saying nothing. Only
|
||||
a catalog we could read, with not one TTS repo cached, earns the offline
|
||||
message.
|
||||
"""
|
||||
try:
|
||||
from api.routers.setup.models import get_model_catalog, is_cached
|
||||
|
||||
tts = [m for m in get_model_catalog().all if m.get("role") == "TTS"]
|
||||
return bool(tts) and not any(is_cached(m["repo_id"]) for m in tts)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _preview_source(a: dict) -> tuple[str, str]:
|
||||
"""Which path ``/preview`` will take for *a*, and what to tell the user.
|
||||
|
||||
Replaces the old "see Settings → Logs → Backend" advice, which asked a user
|
||||
who wanted to hear a voice to go read a log file. The three states that
|
||||
actually differ are: we already have the audio (gallery), we can make it
|
||||
(render — say so, it takes a moment), and we can neither fetch nor make it
|
||||
(no model — the one state with an action attached).
|
||||
"""
|
||||
key = _preview_key(a)
|
||||
if gallery.cached_preview(key) is not None:
|
||||
return "gallery", (
|
||||
"Pre-rendered preview from the voice gallery — a fixed reference "
|
||||
"rendering, not a render from your current engine."
|
||||
)
|
||||
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
|
||||
return "cached", ""
|
||||
if _no_voice_model_downloaded():
|
||||
return "no_model", (
|
||||
"You're offline and no voice model is downloaded yet — download "
|
||||
"one from the engine's Weights list in Model Catalogue."
|
||||
)
|
||||
return "rendering", "Rendering this preview on your machine — it may take a moment."
|
||||
|
||||
|
||||
# ── Read endpoints (no model) ─────────────────────────────────────────────────
|
||||
# NOTE: declare the literal `/archetypes/categories` before `/archetypes/{id}`
|
||||
# so it isn't swallowed by the path-parameter route.
|
||||
@@ -443,37 +223,6 @@ def list_categories():
|
||||
return archetypes.categories()
|
||||
|
||||
|
||||
# ── Voice-gallery (pre-rendered previews) ─────────────────────────────────────
|
||||
# Declared above `/archetypes/{archetype_id}` for the same reason as
|
||||
# `/categories`: keep literal paths out of the path-parameter route's reach.
|
||||
@router.get("/archetypes/previews/status")
|
||||
def preview_gallery_status():
|
||||
"""Consent state, coverage and freshness for the Settings line."""
|
||||
return gallery.status()
|
||||
|
||||
|
||||
@router.put("/archetypes/previews")
|
||||
async def set_preview_gallery(enabled: bool = Body(..., embed=True)):
|
||||
"""Turn pre-rendered previews on or off.
|
||||
|
||||
Turning it ON is the user's explicit yes to an outbound call, and is the
|
||||
only thing that ever starts one — there is no on-install background fetch.
|
||||
The featured set is pulled right here so the yes has a visible effect;
|
||||
failures are silent by design (``fetch_featured`` swallows them) and leave
|
||||
previews rendering locally.
|
||||
"""
|
||||
state = gallery.set_enabled(enabled)
|
||||
if enabled:
|
||||
state = await gallery.fetch_featured()
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/archetypes/previews/check")
|
||||
async def check_preview_gallery():
|
||||
"""Manual "check now" — bypasses the 24 h throttle, never the signature."""
|
||||
return await gallery.check_for_updates(force=True)
|
||||
|
||||
|
||||
@router.get("/archetypes")
|
||||
def list_archetypes_endpoint(
|
||||
q: Optional[str] = None,
|
||||
@@ -513,77 +262,33 @@ def get_archetype_endpoint(archetype_id: str):
|
||||
|
||||
|
||||
# ── Render endpoints (model-gated) ────────────────────────────────────────────
|
||||
@router.get("/archetypes/{archetype_id}/preview/state")
|
||||
def preview_archetype_state(archetype_id: str):
|
||||
"""Where the next ``/preview`` for this archetype would come from.
|
||||
|
||||
Touches neither the model nor the network, so a picker can label a voice
|
||||
("may take a moment", "download a model first") *before* it commits to a
|
||||
request that may take 40 seconds or fail.
|
||||
"""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
source, message = _preview_source(a)
|
||||
return {"source": source, "message": message}
|
||||
|
||||
|
||||
@router.get("/archetypes/{archetype_id}/preview")
|
||||
async def preview_archetype(
|
||||
archetype_id: str,
|
||||
local: bool = Query(False, description="Bypass gallery audio after a client decode failure"),
|
||||
):
|
||||
"""Serve a short preview clip — from the gallery, the cache, or the engine."""
|
||||
async def preview_archetype(archetype_id: str):
|
||||
"""Serve a short preview clip — pre-rendered if cached, else render once."""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
|
||||
key = _preview_key(a)
|
||||
# Gallery first, and only for /preview: these bytes are audio we can prove
|
||||
# the provenance of, so they beat a local render of the same key. A miss
|
||||
# (offline, disabled, key not published) is silent — we just render.
|
||||
gallery_path = None if local else gallery.cached_preview(key)
|
||||
if gallery_path is None and not local:
|
||||
gallery_path = await gallery.fetch_preview(key)
|
||||
if gallery_path is not None:
|
||||
# Nothing else in the app polls, so the daily refresh hangs off the
|
||||
# request that proves previews are being used. Fire-and-forget.
|
||||
gallery.maybe_refresh_in_background()
|
||||
return FileResponse(
|
||||
str(gallery_path),
|
||||
media_type="audio/mpeg",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "gallery"},
|
||||
)
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{key}.wav"
|
||||
if not is_playable_wav(cache_path):
|
||||
cache_path = _PREVIEW_DIR / f"{_preview_key(a)}.wav"
|
||||
if not cache_path.exists():
|
||||
try:
|
||||
await _render_wav_atomic(a, cache_path, prefix=".preview-")
|
||||
await _render_archetype_wav(a, cache_path)
|
||||
except Exception as e: # model missing / OOM / inference failure
|
||||
logger.error("Archetype preview render failed", exc_info=True)
|
||||
# Two different failures, two different answers. Without a model
|
||||
# there is nothing to read in a log — there is something to do.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"You're offline and no voice model is downloaded yet — "
|
||||
"download one from the engine's Weights list in Model Catalogue. (Or turn "
|
||||
"on pre-rendered voice previews in Settings → Storage.)"
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't render a preview right now — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Couldn't render a preview right now — the voice engine is "
|
||||
f"unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
# no-cache (not no-store): the URL is stable but its bytes change when an
|
||||
# archetype's preview is re-rendered, so force the client to revalidate
|
||||
# against the ETag instead of serving a stale cached clip indefinitely.
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "local"},
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@@ -595,11 +300,6 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
preview) and inserts a ``voice_profiles`` row carrying the archetype's
|
||||
instruct + language. The profile then shows up everywhere voices are
|
||||
picked (Dub / Generate / Clone).
|
||||
|
||||
Never sourced from the voice gallery, no matter how cheap that would be:
|
||||
this WAV lands in ``VOICES_DIR`` as the profile's reference audio, so a
|
||||
downloaded, lossily-encoded MP3 would silently become the sample every
|
||||
future clone of this voice is built from. It renders locally or it fails.
|
||||
"""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
@@ -611,122 +311,64 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
# Idempotent (dedup): an archetype materializes to exactly ONE voice profile.
|
||||
# Picking the same gallery voice again — from any picker (Gallery grid,
|
||||
# VoiceSelector, …) — must reuse that one row instead of rendering + inserting
|
||||
# a fresh duplicate every time. Use a namespaced personality identity so an
|
||||
# imported persona cannot collide with and be rewritten by an archetype id.
|
||||
# a fresh duplicate every time. The `personality` column already carries the
|
||||
# source archetype id (stamped by the INSERT below), so it's the natural
|
||||
# dedup key; the expensive render + INSERT only run on first use.
|
||||
with db_conn() as conn:
|
||||
existing = _existing_archetype_profile(conn, a)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _materialized_audio_is_current(existing, a):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(
|
||||
a, profile_id, publish=existing is None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Download one from the engine's Weights list in Model Catalogue."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail) from e
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_archetype_profile(conn, a)
|
||||
still_owned = current is not None and (
|
||||
owned is not None and owned["id"] == current["id"]
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, current, a, audio_filename)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
# The row was edited/deleted while rendering. Preserve it and use the
|
||||
# validated staged sample for a fresh canonical materialization.
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(a, profile_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't create a voice from this archetype.",
|
||||
) from e
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
return {"profile_id": existing["id"], "name": existing["name"]}
|
||||
|
||||
if audio_path is None: # defensive: a new profile always rendered above
|
||||
raise RuntimeError("new archetype profile has no rendered audio")
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"is unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
|
||||
profile_name = (name or a["name"]).strip() or a["name"]
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
# Re-check under the write connection right before inserting: a
|
||||
# concurrent /use for the same archetype may have inserted while we
|
||||
# were rendering (the pre-render SELECT above raced). Reuse that row
|
||||
# and drop our just-rendered sample instead of creating a duplicate.
|
||||
# `personality` is not globally UNIQUE, so serialize and re-check.
|
||||
dup = _existing_archetype_profile(conn, a)
|
||||
# (personality is NOT globally unique — marketplace/persona imports
|
||||
# reuse the column — so a UNIQUE index isn't an option; this closes
|
||||
# the realistic window for the single-user desktop app.)
|
||||
dup = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if dup is not None:
|
||||
duplicate_audio = dup["ref_audio_path"]
|
||||
if not _materialized_audio_is_current(dup, a):
|
||||
duplicate_audio = _profile_audio_filename(dup["id"])
|
||||
_duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_duplicate_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(audio_path, _duplicate_path)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, dup, a, duplicate_audio)
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
duplicate_result = {"profile_id": dup["id"], "name": dup["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
instruct, a["language"], _PREVIEW_SEED,
|
||||
_archetype_personality(a), time.time(), vd_states,
|
||||
),
|
||||
)
|
||||
os.remove(audio_path)
|
||||
return {"profile_id": dup["id"], "name": dup["name"]}
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
@@ -25,7 +25,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -361,7 +360,7 @@ LONGFORM_NUM_STEP = 32
|
||||
LONGFORM_GUIDANCE_SCALE = 2.0
|
||||
|
||||
|
||||
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> int | None:
|
||||
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> None:
|
||||
"""Apply a profile's pinned seed to this synth call (#1139).
|
||||
|
||||
``_resolve_voice`` has always fetched the profile ``seed`` — but only the
|
||||
@@ -380,13 +379,11 @@ def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> int | None:
|
||||
must cover /generate and here together, not one path.
|
||||
"""
|
||||
if base_seed is None:
|
||||
return None
|
||||
return
|
||||
import torch
|
||||
|
||||
from services.audiobook import segment_seed
|
||||
seed = segment_seed(base_seed, text, nonce)
|
||||
torch.manual_seed(seed)
|
||||
return seed
|
||||
torch.manual_seed(segment_seed(base_seed, text, nonce))
|
||||
|
||||
|
||||
def _base_seed(opts: ExpressiveOptions, voice: dict):
|
||||
@@ -420,11 +417,8 @@ def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
today exactly: num_step 32, guidance 2.0, and NO temperature/postprocess
|
||||
kwargs (the model keeps its own defaults). Emotion is never forwarded —
|
||||
the VoiceStudio config rejects unknown kwargs."""
|
||||
from services.performance_profiles import tts_defaults
|
||||
|
||||
defaults = tts_defaults()
|
||||
kw = {
|
||||
"num_step": opts.num_step if opts.num_step is not None else defaults.get("num_step", LONGFORM_NUM_STEP),
|
||||
"num_step": opts.num_step if opts.num_step is not None else LONGFORM_NUM_STEP,
|
||||
"guidance_scale": (
|
||||
opts.guidance_scale if opts.guidance_scale is not None else LONGFORM_GUIDANCE_SCALE
|
||||
),
|
||||
@@ -435,8 +429,6 @@ def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
kw["class_temperature"] = opts.class_temperature
|
||||
if opts.postprocess_output is not None:
|
||||
kw["postprocess_output"] = opts.postprocess_output
|
||||
elif "postprocess_output" in defaults:
|
||||
kw["postprocess_output"] = defaults["postprocess_output"]
|
||||
return kw
|
||||
|
||||
|
||||
@@ -515,21 +507,16 @@ def _build_synth(
|
||||
"get_model": get_model, "language": language, "opts": opts}
|
||||
|
||||
backend = cls()
|
||||
native_proxy = bool(getattr(cls, "supports_native_omnivoice_controls", False))
|
||||
extra = (_omnivoice_sampling_kwargs(opts) if native_proxy
|
||||
else _generic_extra_kwargs(opts))
|
||||
extra = _generic_extra_kwargs(opts)
|
||||
next_nonce = _make_occ_counter(opts)
|
||||
|
||||
def synth(text, voice_id, speed=None):
|
||||
v = resolve(voice_id)
|
||||
seed = _seed_segment_rng(_base_seed(opts, v), text, next_nonce())
|
||||
call_extra = dict(extra)
|
||||
if native_proxy and seed is not None:
|
||||
call_extra["seed"] = seed
|
||||
_seed_segment_rng(_base_seed(opts, v), text, next_nonce())
|
||||
return backend.generate(
|
||||
text, language=language, ref_audio=v["ref_audio"],
|
||||
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
|
||||
speed=float(speed) if speed else 1.0, **call_extra,
|
||||
speed=float(speed) if speed else 1.0, **extra,
|
||||
)
|
||||
return {"mode": "generic", "resolve": resolve, "engine_id": engine_id,
|
||||
"synth": synth, "sample_rate": backend.sample_rate}
|
||||
@@ -694,103 +681,6 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
"cached": seg_cache.hits}
|
||||
|
||||
|
||||
def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
|
||||
language, lexicon, opts, cache_dir):
|
||||
"""Build one opaque remote chapter task without loading a local TTS model."""
|
||||
import hashlib
|
||||
|
||||
from services import gpu_gateway
|
||||
from services.text_normalization import normalize_for_tts
|
||||
from services.watermark import is_enabled as watermark_enabled
|
||||
|
||||
rows, voices, refs = [], [], []
|
||||
for span in chapter.spans:
|
||||
profile_id = _map_span_voice(span.voice_id, default_voice, voice_map)
|
||||
voice = _resolve_voice(profile_id)
|
||||
rows.append({
|
||||
"text": normalize_for_tts(span.text, language),
|
||||
"pause_ms_after": span.pause_ms_after,
|
||||
"speed": getattr(span, "speed", None),
|
||||
})
|
||||
refs.append(voice.get("ref_audio"))
|
||||
voices.append({
|
||||
"ref_text": voice.get("ref_text"), "instruct": voice.get("instruct"),
|
||||
"seed": voice.get("seed"),
|
||||
})
|
||||
params = {
|
||||
"spans": rows, "voices": voices, "ref_audio": refs,
|
||||
"language": language, "lexicon": lexicon,
|
||||
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
|
||||
}
|
||||
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
||||
# The worker synthesizes from ``spans``, but the gateway and scheduler read
|
||||
# top-level ``text`` to scale the remote execution deadline. Add this after
|
||||
# the signature so existing content-addressed remote cache keys still hit.
|
||||
params["text"] = "\n".join(row["text"] for row in rows)
|
||||
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
|
||||
|
||||
def decode(result):
|
||||
import soundfile as sf
|
||||
if not os.path.exists(wav_path):
|
||||
partial = f"{wav_path}.part"
|
||||
shutil.copyfile(result.path, partial)
|
||||
os.replace(partial, wav_path)
|
||||
info = sf.info(wav_path)
|
||||
return wav_path, float(info.duration), False, None
|
||||
|
||||
return gpu_gateway.RemoteCall(
|
||||
engine=engine_id, operation="audiobook", params=params,
|
||||
idempotency_key=f"audiobook:{signature}", decode=decode,
|
||||
), wav_path
|
||||
|
||||
|
||||
async def _run_chapter(chapter, *, operation="audiobook", decision, job, default_voice, language, opts,
|
||||
voice_map, lexicon, cache_dir):
|
||||
"""Run one chapter through the gateway; local preparation stays lazy."""
|
||||
from services import gpu_gateway
|
||||
from services.tts_backend import active_backend_id, get_backend_class
|
||||
|
||||
engine_id = active_backend_id()
|
||||
remote, remote_cache = _remote_chapter_call(
|
||||
chapter, engine_id=engine_id, default_voice=default_voice,
|
||||
voice_map=voice_map, language=language, lexicon=lexicon,
|
||||
opts=opts, cache_dir=cache_dir,
|
||||
)
|
||||
if decision.remote and os.path.exists(remote_cache):
|
||||
import soundfile as sf
|
||||
info = sf.info(remote_cache)
|
||||
return remote_cache, float(info.duration), True, None
|
||||
|
||||
async def prepare_local():
|
||||
from services.model_manager import generate_timeout_s
|
||||
|
||||
synth, sr, resolve, local_engine = await _prepare_synth(
|
||||
default_voice, language=language, opts=opts, voice_map=voice_map
|
||||
)
|
||||
try:
|
||||
timeout_engine = get_backend_class(local_engine)
|
||||
except ValueError:
|
||||
# Tests and third-party integrations may inject a synth under a
|
||||
# non-catalogue id. Keep the canonical host/text policy available;
|
||||
# registered production engines still add their routing metadata.
|
||||
timeout_engine = None
|
||||
return gpu_gateway.LocalCall(
|
||||
fn=lambda: _render_chapter_cached(
|
||||
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
|
||||
language, opts, voice_map,
|
||||
),
|
||||
what="Audiobook chapter",
|
||||
timeout=generate_timeout_s(
|
||||
remote.params["text"], engine=timeout_engine
|
||||
),
|
||||
)
|
||||
|
||||
return await gpu_gateway.run(
|
||||
operation, local=gpu_gateway.LocalCall(prepare=prepare_local),
|
||||
remote=remote, decision=decision, job=job,
|
||||
)
|
||||
|
||||
|
||||
class AudiobookPreviewRequest(ExpressiveMixin):
|
||||
text: str
|
||||
chapter_index: int = 0
|
||||
@@ -810,7 +700,7 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
cache (the later full render reuses it) and a re-preview is instant.
|
||||
"""
|
||||
from core.config import OUTPUTS_DIR
|
||||
from services import gpu_gateway
|
||||
from services.model_manager import _gpu_pool
|
||||
|
||||
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
|
||||
if not plan.chapters:
|
||||
@@ -824,11 +714,16 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
resolved_lang = _resolve_default_language(req.language, req.default_voice)
|
||||
opts = _expressive_opts(req)
|
||||
decision = gpu_gateway.decide("audiobook")
|
||||
wav_path, dur, was_cached, _seg_stats = await _run_chapter(
|
||||
chapter, decision=decision, job=None, default_voice=req.default_voice,
|
||||
language=resolved_lang, opts=opts, voice_map=req.voice_map,
|
||||
lexicon=req.lexicon, cache_dir=cache_dir,
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
req.default_voice,
|
||||
language=resolved_lang,
|
||||
opts=opts,
|
||||
voice_map=req.voice_map,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
|
||||
req.lexicon, resolved_lang, opts, req.voice_map,
|
||||
)
|
||||
return {
|
||||
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
|
||||
@@ -867,7 +762,7 @@ async def _render_longform_sse(
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core.failure import build_failure, build_failure_event
|
||||
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
|
||||
from services import gpu_gateway
|
||||
from services.model_manager import _gpu_pool
|
||||
|
||||
opts = opts or ExpressiveOptions()
|
||||
|
||||
@@ -942,11 +837,13 @@ async def _render_longform_sse(
|
||||
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
prune_cache_dir(cache_dir) # bound disk before this job adds its chapters
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
resolved_lang = _resolve_default_language(language, default_voice)
|
||||
operation = "audiobook" if job_type == "audiobook" else "longform"
|
||||
decision = gpu_gateway.decide(operation)
|
||||
chapter_run = gpu_gateway.JobRun(operation)
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
default_voice, language=resolved_lang, opts=opts, voice_map=voice_map
|
||||
)
|
||||
|
||||
total = len(plan.chapters)
|
||||
chapter_files: list[str] = []
|
||||
@@ -980,11 +877,10 @@ async def _render_longform_sse(
|
||||
interrupted = True
|
||||
break
|
||||
try:
|
||||
wav_path, dur, was_cached, seg_stats = await _run_chapter(
|
||||
chapter, operation=operation, decision=decision, job=chapter_run,
|
||||
default_voice=default_voice, language=resolved_lang,
|
||||
opts=opts, voice_map=voice_map, lexicon=lexicon,
|
||||
cache_dir=cache_dir,
|
||||
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached,
|
||||
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
|
||||
resolved_lang, opts, voice_map,
|
||||
)
|
||||
except Exception as e: # isolate a bad chapter — keep going
|
||||
logger.warning("[%s] chapter %d (%s) failed to render",
|
||||
@@ -1022,11 +918,6 @@ async def _render_longform_sse(
|
||||
ev["cached_segments"] = seg_stats["cached"]
|
||||
yield _emit(ev)
|
||||
|
||||
route_notice = chapter_run.notice()
|
||||
if route_notice is not None:
|
||||
yield _emit({"type": "routing_notice", "status": route_notice[0],
|
||||
"reason": route_notice[1]})
|
||||
|
||||
if interrupted:
|
||||
logger.info("[%s] client disconnected — stopped after %d/%d chapters",
|
||||
job_id, len(chapter_files), total)
|
||||
@@ -1142,25 +1033,6 @@ async def _render_longform_sse(
|
||||
yield _emit({"type": "error", "error": "render failed (see backend log)"})
|
||||
|
||||
|
||||
async def _public_longform_stream(plan, **render_kwargs):
|
||||
"""Keep generator diagnostics local if setup fails before its own guard."""
|
||||
try:
|
||||
async for event in _render_longform_sse(plan, **render_kwargs):
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
from core.public_errors import public_failure
|
||||
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Longform response stream failed",
|
||||
exc,
|
||||
response="Render failed; check the backend log for details.",
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': error})}\n\n"
|
||||
|
||||
|
||||
@router.post("/audiobook")
|
||||
async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
"""Synthesize a chapterized audiobook from a script, streaming SSE progress."""
|
||||
@@ -1169,7 +1041,7 @@ async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
# to a direct in-process call, e.g. a unit test); its disconnect poll is what
|
||||
# lets Stop cancel the render mid-book (#1216).
|
||||
return StreamingResponse(
|
||||
_public_longform_stream(
|
||||
_render_longform_sse(
|
||||
plan, default_voice=req.default_voice, language=req.language,
|
||||
fmt=req.format, bitrate=req.bitrate,
|
||||
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
|
||||
@@ -1230,7 +1102,7 @@ async def longform_render(req: LongformRenderRequest, request: Request = None):
|
||||
chapters.append(Chapter(title=c.title or f"Chapter {i + 1}", spans=spans))
|
||||
plan = AudiobookPlan(chapters=chapters)
|
||||
return StreamingResponse(
|
||||
_public_longform_stream(
|
||||
_render_longform_sse(
|
||||
plan, default_voice=req.default_voice, language=req.language,
|
||||
fmt=req.format, bitrate=req.bitrate,
|
||||
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
|
||||
@@ -1323,7 +1195,7 @@ async def resume_longform(job_id: str, request: Request = None):
|
||||
# unrendered ones synthesize. Using a fresh id means the request's job_id
|
||||
# never names a work dir / output file (defence-in-depth path-injection).
|
||||
return StreamingResponse(
|
||||
_public_longform_stream(
|
||||
_render_longform_sse(
|
||||
plan, default_voice=p.get("default_voice"), language=p.get("language"),
|
||||
fmt=p.get("fmt", "m4b"), bitrate=p.get("bitrate", "128k"),
|
||||
loudness=p.get("loudness"), cover_path=p.get("cover_path"),
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Short-lived credentials for the first-party remote administration UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
authorization_credential_present,
|
||||
legacy_master_cookie_valid,
|
||||
master_header_valid,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import cookie_csrf_allowed, effective_scheme
|
||||
from services.admin_sessions import (
|
||||
SESSION_TTL_SECONDS,
|
||||
WS_TICKET_TTL_SECONDS,
|
||||
admin_session_store,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
_FAILED_EXCHANGE_LIMIT = 10
|
||||
_FAILED_EXCHANGE_WINDOW_SECONDS = 60
|
||||
_MAX_TRACKED_CLIENTS = 1024
|
||||
|
||||
|
||||
class _ExchangeAttemptLimiter:
|
||||
"""Bounded per-client sliding window for failed pre-auth exchanges."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
limit: int = _FAILED_EXCHANGE_LIMIT,
|
||||
window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS,
|
||||
max_clients: int = _MAX_TRACKED_CLIENTS,
|
||||
) -> None:
|
||||
if limit <= 0 or window_seconds <= 0 or max_clients <= 0:
|
||||
raise ValueError("rate-limit bounds must be positive")
|
||||
self._monotonic = monotonic
|
||||
self._limit = limit
|
||||
self._window_seconds = window_seconds
|
||||
self._max_clients = max_clients
|
||||
self._attempts: OrderedDict[str, deque[float]] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register_failure(self, client_id: str) -> int | None:
|
||||
now = self._monotonic()
|
||||
cutoff = now - self._window_seconds
|
||||
with self._lock:
|
||||
failures = self._attempts.setdefault(client_id, deque())
|
||||
while failures and failures[0] <= cutoff:
|
||||
failures.popleft()
|
||||
self._attempts.move_to_end(client_id)
|
||||
while len(self._attempts) > self._max_clients:
|
||||
self._attempts.popitem(last=False)
|
||||
if len(failures) >= self._limit:
|
||||
return max(
|
||||
1,
|
||||
math.ceil(self._window_seconds - (now - failures[0])),
|
||||
)
|
||||
failures.append(now)
|
||||
return None
|
||||
|
||||
def clear(self, client_id: str) -> None:
|
||||
with self._lock:
|
||||
self._attempts.pop(client_id, None)
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._attempts.clear()
|
||||
|
||||
|
||||
_exchange_attempt_limiter = _ExchangeAttemptLimiter()
|
||||
|
||||
|
||||
class SessionRequest(BaseModel):
|
||||
transport: Literal["cookie", "bearer"]
|
||||
|
||||
|
||||
class WebSocketTicketRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
def _secure_cookie(request: Request) -> bool:
|
||||
# Same effective-scheme logic as the exact-origin CSRF check: the resolved
|
||||
# scope first (uvicorn's trusted-proxy rewrite), upgraded — never
|
||||
# downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn
|
||||
# doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on
|
||||
# a plain-http hop can only ADD the Secure flag, which fails safe: the
|
||||
# browser drops such a cookie, so the spoofer only breaks their own
|
||||
# session. See core.csrf.effective_scheme for the full analysis.
|
||||
return effective_scheme(request) == "https"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None:
|
||||
response.set_cookie(
|
||||
"ov_session",
|
||||
token,
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
expires=datetime.fromtimestamp(expires_at, tz=UTC),
|
||||
path="/",
|
||||
secure=_secure_cookie(request),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _expire_cookie(response: Response, request: Request, name: str) -> None:
|
||||
response.delete_cookie(
|
||||
name,
|
||||
path="/",
|
||||
secure=_secure_cookie(request),
|
||||
httponly=name == "ov_session",
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _client_id(request: Request) -> str:
|
||||
host = request.client.host if request.client else "unknown"
|
||||
return str(host).strip().lower()[:255] or "unknown"
|
||||
|
||||
|
||||
def _reject_master_exchange(request: Request) -> None:
|
||||
retry_after = _exchange_attempt_limiter.register_failure(_client_id(request))
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many authentication attempts",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
def create_session(payload: SessionRequest, request: Request) -> Response:
|
||||
configured = remote_api_key()
|
||||
if not configured:
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
authorization_present = authorization_credential_present(request)
|
||||
header_authorized = master_header_valid(request)
|
||||
legacy_authorized = legacy_master_cookie_valid(request)
|
||||
migrating_legacy = False
|
||||
|
||||
if authorization_present:
|
||||
if not header_authorized:
|
||||
_reject_master_exchange(request)
|
||||
elif legacy_authorized:
|
||||
if payload.transport != "cookie" or not cookie_csrf_allowed(request):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
migrating_legacy = True
|
||||
else:
|
||||
_reject_master_exchange(request)
|
||||
|
||||
_exchange_attempt_limiter.clear(_client_id(request))
|
||||
issued = admin_session_store.issue(configured)
|
||||
if payload.transport == "bearer":
|
||||
return JSONResponse(
|
||||
{
|
||||
"token": issued.token,
|
||||
"expires_at": issued.expires_at,
|
||||
"expires_in": SESSION_TTL_SECONDS,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
response = Response(status_code=204)
|
||||
_set_session_cookie(response, request, issued.token, issued.expires_at)
|
||||
if migrating_legacy or request.cookies.get("ov_key"):
|
||||
_expire_cookie(response, request, "ov_key")
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/session", status_code=204)
|
||||
def delete_session(request: Request) -> Response:
|
||||
principal = principal_for(request)
|
||||
if principal.kind is PrincipalKind.ADMIN_SESSION:
|
||||
if (
|
||||
principal.transport is CredentialTransport.COOKIE
|
||||
and not cookie_csrf_allowed(request)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
admin_session_store.revoke_by_credential(principal.credential_id)
|
||||
response = Response(status_code=204)
|
||||
_expire_cookie(response, request, "ov_session")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/ws-ticket")
|
||||
def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse:
|
||||
principal = principal_for(request)
|
||||
if principal.kind is not PrincipalKind.ADMIN_SESSION:
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
if (
|
||||
principal.transport is CredentialTransport.COOKIE
|
||||
and not cookie_csrf_allowed(request)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
try:
|
||||
ticket = admin_session_store.issue_ws_ticket_for_credential(
|
||||
principal.credential_id,
|
||||
payload.path,
|
||||
remote_api_key(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401, detail="admin session required") from None
|
||||
return JSONResponse(
|
||||
{
|
||||
"ticket": ticket.token,
|
||||
"expires_at": ticket.expires_at,
|
||||
"expires_in": WS_TICKET_TTL_SECONDS,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
+83
-594
@@ -9,8 +9,6 @@ the SQLite `jobs` table for history, but the queue itself restarts empty
|
||||
on backend restart — intentional, since GPU jobs can't be safely resumed.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
@@ -18,42 +16,20 @@ import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, File, UploadFile, HTTPException, Form
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core import failure
|
||||
from core.logging_utils import log_safe
|
||||
from core.file_cleanup import FileCleanupError, unlink_if_present
|
||||
from services.dub_batching import (
|
||||
BATCH_WIDTH_ENV,
|
||||
batch_timeout_s as _batch_timeout_s,
|
||||
native_batch_width as _native_batch_width,
|
||||
)
|
||||
from services import gpu_gateway
|
||||
from services.segment_bundle import extract_segment_wavs, remove_segment_wavs
|
||||
from services.tts_backend import active_backend_id, resolve_generation_backend
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.batch")
|
||||
|
||||
# Compatibility values emitted by the established Tauri Batch picker. They
|
||||
# are taxonomy tokens, not arbitrary prose, and are resolved server-side so
|
||||
# native watch-folder uploads and both desktop clients use the same voice.
|
||||
_BATCH_PRESET_INSTRUCT = {
|
||||
"narrator": "male, middle-aged, low pitch, british accent",
|
||||
"excited_child": "child, high pitch",
|
||||
"anxious_whisper": "young adult, whisper",
|
||||
"surprised_woman": "female, young adult, high pitch",
|
||||
"elderly_story": "male, elderly, very low pitch",
|
||||
"sichuan": "female, young adult, moderate pitch, \u56db\u5ddd\u8bdd",
|
||||
}
|
||||
|
||||
# ── In-memory queue ─────────────────────────────────────────────────────
|
||||
|
||||
_queue: asyncio.Queue = None # Lazily initialised
|
||||
_worker_task: asyncio.Task = None # Background consumer
|
||||
_processing_job_ids: set[str] = set()
|
||||
_jobs: dict = {} # job_id → status dict
|
||||
|
||||
|
||||
@@ -64,15 +40,11 @@ class BatchJobStatus(BaseModel):
|
||||
langs: List[str]
|
||||
voice_id: Optional[str] = None
|
||||
preserve_bg: bool = True
|
||||
translation_provider: Optional[str] = None
|
||||
created_at: float
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
progress: Optional[dict] = None
|
||||
attempts: int = 1
|
||||
retry_ready: bool = True
|
||||
setup_required: Optional[dict] = None
|
||||
|
||||
|
||||
def _ensure_queue():
|
||||
@@ -94,7 +66,6 @@ async def _worker():
|
||||
|
||||
job["status"] = "running"
|
||||
job["started_at"] = time.time()
|
||||
_processing_job_ids.add(job_id)
|
||||
logger.info("Batch job %s starting: %s", job_id, job["filename"])
|
||||
|
||||
try:
|
||||
@@ -124,9 +95,6 @@ async def _worker():
|
||||
job["finished_at"] = time.time()
|
||||
logger.error("Batch job %s failed: %s", job_id, e, exc_info=True)
|
||||
finally:
|
||||
_processing_job_ids.discard(job_id)
|
||||
if job["status"] == "cancelled":
|
||||
job["retry_ready"] = True
|
||||
_queue.task_done()
|
||||
|
||||
|
||||
@@ -135,136 +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.
|
||||
|
||||
#: 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.
|
||||
|
||||
# Bound each allocation while persisting multipart uploads. Video inputs can
|
||||
# be many gigabytes; `await UploadFile.read()` with no size used to mirror the
|
||||
# entire file in process memory before writing it back out.
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
_REMOTE_BATCH_OPERATION = "batch_segments"
|
||||
|
||||
|
||||
async def _resolve_batch_execution(voice: dict):
|
||||
"""Resolve Batch's TTS target without loading local weights remotely."""
|
||||
engine_id = active_backend_id()
|
||||
decision = gpu_gateway.decide("batch")
|
||||
if decision.remote:
|
||||
await gpu_gateway.preflight(
|
||||
engine_id,
|
||||
decision,
|
||||
operation=_REMOTE_BATCH_OPERATION,
|
||||
)
|
||||
return engine_id, decision, None
|
||||
backend = await resolve_generation_backend(
|
||||
require_cloning=voice["requires_cloning"],
|
||||
cloning_purpose="this batch job's pinned voice",
|
||||
)
|
||||
return engine_id, decision, backend
|
||||
|
||||
|
||||
def _decode_remote_batch(
|
||||
result: gpu_gateway.RemoteResult,
|
||||
batch_dir: str,
|
||||
expected: set[int],
|
||||
) -> tuple[dict[int, str], int]:
|
||||
"""Validate and unpack one worker result before accepting remote success."""
|
||||
import soundfile as sf
|
||||
|
||||
target = os.path.join(batch_dir, ".remote", result.task_id)
|
||||
paths = extract_segment_wavs(result.path or "", target)
|
||||
try:
|
||||
if set(paths) != expected:
|
||||
missing = sorted(expected - set(paths))
|
||||
extra = sorted(set(paths) - expected)
|
||||
raise ValueError(
|
||||
f"segment bundle mismatch (missing={missing}, extra={extra})"
|
||||
)
|
||||
rates = {int(sf.info(path).samplerate) for path in paths.values()}
|
||||
if len(rates) != 1 or next(iter(rates), 0) <= 0:
|
||||
raise ValueError("segment bundle has inconsistent sample rates")
|
||||
return paths, rates.pop()
|
||||
except BaseException:
|
||||
remove_segment_wavs(paths)
|
||||
raise
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: str) -> None:
|
||||
try:
|
||||
with open(destination, "wb") as output:
|
||||
while chunk := await upload.read(_UPLOAD_CHUNK_BYTES):
|
||||
output.write(chunk)
|
||||
except BaseException:
|
||||
try:
|
||||
unlink_if_present(destination)
|
||||
except FileCleanupError:
|
||||
logger.warning("Could not remove incomplete batch upload", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _batch_voice(voice_id: str | None) -> dict:
|
||||
"""Resolve one queue-wide voice into concrete generation inputs.
|
||||
|
||||
Clone profiles contribute their reference; designed profiles contribute
|
||||
their healed instruction and seed. Legacy ``preset:`` selections become
|
||||
the same instruction used by Dubbing instead of falling through to the
|
||||
engine default.
|
||||
"""
|
||||
resolved = {
|
||||
"ref_audio": None,
|
||||
"ref_text": None,
|
||||
"instruct": "",
|
||||
"seed": None,
|
||||
"requires_cloning": False,
|
||||
}
|
||||
if not voice_id:
|
||||
return resolved
|
||||
if voice_id.startswith("preset:"):
|
||||
preset_id = voice_id.removeprefix("preset:")
|
||||
instruct = _BATCH_PRESET_INSTRUCT.get(preset_id)
|
||||
if instruct is None:
|
||||
raise ValueError("That built-in voice preset no longer exists")
|
||||
from omnivoice.utils.voice_design import sanitize_instruct
|
||||
|
||||
resolved["instruct"] = sanitize_instruct(instruct)
|
||||
return resolved
|
||||
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn
|
||||
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?",
|
||||
(voice_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("That saved voice no longer exists")
|
||||
|
||||
if row["kind"] == "design":
|
||||
from omnivoice.utils.voice_design import heal_design_instruct
|
||||
|
||||
resolved["instruct"] = heal_design_instruct(row["instruct"], row["vd_states"])
|
||||
resolved["seed"] = int(row["seed"]) if row["seed"] is not None else None
|
||||
return resolved
|
||||
|
||||
relative = row["locked_audio_path"] if row["is_locked"] else row["ref_audio_path"]
|
||||
if not relative:
|
||||
raise ValueError("That saved voice has no reference audio")
|
||||
ref_audio = os.path.join(VOICES_DIR, relative)
|
||||
if not os.path.isfile(ref_audio):
|
||||
raise ValueError("That saved voice's reference audio is missing")
|
||||
resolved.update({
|
||||
"ref_audio": ref_audio,
|
||||
"ref_text": row["ref_text"],
|
||||
"requires_cloning": True,
|
||||
})
|
||||
return resolved
|
||||
|
||||
|
||||
async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
"""Full batch dub pipeline: extract → transcribe → translate → generate → mix → export."""
|
||||
import subprocess
|
||||
@@ -313,17 +151,14 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# ── 2. Transcribe ─────────────────────────────────────────────────
|
||||
_set_progress(job, "transcribe", 0)
|
||||
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
from services.model_manager import _gpu_pool, _cpu_pool, run_on_gpu_pool_guarded
|
||||
from services.segmentation import (
|
||||
segment_transcript, assign_speakers_heuristic,
|
||||
)
|
||||
|
||||
def _transcribe():
|
||||
# `load_*`, not `get_*`: the plain selector returns engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, failing
|
||||
# the whole batch job at `.transcribe()` instead of degrading (#1185).
|
||||
backend = load_active_asr_backend()
|
||||
backend = get_active_asr_backend()
|
||||
result = backend.transcribe(audio_path, word_timestamps=True)
|
||||
detected_lang = result.get("language", "en")
|
||||
segments = segment_transcript(result, duration=duration)
|
||||
@@ -355,30 +190,19 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
return
|
||||
|
||||
# ── Engine resolution (issue #312 class) ────────────────────────────
|
||||
# Batch used to hardcode VoiceStudio regardless of the engine selected in
|
||||
# Model Catalogue. Clone profiles require a cloning-capable engine; presets
|
||||
# and designed voices use instruction mode. Resolve once for the whole job.
|
||||
# Batch used to hardcode VoiceStudio via get_model() regardless of the
|
||||
# engine selected in Settings → Engines. require_cloning only when a
|
||||
# specific voice is pinned (job["voice_id"]) — an unpinned job is fine on
|
||||
# any active engine. Resolved ONCE for the whole job (every language
|
||||
# below shares the same active engine); an uncaught ValueError here
|
||||
# propagates to _worker()'s existing except-Exception handling, which
|
||||
# already records a structured job failure via core.failure.build_failure.
|
||||
voice = _batch_voice(job.get("voice_id"))
|
||||
engine_id, execution_target, backend = await _resolve_batch_execution(voice)
|
||||
sr = backend.sample_rate if backend is not None else 0
|
||||
from services.performance_profiles import tts_defaults
|
||||
_profile_defaults = tts_defaults(engine_id)
|
||||
_batch_num_step = _profile_defaults.get("num_step", 16)
|
||||
_batch_postprocess = _profile_defaults.get("postprocess_output", True)
|
||||
batch_run = gpu_gateway.JobRun("batch")
|
||||
|
||||
async def _prepare_local_batch() -> gpu_gateway.LocalCall:
|
||||
nonlocal backend, sr
|
||||
if backend is None:
|
||||
backend = await resolve_generation_backend(
|
||||
require_cloning=voice["requires_cloning"],
|
||||
cloning_purpose="this batch job's pinned voice",
|
||||
)
|
||||
sr = backend.sample_rate
|
||||
return gpu_gateway.LocalCall(fn=lambda: None, what="Batch TTS fallback")
|
||||
from services.tts_backend import resolve_generation_backend
|
||||
backend = await resolve_generation_backend(
|
||||
require_cloning=bool(job.get("voice_id")),
|
||||
cloning_purpose="this batch job's pinned voice",
|
||||
)
|
||||
sr = backend.sample_rate
|
||||
|
||||
# ── 3. Translate + Generate per language ───────────────────────────
|
||||
total_langs = len(langs)
|
||||
@@ -397,65 +221,40 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
|
||||
translated_segments = list(segments) # copy
|
||||
if target_lang != source_lang:
|
||||
# Use the same provider dispatch as interactive Dubbing. The old
|
||||
# batch-only implementation hardcoded Google and silently kept the
|
||||
# source text on failure, which could make an English track labelled
|
||||
# "es" while also sending text online despite an offline selection.
|
||||
from api.routers.dub_translate import dub_translate
|
||||
from schemas.requests import TranslateRequest
|
||||
|
||||
from core import prefs
|
||||
|
||||
provider = job.get("translation_provider") or prefs.get("translation_backend", "argos")
|
||||
translation = await dub_translate(TranslateRequest(
|
||||
segments=[
|
||||
{
|
||||
"id": str(segment["id"]),
|
||||
"text": segment.get("text", ""),
|
||||
"start": segment.get("start"),
|
||||
"end": segment.get("end"),
|
||||
try:
|
||||
def _translate_batch(segs, src, tgt):
|
||||
"""Translate segment texts via Google Translate."""
|
||||
from deep_translator import GoogleTranslator
|
||||
TRANSLATE_CODES = {
|
||||
"en": "en", "es": "es", "fr": "fr", "de": "de",
|
||||
"it": "it", "pt": "pt", "ru": "ru", "ja": "ja",
|
||||
"ko": "ko", "zh": "zh-CN", "ar": "ar", "hi": "hi",
|
||||
"tr": "tr", "pl": "pl", "nl": "nl", "sv": "sv",
|
||||
}
|
||||
for segment in segments
|
||||
],
|
||||
source_lang=source_lang,
|
||||
target_lang=target_lang,
|
||||
provider=provider,
|
||||
quality="fast",
|
||||
))
|
||||
if isinstance(translation, JSONResponse):
|
||||
try:
|
||||
payload = json.loads(translation.body)
|
||||
detail = payload.get("error") or payload.get("detail")
|
||||
if payload.get("code") == "argos_pack_missing":
|
||||
job["setup_required"] = {
|
||||
"kind": "argos_packs",
|
||||
"source_lang": source_lang,
|
||||
"target_langs": [
|
||||
pair["target_lang"]
|
||||
for pair in payload.get("pairs", [])
|
||||
if isinstance(pair, dict) and pair.get("target_lang")
|
||||
],
|
||||
}
|
||||
except Exception: # noqa: BLE001 — retain the stable fallback
|
||||
detail = None
|
||||
raise RuntimeError(
|
||||
detail or f"{provider} could not translate this batch"
|
||||
src_code = TRANSLATE_CODES.get(src, src) or "auto"
|
||||
tgt_code = TRANSLATE_CODES.get(tgt, tgt)
|
||||
translator = GoogleTranslator(source=src_code, target=tgt_code)
|
||||
out = []
|
||||
for s in segs:
|
||||
s_copy = dict(s)
|
||||
text = s.get("text", "").strip()
|
||||
if text:
|
||||
try:
|
||||
s_copy["text"] = translator.translate(text) or text
|
||||
except Exception as e:
|
||||
logger.warning("Translate seg failed: %s", e)
|
||||
out.append(s_copy)
|
||||
return out
|
||||
|
||||
translated_segments = await loop.run_in_executor(
|
||||
_cpu_pool, _translate_batch,
|
||||
segments, source_lang, target_lang,
|
||||
)
|
||||
rows = {
|
||||
str(row.get("id")): row
|
||||
for row in translation.get("translated", [])
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
failed = [row for row in rows.values() if row.get("error")]
|
||||
if failed or len(rows) != len(segments):
|
||||
raise RuntimeError(
|
||||
f"{provider} translation failed for "
|
||||
f"{len(failed) or len(segments) - len(rows)} segment(s)"
|
||||
)
|
||||
translated_segments = [
|
||||
{**segment, "text": rows[str(segment["id"])]["text"]}
|
||||
for segment in segments
|
||||
]
|
||||
except ImportError:
|
||||
logger.warning("deep_translator not installed, skipping translation for %s", target_lang)
|
||||
except Exception as e:
|
||||
logger.warning("Translation failed for %s: %s, using original", target_lang, e)
|
||||
translated_segments = segments
|
||||
|
||||
if job["status"] == "cancelled":
|
||||
return
|
||||
@@ -473,194 +272,12 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
from services.audio_io import atomic_save_wav
|
||||
import torch
|
||||
|
||||
remote_segments: dict[int, str] = {}
|
||||
valid_rows = [
|
||||
(i, segment)
|
||||
for i, segment in enumerate(translated_segments)
|
||||
if segment.get("end", 0) - segment.get("start", 0) > 0.05
|
||||
and segment.get("text", "").strip()
|
||||
]
|
||||
if execution_target.remote and valid_rows:
|
||||
remote_rows = [
|
||||
{
|
||||
"index": i,
|
||||
"text": segment.get("text", "").strip(),
|
||||
"language": target_lang,
|
||||
"ref_text": voice["ref_text"],
|
||||
"instruct": voice["instruct"] or None,
|
||||
"duration": segment.get("end", 0) - segment.get("start", 0),
|
||||
"num_step": _batch_num_step,
|
||||
"postprocess_output": _batch_postprocess,
|
||||
"guidance_scale": 2.0,
|
||||
"speed": 1.0,
|
||||
"effect_preset": "batch",
|
||||
"seed": (
|
||||
voice["seed"] + i if voice["seed"] is not None else None
|
||||
),
|
||||
# The assembled track receives one watermark below. Marking
|
||||
# each line here would double-process remote output.
|
||||
"watermark": False,
|
||||
}
|
||||
for i, segment in valid_rows
|
||||
]
|
||||
expected = {row["index"] for row in remote_rows}
|
||||
|
||||
def _remote_state(state: dict) -> None:
|
||||
fraction = max(0.0, min(1.0, float(state.get("progress") or 0.0)))
|
||||
_set_progress(
|
||||
job,
|
||||
"generate",
|
||||
percent=int(((lang_idx + fraction) / total_langs) * 100),
|
||||
current_lang=target_lang,
|
||||
current_segment=min(len(remote_rows), round(fraction * len(remote_rows))),
|
||||
total_segments=len(remote_rows),
|
||||
execution_target=execution_target.label,
|
||||
execution_phase=state.get("phase"),
|
||||
)
|
||||
|
||||
route_task = asyncio.create_task(
|
||||
gpu_gateway.run(
|
||||
"batch",
|
||||
local=gpu_gateway.LocalCall(prepare=_prepare_local_batch),
|
||||
remote=gpu_gateway.RemoteCall(
|
||||
engine=engine_id,
|
||||
operation=_REMOTE_BATCH_OPERATION,
|
||||
params={
|
||||
"segments": remote_rows,
|
||||
"ref_audio": [voice["ref_audio"] for _ in remote_rows],
|
||||
"input_seconds": sum(
|
||||
float(row.get("duration") or 0.0) for row in remote_rows
|
||||
),
|
||||
},
|
||||
idempotency_key=f"batch:{job_id}:{target_lang}",
|
||||
decode=lambda result: _decode_remote_batch(
|
||||
result, batch_dir, expected
|
||||
),
|
||||
),
|
||||
decision=execution_target,
|
||||
job=batch_run,
|
||||
on_state=_remote_state,
|
||||
)
|
||||
)
|
||||
while not route_task.done():
|
||||
await asyncio.wait({route_task}, timeout=0.25)
|
||||
if job["status"] == "cancelled":
|
||||
route_task.cancel()
|
||||
try:
|
||||
await route_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
return
|
||||
routed = route_task.result()
|
||||
if routed is not None:
|
||||
remote_segments, sr = routed
|
||||
|
||||
# A remote-only empty transcript still needs a valid silent-track rate.
|
||||
sr = sr or 24_000
|
||||
total_samples = int(duration * sr)
|
||||
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 = (
|
||||
backend is not None
|
||||
and type(backend).generate_batch is not TTSBackend.generate_batch
|
||||
)
|
||||
if has_native_batch:
|
||||
from services.text_normalization import normalize_for_tts
|
||||
|
||||
batch_ref_audio = voice["ref_audio"]
|
||||
batch_ref_text = voice["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():
|
||||
if voice["seed"] is not None:
|
||||
torch.manual_seed(voice["seed"])
|
||||
generated = backend.generate_batch(
|
||||
batch_texts,
|
||||
language=target_lang,
|
||||
ref_audio=batch_ref_audio,
|
||||
ref_text=batch_ref_text,
|
||||
instruct=voice["instruct"] or None,
|
||||
duration=batch_durations,
|
||||
num_step=_batch_num_step,
|
||||
guidance_scale=2.0,
|
||||
speed=1.0,
|
||||
denoise=True,
|
||||
postprocess_output=_batch_postprocess,
|
||||
)
|
||||
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":
|
||||
remove_segment_wavs(remote_segments)
|
||||
return
|
||||
|
||||
_set_progress(
|
||||
@@ -687,18 +304,32 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
from services.text_normalization import normalize_for_tts
|
||||
text = normalize_for_tts(text, lang)
|
||||
|
||||
ref_audio = None
|
||||
ref_text = None
|
||||
|
||||
# Use voice_id if provided
|
||||
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"]:
|
||||
ref_audio = os.path.join(_VD, row["locked_audio_path"])
|
||||
elif row["ref_audio_path"]:
|
||||
ref_audio = os.path.join(_VD, row["ref_audio_path"])
|
||||
ref_text = row.get("ref_text")
|
||||
|
||||
try:
|
||||
if backend is None:
|
||||
raise RuntimeError("the local TTS fallback was not prepared")
|
||||
if voice["seed"] is not None:
|
||||
torch.manual_seed(voice["seed"] + i)
|
||||
audio_out = backend.generate(
|
||||
text=text, language=lang,
|
||||
ref_audio=voice["ref_audio"], ref_text=voice["ref_text"],
|
||||
instruct=voice["instruct"] or None,
|
||||
duration=dur, num_step=_batch_num_step,
|
||||
ref_audio=ref_audio, ref_text=ref_text,
|
||||
duration=dur, num_step=16,
|
||||
guidance_scale=2.0, speed=1.0,
|
||||
denoise=True, postprocess_output=_batch_postprocess,
|
||||
denoise=True, postprocess_output=True,
|
||||
)
|
||||
if not getattr(backend, "applies_own_mastering", False):
|
||||
audio_out = apply_mastering(audio_out, sample_rate=sr)
|
||||
@@ -722,43 +353,10 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# Budget is the shared length-scaled one (#1190): a long segment
|
||||
# on CPU-class hardware no longer dies on the flat 300s.
|
||||
from services.model_manager import generate_timeout_s
|
||||
remote_path = remote_segments.pop(i, None)
|
||||
if remote_path is not None:
|
||||
import soundfile as sf
|
||||
|
||||
try:
|
||||
audio_array, remote_sr = sf.read(
|
||||
remote_path,
|
||||
dtype="float32",
|
||||
always_2d=True,
|
||||
)
|
||||
if int(remote_sr) != sr:
|
||||
raise ValueError(
|
||||
f"remote segment sample rate changed from {sr} to {remote_sr}"
|
||||
)
|
||||
audio_tensor = torch.from_numpy(audio_array.T).mean(
|
||||
dim=0,
|
||||
keepdim=True,
|
||||
)
|
||||
finally:
|
||||
remove_segment_wavs({i: remote_path})
|
||||
else:
|
||||
if backend is None:
|
||||
await _prepare_local_batch()
|
||||
# This path means a validated remote bundle lost a row
|
||||
# after dispatch. Recover only that row; native batches
|
||||
# were not planned for this language.
|
||||
has_native_batch = False
|
||||
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)
|
||||
@@ -806,23 +404,25 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
f"left silent: {e}"
|
||||
)
|
||||
|
||||
remove_segment_wavs(remote_segments)
|
||||
|
||||
# ── 3c. Save dubbed audio track ───────────────────────────────
|
||||
# Invisible provenance mark on the assembled track (#1169), tensor
|
||||
# stage, before the WAV write / aac mux — batch dubs used to ship
|
||||
# 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
|
||||
@@ -874,7 +474,6 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
outputs[target_lang] = output_path
|
||||
|
||||
job["outputs"] = outputs
|
||||
job.pop("setup_required", None)
|
||||
_set_progress(job, "done", 100)
|
||||
|
||||
|
||||
@@ -886,7 +485,6 @@ async def enqueue_batch_job(
|
||||
langs: str = Form("es"), # comma-separated lang codes
|
||||
voice_id: Optional[str] = Form(None),
|
||||
preserve_bg: bool = Form(True),
|
||||
translation_provider: Optional[str] = Form(None),
|
||||
):
|
||||
"""Enqueue a video for batch dubbing.
|
||||
|
||||
@@ -900,14 +498,6 @@ async def enqueue_batch_job(
|
||||
if not lang_list:
|
||||
raise HTTPException(400, "At least one target language is required")
|
||||
|
||||
# Validate the snapshot before persisting a potentially large upload.
|
||||
# Resolve it again in the worker so deleting or editing a queued profile
|
||||
# cannot silently fall back to the engine's default voice.
|
||||
try:
|
||||
await asyncio.to_thread(_batch_voice, voice_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download CTA
|
||||
# now, instead of accepting the job and having the transcribe stage
|
||||
# silently auto-download multi-GB whisper weights (or fail) in the worker.
|
||||
@@ -916,26 +506,15 @@ async def enqueue_batch_job(
|
||||
if missing is not None:
|
||||
raise HTTPException(409, {**missing, "message": asr_model_missing_detail(missing)})
|
||||
|
||||
# Snapshot the selected translation engine when the user enqueues the job,
|
||||
# so a later Settings change cannot alter work already waiting in the queue.
|
||||
from core import prefs
|
||||
from services import translation_engines
|
||||
|
||||
provider = translation_provider or prefs.get("translation_backend", "argos")
|
||||
if not translation_engines.get_engine(provider):
|
||||
raise HTTPException(400, "Unknown translation engine")
|
||||
if not translation_engines.is_installed(provider):
|
||||
raise HTTPException(409, "Install the selected translation engine before adding this batch")
|
||||
if not translation_engines.is_ready(provider):
|
||||
raise HTTPException(409, "Configure the selected translation provider before adding this batch")
|
||||
|
||||
# Save the uploaded video
|
||||
batch_dir = os.path.join(DATA_DIR, "batch")
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
|
||||
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
|
||||
|
||||
await _save_upload(video, video_path)
|
||||
with open(video_path, "wb") as f:
|
||||
content = await video.read()
|
||||
f.write(content)
|
||||
|
||||
job = {
|
||||
"id": job_id,
|
||||
@@ -945,9 +524,7 @@ async def enqueue_batch_job(
|
||||
"langs": lang_list,
|
||||
"voice_id": voice_id,
|
||||
"preserve_bg": preserve_bg,
|
||||
"translation_provider": provider,
|
||||
"created_at": time.time(),
|
||||
"attempts": 1,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
@@ -970,8 +547,6 @@ def list_batch_jobs(status: Optional[str] = None, limit: int = 50):
|
||||
if status:
|
||||
if status == "active":
|
||||
jobs = [j for j in jobs if j["status"] in ("queued", "running")]
|
||||
elif status == "retryable":
|
||||
jobs = [j for j in jobs if j["status"] in ("failed", "cancelled")]
|
||||
else:
|
||||
jobs = [j for j in jobs if j["status"] == status]
|
||||
jobs.sort(key=lambda j: j["created_at"], reverse=True)
|
||||
@@ -995,88 +570,14 @@ def cancel_batch_job(job_id: str):
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job["status"] in ("done", "failed", "cancelled"):
|
||||
return {"already": job["status"]}
|
||||
was_running = job["status"] == "running" or job_id in _processing_job_ids
|
||||
job["status"] = "cancelled"
|
||||
job["retry_ready"] = not was_running
|
||||
job["finished_at"] = time.time()
|
||||
return {"cancelled": True}
|
||||
|
||||
|
||||
@router.post("/batch/jobs/{job_id}/retry")
|
||||
async def retry_batch_job(job_id: str):
|
||||
"""Retry a terminal job using its original app-owned upload and settings."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job["status"] not in ("failed", "cancelled"):
|
||||
raise HTTPException(409, f"Job is {job['status']}, not retryable")
|
||||
if job_id in _processing_job_ids or not job.get("retry_ready", True):
|
||||
raise HTTPException(409, "The cancelled job is still stopping")
|
||||
if not os.path.isfile(job.get("video_path") or ""):
|
||||
raise HTTPException(409, "The original batch input is no longer available")
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_batch_voice, job.get("voice_id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(409, {**missing, "message": asr_model_missing_detail(missing)})
|
||||
|
||||
from services import translation_engines
|
||||
provider = job.get("translation_provider") or "argos"
|
||||
if not translation_engines.is_ready(provider):
|
||||
raise HTTPException(409, "Configure the selected translation provider before retrying")
|
||||
if provider == "argos" and job.get("source_lang"):
|
||||
status = await asyncio.to_thread(
|
||||
translation_engines.argos_pack_status,
|
||||
job["source_lang"],
|
||||
job["langs"],
|
||||
)
|
||||
if any(not pair["installed"] for pair in status["pairs"]):
|
||||
raise HTTPException(409, "Install the required Argos language packs before retrying")
|
||||
|
||||
batch_root = os.path.realpath(os.path.join(DATA_DIR, "batch"))
|
||||
output_dir = os.path.realpath(os.path.join(batch_root, job_id))
|
||||
if os.path.dirname(output_dir) != batch_root:
|
||||
raise HTTPException(status_code=400, detail="Invalid batch job path")
|
||||
try:
|
||||
if os.path.isdir(output_dir):
|
||||
await asyncio.to_thread(shutil.rmtree, output_dir)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Could not reset the batch output files. Close any app using them and retry.",
|
||||
) from exc
|
||||
|
||||
for key in (
|
||||
"duration",
|
||||
"segments",
|
||||
"source_lang",
|
||||
"outputs",
|
||||
"warnings",
|
||||
"setup_required",
|
||||
"retry_ready",
|
||||
):
|
||||
job.pop(key, None)
|
||||
job.update({
|
||||
"status": "queued",
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"error": None,
|
||||
"progress": None,
|
||||
"attempts": int(job.get("attempts", 1)) + 1,
|
||||
})
|
||||
_ensure_queue()
|
||||
await _queue.put(job_id)
|
||||
return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()}
|
||||
|
||||
|
||||
@router.delete("/batch/jobs/{job_id}")
|
||||
def delete_batch_job(job_id: str):
|
||||
"""Delete a batch job record and every app-owned input/output file."""
|
||||
"""Delete a batch job record and its video file."""
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
@@ -1088,18 +589,6 @@ def delete_batch_job(job_id: str):
|
||||
status_code=500,
|
||||
detail="Could not delete the batch video file. Close any app using it and retry.",
|
||||
) from exc
|
||||
batch_root = os.path.realpath(os.path.join(DATA_DIR, "batch"))
|
||||
output_dir = os.path.realpath(os.path.join(batch_root, job_id))
|
||||
if os.path.dirname(output_dir) != batch_root:
|
||||
raise HTTPException(status_code=400, detail="Invalid batch job path")
|
||||
try:
|
||||
if os.path.isdir(output_dir):
|
||||
shutil.rmtree(output_dir)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Could not delete the batch output files. Close any app using them and retry.",
|
||||
) from exc
|
||||
_jobs.pop(job_id, None)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
+17
-122
@@ -8,11 +8,8 @@ raw audio bytes and get back transcribed text immediately. Used by:
|
||||
• The MCP server's future `transcribe_audio` tool
|
||||
• CLI consumers that just want speech-to-text
|
||||
|
||||
The ASR engine is whatever `load_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured. The *loader*,
|
||||
not the bare selector: it also runs `ensure_loaded()` and falls through to
|
||||
the next healthy engine when the selected one has a broken deep import chain
|
||||
(#1185), which the shallow `is_available()` probe cannot see.
|
||||
The ASR engine is whatever `get_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,17 +25,6 @@ router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.capture")
|
||||
|
||||
|
||||
def _timing(value):
|
||||
"""A segment timing, or ``None`` when the engine could not determine one.
|
||||
|
||||
``dict.get(key, 0)`` hands back a stored ``None`` rather than the default,
|
||||
because the key is present — so rounding it raised and took a transcript
|
||||
that was otherwise fine down with it (#1904). Pass the null through instead:
|
||||
the segment list renders whichever half of the range is known.
|
||||
"""
|
||||
return round(value, 2) if isinstance(value, (int, float)) else None
|
||||
|
||||
|
||||
def _truthy(value: Optional[str]) -> bool:
|
||||
"""Parse a multipart form flag. Treats '1'/'true'/'yes'/'on'/'auto'
|
||||
(any case) as on; everything else — including None — as off."""
|
||||
@@ -60,8 +46,7 @@ async def transcribe_audio(
|
||||
language: Optional language hint (not currently used; auto-detected).
|
||||
model: Whisper model size (legacy; ignored in dual-mode architecture).
|
||||
mode: 'fast' (default) uses MLX Turbo for speed; 'accurate' uses
|
||||
the selected ASR engine with word-level timing. 'reference' uses
|
||||
the selected ASR engine without word-level timing.
|
||||
WhisperX with forced alignment for word-level timing.
|
||||
refine: Opt-in local-LLM cleanup of the final text (disfluencies,
|
||||
self-corrections, punctuation) — same pipeline the live
|
||||
dictation socket uses. Off by default so MCP/CLI callers don't
|
||||
@@ -92,9 +77,7 @@ async def transcribe_audio(
|
||||
tmp.write(content)
|
||||
tmp.close()
|
||||
|
||||
requested_mode = (mode or "").strip().lower()
|
||||
use_accurate = requested_mode == "accurate"
|
||||
use_active_asr = requested_mode in {"accurate", "reference"}
|
||||
use_accurate = (mode or "").strip().lower() == "accurate"
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download
|
||||
# CTA, BEFORE any backend is constructed (the whisper backends
|
||||
@@ -102,8 +85,7 @@ async def transcribe_audio(
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="transcribe" if use_active_asr else "dictation",
|
||||
require_installed=requested_mode == "reference",
|
||||
purpose="transcribe" if use_accurate else "dictation",
|
||||
)
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
@@ -112,16 +94,12 @@ async def transcribe_audio(
|
||||
)
|
||||
|
||||
def _run():
|
||||
if use_active_asr:
|
||||
if use_accurate:
|
||||
# Accurate mode: full WhisperX with forced alignment —
|
||||
# for when the user explicitly wants word-level timing.
|
||||
# `load_*`, not `get_*`: the selector alone hands back an
|
||||
# engine whose shallow probe passed but whose deep import
|
||||
# chain is broken, which then 500s at `.transcribe()`. The
|
||||
# loader degrades to the next healthy engine (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend(require_installed=True) if requested_mode == "reference" else load_active_asr_backend()
|
||||
result = backend.transcribe(tmp.name, word_timestamps=use_accurate)
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
result = backend.transcribe(tmp.name, word_timestamps=True)
|
||||
else:
|
||||
# Fast mode (default): use the fastest available engine
|
||||
# (MLX Turbo on Apple Silicon). Skip word_timestamps for
|
||||
@@ -129,18 +107,13 @@ async def transcribe_audio(
|
||||
from services.asr_backend import get_capture_asr_backend
|
||||
backend = get_capture_asr_backend()
|
||||
result = backend.transcribe(tmp.name, word_timestamps=False)
|
||||
sherpa_model_id = getattr(getattr(backend, "spec", None), "id", None)
|
||||
return result, backend.id, sherpa_model_id
|
||||
return result, backend.id
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result, engine_id, sherpa_model_id = await run_transcribe_guarded(
|
||||
result, engine_id = await run_transcribe_guarded(
|
||||
_gpu_pool, _run, what="Dictation",
|
||||
)
|
||||
except ASRTimeoutError as e:
|
||||
@@ -148,77 +121,6 @@ async def transcribe_audio(
|
||||
# silent hang the UI reads as "can't reach the local backend".
|
||||
logger.warning("Capture transcription timed out: %s", e)
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
# Degraded past the broken engine onto one with no weights on
|
||||
# disk — same typed 409 (+ download CTA) as the preflight above,
|
||||
# never a 500 and never a silent multi-GB auto-download.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
|
||||
# Some sherpa-onnx NeMo-TDT builds load successfully but decode an
|
||||
# entire spoken clip to no tokens. Live dictation already recovers
|
||||
# from that failure; the shared file endpoint must do the same because
|
||||
# it also powers uploaded transcription and automatic profile text.
|
||||
# Retry only through an already-installed fallback, and demote the
|
||||
# silent model only when the second recognizer actually heard words.
|
||||
initial_text = str(result.get("text") or "").strip()
|
||||
if not initial_text and result.get("segments"):
|
||||
initial_text = " ".join(
|
||||
str(segment.get("text") or "")
|
||||
for segment in result["segments"]
|
||||
if isinstance(segment, dict)
|
||||
).strip()
|
||||
recovered_from = None
|
||||
if not use_active_asr and sherpa_model_id and not initial_text:
|
||||
fallback_missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="dictation",
|
||||
skip_sherpa=True,
|
||||
require_installed=True,
|
||||
)
|
||||
if fallback_missing is None:
|
||||
def _run_fallback():
|
||||
from services.asr_backend import get_capture_asr_backend
|
||||
|
||||
fallback = get_capture_asr_backend(skip_sherpa=True)
|
||||
return (
|
||||
fallback.transcribe(tmp.name, word_timestamps=False),
|
||||
fallback.id,
|
||||
)
|
||||
|
||||
try:
|
||||
fallback_result, fallback_engine_id = await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
_run_fallback,
|
||||
what="Dictation fallback",
|
||||
)
|
||||
fallback_text = str(fallback_result.get("text") or "").strip()
|
||||
if not fallback_text and fallback_result.get("segments"):
|
||||
fallback_text = " ".join(
|
||||
str(segment.get("text") or "")
|
||||
for segment in fallback_result["segments"]
|
||||
if isinstance(segment, dict)
|
||||
).strip()
|
||||
if fallback_text:
|
||||
from services.sherpa_dictation import demote_model
|
||||
|
||||
await asyncio.to_thread(demote_model, sherpa_model_id)
|
||||
result = fallback_result
|
||||
engine_id = fallback_engine_id
|
||||
recovered_from = sherpa_model_id
|
||||
logger.warning(
|
||||
"File transcription recovered from silent dictation model %s "
|
||||
"through installed engine %s",
|
||||
sherpa_model_id,
|
||||
fallback_engine_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Installed fallback failed after dictation model %s returned no text",
|
||||
sherpa_model_id,
|
||||
)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
# Normalize result shape
|
||||
@@ -241,15 +143,10 @@ async def transcribe_audio(
|
||||
from services.text_polish import polish_text
|
||||
full_text = polish_text(full_text)
|
||||
|
||||
# Calculate audio duration from segments if available. A segment whose
|
||||
# timing the engine could not determine carries end=None (sherpa's
|
||||
# _sherpa_result when the sample rate yields no duration, and every
|
||||
# plain-text OpenAI-compatible response), so measure only the ones that
|
||||
# have a number and keep 0.0 when none do.
|
||||
# Calculate audio duration from segments if available
|
||||
duration = 0.0
|
||||
if segments:
|
||||
ends = [e for e in (s.get("end") for s in segments) if isinstance(e, (int, float))]
|
||||
duration = max(ends) if ends else 0.0
|
||||
duration = max(s.get("end", 0) for s in segments)
|
||||
|
||||
detected_lang = result.get("language", language or "unknown")
|
||||
|
||||
@@ -270,7 +167,7 @@ async def transcribe_audio(
|
||||
|
||||
logger.info(
|
||||
"Capture transcription done: engine=%s, elapsed=%.2fs, duration=%.1fs, mode=%s, refined=%s",
|
||||
engine_id, elapsed, duration, requested_mode if use_active_asr else "fast",
|
||||
engine_id, elapsed, duration, "accurate" if use_accurate else "fast",
|
||||
refined_text is not None,
|
||||
)
|
||||
|
||||
@@ -278,8 +175,8 @@ async def transcribe_audio(
|
||||
"text": full_text,
|
||||
"segments": [
|
||||
{
|
||||
"start": _timing(s.get("start", 0)),
|
||||
"end": _timing(s.get("end", 0)),
|
||||
"start": round(s.get("start", 0), 2),
|
||||
"end": round(s.get("end", 0), 2),
|
||||
"text": s.get("text", "").strip(),
|
||||
}
|
||||
for s in segments
|
||||
@@ -291,8 +188,6 @@ async def transcribe_audio(
|
||||
}
|
||||
if refined_text is not None:
|
||||
response["refined_text"] = refined_text
|
||||
if recovered_from is not None:
|
||||
response["model_silent"] = recovered_from
|
||||
return response
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -9,9 +9,7 @@ Protocol:
|
||||
→ Client sends binary audio frames (16-bit PCM or WebM/Opus blobs)
|
||||
← Server sends JSON messages:
|
||||
|
||||
Raw PCM mode (``?pcm=1&sr=16000``) is the container-free fallback for
|
||||
WebViews without MediaRecorder. Opt-in AEC mode
|
||||
(``?aec=1[&sr=16000]``, parity Action 8b): for dictating
|
||||
Opt-in AEC mode (``?aec=1[&sr=16000]``, parity Action 8b): for dictating
|
||||
while the app plays audio. Frames must be raw int16 mono PCM, each tagged
|
||||
with a 1-byte prefix — 0x00 = microphone, 0x01 = playback reference. The
|
||||
server runs an NLMS echo canceller, cleaning the mic against the reference
|
||||
@@ -27,10 +25,6 @@ Protocol:
|
||||
"detail": "..."} — error ("detail"
|
||||
kept for legacy)
|
||||
|
||||
Sherpa ``final`` frames additionally carry
|
||||
``"final_kind": "utterance"|"summary"``. Utterances are mid-session
|
||||
commits; the summary is the authoritative whole-session result at EOF.
|
||||
|
||||
Every ``final`` text is normalised by services.text_polish (leading
|
||||
capital for Latin scripts, terminal punctuation, single-spaced) so the
|
||||
pasted result reads like typed text. Partials are raw.
|
||||
@@ -38,14 +32,10 @@ Protocol:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
@@ -55,20 +45,6 @@ from services.text_polish import polish_text
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.capture_ws")
|
||||
|
||||
|
||||
def _timing(value):
|
||||
"""A segment timing, or ``None`` when the engine could not determine one.
|
||||
|
||||
``dict.get(key, 0)`` returns a stored ``None`` rather than the default, so
|
||||
rounding it raised (#1904). The null is the honest answer here — this module
|
||||
emits it deliberately for un-endpointed utterances — and the segment list
|
||||
renders whichever half of the range is known.
|
||||
"""
|
||||
return round(value, 2) if isinstance(value, (int, float)) else None
|
||||
|
||||
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"))
|
||||
@@ -92,81 +68,6 @@ _AEC_NEAR = 0x00 # microphone frame (clean it, then buffer for ASR)
|
||||
_AEC_FAR = 0x01 # playback reference frame (feed the echo model only)
|
||||
|
||||
|
||||
# Client-supplied ``?sr=`` values outside the range real capture devices use
|
||||
# are replaced with 16 kHz. The rate sizes server-side state — RecoveryTail
|
||||
# multiplies it by RECOVERY_TAIL_SECONDS to compute its byte ceiling — so an
|
||||
# absurd rate must never be believed: it would re-open the unbounded-memory
|
||||
# path the recovery-tail cap closed.
|
||||
SR_MIN, SR_MAX = 8000, 96000
|
||||
|
||||
|
||||
def _is_end_control(text: str | None) -> bool:
|
||||
"""Accept the versioned JSON control frame and the legacy ``EOF`` frame."""
|
||||
if text == "EOF":
|
||||
return True
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
message = json.loads(text)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return False
|
||||
return isinstance(message, dict) and message.get("type") == "input_audio.end"
|
||||
|
||||
|
||||
class _PlatformWebSocket:
|
||||
"""Add v1 session metadata without changing the legacy WebSocket contract."""
|
||||
|
||||
def __init__(self, websocket: WebSocket):
|
||||
self._websocket = websocket
|
||||
self.session_id = uuid.uuid4().hex
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._websocket, name)
|
||||
|
||||
async def send_json(self, data: Any, mode: str = "text") -> None:
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
data.setdefault("protocol", SPEECH_PROTOCOL)
|
||||
data.setdefault("session_id", self.session_id)
|
||||
if data.get("type") == "final":
|
||||
data.setdefault("final_kind", "summary")
|
||||
await self._websocket.send_json(data, mode=mode)
|
||||
|
||||
|
||||
def _bounded_sample_rate(query_params) -> int:
|
||||
try:
|
||||
sample_rate = int(query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
return 16000
|
||||
return sample_rate if SR_MIN <= sample_rate <= SR_MAX else 16000
|
||||
|
||||
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return the bounded rate when the client transport is raw PCM.
|
||||
|
||||
Sherpa clients omit ``pcm=1`` because the selected model already defines
|
||||
that transport. If the model is demoted or its runtime is unavailable, the
|
||||
legacy recognizer fallback must still decode those same bytes as PCM.
|
||||
"""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
sherpa_pcm = False
|
||||
requested_model = query_params.get("model")
|
||||
if requested_model:
|
||||
try:
|
||||
from services.sherpa_dictation import is_sherpa_model
|
||||
sherpa_pcm = is_sherpa_model(requested_model)
|
||||
except Exception: # noqa: BLE001
|
||||
# A broken sherpa install must not decide the framing question —
|
||||
# sherpa_pcm stays False and the session negotiates the
|
||||
# MediaRecorder path; availability is re-probed (and reported)
|
||||
# when the model is actually selected.
|
||||
sherpa_pcm = False
|
||||
if not raw_pcm and not aec and not sherpa_pcm:
|
||||
return None
|
||||
return _bounded_sample_rate(query_params)
|
||||
|
||||
|
||||
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
|
||||
"""Split a prefixed AEC binary frame into ``(kind, pcm)``.
|
||||
|
||||
@@ -221,50 +122,24 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
from services import sherpa_dictation as sd
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _usable_spec(model_id):
|
||||
spec = sd.get_spec(model_id)
|
||||
if spec is not None and sd.is_demoted(spec.id):
|
||||
logger.warning(
|
||||
"dictation model %s is demoted — using the capture ASR fallback",
|
||||
spec.id,
|
||||
)
|
||||
return None
|
||||
return spec
|
||||
|
||||
requested = websocket.query_params.get("model")
|
||||
if requested:
|
||||
return _usable_spec(requested) # explicit selection (may be unavailable)
|
||||
return sd.get_spec(requested) # explicit selection (may be None if bad)
|
||||
# Fall back to the persisted dictation pref.
|
||||
try:
|
||||
from services.asr_backend import dictation_model_id
|
||||
mid = dictation_model_id()
|
||||
except Exception:
|
||||
mid = None
|
||||
return _usable_spec(mid) if mid else None
|
||||
return sd.get_spec(mid) if mid else None
|
||||
|
||||
|
||||
@router.websocket(PLATFORM_STREAM_PATH)
|
||||
@router.websocket("/ws/transcribe")
|
||||
async def ws_transcribe(websocket: WebSocket):
|
||||
"""Stream audio in, get partial + final transcription out."""
|
||||
is_platform_stream = websocket.url.path == PLATFORM_STREAM_PATH
|
||||
if is_platform_stream:
|
||||
websocket = _PlatformWebSocket(websocket)
|
||||
# A browser can reach localhost regardless of the page's own origin.
|
||||
# Reject ambient cross-site WebSocket handshakes before the loopback-host
|
||||
# shortcut or accept(), while keeping native clients (no Origin header)
|
||||
# and configured/same-origin browser UIs working (#1646 review).
|
||||
origin = websocket.headers.get("origin")
|
||||
if origin:
|
||||
from core.csrf import origin_allowed
|
||||
|
||||
if not origin_allowed(websocket):
|
||||
await websocket.close(code=1008, reason="browser origin not allowed")
|
||||
return
|
||||
# Loopback origin guard — refuse anything not from 127.0.0.1, ::1, or
|
||||
# localhost. Privileged HTTP routers use Depends(require_admin) at router
|
||||
# level; WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# localhost. HTTP routers use Depends(require_loopback) at router level;
|
||||
# WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# inline the check before accept(). Without it, any local process could
|
||||
# stream the user's microphone over this endpoint.
|
||||
# Wave 2.3 (remote backend): a non-loopback client that presents the
|
||||
@@ -276,16 +151,6 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
if is_platform_stream:
|
||||
await websocket.send_json({
|
||||
"type": "session.started",
|
||||
"input_format": (
|
||||
"audio/pcm;encoding=s16le;channels=1"
|
||||
if _requested_pcm_sample_rate(websocket.query_params) is not None
|
||||
else "audio/webm;codecs=opus"
|
||||
),
|
||||
"sample_rate": _bounded_sample_rate(websocket.query_params),
|
||||
})
|
||||
|
||||
# Live-dictation engine selection. When a sherpa-onnx model is selected
|
||||
# (via ?model= or the dictation.model_id pref) AND sherpa is installed,
|
||||
@@ -345,11 +210,12 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
# identical legacy behaviour. When on, frames are 1-byte-tagged raw PCM
|
||||
# and the cleaned mic stream is muxed via stdlib wave (not ffmpeg).
|
||||
aec = None
|
||||
pcm_sr = _requested_pcm_sample_rate(websocket.query_params)
|
||||
pcm_sr: int | None = None
|
||||
if websocket.query_params.get("aec") in ("1", "true", "on"):
|
||||
try:
|
||||
pcm_sr = int(websocket.query_params.get("sr", "16000"))
|
||||
from services.aec import NlmsEchoCanceller
|
||||
aec = NlmsEchoCanceller(sample_rate=pcm_sr or 16000)
|
||||
aec = NlmsEchoCanceller(sample_rate=pcm_sr)
|
||||
logger.info("AEC enabled for dictation session (sr=%d)", pcm_sr)
|
||||
except Exception as e:
|
||||
# Bad sr or import failure → fall back to plain dictation.
|
||||
@@ -360,7 +226,6 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
audio_chunks: list[bytes] = []
|
||||
total_bytes = 0
|
||||
last_audio_time = time.monotonic()
|
||||
paused = False
|
||||
running = True
|
||||
partial_text = ""
|
||||
# Track whether the client initiated the disconnect. When True the
|
||||
@@ -378,7 +243,7 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
message as the authoritative result and skip the duplicate HTTP
|
||||
POST that used to run on every dictation.
|
||||
"""
|
||||
nonlocal total_bytes, last_audio_time, running, client_disconnected, paused
|
||||
nonlocal total_bytes, last_audio_time, running, client_disconnected
|
||||
try:
|
||||
while running:
|
||||
msg = await websocket.receive()
|
||||
@@ -409,11 +274,7 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
total_bytes += len(data)
|
||||
last_audio_time = time.monotonic()
|
||||
continue
|
||||
if msg.get("text") in ("PAUSE", "RESUME"):
|
||||
paused = msg["text"] == "PAUSE"
|
||||
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
|
||||
@@ -445,9 +306,6 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
if not running:
|
||||
break
|
||||
|
||||
if paused:
|
||||
continue
|
||||
|
||||
# Check silence timeout
|
||||
if time.monotonic() - last_audio_time > SILENCE_TIMEOUT_S and total_bytes > MIN_BUFFER_BYTES:
|
||||
running = False
|
||||
@@ -550,64 +408,6 @@ SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENC
|
||||
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
|
||||
|
||||
|
||||
#: Seconds of audio retained for silent-model recovery. Recovery only needs
|
||||
#: enough speech to prove the model is broken and to re-transcribe what was
|
||||
#: said; retaining the whole session grew ~115 MB/hour at 16 kHz on an open
|
||||
#: mic, unbounded, and only ever got read when the fallback fired.
|
||||
RECOVERY_TAIL_DEFAULT_SECONDS = 120.0
|
||||
RECOVERY_TAIL_MAX_SECONDS = 300.0
|
||||
|
||||
|
||||
def _bounded_recovery_tail_seconds(value: str | None) -> float:
|
||||
"""Parse the recovery tail override without allowing unbounded buffers."""
|
||||
try:
|
||||
seconds = float(value) if value is not None else RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
except (TypeError, ValueError):
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
return min(seconds, RECOVERY_TAIL_MAX_SECONDS)
|
||||
|
||||
|
||||
RECOVERY_TAIL_SECONDS = _bounded_recovery_tail_seconds(
|
||||
os.environ.get("OMNIVOICE_DICTATION_RECOVERY_TAIL_S")
|
||||
)
|
||||
|
||||
|
||||
class RecoveryTail:
|
||||
"""The most recent ``RECOVERY_TAIL_SECONDS`` of session audio.
|
||||
|
||||
Keeps the *tail* rather than the head: a long dictation's useful speech is
|
||||
what the user just said, and the silent-model check cares about how much
|
||||
audio the session carried overall — which ``total_bytes`` still reports
|
||||
truthfully after trimming.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buf", "_max", "total_bytes")
|
||||
|
||||
def __init__(self, sample_rate: int, seconds: float = RECOVERY_TAIL_SECONDS):
|
||||
# int16 mono → 2 bytes/sample. Floor of one frame so a nonsense rate
|
||||
# or seconds value can't produce a zero-length buffer.
|
||||
self._max = max(2, int(seconds * max(1, sample_rate)) * 2)
|
||||
self._buf = bytearray()
|
||||
self.total_bytes = 0
|
||||
|
||||
def extend(self, pcm: bytes) -> None:
|
||||
self._buf.extend(pcm)
|
||||
self.total_bytes += len(pcm)
|
||||
excess = len(self._buf) - self._max
|
||||
if excess > 0:
|
||||
# int16 mono: trim whole samples only. A split frame can carry an
|
||||
# odd byte count, and an odd trim would leave the tail starting
|
||||
# mid-sample — every later sample byte-shifted, and the recovery
|
||||
# transcription fed noise.
|
||||
excess += excess % 2
|
||||
del self._buf[:excess]
|
||||
|
||||
def tail(self) -> bytes:
|
||||
return bytes(self._buf)
|
||||
|
||||
|
||||
def is_model_silent(text: str, heard_speech: bool, pcm_bytes: int) -> bool:
|
||||
"""True when the dictation model produced NO text despite real speech.
|
||||
|
||||
@@ -634,74 +434,19 @@ def _pcm16_to_f32(pcm: bytes):
|
||||
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def _pcm16_rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
|
||||
async def _recover_silent_sherpa(
|
||||
spec, pcm: bytes, pcm_sr: int,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Retry a token-silent Sherpa session through an installed local ASR."""
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(pcm) / float(max(1, pcm_sr) * 2),
|
||||
)
|
||||
try:
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
fallback_missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="dictation",
|
||||
skip_sherpa=True,
|
||||
require_installed=True,
|
||||
)
|
||||
if fallback_missing is not None:
|
||||
logger.warning(
|
||||
"dictation silent-model fallback is not installed (%s); "
|
||||
"skipping recovery to avoid an automatic download",
|
||||
fallback_missing.get("missing_repo_id", "unknown"),
|
||||
)
|
||||
return "", []
|
||||
|
||||
result = await _transcribe_buffer_full(
|
||||
[pcm], pcm_sr=pcm_sr, skip_sherpa=True,
|
||||
)
|
||||
text = polish_text(_result_text(result))
|
||||
if not text:
|
||||
return "", []
|
||||
# The RMS gate can fire on fan/keyboard noise. Only another recognizer
|
||||
# producing words proves the audio held speech and makes persistent
|
||||
# demotion safe.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if await asyncio.to_thread(demote_model, spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": text}
|
||||
]
|
||||
return text, segments
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
return "", []
|
||||
|
||||
|
||||
async def _sherpa_session(websocket: WebSocket):
|
||||
"""Shared WS setup for the sherpa handlers.
|
||||
"""Shared WS receive setup for the sherpa handlers.
|
||||
|
||||
Returns ``(pcm_sr, aec)``: the bounded PCM sample rate for the session
|
||||
and the echo canceller when ``?aec=1`` requested one (``None`` otherwise
|
||||
or when AEC setup fails).
|
||||
Returns ``(get_frame, state)`` where ``get_frame`` is an async callable
|
||||
that yields the next near-end (mic) PCM bytes, ``b""`` for a keepalive/ref
|
||||
frame, or ``None`` on EOF/disconnect. ``state`` carries sample rate, AEC,
|
||||
and the disconnect flag for the caller's finaliser.
|
||||
"""
|
||||
pcm_sr = _bounded_sample_rate(websocket.query_params)
|
||||
pcm_sr = 16000
|
||||
try:
|
||||
pcm_sr = int(websocket.query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
pcm_sr = 16000
|
||||
aec = None
|
||||
if websocket.query_params.get("aec") in ("1", "true", "on"):
|
||||
try:
|
||||
@@ -739,7 +484,7 @@ async def _recv_pcm_frame(websocket: WebSocket, aec):
|
||||
return "skip", b""
|
||||
return "near", aec.process_near_end(payload)
|
||||
return "near", data
|
||||
if _is_end_control(msg.get("text")):
|
||||
if msg.get("text") == "EOF":
|
||||
return "eof", b""
|
||||
return "skip", b""
|
||||
|
||||
@@ -810,8 +555,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
|
||||
last_partial = ""
|
||||
committed: list[str] = [] # finalized utterances this session
|
||||
session_pcm = RecoveryTail(pcm_sr) # bounded audio for silent-model recovery
|
||||
heard_speech = False
|
||||
client_disconnected = False
|
||||
|
||||
async def _send(payload) -> bool:
|
||||
@@ -853,9 +596,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
break
|
||||
if kind == "skip":
|
||||
continue
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
|
||||
if endpoint:
|
||||
# Commit this utterance (polished — it gets pasted); reset
|
||||
@@ -864,7 +604,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
rec.reset(stream)
|
||||
@@ -891,28 +630,7 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(t for t in committed if t).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed if t]
|
||||
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
payload["engine"] = "capture-asr-fallback" if full else backend.id
|
||||
payload["model_silent"] = spec.id
|
||||
payload["warning"] = (
|
||||
f"The selected dictation model ({spec.id}) produced no text from your "
|
||||
"speech. Switched to the fallback engine for this session — pick a "
|
||||
"different model in Settings → Dictation."
|
||||
)
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s): never delays this summary `final`
|
||||
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
|
||||
@@ -921,9 +639,14 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
refined = await maybe_refine_async(full)
|
||||
except Exception:
|
||||
refined = None
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if refined and refined != full:
|
||||
payload["refined_text"] = refined
|
||||
await _send(payload)
|
||||
await _send(payload)
|
||||
else:
|
||||
await _send({"type": "final", "text": "", "segments": [],
|
||||
"language": "auto", "engine": backend.id})
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
@@ -960,7 +683,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# whisper/zipformer transcribe the same bytes). Keep the whole session's
|
||||
# audio and whether any of it was speech-level, so the finaliser can tell
|
||||
# "user said nothing" (fine) from "model produced nothing" (broken).
|
||||
session_pcm = RecoveryTail(pcm_sr)
|
||||
session_pcm = bytearray()
|
||||
heard_speech = False
|
||||
running = True
|
||||
client_disconnected = False
|
||||
@@ -979,6 +702,12 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
client_disconnected = True
|
||||
return False
|
||||
|
||||
def _rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
def _decode_window(pcm: bytes) -> str:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
@@ -997,7 +726,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
buf.extend(pcm)
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if not heard_speech and _rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
last_audio = time.monotonic()
|
||||
except WebSocketDisconnect:
|
||||
@@ -1023,7 +752,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
|
||||
@@ -1035,8 +763,8 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
snapshot = bytes(buf)
|
||||
if len(snapshot) > sil_bytes and \
|
||||
_pcm16_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _pcm16_rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
await _commit(snapshot)
|
||||
else:
|
||||
# Pure silence — drop it (keep the gate window for
|
||||
@@ -1082,18 +810,39 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# quiet user — hand the session to the capture ASR backend so the user
|
||||
# still gets their words, and say which model let them down. Bounded to
|
||||
# this session; the pref is left alone so the user stays in control.
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
model_silent = is_model_silent(full, heard_speech, len(session_pcm))
|
||||
if model_silent:
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(session_pcm) / float(max(1, pcm_sr) * 2),
|
||||
)
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
# Demote it so the NEXT session doesn't repeat this round trip. The
|
||||
# curated default can be broken on a platform we never tested (the
|
||||
# NeMo-TDT decoder is, on Windows), and observing it beats guessing.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if demote_model(spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
try:
|
||||
result = await _transcribe_buffer_full([bytes(session_pcm)], pcm_sr=pcm_sr)
|
||||
fb_text = polish_text((result or {}).get("text", "") or "")
|
||||
if fb_text:
|
||||
full = fb_text
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": fb_text}
|
||||
]
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
# The client surfaces this so a silently-broken model can't look
|
||||
@@ -1121,35 +870,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
pass
|
||||
|
||||
|
||||
def _result_text(result: dict | None) -> str:
|
||||
"""Normalize text from every ASR backend result shape.
|
||||
|
||||
Some backends return a top-level ``text`` value, while WhisperX, Faster
|
||||
Whisper, Moonshine, and OpenAI-compatible ASR expose only ``segments`` and
|
||||
``chunks``. Dictation partials and finals must interpret both contracts the
|
||||
same way.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return ""
|
||||
|
||||
text = result.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()
|
||||
|
||||
for key in ("segments", "chunks"):
|
||||
items = result.get(key)
|
||||
if not isinstance(items, (list, tuple)):
|
||||
continue
|
||||
text = " ".join(
|
||||
str(item.get("text", "")).strip()
|
||||
for item in items
|
||||
if isinstance(item, dict) and item.get("text")
|
||||
).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None) -> str:
|
||||
"""Quick partial transcription of the current audio buffer."""
|
||||
|
||||
@@ -1164,7 +884,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
def _run():
|
||||
backend = get_capture_asr_backend()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
return _result_text(result)
|
||||
return result.get("text", "")
|
||||
|
||||
# Bound dictation transcribes (#730): a wedged whisperx/CTranslate2 call
|
||||
# must not hold its GPU-pool worker forever and starve TTS / other ASR
|
||||
@@ -1178,9 +898,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
pass
|
||||
|
||||
|
||||
async def _transcribe_buffer_full(
|
||||
chunks: list[bytes], *, pcm_sr: int | None = None, skip_sherpa: bool = False,
|
||||
) -> dict:
|
||||
async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = None) -> dict:
|
||||
"""Full transcription with timing info for the final result."""
|
||||
tmp = _pcm16_to_wav(b"".join(chunks), pcm_sr) if pcm_sr else _chunks_to_wav(chunks)
|
||||
if tmp is None:
|
||||
@@ -1192,13 +910,15 @@ async def _transcribe_buffer_full(
|
||||
from services.asr_backend import get_capture_asr_backend, run_transcribe_guarded
|
||||
|
||||
def _run():
|
||||
backend = get_capture_asr_backend(skip_sherpa=skip_sherpa)
|
||||
backend = get_capture_asr_backend()
|
||||
t0 = time.perf_counter()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
full_text = _result_text(result)
|
||||
full_text = result.get("text", "")
|
||||
if not full_text and segments:
|
||||
full_text = " ".join(s.get("text", "") for s in segments).strip()
|
||||
|
||||
# Wave 1.1: strip Whisper hallucination loops from the final
|
||||
# text (the string that gets auto-pasted). Segments keep the
|
||||
@@ -1206,19 +926,13 @@ async def _transcribe_buffer_full(
|
||||
from services.refinement import collapse_repetitive_artifacts
|
||||
full_text = collapse_repetitive_artifacts(full_text)
|
||||
|
||||
# end=None means the engine could not determine the timing — this
|
||||
# module writes exactly that in its own streaming payloads, and
|
||||
# sherpa's _sherpa_result does too when the sample rate yields no
|
||||
# duration. Measure only real numbers, and pass the nulls through
|
||||
# rather than rounding them (#1904).
|
||||
ends = [e for e in (s.get("end") for s in segments) if isinstance(e, (int, float))]
|
||||
duration = max(ends) if ends else 0.0
|
||||
duration = max((s.get("end", 0) for s in segments), default=0.0)
|
||||
|
||||
return {
|
||||
"text": full_text,
|
||||
"segments": [
|
||||
{"start": _timing(s.get("start", 0)),
|
||||
"end": _timing(s.get("end", 0)),
|
||||
{"start": round(s.get("start", 0), 2),
|
||||
"end": round(s.get("end", 0), 2),
|
||||
"text": s.get("text", "").strip()}
|
||||
for s in segments
|
||||
],
|
||||
|
||||
@@ -20,26 +20,18 @@ Design / safety
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import DATA_DIR, VOICES_DIR
|
||||
from core.config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.community")
|
||||
router = APIRouter()
|
||||
@@ -50,32 +42,9 @@ _ALLOWED_AUDIO_HOSTS = {
|
||||
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
|
||||
}
|
||||
_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"}
|
||||
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
|
||||
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
|
||||
_SOURCE_RE = re.compile(
|
||||
r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$",
|
||||
) # owner/repo only
|
||||
_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# A gallery open may touch this loader several times (grid, preview, use). Keep
|
||||
# a successful response for six hours, then revalidate it once. On a network
|
||||
# failure the readable stale copy remains usable and its check time advances,
|
||||
# preventing every offline gallery open from waiting through the same timeout.
|
||||
_MANIFEST_MAX_AGE_S = 6 * 60 * 60
|
||||
_MAX_MANIFEST_BYTES = 4 << 20
|
||||
_MAX_SAMPLE_SCRIPT_CHARS = 2_000
|
||||
_MAX_REF_TEXT_CHARS = 4_000
|
||||
|
||||
# Community voice submissions are documented as short clean WAV clips. The cap
|
||||
# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a
|
||||
# remote manifest from turning Preview into an unbounded disk/memory download.
|
||||
_MAX_VOICE_AUDIO_BYTES = 32 << 20
|
||||
|
||||
_ATTR_NAMES = (
|
||||
"Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect",
|
||||
)
|
||||
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
|
||||
|
||||
|
||||
# ── Config: which content repos to load ───────────────────────────────────────
|
||||
@@ -83,18 +52,14 @@ def configured_sources() -> list[str]:
|
||||
"""Gallery sources, in priority order. Env var > config file > default."""
|
||||
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
|
||||
if env:
|
||||
sources = [s.strip() for s in env.split(",")]
|
||||
valid = [s for s in sources if _SOURCE_RE.fullmatch(s)]
|
||||
return valid or list(_DEFAULT_SOURCES)
|
||||
return [s.strip() for s in env.split(",") if s.strip()]
|
||||
cfg = Path(DATA_DIR) / "gallery_sources.json"
|
||||
if cfg.exists():
|
||||
try:
|
||||
data = json.loads(cfg.read_text(encoding="utf-8"))
|
||||
srcs = data.get("sources")
|
||||
if isinstance(srcs, list) and srcs:
|
||||
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
|
||||
if valid:
|
||||
return valid
|
||||
return [str(s) for s in srcs]
|
||||
except Exception:
|
||||
logger.warning("gallery_sources.json unreadable; using default")
|
||||
return list(_DEFAULT_SOURCES)
|
||||
@@ -116,51 +81,9 @@ def _safe_audio_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _safe_manifest_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]:
|
||||
"""Normalize one validator-safe tag per design category.
|
||||
|
||||
Membership in the vocabulary is not enough: ``male, female`` contains two
|
||||
individually valid tokens but the engine rejects the pair as conflicting.
|
||||
Build the frontend's full ``vd_states`` shape at this trust boundary too,
|
||||
so Magic Wand never inherits stale sliders from the previous voice.
|
||||
"""
|
||||
attrs = {name: "Auto" for name in _ATTR_NAMES}
|
||||
normalized: list[str] = []
|
||||
seen_categories: set[int] = set()
|
||||
for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")):
|
||||
token = raw.strip().lower()
|
||||
if not token or token not in _VALID_TOKENS:
|
||||
return None
|
||||
category = archetypes._VD._instruct_category_index(token)
|
||||
if category < 0 or category in seen_categories:
|
||||
return None
|
||||
seen_categories.add(category)
|
||||
|
||||
# The picker represents the universal gender/age/pitch/style axes in
|
||||
# English even for Chinese speech; dialect remains Chinese-only.
|
||||
canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token)
|
||||
attrs[_ATTR_NAMES[category]] = canonical
|
||||
normalized.append(canonical)
|
||||
|
||||
if not normalized:
|
||||
return None
|
||||
# Accent and Chinese dialect are separate taxonomy buckets but the engine
|
||||
# deliberately forbids mixing them in a single design.
|
||||
if 4 in seen_categories and 5 in seen_categories:
|
||||
return None
|
||||
return ", ".join(normalized), attrs
|
||||
|
||||
|
||||
def is_valid_instruct(instruct: str) -> bool:
|
||||
return normalize_preset_instruct(instruct) is not None
|
||||
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
|
||||
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
|
||||
|
||||
|
||||
def validate_item(raw: dict) -> Optional[dict]:
|
||||
@@ -170,203 +93,62 @@ def validate_item(raw: dict) -> Optional[dict]:
|
||||
it = dict(raw)
|
||||
if it.get("type") not in ("preset", "voice"):
|
||||
return None
|
||||
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
|
||||
if not it.get("id") or not it.get("name"):
|
||||
return None
|
||||
if not isinstance(it.get("name"), str) or not it["name"].strip():
|
||||
return None
|
||||
it["name"] = it["name"].strip()[:80]
|
||||
if it.get("use_case") not in _USE_CASE_IDS:
|
||||
return None
|
||||
raw_facets = it.get("facets")
|
||||
if not isinstance(raw_facets, dict):
|
||||
raw_facets = {}
|
||||
language = it.get("language")
|
||||
if not isinstance(language, str) or not language.strip():
|
||||
language = raw_facets.get("lang", "English")
|
||||
it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English"
|
||||
|
||||
facets = dict(raw_facets)
|
||||
if it["type"] == "preset":
|
||||
normalized = normalize_preset_instruct(it.get("instruct", ""))
|
||||
if normalized is None:
|
||||
return None # unknown/conflicting tokens would crash synthesis
|
||||
it["instruct"], it["attrs"] = normalized
|
||||
attrs = it["attrs"]
|
||||
facets.update({
|
||||
"gender": None if attrs["Gender"] == "Auto" else attrs["Gender"],
|
||||
"age": None if attrs["Age"] == "Auto" else attrs["Age"],
|
||||
"pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"],
|
||||
"accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"],
|
||||
"whisper": attrs["Style"] == "whisper",
|
||||
"lang": it["language"],
|
||||
})
|
||||
sample_script = it.get("sample_script")
|
||||
it["sample_script"] = (
|
||||
sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS]
|
||||
if isinstance(sample_script, str) else ""
|
||||
)
|
||||
else:
|
||||
audio = it.get("audio")
|
||||
if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")):
|
||||
return None
|
||||
expected = audio.get("sha256")
|
||||
if expected is not None:
|
||||
expected = str(expected).lower()
|
||||
if not _SHA256_RE.fullmatch(expected):
|
||||
return None
|
||||
audio = {**audio, "sha256": expected}
|
||||
ref_text = audio.get("ref_text")
|
||||
audio = {
|
||||
**audio,
|
||||
"ref_text": (
|
||||
ref_text.strip()[:_MAX_REF_TEXT_CHARS]
|
||||
if isinstance(ref_text, str) else ""
|
||||
),
|
||||
}
|
||||
it["audio"] = audio
|
||||
facets.setdefault("gender", None)
|
||||
facets.setdefault("age", None)
|
||||
facets.setdefault("pitch", None)
|
||||
facets.setdefault("accent", None)
|
||||
facets.setdefault("whisper", False)
|
||||
facets.setdefault("lang", it["language"])
|
||||
it["facets"] = facets
|
||||
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
|
||||
return None # would crash synthesis — drop it
|
||||
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
|
||||
return None
|
||||
it.setdefault("facets", {})
|
||||
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
|
||||
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
|
||||
it["is_community"] = it.get("source") != "starter"
|
||||
it["preview_url"] = f"/community/items/{it['id']}/preview"
|
||||
return it
|
||||
|
||||
|
||||
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
|
||||
items, packs, seen = [], [], set()
|
||||
for src, m in manifests:
|
||||
if not isinstance(m, dict):
|
||||
if not m:
|
||||
continue
|
||||
raw_items = m.get("items")
|
||||
for raw in raw_items if isinstance(raw_items, list) else []:
|
||||
for raw in (m.get("items") or []):
|
||||
v = validate_item(raw)
|
||||
if v and v["id"] not in seen:
|
||||
v["_source_repo"] = src
|
||||
seen.add(v["id"])
|
||||
items.append(v)
|
||||
raw_packs = m.get("packs")
|
||||
for p in raw_packs if isinstance(raw_packs, list) else []:
|
||||
for p in (m.get("packs") or []):
|
||||
if isinstance(p, dict):
|
||||
packs.append({**p, "_source_repo": src})
|
||||
return items, packs
|
||||
|
||||
|
||||
def _read_manifest_cache(cache: Path) -> Optional[dict]:
|
||||
try:
|
||||
if cache.stat().st_size > _MAX_MANIFEST_BYTES:
|
||||
return None
|
||||
data = json.loads(cache.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_bytes_atomic(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_remote_manifest(source: str, *, client=None) -> dict:
|
||||
"""Fetch one bounded manifest, validating every redirect before request."""
|
||||
import httpx
|
||||
|
||||
if not _SOURCE_RE.fullmatch(source or ""):
|
||||
raise ValueError("invalid gallery source")
|
||||
owned_client = client is None
|
||||
http = client or httpx.Client(timeout=15.0, follow_redirects=False)
|
||||
current_url = _manifest_url(source)
|
||||
payload = bytearray()
|
||||
try:
|
||||
fetched = False
|
||||
for _redirect in range(6):
|
||||
if not _safe_manifest_url(current_url):
|
||||
raise ValueError("gallery manifest URL is not from an allowed host")
|
||||
with http.stream("GET", current_url, follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
next_url = urljoin(current_url, location or "")
|
||||
if not location or not _safe_manifest_url(next_url):
|
||||
raise ValueError("gallery manifest redirected to a disallowed host")
|
||||
current_url = next_url
|
||||
continue
|
||||
response.raise_for_status()
|
||||
length = response.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
declared_length = int(length)
|
||||
except ValueError:
|
||||
declared_length = None
|
||||
if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the size limit")
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the size limit")
|
||||
payload.extend(chunk)
|
||||
fetched = True
|
||||
break
|
||||
if not fetched:
|
||||
raise ValueError("gallery manifest followed too many redirects")
|
||||
finally:
|
||||
if owned_client:
|
||||
http.close()
|
||||
if not payload:
|
||||
raise ValueError("gallery manifest was empty")
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("gallery manifest is not a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_manifest(
|
||||
source: str, refresh: bool, *, now: Optional[float] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Return a fresh manifest, with a throttled stale-cache offline fallback."""
|
||||
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
|
||||
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
|
||||
cache = _cache_path(source)
|
||||
cached = _read_manifest_cache(cache)
|
||||
checked_at = time.time() if now is None else float(now)
|
||||
if not refresh and cached is not None:
|
||||
if not refresh and cache.exists():
|
||||
try:
|
||||
if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S:
|
||||
return cached
|
||||
except OSError:
|
||||
pass # treat a stat race as stale and try the source once
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data = _fetch_remote_manifest(source)
|
||||
encoded = json.dumps(
|
||||
data, ensure_ascii=False, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if len(encoded) > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the cache size limit")
|
||||
_write_bytes_atomic(cache, encoded)
|
||||
# Tests inject their own clock; production's value equals wall time.
|
||||
os.utime(cache, (checked_at, checked_at))
|
||||
import httpx
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||
resp = client.get(_manifest_url(source))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(data), encoding="utf-8")
|
||||
return data
|
||||
except Exception as e: # offline / 404 / bad json
|
||||
logger.warning("manifest fetch failed for %s: %s", source, e)
|
||||
if cached is not None:
|
||||
# This mtime is a last-*check* marker. Advancing it on failure keeps
|
||||
# an offline app responsive while guaranteeing another check after
|
||||
# the bounded freshness interval.
|
||||
with contextlib.suppress(OSError):
|
||||
os.utime(cache, (checked_at, checked_at))
|
||||
return cached
|
||||
if cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -432,385 +214,6 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source:
|
||||
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
|
||||
|
||||
|
||||
def _find_item(items: list[dict], item_id: str) -> dict:
|
||||
if not _ITEM_ID_RE.fullmatch(item_id or ""):
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
return item
|
||||
|
||||
|
||||
def _canonical_archetype(item: dict) -> Optional[dict]:
|
||||
"""The built-in archetype represented exactly by a marketplace preset."""
|
||||
if item.get("type") != "preset":
|
||||
return None
|
||||
canonical = archetypes.get_archetype(item["id"])
|
||||
if canonical is None:
|
||||
return None
|
||||
if (canonical.get("instruct") != item.get("instruct")
|
||||
or canonical.get("language") != item.get("language")):
|
||||
return None
|
||||
remote_script = (item.get("sample_script") or "").strip()
|
||||
if remote_script and remote_script != (canonical.get("sample_script") or "").strip():
|
||||
return None
|
||||
return canonical
|
||||
|
||||
|
||||
def _preset_preview_path(item: dict) -> Path:
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps({
|
||||
"instruct": item.get("instruct"),
|
||||
"language": item.get("language"),
|
||||
"sample_script": item.get("sample_script"),
|
||||
}, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav"
|
||||
|
||||
|
||||
def _voice_audio_fingerprint(item: dict) -> str:
|
||||
audio = item.get("audio") or {}
|
||||
return hashlib.sha256(
|
||||
f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _voice_audio_path(item: dict) -> Path:
|
||||
return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav"
|
||||
|
||||
|
||||
async def _render_preset_atomic(item: dict, out_path: Path) -> Path:
|
||||
if is_playable_wav(out_path):
|
||||
return out_path
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav")
|
||||
os.close(fd)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
await _render_archetype_wav({
|
||||
"instruct": item["instruct"],
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": (
|
||||
(item.get("sample_script") or "").strip()
|
||||
or "Hello — this is a preview of this voice."
|
||||
),
|
||||
}, tmp)
|
||||
if not is_playable_wav(tmp):
|
||||
raise RuntimeError("the voice engine produced an invalid preview WAV")
|
||||
os.replace(tmp, out_path)
|
||||
return out_path
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None:
|
||||
"""Stream one allow-listed voice clip into an atomic, size-bounded file."""
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
|
||||
import httpx
|
||||
|
||||
owned_client = client is None
|
||||
http = client or httpx.Client(timeout=30.0, follow_redirects=False)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part")
|
||||
total = 0
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
current_url = url
|
||||
downloaded = False
|
||||
for _redirect in range(6):
|
||||
with http.stream("GET", current_url, follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
next_url = urljoin(current_url, location or "")
|
||||
if not location or not _safe_audio_url(next_url):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio redirected to a disallowed host.",
|
||||
)
|
||||
current_url = next_url
|
||||
continue
|
||||
response.raise_for_status()
|
||||
length = response.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
if int(length) > _MAX_VOICE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio exceeded the download size limit.",
|
||||
)
|
||||
except ValueError:
|
||||
# A non-numeric Content-Length header is the
|
||||
# server's problem, not a reason to refuse the
|
||||
# download — the streamed byte counter below
|
||||
# still enforces the same cap on what actually
|
||||
# arrives.
|
||||
pass
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > _MAX_VOICE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio exceeded the download size limit.",
|
||||
)
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
downloaded = True
|
||||
break
|
||||
if not downloaded:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio followed too many redirects.",
|
||||
)
|
||||
if total == 0:
|
||||
raise HTTPException(status_code=502, detail="Community voice audio was empty.")
|
||||
expected = audio.get("sha256")
|
||||
if expected and digest.hexdigest() != expected:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Downloaded voice failed its integrity check.",
|
||||
)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if not is_playable_wav(Path(tmp_name)):
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Community voice audio was not a valid WAV.",
|
||||
)
|
||||
os.replace(tmp_name, out_path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
finally:
|
||||
if owned_client:
|
||||
http.close()
|
||||
|
||||
|
||||
def _cached_voice_audio(item: dict) -> Path:
|
||||
path = _voice_audio_path(item)
|
||||
if is_playable_wav(path):
|
||||
return path
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
_download_voice_audio(item, path)
|
||||
return path
|
||||
|
||||
|
||||
def _copy_atomic(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, source.open("rb") as src:
|
||||
shutil.copyfileobj(src, out)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(tmp_name, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/community/items/{item_id}/preview")
|
||||
async def community_preview(
|
||||
item_id: str,
|
||||
local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"),
|
||||
):
|
||||
"""Serve every community preview through the authenticated same-origin API."""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = _find_item(items, item_id)
|
||||
|
||||
canonical = _canonical_archetype(item)
|
||||
if canonical is not None:
|
||||
# Reuse the signed-gallery/local-render fallback and cache owned by the
|
||||
# canonical endpoint rather than synthesizing the same preset twice.
|
||||
# Delegate in-process: a root-relative HTTP redirect drops supported
|
||||
# reverse-proxy path prefixes such as ``https://host/api``.
|
||||
from api.routers.archetypes import preview_archetype
|
||||
return await preview_archetype(canonical["id"], local=local)
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
path = await _render_preset_atomic(item, _preset_preview_path(item))
|
||||
else:
|
||||
path = await asyncio.to_thread(_cached_voice_audio, item)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Community preview unavailable (%s)", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="This community voice preview is unavailable right now.",
|
||||
) from exc
|
||||
return FileResponse(
|
||||
path, media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"},
|
||||
)
|
||||
|
||||
|
||||
def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]:
|
||||
if item["type"] == "preset":
|
||||
return "design", item["instruct"], json.dumps(item["attrs"]), 42
|
||||
return "clone", "", None, None
|
||||
|
||||
|
||||
def _community_profile_audio_filename(profile_id: str, item: dict) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
if item["type"] == "voice":
|
||||
# The manifest URL/checksum fingerprint makes a changed submission
|
||||
# invalidate its already-materialized clone without a schema change.
|
||||
return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav"
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]:
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _community_audio_is_current(row, item: dict, ref_text: str) -> bool:
|
||||
path = _stored_profile_audio(row["ref_audio_path"])
|
||||
expected_filename = _community_profile_audio_filename(row["id"], item)
|
||||
if row["ref_audio_path"] != expected_filename or not is_playable_wav(path):
|
||||
return False
|
||||
kind, instruct, _vd_states, seed = _profile_fields(item)
|
||||
inputs_match = (
|
||||
row["instruct"] == instruct
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == ref_text
|
||||
and row["seed"] == seed
|
||||
)
|
||||
if not inputs_match:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _materialize_item_audio(
|
||||
item: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Copy the current manifest audio, optionally staging it for a later CAS."""
|
||||
audio_filename = _community_profile_audio_filename(profile_id, item)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
audio_path = destination
|
||||
if not publish:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav"
|
||||
if item["type"] == "preset":
|
||||
cached = await _render_preset_atomic(item, _preset_preview_path(item))
|
||||
else:
|
||||
cached = await asyncio.to_thread(_cached_voice_audio, item)
|
||||
await asyncio.to_thread(_copy_atomic, cached, audio_path)
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
def _community_personality(item: dict) -> str:
|
||||
source = item.get("_source_repo")
|
||||
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
|
||||
source = _DEFAULT_SOURCES[0]
|
||||
return f"community:{source}:{item['id']}"
|
||||
|
||||
|
||||
def _is_materialized_community_row(row, item: dict) -> bool:
|
||||
if (
|
||||
row["personality"] != _community_personality(item)
|
||||
or row["is_locked"] or row["verified_own_voice"]
|
||||
):
|
||||
return False
|
||||
if item["type"] == "voice":
|
||||
safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split(
|
||||
"-community-", 1,
|
||||
)[0]
|
||||
return bool(
|
||||
row["kind"] == "clone"
|
||||
and row["seed"] is None
|
||||
and not row["vd_states"]
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "")
|
||||
and re.fullmatch(
|
||||
rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav",
|
||||
row["ref_audio_path"] or "",
|
||||
)
|
||||
)
|
||||
try:
|
||||
states = json.loads(row["vd_states"])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["kind"] == "design"
|
||||
and row["seed"] == 42
|
||||
and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item)
|
||||
and row["instruct"] == item["instruct"]
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == (item.get("sample_script") or "")
|
||||
and states == item["attrs"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_community_profile(conn, item: dict, personality: str):
|
||||
candidates = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
existing = next(
|
||||
(row for row in candidates if _is_materialized_community_row(row, item)), None,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# Old builds stored the bare item id. Import formats preserve arbitrary
|
||||
# personality text too, so adopt only the exact shape the old materializer
|
||||
# wrote; otherwise a remote item id could rewrite a user's imported voice.
|
||||
if archetypes.get_archetype(item["id"]) is None:
|
||||
legacy = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(item["id"],),
|
||||
).fetchone()
|
||||
if legacy is not None:
|
||||
kind, instruct, _vd_states, _seed = _profile_fields(item)
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get(
|
||||
"ref_text", "",
|
||||
)
|
||||
if (
|
||||
legacy["ref_audio_path"] == f"{legacy['id']}.wav"
|
||||
and legacy["kind"] == kind
|
||||
and legacy["instruct"] == instruct
|
||||
and legacy["language"] == item.get("language", "Auto")
|
||||
and legacy["ref_text"] == ref_text
|
||||
and legacy["seed"] is None
|
||||
and not legacy["vd_states"]
|
||||
and not legacy["is_locked"]
|
||||
and not legacy["verified_own_voice"]
|
||||
):
|
||||
return legacy
|
||||
return None
|
||||
|
||||
|
||||
def _heal_existing_profile(
|
||||
conn, row, item: dict, ref_text: str, personality: str, audio_filename: str,
|
||||
) -> None:
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?",
|
||||
(
|
||||
kind, instruct, vd_states, item.get("language", "Auto"), ref_text,
|
||||
seed, personality, audio_filename, row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/community/items/{item_id}/use")
|
||||
async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
"""Materialize a community item into a reusable voice profile.
|
||||
@@ -820,108 +223,76 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
``voice_profiles`` row usable everywhere voices are picked.
|
||||
"""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = _find_item(items, item_id)
|
||||
|
||||
canonical = _canonical_archetype(item)
|
||||
if canonical is not None:
|
||||
from api.routers.archetypes import use_archetype
|
||||
return await use_archetype(canonical["id"], name)
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
personality = _community_personality(item)
|
||||
with db_conn() as conn:
|
||||
existing = _existing_community_profile(conn, item, personality)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _community_audio_is_current(existing, item, ref_text):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _materialize_item_audio(
|
||||
item, profile_id, publish=existing is None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't add this voice right now.",
|
||||
) from e
|
||||
|
||||
if existing is not None:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_community_profile(conn, item, personality)
|
||||
still_owned = current is not None and (
|
||||
_is_materialized_community_row(current, item)
|
||||
or (owned is not None and owned["id"] == current["id"])
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_existing_profile(
|
||||
conn, current, item, ref_text, personality, audio_filename,
|
||||
)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _community_profile_audio_filename(profile_id, item)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
audio_filename, audio_path = await _materialize_item_audio(item, profile_id)
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
|
||||
if audio_path is None: # defensive: a new profile always materialized above
|
||||
raise RuntimeError("new community profile has no materialized audio")
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
profile_name = (name or item["name"]).strip() or item["name"]
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
pseudo = {
|
||||
"instruct": instruct,
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": ref_text or "Hello — this is a preview of this voice.",
|
||||
}
|
||||
await _render_archetype_wav(pseudo, audio_path)
|
||||
else: # voice — download the reference clip (off the event loop)
|
||||
await asyncio.to_thread(_download_voice_audio, item, audio_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
|
||||
|
||||
try:
|
||||
# A community "preset" is a synthetic designed voice (rendered from an
|
||||
# instruct string) → kind='design'; a "voice" carries a real reference
|
||||
# clip → kind='clone'. Setting kind makes the persona-gallery
|
||||
# synthetic-only gating work (§R3) instead of defaulting all imports to
|
||||
# 'clone'.
|
||||
kind = "design" if item["type"] == "preset" else "clone"
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
duplicate = _existing_community_profile(conn, item, personality)
|
||||
if duplicate is not None:
|
||||
duplicate_audio = duplicate["ref_audio_path"]
|
||||
if not _community_audio_is_current(duplicate, item, ref_text):
|
||||
duplicate_audio = _community_profile_audio_filename(duplicate["id"], item)
|
||||
duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_copy_atomic(audio_path, duplicate_path)
|
||||
_heal_existing_profile(
|
||||
conn, duplicate, item, ref_text, personality, duplicate_audio,
|
||||
)
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), None, item["id"], time.time(), kind),
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path) -> None:
|
||||
import hashlib
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
expected = audio.get("sha256")
|
||||
if expected and hashlib.sha256(data).hexdigest() != expected:
|
||||
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(data)
|
||||
|
||||
@@ -21,7 +21,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal, Optional
|
||||
from typing import Optional
|
||||
|
||||
from api.dependencies import require_local
|
||||
from api.public_engine_metadata import public_unavailability
|
||||
@@ -86,23 +86,6 @@ def list_dictation_models():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dictation/readiness", dependencies=[Depends(require_local)])
|
||||
def dictation_readiness(
|
||||
model_id: str | None = None,
|
||||
purpose: Literal["dictation", "transcribe"] = "dictation",
|
||||
) -> dict:
|
||||
"""Check a selected ASR path without loading or downloading weights."""
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
|
||||
missing = asr_model_missing_error(
|
||||
purpose=purpose,
|
||||
sherpa_model_id=(model_id or _read_prefs()["model_id"])
|
||||
if purpose == "dictation"
|
||||
else None,
|
||||
)
|
||||
return {"ready": missing is None, "missing": missing}
|
||||
|
||||
|
||||
@router.get("/dictation/prefs", dependencies=[Depends(require_local)])
|
||||
def get_dictation_prefs():
|
||||
return _read_prefs()
|
||||
|
||||
+83
-622
@@ -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.
|
||||
@@ -355,138 +254,6 @@ async def dub_import_srt(job_id: str, file: UploadFile = File(...)):
|
||||
}
|
||||
|
||||
|
||||
def _select_downloaded_caption_track(
|
||||
tracks: dict[str, list[dict]], preferred: str | None,
|
||||
) -> str | None:
|
||||
"""Choose the closest original-language caption track deterministically."""
|
||||
available = [key for key, cues in tracks.items() if isinstance(cues, list) and cues]
|
||||
if not available:
|
||||
return None
|
||||
preferred_tag = (preferred or "").strip().lower().replace("_", "-")
|
||||
preferred_base = preferred_tag.split("-", 1)[0]
|
||||
|
||||
def rank(key: str) -> tuple[int, int, int, str]:
|
||||
tag = key.strip().lower().replace("_", "-")
|
||||
base = tag.split("-", 1)[0]
|
||||
if preferred_tag:
|
||||
language_rank = 0 if tag == preferred_tag else 1 if base == preferred_base else 2
|
||||
else:
|
||||
language_rank = 0
|
||||
return (
|
||||
language_rank,
|
||||
0 if tag.endswith("-orig") else 1,
|
||||
0 if "-" not in tag else 1,
|
||||
tag,
|
||||
)
|
||||
|
||||
return min(available, key=rank)
|
||||
|
||||
|
||||
def _prepare_downloaded_caption_segments(cues: list[dict], duration: float) -> list[dict]:
|
||||
"""Normalize downloaded VTT cues into safe, sequential Dub segments."""
|
||||
def cue_start(cue: dict) -> float:
|
||||
try:
|
||||
return float(cue.get("start") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def remove_repeated_prefix(previous: str, current: str) -> str:
|
||||
previous_words = previous.split()
|
||||
current_words = current.split()
|
||||
folded_previous = [word.casefold() for word in previous_words]
|
||||
folded_current = [word.casefold() for word in current_words]
|
||||
for count in range(min(len(previous_words), len(current_words)), 0, -1):
|
||||
if folded_previous[-count:] == folded_current[:count]:
|
||||
return " ".join(current_words[count:])
|
||||
return current
|
||||
|
||||
prepared: list[dict] = []
|
||||
previous_end = 0.0
|
||||
ordered = sorted((cue for cue in cues if isinstance(cue, dict)), key=cue_start)
|
||||
for index, cue in enumerate(ordered):
|
||||
try:
|
||||
raw_start = max(0.0, float(cue.get("start") or 0.0))
|
||||
end = float(cue.get("end") or raw_start)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
text = " ".join(str(cue.get("text") or "").split())
|
||||
if duration > 0:
|
||||
if raw_start >= duration:
|
||||
continue
|
||||
end = min(end, duration)
|
||||
if prepared and raw_start < previous_end:
|
||||
text = remove_repeated_prefix(prepared[-1]["text"], text)
|
||||
if not text:
|
||||
prepared[-1]["end"] = round(max(previous_end, end), 3)
|
||||
previous_end = max(previous_end, end)
|
||||
continue
|
||||
# Caption hosts commonly emit slightly overlapping cues. Dubbing needs
|
||||
# a monotonic timeline, so trim the later cue rather than manufacture
|
||||
# overlapping speech slots.
|
||||
start = max(raw_start, previous_end)
|
||||
if not text or end <= start:
|
||||
continue
|
||||
prepared.append({
|
||||
"id": str(index),
|
||||
"start": round(start, 3),
|
||||
"end": round(end, 3),
|
||||
"text": text,
|
||||
"speaker_id": "Speaker 1",
|
||||
})
|
||||
previous_end = end
|
||||
|
||||
cleaned = clean_up_segments(prepared)
|
||||
return [
|
||||
{
|
||||
**segment,
|
||||
"id": index,
|
||||
"text_original": segment.get("text", ""),
|
||||
}
|
||||
for index, segment in enumerate(cleaned)
|
||||
]
|
||||
|
||||
|
||||
@router.post("/dub/use-downloaded-captions/{job_id}")
|
||||
def dub_use_downloaded_captions(job_id: str):
|
||||
"""Seed a prepared Dub job from its downloaded caption track."""
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
tracks = job.get("youtube_subs")
|
||||
if not isinstance(tracks, dict):
|
||||
raise HTTPException(status_code=404, detail="No downloaded captions are available")
|
||||
caption_lang = _select_downloaded_caption_track(
|
||||
tracks,
|
||||
job.get("source_lang_override") or job.get("source_lang"),
|
||||
)
|
||||
if caption_lang is None:
|
||||
raise HTTPException(status_code=404, detail="No downloaded captions are available")
|
||||
segments = _prepare_downloaded_caption_segments(
|
||||
tracks[caption_lang],
|
||||
float(job.get("duration") or 0.0),
|
||||
)
|
||||
if not segments:
|
||||
raise HTTPException(status_code=422, detail="Downloaded captions contain no usable cues")
|
||||
|
||||
source_lang = job.get("source_lang_override") or _detected_source_lang(caption_lang)
|
||||
job["segments"] = segments
|
||||
job["source_lang"] = source_lang
|
||||
job["full_transcript"] = " ".join(segment["text"] for segment in segments)
|
||||
# Caption files contain timing and text, but no trustworthy speaker or
|
||||
# reference-audio attribution. Never retain stale clone maps from a prior
|
||||
# transcript on the same job.
|
||||
job["segment_clones"] = {}
|
||||
job["speaker_clones"] = {}
|
||||
job.pop("cast_sources", None)
|
||||
_save_job(job_id, job)
|
||||
return {
|
||||
"segments": segments,
|
||||
"source_lang": source_lang,
|
||||
"caption_lang": caption_lang,
|
||||
"available": sorted(tracks.keys()),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dub/cleanup-segments/{job_id}")
|
||||
def dub_cleanup_segments(job_id: str):
|
||||
"""Re-run merge/stitch passes on a job's existing segments to drop fragments."""
|
||||
@@ -507,7 +274,8 @@ def dub_abort(job_id: str):
|
||||
had_procs = bool(_active_procs.get(job_id))
|
||||
_kill_job_procs(job_id)
|
||||
try:
|
||||
had_task = task_manager.cancel_task(job_id)
|
||||
if task_manager.cancel_task(job_id) is False:
|
||||
raise RuntimeError("task cancellation was declined")
|
||||
except Exception as exc:
|
||||
logger.warning("Dub task cancellation failed")
|
||||
raise HTTPException(
|
||||
@@ -517,13 +285,7 @@ def dub_abort(job_id: str):
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is not None:
|
||||
job["aborted"] = True
|
||||
# Cancellation is idempotent: a missing active task means it already
|
||||
# stopped between the renderer aborting its stream and this request.
|
||||
return {
|
||||
"aborted": True,
|
||||
"had_active_procs": had_procs,
|
||||
"had_active_task": had_task,
|
||||
}
|
||||
return {"aborted": True, "had_active_procs": had_procs}
|
||||
|
||||
|
||||
@router.get("/dub/history")
|
||||
@@ -589,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,
|
||||
@@ -607,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}",
|
||||
@@ -655,70 +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.
|
||||
|
||||
A rejection NAMES the code it rejected. "Invalid source language code" on
|
||||
its own cannot be acted on or reported usefully: it does not say which of
|
||||
the ninety-odd codes was wrong, so neither the user nor a maintainer
|
||||
reading the auto-filed issue can tell whether the picker offered something
|
||||
the backend does not accept, or a stale preference from an older build is
|
||||
still being sent (#1960).
|
||||
|
||||
The value is a language code the user chose from a menu — not private
|
||||
data — and the neighbouring engine validator already echoes its input the
|
||||
same way.
|
||||
"""
|
||||
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=(
|
||||
f"Invalid source language code: {code!r}. Pick a language from "
|
||||
"the Dubbing source-language menu, or leave it on auto-detect."
|
||||
),
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
def _detected_source_lang(value: str | None) -> str:
|
||||
"""Normalize an ASR language without truncating valid three-letter codes."""
|
||||
code = (value or "en").split("_", 1)[0].strip().lower()
|
||||
if code in _DUB_SOURCE_LANG_CODES:
|
||||
return code
|
||||
short = code[:2]
|
||||
return short if short in _DUB_SOURCE_LANG_CODES else "en"
|
||||
|
||||
|
||||
@router.post("/dub/upload")
|
||||
async def dub_upload(
|
||||
video: UploadFile = File(...),
|
||||
job_id: Optional[str] = Form(None),
|
||||
input_type: str = Form("video"),
|
||||
source_lang: Optional[str] = Form(None),
|
||||
):
|
||||
"""Accept a media upload, write to disk, queue background prep task.
|
||||
|
||||
@@ -748,36 +445,18 @@ 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}")
|
||||
|
||||
def _stream_upload_to_disk() -> None:
|
||||
# UploadFile is already a spooled file. Copy it in bounded chunks on a
|
||||
# worker thread instead of materialising a multi-GB video in RAM and
|
||||
# blocking every API request while the event loop writes it.
|
||||
video.file.seek(0)
|
||||
with open(video_path, "wb") as output:
|
||||
shutil.copyfileobj(video.file, output, length=1024 * 1024)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_stream_upload_to_disk)
|
||||
finally:
|
||||
await video.close()
|
||||
with open(video_path, "wb") as f:
|
||||
f.write(await video.read())
|
||||
|
||||
filename = video.filename or f"video{ext}"
|
||||
task_id = f"prep_{job_id}"
|
||||
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,
|
||||
@@ -799,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
|
||||
@@ -835,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(
|
||||
@@ -880,7 +557,7 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
|
||||
#: into one reference, which is how "made up" clone voices happen).
|
||||
CLONE_SKIP_HEURISTIC_MSG = (
|
||||
"auto voice cloning skipped: speaker labels are gap-based estimates — "
|
||||
"set up diarization (Model Catalogue → Other weights → pyannote) for per-speaker clones"
|
||||
"set up diarization (Settings → Models → pyannote) for per-speaker clones"
|
||||
)
|
||||
|
||||
|
||||
@@ -900,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,
|
||||
@@ -1107,23 +672,6 @@ async def dub_transcribe_stream(
|
||||
|
||||
job = _get_job(job_id)
|
||||
|
||||
# The durable job is written before the terminal SSE events below. If
|
||||
# the renderer, proxy, or backend connection drops in that narrow
|
||||
# window, reconnecting must replay the completed result instead of
|
||||
# running a second whole-file ASR pass. This is deliberately gated by
|
||||
# an explicit completion marker so partial work and imported subtitle
|
||||
# rows still take their established paths.
|
||||
if job and job.get("transcription_complete") and isinstance(job.get("segments"), list):
|
||||
yield _sse_event("final", {
|
||||
"segments": job["segments"],
|
||||
"source_lang": job.get("source_lang") or "en",
|
||||
"full_transcript": job.get("full_transcript") or "",
|
||||
"speaker_clones": job.get("cast_sources", {}),
|
||||
"cast_sources": job.get("cast_sources", {}),
|
||||
})
|
||||
yield _sse_event("done", {})
|
||||
return
|
||||
|
||||
preflight_error: Optional[str] = None
|
||||
# Extra machine-readable fields merged into the preflight `error` SSE event
|
||||
# (e.g. the typed asr_model_missing payload → download-CTA in the UI).
|
||||
@@ -1221,16 +769,6 @@ async def dub_transcribe_stream(
|
||||
preflight_payload = _missing
|
||||
if _missing is None:
|
||||
try:
|
||||
# Free recoverable TTS VRAM before ASR chooses its
|
||||
# device. Probing first falsely routed Whisper to
|
||||
# CPU even when this offload made CUDA viable.
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_cpu_pool, offload_tts_for_asr
|
||||
)
|
||||
_tts_offloaded["v"] = True
|
||||
except Exception as e:
|
||||
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1.
|
||||
@@ -1360,20 +898,24 @@ async def dub_transcribe_stream(
|
||||
"chunk_s": transcribe_chunk_s,
|
||||
})
|
||||
|
||||
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
|
||||
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
|
||||
# Non-fatal: an offload failure must not drop the stream (#255) —
|
||||
# transcription can still proceed (it just has less headroom).
|
||||
try:
|
||||
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
|
||||
# Restore is now owed on every exit path, not just success (#1191).
|
||||
_tts_offloaded["v"] = True
|
||||
except Exception as e:
|
||||
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
|
||||
|
||||
all_segments: list[dict] = []
|
||||
# Words (global-timeline) retained so diarization can re-split a segment
|
||||
# that spans two speakers' turns at the word boundary (#486).
|
||||
all_words: list = []
|
||||
# Preserve the ASR backend's natural phrase boundaries before
|
||||
# segment_transcript merges short neighboring phrases. Pyannote 3.1
|
||||
# occasionally collapses rapid exchanges into one dominant speaker; in
|
||||
# that narrow case these phrase spans give its own WeSpeaker embedding
|
||||
# model clean candidate utterances for a conservative recovery pass.
|
||||
asr_phrase_segments: list[dict] = []
|
||||
detected_lang = None
|
||||
next_seg_id = 0
|
||||
chunk_errors: list[str] = []
|
||||
chunk_error_codes: list[str] = []
|
||||
# Speaker turns from an ASR backend that diarizes inline (FunASR cam++).
|
||||
# When present, _diarize() uses them and skips pyannote (Phase 2, #182).
|
||||
asr_speaker_turns: list[dict] = []
|
||||
@@ -1418,20 +960,10 @@ async def dub_transcribe_stream(
|
||||
continue
|
||||
turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk})
|
||||
return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns}
|
||||
except Exception as exc:
|
||||
# Keep diagnostics local and fixed-shape. In particular,
|
||||
# CUDA OOM is a distinct, actionable recovery class rather
|
||||
# than the generic "no segments" dead end.
|
||||
is_memory = isinstance(exc, torch.OutOfMemoryError)
|
||||
logger.error(
|
||||
"Chunk transcription failed (backend=%s; class=%s; details withheld)",
|
||||
_asr_backend.id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
except Exception:
|
||||
logger.error("Chunk transcription failed (backend=%s)", _asr_backend.id)
|
||||
from core.public_errors import stream_failure
|
||||
failure = stream_failure(
|
||||
"transcription_memory" if is_memory else "transcription_failed"
|
||||
)
|
||||
failure = stream_failure("transcription_failed")
|
||||
return {
|
||||
"chunks": [],
|
||||
"language": None,
|
||||
@@ -1439,13 +971,22 @@ async def dub_transcribe_stream(
|
||||
"error_code": failure["code"],
|
||||
}
|
||||
|
||||
# Retry an ordinary completed failure once. A timed-out native call
|
||||
# is different: its thread is still executing and must not overlap
|
||||
# a retry against the same backend (#1669).
|
||||
# Retry a failed/timed-out chunk once on a fresh pool before giving
|
||||
# up. Otherwise a transient wedge on the FIRST chunk (whisperx often
|
||||
# cold-loads its model there, the #730 hang) drops that whole window
|
||||
# and the transcript is "missing the beginning, only middle+end".
|
||||
# The retry reuses the same audio window, so a recovered chunk fills
|
||||
# the hole instead of leaving silent gaps.
|
||||
part = None
|
||||
timed_out = False
|
||||
for _attempt in range(1, _CHUNK_TRANSCRIBE_ATTEMPTS + 1):
|
||||
# Run as a task and poll so pings keep the EventSource alive.
|
||||
# A wedged chunk gets the SAME guarded-timeout + pool-reset
|
||||
# semantics as the whole-file paths (#730/#851):
|
||||
# run_transcribe_guarded bounds the call, abandons the poisoned
|
||||
# pool so the retry (and any concurrent TTS work) gets a fresh
|
||||
# worker, and raises the actionable ASRTimeoutError. Run it as
|
||||
# a task and poll so we can keep yielding pings — the
|
||||
# EventSource connection drops without them.
|
||||
pool_reset_by_guard = False
|
||||
task = asyncio.ensure_future(run_transcribe_guarded(
|
||||
_gpu_pool, _transcribe_chunk,
|
||||
what=f"Dub chunk {i + 1}/{chunks_n}",
|
||||
@@ -1460,12 +1001,10 @@ async def dub_transcribe_stream(
|
||||
try:
|
||||
part = task.result()
|
||||
except ASRTimeoutError:
|
||||
# Python cannot kill an in-process native transcribe. Do
|
||||
# not swap pools and retry over the still-running call:
|
||||
# concurrent whisperx/CTranslate2 access caused the native
|
||||
# Windows access violation in #1669. Stop this transcript;
|
||||
# the worker remains honestly occupied until it exits.
|
||||
timed_out = True
|
||||
# The guard already reset the pool; keep the actionable
|
||||
# message (it names the durable fixes, and — after repeated
|
||||
# timeouts — the crash-isolated engine escape hatch).
|
||||
pool_reset_by_guard = True
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, transcribe_timeout_s, _attempt,
|
||||
@@ -1484,36 +1023,20 @@ async def dub_transcribe_stream(
|
||||
# error-part; the timeout path already reset the pool).
|
||||
if part is not None and not part.get("error"):
|
||||
break
|
||||
if timed_out:
|
||||
break
|
||||
if not timed_out and _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
|
||||
if _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, log_safe(job_id),
|
||||
)
|
||||
# A completed exception did not leave native work behind,
|
||||
# so retrying this same audio window is safe.
|
||||
if not pool_reset_by_guard:
|
||||
reset_pool_after_wedge(
|
||||
_gpu_pool, what=f"Dub chunk {i + 1}/{chunks_n}")
|
||||
if part.get("error"):
|
||||
chunk_errors.append(part["error"])
|
||||
if part.get("error_code"):
|
||||
chunk_error_codes.append(part["error_code"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, log_safe(part["error"]))
|
||||
if timed_out:
|
||||
break
|
||||
if detected_lang is None and part.get("language"):
|
||||
detected_lang = part["language"]
|
||||
asr_speaker_turns.extend(part.get("speaker_turns") or [])
|
||||
for _phrase in part.get("chunks", []) or []:
|
||||
_pts = _phrase.get("timestamp") or (None, None)
|
||||
_ptext = (_phrase.get("text") or "").strip()
|
||||
try:
|
||||
_ps, _pe = float(_pts[0]), float(_pts[1])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
continue
|
||||
if _ptext and _pe > _ps:
|
||||
asr_phrase_segments.append({
|
||||
"start": _ps, "end": _pe, "text": _ptext,
|
||||
})
|
||||
chunk_segs = segment_transcript(part, duration=t1, scene_cuts=scene_cuts)
|
||||
# Same word source segment_transcript used (already global-timeline),
|
||||
# kept for the post-diarization speaker re-split (#486).
|
||||
@@ -1574,9 +1097,7 @@ async def dub_transcribe_stream(
|
||||
seen.add(s)
|
||||
uniq.append(s)
|
||||
if uniq:
|
||||
# Chunk failures already carry a complete recovery message.
|
||||
# Do not prepend another generic sentence to it.
|
||||
detail = " | ".join(uniq[:3])
|
||||
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
|
||||
# Add the actionable hint for a recognized failure class
|
||||
# (e.g. pkg_resources missing → install setuptools).
|
||||
hint = build_failure(" ".join(uniq), stage="transcribe", include_diagnostic=False).get("hint")
|
||||
@@ -1589,10 +1110,7 @@ async def dub_transcribe_stream(
|
||||
"check that the source has an audible speech track."
|
||||
)
|
||||
logger.error("transcribe yielded 0 segments (job=%s): %s", log_safe(job_id), log_safe(detail))
|
||||
payload = {"detail": detail, "retryable": True}
|
||||
if chunk_error_codes:
|
||||
payload["code"] = chunk_error_codes[0]
|
||||
yield _sse_event("error", payload)
|
||||
yield _sse_event("error", {"detail": detail, "retryable": True})
|
||||
yield _sse_event("done", {})
|
||||
return
|
||||
|
||||
@@ -1618,7 +1136,6 @@ async def dub_transcribe_stream(
|
||||
from services.model_manager import (
|
||||
DIARIZATION_ERR_LICENSE,
|
||||
DIARIZATION_ERR_NO_TOKEN,
|
||||
DIARIZATION_ERR_MISSING,
|
||||
)
|
||||
from core import error_docs_map
|
||||
|
||||
@@ -1670,7 +1187,7 @@ async def dub_transcribe_stream(
|
||||
f"unavailable, so the ASR engine's built-in speaker "
|
||||
f"turns were used and the detected count may differ "
|
||||
f"from the {num_speakers} you set. Set up diarization "
|
||||
f"(Model Catalogue → Other weights → pyannote) to enforce an exact "
|
||||
f"(Settings → Models → pyannote) to enforce an exact "
|
||||
f"speaker count."
|
||||
)
|
||||
return resplit, {
|
||||
@@ -1711,23 +1228,7 @@ async def dub_transcribe_stream(
|
||||
from services import token_resolver
|
||||
resolved = token_resolver.resolve()
|
||||
|
||||
if err_sentinel == DIARIZATION_ERR_MISSING:
|
||||
from services.diarization_runtime import SORTFORMER, selected_backend
|
||||
native_selected = selected_backend() == SORTFORMER
|
||||
detail = (
|
||||
"Native Sortformer files are missing. Install audiocpp_cli beside "
|
||||
"the audio.cpp native bundle in Settings > Models > "
|
||||
"Diarisation, then retry transcription. "
|
||||
"Using silence gaps for now; rapid speaker turns may be merged."
|
||||
) if native_selected else (
|
||||
"Speaker diarization files are missing or incomplete. "
|
||||
"Install or repair pyannote in Settings > Models > Diarisation, "
|
||||
"then retry transcription. No models were downloaded during "
|
||||
"this job. Using silence gaps for now; rapid speaker turns "
|
||||
"may be merged."
|
||||
)
|
||||
error_class = "DIARIZATION_MODEL_MISSING"
|
||||
elif err_sentinel == DIARIZATION_ERR_NO_TOKEN:
|
||||
if err_sentinel == DIARIZATION_ERR_NO_TOKEN or not resolved:
|
||||
detail = (
|
||||
"Speaker diarization is disabled because no HuggingFace token "
|
||||
"was found in any source (Settings → API Keys, the HF_TOKEN "
|
||||
@@ -1740,12 +1241,12 @@ async def dub_transcribe_stream(
|
||||
)
|
||||
error_class = "HF_AUTH_FAILED"
|
||||
elif err_sentinel == DIARIZATION_ERR_LICENSE:
|
||||
who = resolved.username if resolved else "(not signed in)"
|
||||
who = resolved.username or "(whoami suppressed)"
|
||||
detail = (
|
||||
f"Speaker diarization model is gated — the "
|
||||
f"pyannote/speaker-diarization-3.1 license has not been "
|
||||
f"accepted on HuggingFace by this account "
|
||||
f"(user={who}). Visit "
|
||||
f"(source={resolved.source}, user={who}). Visit "
|
||||
f"huggingface.co/pyannote/speaker-diarization-3.1 AND "
|
||||
f"huggingface.co/pyannote/segmentation-3.0 while signed "
|
||||
f"in and click 'Agree and access repository' on both, "
|
||||
@@ -1757,13 +1258,17 @@ async def dub_transcribe_stream(
|
||||
else:
|
||||
# err_sentinel == DIARIZATION_ERR_LOAD (or unexpected None
|
||||
# with a resolved token — historical safety net).
|
||||
who = resolved.username or "(whoami suppressed)"
|
||||
detail = (
|
||||
f"The installed speaker diarization model failed to load. "
|
||||
f"See Settings > Logs > Backend for "
|
||||
f"Speaker diarization model failed to load even though an HF "
|
||||
f"token was found (source={resolved.source}, user={who}). "
|
||||
f"Most common causes: the pyannote/speaker-diarization-3.1 "
|
||||
f"license has not been accepted on HuggingFace, or there is "
|
||||
f"a pyannote/torch version mismatch. See backend logs for "
|
||||
f"the underlying error. Falling back to a silence-gap "
|
||||
f"heuristic; rapid speaker turns may be merged."
|
||||
)
|
||||
error_class = "DIARIZATION_LOAD_FAILED"
|
||||
error_class = "PYANNOTE_LICENSE_REQUIRED"
|
||||
warning = {
|
||||
"detail": detail + _hint_suffix(),
|
||||
"error_class": error_class,
|
||||
@@ -1784,13 +1289,7 @@ async def dub_transcribe_stream(
|
||||
# provided (#274). pyannote's apply() accepts num_speakers;
|
||||
# omit it entirely when None so we don't depend on the kwarg
|
||||
# existing in every pyannote build.
|
||||
from services.diarization_native import NativeSortformer
|
||||
if isinstance(diar_pipe, NativeSortformer):
|
||||
diar = diar_pipe(
|
||||
asr_audio_target, num_speakers=num_speakers, job_id=job_id,
|
||||
cancel_check=lambda: bool(job.get("aborted")) or task_manager.is_cancelled(job_id),
|
||||
)
|
||||
elif num_speakers:
|
||||
if num_speakers:
|
||||
logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers)
|
||||
diar = diar_pipe(asr_audio_target, num_speakers=num_speakers)
|
||||
else:
|
||||
@@ -1798,25 +1297,7 @@ async def dub_transcribe_stream(
|
||||
assigned = assign_speakers_from_diarization(all_segments, diar)
|
||||
# #486: split any segment that spans two speakers' turns at the
|
||||
# word boundary (single-speaker segments pass through unchanged).
|
||||
resplit = resplit_segments_by_diarization(assigned, all_words, diar)
|
||||
recovered = _recover_from_phrase_embeddings(
|
||||
diar_pipe,
|
||||
resplit,
|
||||
phrases=asr_phrase_segments,
|
||||
requested_speakers=num_speakers,
|
||||
audio_target=asr_audio_target,
|
||||
segments=all_segments,
|
||||
words=all_words,
|
||||
)
|
||||
if recovered is not None:
|
||||
recovered_segments, separation = recovered
|
||||
logger.info(
|
||||
"Recovered rapid two-speaker exchange from ASR phrase embeddings "
|
||||
"(phrases=%d, separation=%.3f).",
|
||||
len(asr_phrase_segments), separation,
|
||||
)
|
||||
return recovered_segments, None, "phrase_embeddings"
|
||||
return resplit, None, "audiocpp-sortformer" if isinstance(diar_pipe, NativeSortformer) else "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
|
||||
@@ -1865,9 +1346,6 @@ async def dub_transcribe_stream(
|
||||
final_segs, diar_warning, labels_source = done.pop().result()
|
||||
break
|
||||
yield _sse_event("ping", {})
|
||||
if job.get("aborted") or task_manager.is_cancelled(job_id):
|
||||
yield _sse_event("aborted", {})
|
||||
return
|
||||
if diar_warning:
|
||||
logger.warning("diarization fallback: %s", diar_warning.get("detail"))
|
||||
payload = {
|
||||
@@ -1882,8 +1360,6 @@ async def dub_transcribe_stream(
|
||||
payload["speaker_hint"] = diar_warning["speaker_hint"]
|
||||
yield _sse_event("warning", payload)
|
||||
|
||||
from services.segmentation import deduplicate_chunk_segments
|
||||
final_segs = deduplicate_chunk_segments(final_segs)
|
||||
job["segments"] = final_segs
|
||||
|
||||
# Auto-speaker-clone: sample each detected speaker's voice from the
|
||||
@@ -1892,11 +1368,7 @@ async def dub_transcribe_stream(
|
||||
# new target language and have the ORIGINAL speaker speak it — the
|
||||
# central pro-grade dubbing promise.
|
||||
try:
|
||||
from services.speaker_clone import (
|
||||
auto_profile_id,
|
||||
build_cast_sources,
|
||||
extract_speaker_clones,
|
||||
)
|
||||
from services.speaker_clone import extract_speaker_clones, auto_profile_id
|
||||
vocals_for_clone = job.get("vocals_path") or asr_audio_target
|
||||
clones = {}
|
||||
if labels_source == "heuristic":
|
||||
@@ -1998,9 +1470,7 @@ async def dub_transcribe_stream(
|
||||
except Exception as e:
|
||||
logger.warning("per-segment clone refs skipped: %s", e)
|
||||
|
||||
cast_sources = build_cast_sources(final_segs, clones, seg_clones)
|
||||
job["cast_sources"] = cast_sources
|
||||
if cast_sources:
|
||||
if clones or seg_clones:
|
||||
if clones:
|
||||
job["speaker_clones"] = clones
|
||||
# Default each segment's profile_id to its detected speaker's
|
||||
@@ -2022,19 +1492,21 @@ async def dub_transcribe_stream(
|
||||
if s.get("profile_id"):
|
||||
continue
|
||||
spk = s.get("speaker_id") or "Speaker 1"
|
||||
if spk in cast_sources:
|
||||
# Keep one UI-visible value for pooled and per-segment
|
||||
# sources. Generation resolves this line's own clip
|
||||
# first and falls back to the speaker's best clip.
|
||||
if spk in clones:
|
||||
s["profile_id"] = auto_profile_id(spk)
|
||||
continue
|
||||
# No per-speaker clone for this speaker (too little usable
|
||||
# audio overall) but this single line was long enough for
|
||||
# its own ref — fall back to the per-segment id. The editor
|
||||
# can't render it, but generation still clones correctly.
|
||||
sid = str(s.get("id", ""))
|
||||
if sid and sid in seg_clones:
|
||||
s["profile_id"] = f"auto-seg:{sid}"
|
||||
except Exception as e:
|
||||
logger.warning("speaker_clone extraction skipped: %s", e)
|
||||
|
||||
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
|
||||
detected_lang
|
||||
)
|
||||
job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower()
|
||||
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
|
||||
job["transcription_complete"] = True
|
||||
_save_job(job_id, job)
|
||||
|
||||
# Restore TTS model to GPU now that ASR is done. unload() blocks
|
||||
@@ -2062,10 +1534,7 @@ async def dub_transcribe_stream(
|
||||
"segments": final_segs,
|
||||
"source_lang": job["source_lang"],
|
||||
"full_transcript": job["full_transcript"],
|
||||
# The client only needs labels and durations. Never send host
|
||||
# paths or reference transcripts through this public event.
|
||||
"speaker_clones": job.get("cast_sources", {}),
|
||||
"cast_sources": job.get("cast_sources", {}),
|
||||
"speaker_clones": job.get("speaker_clones", {}),
|
||||
})
|
||||
yield _sse_event("done", {})
|
||||
|
||||
@@ -2198,11 +1667,8 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
|
||||
# / mlx / pytorch based on what's installed + user preference. Works
|
||||
# identically on all platforms; the older mlx-vs-pytorch branching
|
||||
# here duplicated the logic in asr_backend.py and skipped WhisperX.
|
||||
# `load_*`, not `get_*`: the plain selector hands back engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, which
|
||||
# then dies at `.transcribe()`. The loader degrades (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
_asr = load_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
_asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
try:
|
||||
try:
|
||||
logger.info("Transcribing full audio via %s ...", _asr.id)
|
||||
@@ -2230,9 +1696,7 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
|
||||
except Exception as e:
|
||||
logger.warning("Failed to unload ASR backend: %s", e)
|
||||
|
||||
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
|
||||
detected_lang
|
||||
)
|
||||
job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower()
|
||||
|
||||
scene_cuts = job.get("scene_cuts") or []
|
||||
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
|
||||
@@ -2288,16 +1752,13 @@ 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
|
||||
raise
|
||||
if job.get("aborted"):
|
||||
raise HTTPException(status_code=499, detail="Transcription aborted")
|
||||
from services.segmentation import deduplicate_chunk_segments
|
||||
segments_result = deduplicate_chunk_segments(segments_result)
|
||||
job["segments"] = segments_result
|
||||
source_lang = job.get("source_lang")
|
||||
_save_job(job_id, job)
|
||||
|
||||
@@ -15,7 +15,7 @@ from core.http_headers import content_disposition
|
||||
from core.logging_utils import log_safe
|
||||
from core.path_security import UnsafePath, resolve_within
|
||||
from core.tasks import task_manager
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request, Response
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from services.ffmpeg_utils import (
|
||||
bed_mix_filter,
|
||||
@@ -23,7 +23,6 @@ from services.ffmpeg_utils import (
|
||||
find_ffmpeg,
|
||||
run_ffmpeg,
|
||||
)
|
||||
from services.karaoke_ass import build_ass, scale_words
|
||||
from services.video_retime import (
|
||||
DRIFT_TOLERANCE_S,
|
||||
RetimeError,
|
||||
@@ -38,31 +37,6 @@ router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
|
||||
async def _preserved_background(job: dict, job_id: str, lang: str, *, prepare: bool = True) -> str:
|
||||
"""All mixed preview/download paths share the same dialogue-only bed."""
|
||||
from services.dub_background import surgical_background
|
||||
|
||||
bed = _optional_dub_artifact(job.get("no_vocals_path"), job_id)
|
||||
source = _optional_dub_artifact(job.get("video_path"), job_id) or _optional_dub_artifact(job.get("audio_path"), job_id)
|
||||
if not bed or not source:
|
||||
raise HTTPException(status_code=409, detail={"code": "dub_background_unavailable", "message": "Original audio and background separation are required"})
|
||||
track = (job.get("dubbed_tracks") or {}).get(lang) or {}
|
||||
segments = track.get("source_segments") or job.get("segments") or []
|
||||
if not segments:
|
||||
raise HTTPException(status_code=409, detail={"code": "dub_background_unavailable", "message": "Dialogue timing is required"})
|
||||
if not prepare:
|
||||
return bed
|
||||
strategy = track.get("timing_strategy") or job.get("timing_strategy")
|
||||
plans = job.get("fit_plans" if strategy == "smart_fit" else "video_stretch_plans") or {}
|
||||
entry = (plans.get(lang) or {}) if strategy in {"smart_fit", "stretch_video"} else {}
|
||||
directory = os.path.join(_existing_job_dir_or_404(job_id), "exports")
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
try:
|
||||
return await surgical_background(source, bed, directory, segments, entry.get("plan") or [], float(entry.get("orig_duration") or job.get("duration") or 0))
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=409, detail={"code": "dub_background_unavailable", "message": str(exc)}) from exc
|
||||
|
||||
|
||||
def _unique_stamp() -> str:
|
||||
"""Return a short unique suffix like '20260415T142301-ab12cd34' for export files."""
|
||||
return f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
||||
@@ -429,27 +403,6 @@ def _write_burn_srt(job: dict, exports_dir: str, stamp: str, dual: bool,
|
||||
return sub_path
|
||||
|
||||
|
||||
def _write_burn_ass(job: dict, exports_dir: str, stamp: str,
|
||||
fitted_segments: "list[dict] | None" = None,
|
||||
lang: "str | None" = None) -> str | None:
|
||||
"""Karaoke variant of ``_write_burn_srt``: word-timed ASS via ``build_ass``.
|
||||
|
||||
Same text/timing resolution (``_segments_for_lang`` + fitted-cue overlay,
|
||||
which also scales per-word times onto the fitted timeline); the basename
|
||||
is plain ASCII under exports_dir so it is ffmpeg-filter-safe. Returns
|
||||
None if there are no segments to render.
|
||||
"""
|
||||
segments = _segments_for_lang(job, lang)
|
||||
if not segments:
|
||||
return None
|
||||
if fitted_segments:
|
||||
segments = _apply_fitted_times(segments, fitted_segments)
|
||||
sub_path = os.path.join(exports_dir, f"burn_subs_{stamp}.ass")
|
||||
with open(sub_path, "w", encoding="utf-8") as f:
|
||||
f.write(build_ass(segments))
|
||||
return sub_path
|
||||
|
||||
|
||||
def _ffmpeg_filter_escape(path: str) -> str:
|
||||
"""Escape a path for use inside an ffmpeg filter value (subtitles=...).
|
||||
|
||||
@@ -562,20 +515,6 @@ def _apply_fitted_times(segments: list[dict], fitted: list[dict]) -> list[dict]:
|
||||
patched = dict(seg)
|
||||
patched["start"] = float(cue["start"])
|
||||
patched["end"] = float(cue["end"])
|
||||
# Karaoke burn-in: persisted word times live on the original timeline;
|
||||
# scale them linearly onto the fitted cue span so the highlight sweep
|
||||
# follows the retimed audio. Degenerate spans drop the words — export
|
||||
# then falls back to an even split over the fitted span. Inert for
|
||||
# SRT/VTT, which never read ``words``.
|
||||
if isinstance(seg.get("words"), list) and seg.get("words"):
|
||||
scaled = scale_words(
|
||||
seg["words"], seg.get("start", 0.0), seg.get("end", 0.0),
|
||||
patched["start"], patched["end"],
|
||||
)
|
||||
if scaled is not None:
|
||||
patched["words"] = scaled
|
||||
else:
|
||||
patched.pop("words", None)
|
||||
out.append(patched)
|
||||
return out
|
||||
|
||||
@@ -621,7 +560,7 @@ def _build_audio_export_cmd(
|
||||
# Mix the dubbed voice over the original background bed (same weights
|
||||
# as the video mux path) so ambience/music is preserved.
|
||||
cmd += ["-i", bg_path, "-filter_complex",
|
||||
bed_mix_filter("1:a", "0:a", bed_gain=1.0),
|
||||
bed_mix_filter("1:a", "0:a"),
|
||||
"-map", "[aout]"]
|
||||
cmd += codec
|
||||
cmd.append(out_path)
|
||||
@@ -633,12 +572,11 @@ def _build_audio_export_cmd(
|
||||
async def dub_download(
|
||||
job_id: str,
|
||||
preserve_bg: bool = Query(True, description="Mix background noise into dubbed tracks"),
|
||||
default_track: str = Query("", description="Default audio track; omitted selects the first dubbed track"),
|
||||
default_track: str = Query("original"),
|
||||
include_tracks: str = Query("", description="Comma-separated list of tracks to include (e.g. 'original,de,es'). Empty = include all."),
|
||||
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
|
||||
burn_subs: bool = Query(False, description="Burn subtitles into the video stream (forces re-encode). Uses dual-subtitle layout when dual=1."),
|
||||
dual: bool = Query(False, description="When burn_subs=1, render translated on top of italicised original."),
|
||||
karaoke: bool = Query(False, description="When burn_subs=1, burn a word-timed karaoke highlight (ASS) instead of line subtitles. Ignored when dual=1 (dual karaoke is unsupported — the line burn renders instead)."),
|
||||
out_format: str = Query("m4a", description="Audio-only jobs (#119): output container — wav, m4a, mp3, or flac. Ignored for video jobs."),
|
||||
):
|
||||
# Strict allowlist on the path param BEFORE it reaches any filesystem
|
||||
@@ -669,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")
|
||||
|
||||
@@ -705,18 +631,13 @@ 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)
|
||||
bg = await _preserved_background(job, job_id, lang_code) if preserve_bg else None
|
||||
# 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:
|
||||
rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
|
||||
@@ -733,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)},
|
||||
@@ -791,18 +699,7 @@ async def dub_download(
|
||||
fitted_segments = _fitted_segments_for(job, default_track) if default_track and default_track != "original" else None
|
||||
# Burn the DEFAULT track's text (P1.2) — it's the audio the viewer hears.
|
||||
_burn_lang = default_track if default_track and default_track != "original" else None
|
||||
# Karaoke (word-highlight) burn writes an ASS instead of the line SRT.
|
||||
# Dual layout keeps the line burn — dual karaoke is out of scope, matching
|
||||
# the disabled control in the Export drawer. The default (karaoke off)
|
||||
# takes exactly the legacy SRT path.
|
||||
sub_path = None
|
||||
sub_is_ass = False
|
||||
if burn_subs:
|
||||
if karaoke and not dual:
|
||||
sub_path = _write_burn_ass(job, exports_dir, stamp, fitted_segments=fitted_segments, lang=_burn_lang)
|
||||
sub_is_ass = sub_path is not None
|
||||
if sub_path is None:
|
||||
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang)
|
||||
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang) if burn_subs else None
|
||||
|
||||
# ── Smart Fit video retime (two-tier) ─────────────────────────────────
|
||||
# Tier 1 (≤48 chunks): single filter_complex graph inlined into the mux
|
||||
@@ -864,16 +761,17 @@ async def dub_download(
|
||||
retimed_idx = input_idx
|
||||
input_idx += 1
|
||||
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
bg_idx = None
|
||||
if bg_audio and filtered_tracks:
|
||||
cmd += ["-i", bg_audio]
|
||||
bg_idx = input_idx
|
||||
input_idx += 1
|
||||
|
||||
tracks_to_process = []
|
||||
for lang_code, track_info in filtered_tracks.items():
|
||||
if preserve_bg:
|
||||
bg_audio = await _preserved_background(job, job_id, lang_code)
|
||||
cmd += ["-i", bg_audio]
|
||||
bg_idx = input_idx
|
||||
input_idx += 1
|
||||
cmd += ["-i", track_info["path"]]
|
||||
tracks_to_process.append({"lang_code": lang_code, "idx": input_idx, "bg_idx": bg_idx, "info": track_info})
|
||||
tracks_to_process.append({"lang_code": lang_code, "idx": input_idx, "info": track_info})
|
||||
input_idx += 1
|
||||
|
||||
filter_parts: list[str] = []
|
||||
@@ -901,16 +799,14 @@ async def dub_download(
|
||||
esc = _ffmpeg_filter_escape(sub_path)
|
||||
# Burn AFTER any retime so cues (already on the fitted timeline for
|
||||
# Smart Fit) land on the retimed video. Without retime this reduces
|
||||
# to the legacy `[0:v]subtitles=…[vsub]` graph. Karaoke burns the
|
||||
# word-timed ASS through the ass filter at the same graph position.
|
||||
# to the legacy `[0:v]subtitles=…[vsub]` graph.
|
||||
if video_map.startswith("["):
|
||||
sub_src = video_map
|
||||
elif retimed_idx is not None:
|
||||
sub_src = f"[{retimed_idx}:v]"
|
||||
else:
|
||||
sub_src = "[0:v]"
|
||||
_sub_filter = "ass" if sub_is_ass else "subtitles"
|
||||
filter_parts.append(f"{sub_src}{_sub_filter}='{esc}'[vsub]")
|
||||
filter_parts.append(f"{sub_src}subtitles='{esc}'[vsub]")
|
||||
video_map = "[vsub]"
|
||||
if stretch_entry:
|
||||
orig_dur = float(stretch_entry.get("orig_duration") or job.get("duration") or 0.0)
|
||||
@@ -942,7 +838,7 @@ async def dub_download(
|
||||
for i, t in enumerate(tracks_to_process):
|
||||
tail = f",apad=whole_dur={apad_dur:.4f}" if apad_dur else ""
|
||||
filter_parts.append(bed_mix_filter(
|
||||
f"{t['bg_idx']}:a", f"{t['idx']}:a", out=f"aout{i}", tail=tail, uniq=str(i), bed_gain=1.0,
|
||||
f"{bg_idx}:a", f"{t['idx']}:a", out=f"aout{i}", tail=tail, uniq=str(i),
|
||||
))
|
||||
t["out_label"] = f"[aout{i}]"
|
||||
for t in tracks_to_process:
|
||||
@@ -991,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"]
|
||||
@@ -1074,8 +967,8 @@ _MEDIA_TYPES = {
|
||||
}
|
||||
|
||||
|
||||
@router.api_route("/dub/media/{job_id}", methods=["GET", "HEAD"])
|
||||
async def dub_get_media(job_id: str, request: Request):
|
||||
@router.get("/dub/media/{job_id}")
|
||||
async def dub_get_media(job_id: str):
|
||||
_job_dir_or_400(job_id)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
@@ -1088,15 +981,7 @@ async def dub_get_media(job_id: str, request: Request):
|
||||
# silent black box. Default to video/mp4 because the ingest pipeline
|
||||
# remuxes URL downloads to mp4 (dub_pipeline.yt_download_sync).
|
||||
ext = os.path.splitext(video_path)[1].lower()
|
||||
media_type = _MEDIA_TYPES.get(ext, "video/mp4")
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"Accept-Ranges": "bytes",
|
||||
}
|
||||
if request.method == "HEAD":
|
||||
headers["Content-Length"] = str(os.path.getsize(video_path))
|
||||
return Response(media_type=media_type, headers=headers)
|
||||
return FileResponse(video_path, media_type=media_type, headers=headers)
|
||||
return FileResponse(video_path, media_type=_MEDIA_TYPES.get(ext, "video/mp4"))
|
||||
|
||||
# One mux at a time per preview file. Without this, two overlapping requests
|
||||
# (e.g. the <video> element remounting right after a re-dub) both ran ffmpeg
|
||||
@@ -1113,9 +998,8 @@ def _preview_lock(path: str) -> asyncio.Lock:
|
||||
return lock
|
||||
|
||||
|
||||
@router.api_route("/dub/preview-video/{job_id}", methods=["GET", "HEAD"])
|
||||
@router.get("/dub/preview-video/{job_id}")
|
||||
async def dub_preview_video(
|
||||
request: Request,
|
||||
job_id: str,
|
||||
lang: str = Query(..., description="Language code of the dubbed track to mux in"),
|
||||
preserve_bg: bool = Query(True),
|
||||
@@ -1143,7 +1027,7 @@ async def dub_preview_video(
|
||||
|
||||
video_path = _dub_artifact(job.get("video_path"), job_id, missing_detail="Source video missing")
|
||||
|
||||
bg_audio = await _preserved_background(job, job_id, lang, prepare=request.method != "HEAD") if preserve_bg else None
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
has_bg = bool(bg_audio)
|
||||
|
||||
# realpath-normalised + containment-checked inline BEFORE any filesystem
|
||||
@@ -1155,9 +1039,9 @@ async def dub_preview_video(
|
||||
if not exports_dir.startswith(_base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
bg_suffix = "surgical_v2_" + Path(bg_audio).stem if (preserve_bg and has_bg) else "nobg"
|
||||
bg_suffix = "bg" if (preserve_bg and has_bg) else "nobg"
|
||||
preview_path = os.path.realpath(
|
||||
os.path.join(exports_dir, f"preview_v2_{lang}_{bg_suffix}.mp4")
|
||||
os.path.join(exports_dir, f"preview_{lang}_{bg_suffix}.mp4")
|
||||
)
|
||||
if not preview_path.startswith(_base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
@@ -1171,18 +1055,6 @@ async def dub_preview_video(
|
||||
and os.path.getmtime(preview_path) >= track_mtime
|
||||
)
|
||||
|
||||
# Vidstack probes extensionless routes with HEAD before choosing a native
|
||||
# provider. Confirm that this preview is valid without starting an ffmpeg
|
||||
# mux; the following GET builds it lazily when needed.
|
||||
if request.method == "HEAD":
|
||||
headers = {
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"Accept-Ranges": "bytes",
|
||||
}
|
||||
if _cache_ok():
|
||||
headers["Content-Length"] = str(os.path.getsize(preview_path))
|
||||
return Response(media_type="video/mp4", headers=headers)
|
||||
|
||||
async def _mux_preview():
|
||||
# Mux into a temp file and os.replace() into place so a concurrent
|
||||
# reader never sees a partially-written preview (#281: video stuck
|
||||
@@ -1294,7 +1166,7 @@ async def dub_preview_video(
|
||||
audio_map = f"{track_idx}:a:0"
|
||||
if bg_idx is not None:
|
||||
tail = f",apad=whole_dur={apad_dur:.4f}" if apad_dur else ""
|
||||
filter_parts.append(bed_mix_filter(f"{bg_idx}:a", f"{track_idx}:a", tail=tail, bed_gain=1.0))
|
||||
filter_parts.append(bed_mix_filter(f"{bg_idx}:a", f"{track_idx}:a", tail=tail))
|
||||
audio_map = "[aout]"
|
||||
elif apad_dur:
|
||||
filter_parts.append(f"[{track_idx}:a]apad=whole_dur={apad_dur:.4f}[aout]")
|
||||
@@ -1311,7 +1183,7 @@ async def dub_preview_video(
|
||||
cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p"]
|
||||
else:
|
||||
cmd += ["-c:v", "copy"]
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"]
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k"]
|
||||
# `-shortest` would cut the retimed video at the (slightly different)
|
||||
# audio length and lose the trailing frame; only use it on the copy path.
|
||||
if not stretch_entry and retime_decision is None:
|
||||
@@ -1356,37 +1228,22 @@ async def dub_preview_video(
|
||||
if not _cache_ok():
|
||||
await _mux_preview()
|
||||
|
||||
# The renderer includes the segment-fingerprint revision in the URL, so a
|
||||
# regenerated track gets a fresh cache key. Keep each completed preview:
|
||||
# switching Original/Dub then reuses local ranges instead of re-reading a
|
||||
# multi-hundred-megabyte MP4 from the backend.
|
||||
# no-store: the URL is stable across re-dubs, so any HTTP-level caching
|
||||
# in the WebView would keep showing the previous dub after a re-generate
|
||||
# (#281: "edits don't change the result").
|
||||
return FileResponse(
|
||||
preview_path,
|
||||
media_type="video/mp4",
|
||||
headers={"Cache-Control": "private, max-age=31536000, immutable", "Accept-Ranges": "bytes"},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _compute_timeline_sync(src_path: str) -> tuple[list[float], list[float]]:
|
||||
def _compute_onsets_sync(src_path: str) -> list[float]:
|
||||
"""Blocking part of onset analysis — runs in a worker thread."""
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from services.onset_align import detect_speech_onsets
|
||||
audio, sr = sf.read(src_path, dtype="float32")
|
||||
onsets = detect_speech_onsets(audio, sr)
|
||||
mono = np.asarray(audio, dtype=np.float32)
|
||||
if mono.ndim > 1:
|
||||
mono = mono.mean(axis=1)
|
||||
mono = mono.reshape(-1)
|
||||
if mono.size == 0:
|
||||
return onsets, []
|
||||
bucket_count = min(2048, int(mono.size))
|
||||
bucket_width = max(1, (int(mono.size) + bucket_count - 1) // bucket_count)
|
||||
padded_size = bucket_count * bucket_width
|
||||
if padded_size != mono.size:
|
||||
mono = np.pad(mono, (0, padded_size - int(mono.size)))
|
||||
peaks = np.max(np.abs(mono.reshape(bucket_count, bucket_width)), axis=1)
|
||||
return onsets, [round(float(value), 5) for value in peaks]
|
||||
return detect_speech_onsets(audio, sr)
|
||||
|
||||
|
||||
@router.get("/dub/onsets/{job_id}")
|
||||
@@ -1425,24 +1282,20 @@ async def dub_get_onsets(job_id: str):
|
||||
):
|
||||
with open(cache_path, "r", encoding="utf-8") as f:
|
||||
cached = json.load(f)
|
||||
if (
|
||||
isinstance(cached, dict)
|
||||
and isinstance(cached.get("onsets"), list)
|
||||
and isinstance(cached.get("peaks"), list)
|
||||
):
|
||||
if isinstance(cached, dict) and isinstance(cached.get("onsets"), list):
|
||||
return cached
|
||||
except (OSError, ValueError):
|
||||
pass # unreadable/corrupt cache → recompute below
|
||||
|
||||
try:
|
||||
onsets, peaks = await asyncio.to_thread(_compute_timeline_sync, src_path)
|
||||
onsets = await asyncio.to_thread(_compute_onsets_sync, src_path)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Onset analysis failed: {str(e)[:200]}",
|
||||
)
|
||||
|
||||
payload = {"onsets": onsets, "peaks": peaks, "source": source}
|
||||
payload = {"onsets": onsets, "source": source}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
|
||||
tmp_path = cache_path + ".tmp"
|
||||
@@ -1581,20 +1434,13 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
)
|
||||
|
||||
def _recognize():
|
||||
# `load_*`, not `get_*`: the plain selector returns engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, which
|
||||
# then 500s at `.transcribe()`. The loader degrades (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
result = backend.transcribe(wav_path, word_timestamps=False)
|
||||
return result.get("segments", []), backend.id
|
||||
|
||||
try:
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
from services.model_manager import _get_gpu_pool
|
||||
recognized, engine_id = await run_transcribe_guarded(
|
||||
_get_gpu_pool(), _recognize, what="QC",
|
||||
@@ -1603,13 +1449,6 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
# Backend is alive; ASR just couldn't finish in time. 504, not 500/connection.
|
||||
logger.warning("dub QC ASR pass timed out")
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
# Degraded onto an engine with no weights on disk — typed 409 with the
|
||||
# download CTA, matching the preflight above.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("dub QC ASR pass failed")
|
||||
raise HTTPException(status_code=500, detail=f"QC transcription failed: {e}")
|
||||
@@ -1685,13 +1524,13 @@ async def dub_download_audio(
|
||||
exports_dir = os.path.join(job_dir, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
|
||||
bg_audio = await _preserved_background(job, job_id, lang_label) if preserve_bg else None
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
if bg_audio:
|
||||
ffmpeg = find_ffmpeg()
|
||||
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{stamp}.wav")
|
||||
cmd = [
|
||||
ffmpeg, "-i", bg_audio, "-i", wav_path,
|
||||
"-filter_complex", bed_mix_filter("0:a", "1:a", bed_gain=1.0),
|
||||
"-filter_complex", bed_mix_filter("0:a", "1:a"),
|
||||
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", final_audio_path
|
||||
]
|
||||
try:
|
||||
@@ -1702,9 +1541,8 @@ async def dub_download_audio(
|
||||
raise Exception("ffmpeg mix produced no output file")
|
||||
wav_path = final_audio_path
|
||||
logger.info("Dub audio mix completed")
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("Failed to mix audio")
|
||||
raise HTTPException(status_code=500, detail={"code": "dub_background_unavailable", "message": "Could not preserve background audio"}) from exc
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
|
||||
@@ -1714,10 +1552,7 @@ async def dub_download_audio(
|
||||
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
|
||||
return FileResponse(
|
||||
wav_path, media_type="audio/wav",
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Disposition": content_disposition(dl_name),
|
||||
},
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
|
||||
@@ -1859,50 +1694,6 @@ async def dub_export_vtt(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dub/ass/{job_id}")
|
||||
@router.get("/dub/ass/{job_id}/{filename}")
|
||||
async def dub_export_ass(
|
||||
job_id: str,
|
||||
lang: str = Query(None, description="Track language code. Same text/timing resolution as /dub/srt, rendered as a karaoke (word-highlight) ASS sidecar."),
|
||||
):
|
||||
"""Karaoke ASS sidecar — the same script the karaoke burn-in renders.
|
||||
|
||||
Raw text body like /dub/srt and /dub/vtt (the Tauri side writes the file
|
||||
itself; no ?save_path= variant — see the comment above /dub/srt).
|
||||
"""
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
segments = _segments_for_lang(job, lang)
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="No transcript segments available")
|
||||
|
||||
# Same strategy-aware cue timing as /dub/srt. The fitted overlay also
|
||||
# scales word times; the stretch_video cue path has no per-word record,
|
||||
# so words are dropped and build_ass even-splits over the new spans.
|
||||
fitted = _fitted_segments_for(job, lang)
|
||||
if fitted:
|
||||
segments = _apply_fitted_times(segments, fitted)
|
||||
else:
|
||||
cues = _fitted_cue_times(job, lang)
|
||||
if cues:
|
||||
segments = [
|
||||
{**{k: v for k, v in seg.items() if k != "words"}, "start": s, "end": e}
|
||||
for seg, (s, e) in zip(segments, cues)
|
||||
]
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'video'))[0]
|
||||
dl_name = f"subtitles_{base_name}_karaoke.ass"
|
||||
return Response(
|
||||
content=build_ass(segments),
|
||||
media_type="text/plain",
|
||||
headers={"Content-Disposition": content_disposition(dl_name)},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dub/export-segments/{job_id}")
|
||||
async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
|
||||
import zipfile
|
||||
@@ -1972,23 +1763,20 @@ async def dub_download_mp3(
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
|
||||
source_path = wav_path
|
||||
bg_audio = await _preserved_background(job, job_id, lang_label) if preserve_bg else None
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
if bg_audio:
|
||||
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{stamp}.wav")
|
||||
cmd_mix = [
|
||||
ffmpeg, "-i", bg_audio, "-i", wav_path,
|
||||
"-filter_complex", bed_mix_filter("0:a", "1:a", bed_gain=1.0),
|
||||
"-filter_complex", bed_mix_filter("0:a", "1:a"),
|
||||
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", mixed_path
|
||||
]
|
||||
try:
|
||||
rc, _, _ = await run_ffmpeg(cmd_mix, timeout=900.0)
|
||||
if rc == 0 and os.path.exists(mixed_path) and os.path.getsize(mixed_path) > 0:
|
||||
source_path = mixed_path
|
||||
else:
|
||||
raise RuntimeError("Background mixing failed")
|
||||
except Exception as exc:
|
||||
except Exception:
|
||||
logger.exception("Failed to mix audio for MP3")
|
||||
raise HTTPException(status_code=500, detail={"code": "dub_background_unavailable", "message": "Could not preserve background audio"}) from exc
|
||||
|
||||
mp3_path = os.path.join(exports_dir, f"dubbed_{stamp}.mp3")
|
||||
# Accept '128', '192k' etc. — normalize to ffmpeg's 'Nk' form and clamp
|
||||
|
||||
+172
-728
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,12 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from schemas.requests import AgentFitRequest, TranslateRequest
|
||||
from schemas.requests import TranslateRequest
|
||||
from services.model_manager import _cpu_pool, _gpu_pool
|
||||
from services.hf_revisions import revision_for
|
||||
from services.translator import cinematic_available, cinematic_refine_many, _cinematic_budget
|
||||
@@ -20,11 +19,10 @@ _NLLB_REPO_ID = "facebook/nllb-200-distilled-600M"
|
||||
|
||||
|
||||
def _load_nllb_component(factory):
|
||||
"""Load explicitly installed NLLB weights at their reviewed revision."""
|
||||
"""Load a curated NLLB component from its reviewed immutable revision."""
|
||||
return factory.from_pretrained(
|
||||
_NLLB_REPO_ID,
|
||||
revision=revision_for(_NLLB_REPO_ID),
|
||||
local_files_only=True,
|
||||
)
|
||||
|
||||
TRANSLATE_CODES = {
|
||||
@@ -41,33 +39,8 @@ FLORES_CODES = {
|
||||
"hi": "hin_Deva", "tr": "tur_Latn", "pl": "pol_Latn", "nl": "nld_Latn",
|
||||
"sv": "swe_Latn", "th": "tha_Thai", "vi": "vie_Latn", "id": "ind_Latn",
|
||||
"uk": "ukr_Cyrl",
|
||||
"zh-TW": "zho_Hant", "zh-Hant": "zho_Hant", "cmn-Hant": "zho_Hant",
|
||||
"zh-Hans": "zho_Hans", "yue": "yue_Hant",
|
||||
"bn": "ben_Beng", "ta": "tam_Taml", "te": "tel_Telu", "ml": "mal_Mlym",
|
||||
"kn": "kan_Knda", "gu": "guj_Gujr", "mr": "mar_Deva", "ur": "urd_Arab",
|
||||
"fa": "pes_Arab", "he": "heb_Hebr", "el": "ell_Grek", "cs": "ces_Latn",
|
||||
"da": "dan_Latn", "fi": "fin_Latn", "nb": "nob_Latn", "nn": "nno_Latn",
|
||||
"ro": "ron_Latn", "hu": "hun_Latn", "bg": "bul_Cyrl", "sk": "slk_Latn",
|
||||
"sl": "slv_Latn", "hr": "hrv_Latn", "sr": "srp_Cyrl", "lt": "lit_Latn",
|
||||
"et": "est_Latn", "sw": "swh_Latn", "af": "afr_Latn", "ms": "zsm_Latn",
|
||||
}
|
||||
|
||||
|
||||
def _nllb_language(code: str) -> str | None:
|
||||
"""Resolve aliases or tokenizer-supported FLORES codes without loading weights."""
|
||||
from transformers.models.nllb.tokenization_nllb import FAIRSEQ_LANGUAGE_CODES
|
||||
|
||||
normalized = code.strip().replace("_", "-").lower()
|
||||
aliases = {key.lower(): value for key, value in FLORES_CODES.items()}
|
||||
if normalized in aliases:
|
||||
return aliases[normalized]
|
||||
exact = [value for value in FAIRSEQ_LANGUAGE_CODES if value.replace("_", "-").lower() == normalized]
|
||||
if exact:
|
||||
return exact[0]
|
||||
# Bare ISO-639-3 codes are safe only when the tokenizer has one script.
|
||||
matches = [value for value in FAIRSEQ_LANGUAGE_CODES if value.split("_")[0] == normalized]
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
# Human-readable language names for LLM prompts. Empirically a tiny / 7B
|
||||
# local LLM produces Devanagari Hindi reliably when told "translate into
|
||||
# Hindi" but drifts to German / English / phonetic-Latin when told
|
||||
@@ -190,77 +163,9 @@ def _looks_like_target(text: str, code: str, threshold: float = 0.5) -> bool:
|
||||
codepoints alone."""
|
||||
return _script_ratio(text, code) >= threshold
|
||||
|
||||
|
||||
def _translation_output_error(text: object) -> str | None:
|
||||
"""Reject provider error pages that arrive with HTTP 200.
|
||||
|
||||
Google's mobile endpoint occasionally returns its generic HTML error copy
|
||||
inside the element deep-translator treats as a successful translation.
|
||||
Passing that through would replace the user's transcript with the error
|
||||
page, so treat it like any other transient provider failure and retry.
|
||||
"""
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return "empty translation"
|
||||
normalized = " ".join(text.split()).casefold()
|
||||
error_markers = (
|
||||
"error 500 (server error)",
|
||||
"that's an error",
|
||||
"that’s an error",
|
||||
"there was an error. please try again later",
|
||||
"no translation was found using the current translator",
|
||||
)
|
||||
if "\ufffd" in text or any(marker in normalized for marker in error_markers):
|
||||
return "translation provider returned invalid output"
|
||||
return None
|
||||
|
||||
_nllb_model = None
|
||||
_nllb_tokenizer = None
|
||||
_nllb_device = None
|
||||
_NLLB_BATCH_SIZE_ENV = "OMNIVOICE_NLLB_BATCH_SIZE"
|
||||
_NLLB_MAX_BATCH_SIZE = 32
|
||||
|
||||
|
||||
def _nllb_batch_size() -> int:
|
||||
"""Bound NLLB forward-pass width; explicit overrides remain available."""
|
||||
configured = os.environ.get(_NLLB_BATCH_SIZE_ENV, "").strip()
|
||||
if configured:
|
||||
try:
|
||||
return max(1, min(_NLLB_MAX_BATCH_SIZE, int(configured)))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("%s=%r is not an integer; using the safe default", _NLLB_BATCH_SIZE_ENV, configured)
|
||||
# The 600M checkpoint leaves ample room on modern discrete GPUs. Scale the
|
||||
# forward-pass width there; CPU and unified-memory MPS keep the conservative
|
||||
# width because their failure recovery moves the whole model.
|
||||
if _nllb_device == "cuda":
|
||||
try:
|
||||
import torch
|
||||
|
||||
free_gib = int(torch.cuda.mem_get_info()[0]) / 1024**3
|
||||
if free_gib >= 16:
|
||||
return 24
|
||||
if free_gib >= 8:
|
||||
return 12
|
||||
except Exception:
|
||||
pass
|
||||
return 8
|
||||
return 4
|
||||
|
||||
|
||||
def _nllb_hypothesis_budget() -> int:
|
||||
"""Bound batch × beam hypotheses by currently available device memory."""
|
||||
if _nllb_device != "cuda":
|
||||
return 16
|
||||
try:
|
||||
import torch
|
||||
|
||||
free_gib = int(torch.cuda.mem_get_info()[0]) / 1024**3
|
||||
if free_gib >= 16:
|
||||
return 64
|
||||
if free_gib >= 8:
|
||||
return 32
|
||||
except Exception:
|
||||
pass
|
||||
return 16
|
||||
|
||||
|
||||
def _dialect_flags(req, applied: bool) -> dict:
|
||||
@@ -350,11 +255,10 @@ def _resolve_translation_context(req, client, model_name: str, timeout: float,
|
||||
|
||||
def _unload_nllb():
|
||||
"""Release NLLB VRAM so TTS model can reload."""
|
||||
global _nllb_device, _nllb_model, _nllb_tokenizer
|
||||
global _nllb_model, _nllb_tokenizer
|
||||
import gc
|
||||
_nllb_model = None
|
||||
_nllb_tokenizer = None
|
||||
_nllb_device = None
|
||||
gc.collect()
|
||||
try:
|
||||
import torch
|
||||
@@ -366,33 +270,10 @@ def _unload_nllb():
|
||||
pass
|
||||
|
||||
|
||||
def _should_unload_nllb() -> bool:
|
||||
"""Retain a warm local translator only when the accelerator has safe headroom."""
|
||||
override = os.environ.get("OMNIVOICE_UNLOAD_NLLB")
|
||||
if override is not None:
|
||||
return override.strip().lower() not in {"0", "false", "no", "off"}
|
||||
if _nllb_device != "cuda":
|
||||
return True
|
||||
try:
|
||||
import torch
|
||||
|
||||
free_bytes, total_bytes = torch.cuda.mem_get_info()
|
||||
return total_bytes < 16 * 1024**3 or free_bytes < 8 * 1024**3
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/dub/translate")
|
||||
async def dub_translate(req: TranslateRequest):
|
||||
try:
|
||||
provider = (req.provider if req.provider else os.environ.get("TRANSLATE_PROVIDER", "google")).lower()
|
||||
from services import translation_engines
|
||||
|
||||
if not translation_engines.get_engine(provider):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "Choose a supported translation engine."},
|
||||
)
|
||||
lang_code = TRANSLATE_CODES.get(req.target_lang, req.target_lang)
|
||||
api_key = os.environ.get("TRANSLATE_API_KEY", "")
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -400,16 +281,8 @@ async def dub_translate(req: TranslateRequest):
|
||||
|
||||
# Offline NLLB Transformer Translation
|
||||
if provider == "nllb":
|
||||
requested = [src_lang, req.target_lang, *(seg.target_lang for seg in req.segments if seg.target_lang)]
|
||||
resolved = {code: _nllb_language(code) for code in requested}
|
||||
unsupported = [code for code, language in resolved.items() if language is None]
|
||||
if unsupported:
|
||||
return JSONResponse(status_code=400, content={
|
||||
"error": "NLLB does not support the requested language.",
|
||||
"code": "unsupported_translation_language", "languages": unsupported,
|
||||
})
|
||||
flores_tgt = resolved[req.target_lang]
|
||||
flores_src = resolved[src_lang]
|
||||
flores_tgt = FLORES_CODES.get(req.target_lang, "eng_Latn")
|
||||
flores_src = FLORES_CODES.get(src_lang, "eng_Latn")
|
||||
|
||||
def _translate_nllb():
|
||||
global _nllb_model, _nllb_tokenizer, _nllb_device
|
||||
@@ -441,112 +314,44 @@ async def dub_translate(req: TranslateRequest):
|
||||
logger.exception("NLLB model load failed")
|
||||
return [{"id": seg.id, "text": seg.text, "error": f"Model load error: {str(e)}"} for seg in req.segments]
|
||||
|
||||
from services.performance_profiles import translation_decode_defaults
|
||||
|
||||
# Snapshot once so every segment and device fallback in this
|
||||
# job uses the same decoding effort even if preferences change.
|
||||
decode_options = translation_decode_defaults()
|
||||
def _generate_rows(rows, target_language):
|
||||
global _nllb_device
|
||||
|
||||
_nllb_tokenizer.src_lang = flores_src
|
||||
inputs = _nllb_tokenizer(
|
||||
[seg.text for _, seg in rows],
|
||||
return_tensors="pt",
|
||||
padding=True,
|
||||
)
|
||||
if _nllb_device and _nllb_device != "cpu":
|
||||
inputs = {key: value.to(_nllb_device) for key, value in inputs.items()}
|
||||
forced_bos_token_id = _nllb_tokenizer.convert_tokens_to_ids(target_language)
|
||||
results = []
|
||||
for seg in req.segments:
|
||||
try:
|
||||
tokens = _nllb_model.generate(
|
||||
**inputs,
|
||||
forced_bos_token_id=forced_bos_token_id,
|
||||
max_length=400,
|
||||
**decode_options,
|
||||
)
|
||||
except (RuntimeError, NotImplementedError) as error:
|
||||
if _nllb_device != "mps":
|
||||
raise
|
||||
logger.warning("MPS generate failed, retrying on CPU: %s", error)
|
||||
_nllb_model.to("cpu")
|
||||
_nllb_device = "cpu"
|
||||
inputs = {key: value.to("cpu") for key, value in inputs.items()}
|
||||
tokens = _nllb_model.generate(
|
||||
**inputs,
|
||||
forced_bos_token_id=forced_bos_token_id,
|
||||
max_length=400,
|
||||
**decode_options,
|
||||
)
|
||||
decoded = _nllb_tokenizer.batch_decode(tokens, skip_special_tokens=True)
|
||||
if len(decoded) != len(rows):
|
||||
raise RuntimeError(
|
||||
f"NLLB returned {len(decoded)} translations for {len(rows)} segments"
|
||||
)
|
||||
return decoded
|
||||
|
||||
# A target-language BOS token is shared by a forward pass, so
|
||||
# group mixed-language rows first. Preserve request order in
|
||||
# the final response even though groups render independently.
|
||||
grouped: dict[str, list[tuple[int, object]]] = {}
|
||||
results_by_index: dict[int, dict] = {}
|
||||
for index, seg in enumerate(req.segments):
|
||||
if not seg.text or not seg.text.strip():
|
||||
results_by_index[index] = {"id": seg.id, "text": seg.text}
|
||||
continue
|
||||
target = resolved[seg.target_lang] if seg.target_lang else flores_tgt
|
||||
grouped.setdefault(target, []).append((index, seg))
|
||||
|
||||
# Beam search multiplies decoder memory per row. Keep the
|
||||
# effective hypothesis count bounded while still widening the
|
||||
# Fast path aggressively.
|
||||
beam_count = max(1, int(decode_options.get("num_beams", 1)))
|
||||
width = min(
|
||||
_nllb_batch_size(),
|
||||
max(1, _nllb_hypothesis_budget() // beam_count),
|
||||
)
|
||||
for target, rows in grouped.items():
|
||||
for start in range(0, len(rows), width):
|
||||
batch = rows[start : start + width]
|
||||
try:
|
||||
translated_texts = _generate_rows(batch, target)
|
||||
except Exception as batch_error:
|
||||
if len(batch) == 1:
|
||||
index, seg = batch[0]
|
||||
results_by_index[index] = {
|
||||
"id": seg.id,
|
||||
"text": seg.text,
|
||||
"error": str(batch_error),
|
||||
}
|
||||
continue
|
||||
# A single unusually long row must not sink its
|
||||
# neighbours. Clear a failed device allocation and
|
||||
# retain the established per-segment degradation.
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
logger.warning(
|
||||
"NLLB batch of %d failed; retrying rows individually: %s",
|
||||
len(batch),
|
||||
batch_error,
|
||||
)
|
||||
for index, seg in batch:
|
||||
try:
|
||||
translated_text = _generate_rows([(index, seg)], target)[0]
|
||||
results_by_index[index] = {"id": seg.id, "text": translated_text}
|
||||
except Exception as row_error:
|
||||
results_by_index[index] = {
|
||||
"id": seg.id,
|
||||
"text": seg.text,
|
||||
"error": str(row_error),
|
||||
}
|
||||
if not seg.text or not seg.text.strip():
|
||||
results.append({"id": seg.id, "text": seg.text})
|
||||
continue
|
||||
for (index, seg), translated_text in zip(batch, translated_texts):
|
||||
results_by_index[index] = {"id": seg.id, "text": translated_text}
|
||||
|
||||
return [results_by_index[index] for index in range(len(req.segments))]
|
||||
tgt = FLORES_CODES.get(seg.target_lang, flores_tgt) if seg.target_lang else flores_tgt
|
||||
|
||||
_nllb_tokenizer.src_lang = flores_src
|
||||
inputs = _nllb_tokenizer(seg.text, return_tensors="pt")
|
||||
if _nllb_device and _nllb_device != "cpu":
|
||||
inputs = {k: v.to(_nllb_device) for k, v in inputs.items()}
|
||||
|
||||
forced_bos_token_id = _nllb_tokenizer.convert_tokens_to_ids(tgt)
|
||||
try:
|
||||
translated_tokens = _nllb_model.generate(
|
||||
**inputs, forced_bos_token_id=forced_bos_token_id, max_length=400
|
||||
)
|
||||
except (RuntimeError, NotImplementedError) as e:
|
||||
if _nllb_device == "mps":
|
||||
logger.warning("MPS generate failed, retrying on CPU: %s", e)
|
||||
_nllb_model.to("cpu")
|
||||
_nllb_device = "cpu"
|
||||
inputs = {k: v.to("cpu") for k, v in inputs.items()}
|
||||
translated_tokens = _nllb_model.generate(
|
||||
**inputs, forced_bos_token_id=forced_bos_token_id, max_length=400
|
||||
)
|
||||
else:
|
||||
raise
|
||||
translated_text = _nllb_tokenizer.batch_decode(translated_tokens, skip_special_tokens=True)[0]
|
||||
results.append({"id": seg.id, "text": translated_text})
|
||||
except Exception as e:
|
||||
results.append({"id": seg.id, "text": seg.text, "error": str(e)})
|
||||
return results
|
||||
|
||||
translated = await loop.run_in_executor(_gpu_pool, _translate_nllb)
|
||||
if _should_unload_nllb():
|
||||
if os.environ.get("OMNIVOICE_UNLOAD_NLLB", "1") == "1":
|
||||
_unload_nllb()
|
||||
# Cinematic/Autofit refine + rate-ratio badges must run for NLLB too
|
||||
# (previously this returned before _maybe_cinematic, so a Cinematic
|
||||
@@ -667,7 +472,6 @@ async def dub_translate(req: TranslateRequest):
|
||||
f"You are a professional dubbing translator. "
|
||||
f"Translate the user's text from {src_name} into "
|
||||
f"{tgt_name}.{script_clause}{dia_clause} "
|
||||
f"{translation_style_brief(req)} "
|
||||
f"Reply ONLY with the translated {tgt_name} text, do not "
|
||||
f"add quotes, notes, headers, explanations, or commentary."
|
||||
)
|
||||
@@ -739,7 +543,7 @@ async def dub_translate(req: TranslateRequest):
|
||||
source_lang=src_lang,
|
||||
target_lang=tgt_code,
|
||||
target_name=LANG_NAMES.get(tgt_code, tgt_code),
|
||||
extra_clause="\n".join(filter(None, [context_extra, translation_style_brief(req)])),
|
||||
extra_clause=context_extra,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("reflect pass skipped for %s: %s",
|
||||
@@ -790,35 +594,18 @@ async def dub_translate(req: TranslateRequest):
|
||||
f"switch the Engine dropdown to another provider."
|
||||
)
|
||||
return JSONResponse(status_code=400, content={"error": friendly})
|
||||
target_codes = list(dict.fromkeys(
|
||||
seg.target_lang if seg.target_lang else req.target_lang
|
||||
for seg in req.segments
|
||||
))
|
||||
try:
|
||||
pack_status = translation_engines.argos_pack_status(src_lang, target_codes)
|
||||
except (ImportError, ValueError) as exc:
|
||||
return JSONResponse(status_code=422, content={"error": str(exc)})
|
||||
missing_packs = [
|
||||
pair for pair in pack_status["pairs"] if not pair["installed"]
|
||||
]
|
||||
if missing_packs:
|
||||
pairs = ", ".join(
|
||||
f'{pair["source_lang"]} → {pair["target_lang"]}'
|
||||
for pair in missing_packs
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"error": f"Install the Argos language pack for {pairs} before translating.",
|
||||
"code": "argos_pack_missing",
|
||||
"pairs": missing_packs,
|
||||
},
|
||||
)
|
||||
|
||||
def _translate_argos():
|
||||
cache_dir = os.environ.get("OMNIVOICE_CACHE_DIR")
|
||||
if cache_dir:
|
||||
argos_cache = os.path.join(cache_dir, "argos-translate")
|
||||
os.makedirs(argos_cache, exist_ok=True)
|
||||
os.environ.setdefault("ARGOS_PACKAGES_DIR", argos_cache)
|
||||
os.environ.setdefault("ARGOS_DATA_DIR", argos_cache)
|
||||
import argostranslate.package
|
||||
import argostranslate.translate
|
||||
|
||||
from_code = pack_status["source_lang"]
|
||||
from_code = src_lang
|
||||
available_packages = argostranslate.package.get_installed_packages()
|
||||
|
||||
results = []
|
||||
for seg in req.segments:
|
||||
@@ -827,12 +614,19 @@ async def dub_translate(req: TranslateRequest):
|
||||
results.append({"id": seg.id, "text": seg.text})
|
||||
continue
|
||||
to_code = seg.target_lang if seg.target_lang else req.target_lang
|
||||
to_code = translation_engines.argos_lang_code(to_code)
|
||||
translated_text = (
|
||||
seg.text
|
||||
if from_code == to_code
|
||||
else argostranslate.translate.translate(seg.text, from_code, to_code)
|
||||
)
|
||||
installed_pkg = next(filter(lambda x: x.from_code == from_code and x.to_code == to_code, available_packages), None)
|
||||
|
||||
if installed_pkg is None:
|
||||
argostranslate.package.update_package_index()
|
||||
all_packages = argostranslate.package.get_available_packages()
|
||||
package_to_install = next(filter(lambda x: x.from_code == from_code and x.to_code == to_code, all_packages), None)
|
||||
if package_to_install:
|
||||
argostranslate.package.install_from_path(package_to_install.download())
|
||||
available_packages = argostranslate.package.get_installed_packages()
|
||||
else:
|
||||
raise Exception(f"No Argos package available for {from_code} -> {to_code}")
|
||||
|
||||
translated_text = argostranslate.translate.translate(seg.text, from_code, to_code)
|
||||
results.append({"id": seg.id, "text": translated_text})
|
||||
except Exception as e:
|
||||
results.append({"id": seg.id, "text": seg.text, "error": str(e)})
|
||||
@@ -906,10 +700,9 @@ async def dub_translate(req: TranslateRequest):
|
||||
for attempt, src in enumerate([src_arg, src_arg, "auto"]):
|
||||
try:
|
||||
out = _build_translator(src, seg_lc).translate(seg.text)
|
||||
output_error = _translation_output_error(out)
|
||||
if output_error is None:
|
||||
if out and out.strip():
|
||||
return {"id": seg.id, "text": out}
|
||||
last_err = output_error
|
||||
last_err = "empty translation"
|
||||
except Exception as e:
|
||||
last_err = f"{type(e).__name__}: {e}"
|
||||
logger.warning(
|
||||
@@ -1102,7 +895,7 @@ async def _apply_fit_pass(rows, req, slots_by_id, source_by_id, quality, loop, d
|
||||
their current text and get ``rate_error='fit-budget'``. Only rows with a
|
||||
slot + text + no prior error participate.
|
||||
"""
|
||||
strict = quality in ("autofit", "agent")
|
||||
strict = (quality == "autofit")
|
||||
items = []
|
||||
for row in rows:
|
||||
seg_id = str(row["id"])
|
||||
@@ -1165,7 +958,7 @@ async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False
|
||||
|
||||
# Fast (and anything unrecognised) returns the plain translation unchanged
|
||||
# (plus the pre-synthesis duration-plan badges — no LLM needed for those).
|
||||
if quality not in ("cinematic", "autofit", "agent"):
|
||||
if quality not in ("cinematic", "autofit"):
|
||||
await _finalize_duration_plan(translated, req, loop)
|
||||
return base
|
||||
|
||||
@@ -1236,7 +1029,7 @@ async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False
|
||||
target_lang=req.target_lang,
|
||||
glossary=req.glossary,
|
||||
directions=directions,
|
||||
dialect_hint="\n".join(filter(None, [dialect_hint, translation_style_brief(req)])),
|
||||
dialect_hint=dialect_hint,
|
||||
executor=_cpu_pool,
|
||||
)
|
||||
refined_by_id = {r["id"]: r for r in refined}
|
||||
@@ -1283,70 +1076,3 @@ async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False
|
||||
"quality_used": quality,
|
||||
**_dialect_flags(req, applied=bool(dialect_hint)),
|
||||
}
|
||||
|
||||
|
||||
def translation_style_brief(req) -> str:
|
||||
instructions = (getattr(req, "translation_instructions", None) or "").strip()
|
||||
return ("User translation style brief (tone and wording only; preserve meaning, timing and output format): "
|
||||
+ json.dumps(instructions, ensure_ascii=False)) if instructions else ""
|
||||
|
||||
|
||||
@router.post("/dub/agent-fit")
|
||||
async def dub_agent_fit(req: AgentFitRequest):
|
||||
"""Rewrite rendered lines from real duration evidence.
|
||||
|
||||
Synthesis stays in the normal Dubbing pipeline. The client renders each
|
||||
candidate, measures it, and may request one more bounded correction.
|
||||
"""
|
||||
from services import llm_skills
|
||||
from services.speech_rate import adjust_for_measured_slot_many
|
||||
|
||||
readiness = llm_skills.resolve_skill("slot_fitting")
|
||||
if not readiness.ready:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "llm_skill_unavailable",
|
||||
"skill": "slot_fitting",
|
||||
"reason": readiness.reason or "unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
items = [
|
||||
(
|
||||
segment.id,
|
||||
segment.text,
|
||||
segment.slot_seconds,
|
||||
segment.measured_seconds,
|
||||
req.target_lang,
|
||||
segment.source_text,
|
||||
segment.context_before,
|
||||
segment.context_after,
|
||||
)
|
||||
for segment in req.segments
|
||||
]
|
||||
budget = _cinematic_budget()
|
||||
try:
|
||||
call = adjust_for_measured_slot_many(items, executor=_cpu_pool, translation_instructions=req.translation_instructions)
|
||||
rows = await asyncio.wait_for(call, timeout=budget) if budget and budget > 0 else await call
|
||||
except asyncio.TimeoutError:
|
||||
rows = {
|
||||
segment.id: {
|
||||
"text": segment.text,
|
||||
"changed": False,
|
||||
"measured_seconds": round(segment.measured_seconds, 3),
|
||||
"target_seconds": round(segment.slot_seconds, 3),
|
||||
"measured_ratio": round(
|
||||
segment.measured_seconds / max(segment.slot_seconds, 0.001), 3
|
||||
),
|
||||
"error": "fit-budget",
|
||||
}
|
||||
for segment in req.segments
|
||||
}
|
||||
return {
|
||||
"target_lang": req.target_lang,
|
||||
"segments": [
|
||||
{"id": segment.id, **rows[str(segment.id)]}
|
||||
for segment in req.segments
|
||||
],
|
||||
}
|
||||
|
||||
+51
-370
@@ -15,7 +15,6 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
|
||||
`OMNIVOICE_LLM_BACKEND`) still win over the UI choice so power-users can pin
|
||||
a backend without Settings silently undoing it.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
@@ -24,11 +23,10 @@ from time import perf_counter
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from huggingface_hub import utils as hf_utils
|
||||
from huggingface_hub.errors import HFValidationError
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_admin, require_admin_action, require_desktop
|
||||
from api.dependencies import require_loopback
|
||||
from core import prefs
|
||||
from core.engine_licenses import LICENSE_GATED_ENGINES
|
||||
from services import tts_backend, asr_backend, llm_backend, translation_engines
|
||||
from services.audio_dsp import list_effect_presets
|
||||
from api.schemas import EffectPresetsResponse
|
||||
@@ -43,68 +41,6 @@ _FAMILIES = {
|
||||
"llm": (llm_backend, "llm_backend"),
|
||||
}
|
||||
|
||||
|
||||
def _catalogue_active_id(family: str, module) -> str:
|
||||
"""Return the active id represented by the public engine catalogue."""
|
||||
active = module.active_backend_id()
|
||||
if family != "tts" or active != "omnivoice-subprocess":
|
||||
return active
|
||||
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
try:
|
||||
return "omnivoice" if detect_host_caps().family == "mps" else active
|
||||
except Exception:
|
||||
return active
|
||||
|
||||
|
||||
def _family_payload(family: str, module):
|
||||
"""Public inventory plus whether an environment pin owns this family."""
|
||||
active = _catalogue_active_id(family, module)
|
||||
model = None
|
||||
if family == "asr":
|
||||
model = asr_backend._offline_asr_repo(active)
|
||||
elif family == "llm" and active != "off":
|
||||
model = llm_backend.get_active_llm_backend().model_name
|
||||
elif family == "tts":
|
||||
if active in {"omnivoice", "omnivoice-subprocess"}:
|
||||
from services.model_manager import resolve_omnivoice_checkpoint
|
||||
model = resolve_omnivoice_checkpoint()
|
||||
elif active == "mlx-audio":
|
||||
from core import prefs
|
||||
cls = tts_backend.MLXAudioBackend
|
||||
key = prefs.resolve("mlx_audio_model_id", env="OMNIVOICE_MLX_AUDIO_MODEL", default=cls.DEFAULT_MODEL_KEY)
|
||||
model = cls.CURATED_MODELS.get(key, key)
|
||||
else:
|
||||
instance = getattr(tts_backend, "_active_instance", None)
|
||||
if instance is not None and getattr(tts_backend, "_active_instance_id", None) == active:
|
||||
model = instance.model_identity()
|
||||
backends = public_backends(module.list_backends())
|
||||
if family == "tts":
|
||||
from services import settings_store
|
||||
|
||||
for backend in backends:
|
||||
engine_id = backend.get("id")
|
||||
if engine_id in LICENSE_GATED_ENGINES:
|
||||
backend["license_required"] = True
|
||||
try:
|
||||
backend["license_accepted"] = settings_store.get_license_accepted(engine_id)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Could not read license acceptance for %s",
|
||||
engine_id,
|
||||
exc_info=True,
|
||||
)
|
||||
backend["license_accepted"] = False
|
||||
return {
|
||||
# MPS hides the explicit compatibility row, so legacy configs report
|
||||
# the visible canonical equivalent as active to picker consumers.
|
||||
"active": active,
|
||||
"active_model": model,
|
||||
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
|
||||
"backends": backends,
|
||||
}
|
||||
|
||||
def _is_hf_repo_id(value: str) -> bool:
|
||||
"""Validate the route's ``owner/repo`` contract in bounded time."""
|
||||
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
|
||||
@@ -119,40 +55,34 @@ def _is_hf_repo_id(value: str) -> bool:
|
||||
@router.get("/engines")
|
||||
def list_all_engines():
|
||||
return {
|
||||
"tts": _family_payload("tts", tts_backend),
|
||||
"asr": _family_payload("asr", asr_backend),
|
||||
"llm": _family_payload("llm", llm_backend),
|
||||
"tts": {
|
||||
"active": tts_backend.active_backend_id(),
|
||||
"backends": public_backends(tts_backend.list_backends()),
|
||||
},
|
||||
"asr": {
|
||||
"active": asr_backend.active_backend_id(),
|
||||
"backends": public_backends(asr_backend.list_backends()),
|
||||
},
|
||||
"llm": {
|
||||
"active": llm_backend.active_backend_id(),
|
||||
"backends": public_backends(llm_backend.list_backends()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return _family_payload("tts", tts_backend)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/disk-usage",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def engine_disk_usage(engine_id: str):
|
||||
"""Measure owned engine bytes only when a catalogue row is opened."""
|
||||
try:
|
||||
tts_backend.get_backend_class(engine_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Unknown TTS engine")
|
||||
from services.engine_disk_usage import disk_usage_for
|
||||
|
||||
return disk_usage_for(engine_id)
|
||||
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return _family_payload("asr", asr_backend)
|
||||
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return _family_payload("llm", llm_backend)
|
||||
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
@@ -165,90 +95,6 @@ def list_effects_presets():
|
||||
return {"presets": list_effect_presets()}
|
||||
|
||||
|
||||
@router.get("/engines/diarisation")
|
||||
def diarisation_status():
|
||||
"""Describe the selected local diarisation runtime without loading weights."""
|
||||
from services.diarization_runtime import (
|
||||
PYANNOTE,
|
||||
SORTFORMER,
|
||||
selected_backend,
|
||||
sortformer_status,
|
||||
)
|
||||
|
||||
selected = selected_backend()
|
||||
native = selected == SORTFORMER
|
||||
options = []
|
||||
from api.routers.setup.models import KNOWN_MODELS, cache_is_complete, is_cached
|
||||
pyannote_repo = "pyannote/speaker-diarization-3.1"
|
||||
spec = next(model for model in KNOWN_MODELS if model["repo_id"] == pyannote_repo)
|
||||
pyannote_installed = is_cached(pyannote_repo) and cache_is_complete(spec)
|
||||
pyannote_reason = None if pyannote_installed else "Install the pyannote model bundle"
|
||||
options.append({
|
||||
"id": PYANNOTE,
|
||||
"label": "pyannote 3.1",
|
||||
"model": pyannote_repo,
|
||||
"installed": pyannote_installed,
|
||||
"reason": pyannote_reason,
|
||||
})
|
||||
|
||||
native_status = sortformer_status()
|
||||
native_installed = native_status["installed"]
|
||||
native_model = native_status["model"]
|
||||
native_reason = native_status["reason"]
|
||||
options.append({
|
||||
"id": SORTFORMER,
|
||||
"label": "Sortformer v1 (audio.cpp)",
|
||||
"model": native_model,
|
||||
"model_installed": native_status["model_installed"],
|
||||
"runtime_installed": native_status["runtime_installed"],
|
||||
"installed": native_installed,
|
||||
"reason": native_reason,
|
||||
})
|
||||
|
||||
if native:
|
||||
from services.diarization_native import is_running
|
||||
return {"active": SORTFORMER, "label": "Sortformer v1 (audio.cpp)",
|
||||
"model": native_model, "installed": native_installed, "loaded": False,
|
||||
"model_installed": native_status["model_installed"],
|
||||
"runtime_installed": native_status["runtime_installed"],
|
||||
"busy": is_running(), "reason": native_reason, "options": options}
|
||||
from services import model_manager
|
||||
return {"active": PYANNOTE, "label": "pyannote 3.1", "model": pyannote_repo,
|
||||
"installed": pyannote_installed,
|
||||
"loaded": model_manager._diar_pipeline is not None, "reason": pyannote_reason,
|
||||
"options": options}
|
||||
|
||||
|
||||
class DiarisationSelection(BaseModel):
|
||||
engine_id: str
|
||||
|
||||
|
||||
@router.post("/engines/diarisation/select", dependencies=[Depends(require_admin)])
|
||||
def select_diarisation_engine(request: DiarisationSelection):
|
||||
"""Persist an installed diarisation runtime; environment overrides still win."""
|
||||
from services.diarization_runtime import SORTFORMER, select_backend, selected_backend
|
||||
|
||||
status = diarisation_status()
|
||||
option = next(
|
||||
(item for item in status["options"] if item["id"] == request.engine_id),
|
||||
None,
|
||||
)
|
||||
if option is None:
|
||||
raise HTTPException(404, "Unknown diarisation engine")
|
||||
if not option["installed"]:
|
||||
raise HTTPException(409, option.get("reason") or "Install this diarisation engine first")
|
||||
select_backend(request.engine_id)
|
||||
if request.engine_id == SORTFORMER:
|
||||
# Native Sortformer is stateless. Release a previously loaded pyannote
|
||||
# pipeline so Engine Ready cannot hide stale accelerator memory.
|
||||
from services import model_manager
|
||||
model_manager.unload_diarization_pipeline()
|
||||
return {
|
||||
"active": selected_backend(),
|
||||
"env_override": bool(os.environ.get("OMNIVOICE_DIARIZATION_BACKEND")),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/translation")
|
||||
def list_translation_engines():
|
||||
"""Translation engines with per-engine pip-package availability.
|
||||
@@ -259,7 +105,6 @@ def list_translation_engines():
|
||||
an engine whose Python dependency isn't importable yet.
|
||||
"""
|
||||
return {
|
||||
"active": prefs.get("translation_backend", "argos"),
|
||||
"engines": [
|
||||
{**entry, "availability_reason": public_unavailability(entry.get("availability_reason"))}
|
||||
for entry in translation_engines.list_engines()
|
||||
@@ -268,73 +113,7 @@ def list_translation_engines():
|
||||
}
|
||||
|
||||
|
||||
class TranslationSelection(BaseModel):
|
||||
engine_id: str
|
||||
|
||||
|
||||
class ArgosPackRequest(BaseModel):
|
||||
source_lang: str | None = None
|
||||
target_langs: list[str] = Field(min_length=1, max_length=32)
|
||||
job_id: str | None = None
|
||||
|
||||
|
||||
def _argos_pack_request(request: ArgosPackRequest) -> tuple[str, list[str]]:
|
||||
source = request.source_lang
|
||||
if not source and request.job_id:
|
||||
from api.routers.dub_core import _get_job
|
||||
|
||||
job = _get_job(request.job_id)
|
||||
source = job.get("source_lang") if job else None
|
||||
if not source:
|
||||
raise HTTPException(422, "Transcribe the source before installing its language pack")
|
||||
return source, request.target_langs
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/translation/argos/packs/status",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def argos_pack_status(request: ArgosPackRequest):
|
||||
source, targets = _argos_pack_request(request)
|
||||
try:
|
||||
return translation_engines.argos_pack_status(source, targets)
|
||||
except (ImportError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/translation/argos/packs/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def install_argos_packs(request: ArgosPackRequest):
|
||||
source, targets = _argos_pack_request(request)
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
translation_engines.install_argos_packs,
|
||||
source,
|
||||
targets,
|
||||
)
|
||||
except (ImportError, ValueError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/engines/translation/select", dependencies=[Depends(require_admin)])
|
||||
def select_translation_engine(request: TranslationSelection):
|
||||
entry = translation_engines.get_engine(request.engine_id)
|
||||
if not entry:
|
||||
raise HTTPException(404, "Unknown translation engine")
|
||||
if not translation_engines.is_installed(request.engine_id):
|
||||
raise HTTPException(409, "Install this translation engine before selecting it")
|
||||
if not translation_engines.is_ready(request.engine_id):
|
||||
raise HTTPException(409, "Configure this translation provider before selecting it")
|
||||
prefs.set_("translation_backend", request.engine_id)
|
||||
return {"active": request.engine_id}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/translation/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
@router.post("/engines/translation/{engine_id}/install")
|
||||
async def install_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -370,10 +149,7 @@ async def install_translation_engine(engine_id: str):
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/engines/translation/{engine_id}",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
@router.delete("/engines/translation/{engine_id}")
|
||||
async def uninstall_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -391,49 +167,18 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
pkg = entry.get("pip_package")
|
||||
if not pkg:
|
||||
return {"status": "no_op", "engine": engine_id}
|
||||
# The builtin flag is a promise someone has to remember to make; this
|
||||
# check does not depend on it (#2019).
|
||||
blocked = translation_engines.uninstall_blocker(engine_id)
|
||||
if blocked:
|
||||
raise HTTPException(status_code=blocked[0], detail=blocked[1])
|
||||
rc, out = await translation_engines.run_pip(["uninstall", "-y", pkg])
|
||||
if rc != 0:
|
||||
raise HTTPException(status_code=500, detail=f"pip uninstall {pkg} failed ({rc}): {out[-1000:]}")
|
||||
return {"status": "uninstalled", "engine": engine_id, "package": pkg, "log_tail": out[-800:]}
|
||||
|
||||
|
||||
# ── Checksummed native audio.cpp runtime install ───────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/engines/audiocpp/runtime/install/status",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def audiocpp_runtime_install_status():
|
||||
from services import audiocpp_runtime_install
|
||||
|
||||
return audiocpp_runtime_install.status()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/audiocpp/runtime/install",
|
||||
dependencies=[Depends(require_admin), Depends(require_desktop)],
|
||||
)
|
||||
def install_audiocpp_runtime():
|
||||
from services import audiocpp_runtime_install
|
||||
|
||||
try:
|
||||
return audiocpp_runtime_install.start_install()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
|
||||
# ── One-click sidecar-engine install (IndexTTS-2 & friends) ────────────────
|
||||
#
|
||||
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
|
||||
# the parent's transformers>=5.3) used to require four manual terminal steps.
|
||||
# These routes drive services.sidecar_install: POST starts a resumable
|
||||
# background job, GET polls its step-by-step status (the Model Catalogue
|
||||
# background job, GET polls its step-by-step status (the Settings → Engines
|
||||
# Install button polls this), DELETE removes an app-managed install.
|
||||
#
|
||||
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
|
||||
@@ -443,16 +188,15 @@ def install_audiocpp_runtime():
|
||||
# POST /engines/sonitranslate/install). Mirrors the
|
||||
# /engines/translation/{engine_id}/install namespace pattern.
|
||||
#
|
||||
# Desktop-only: installing spawns git/uv against mutable source and writes an
|
||||
# editable environment. An API key does not make that supply-chain path safe to
|
||||
# trigger remotely. The job runs fine in packaged builds: the venv lives under
|
||||
# the user data dir, not inside the signed app bundle, and uv resolves via
|
||||
# OMNIVOICE_BUNDLED_UV/PATH.
|
||||
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
|
||||
# data directory — only the local desktop frontend may trigger it. The job
|
||||
# runs fine in packaged builds: the venv lives under the user data dir, not
|
||||
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin), Depends(require_desktop)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def install_sidecar_engine(engine_id: str):
|
||||
"""Start (or report) the one-click install for a sidecar engine.
|
||||
@@ -464,11 +208,6 @@ def install_sidecar_engine(engine_id: str):
|
||||
from services import sidecar_install
|
||||
try:
|
||||
return sidecar_install.start_install(engine_id)
|
||||
except sidecar_install.HostUnsupported as exc:
|
||||
# The engine has an installer, but not one that can work on this
|
||||
# machine. 409, not 404: the route is right, the host is the problem,
|
||||
# and the message (a VoiceStudio-owned sentence) says what to do.
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -483,7 +222,7 @@ def install_sidecar_engine(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/sidecar/{engine_id}/install/status",
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def sidecar_install_status(engine_id: str):
|
||||
"""Step-by-step status of the sidecar install job (poll while running).
|
||||
@@ -504,7 +243,7 @@ def sidecar_install_status(engine_id: str):
|
||||
|
||||
@router.delete(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def uninstall_sidecar_engine(engine_id: str):
|
||||
"""Remove an app-managed sidecar install (checkout + venv + weights) and
|
||||
@@ -535,22 +274,29 @@ def uninstall_sidecar_engine(engine_id: str):
|
||||
# frame. Result includes wall-clock latency so the UI can render
|
||||
# "1234 ms — pong" inline next to the button.
|
||||
#
|
||||
# Admin-gated (T-02-13): only the local desktop frontend or an authenticated
|
||||
# server-mode administrator may trigger a sidecar spawn through this endpoint.
|
||||
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
|
||||
# a sidecar spawn through this endpoint.
|
||||
|
||||
# Engine instances cached for the lifetime of the FastAPI process so that
|
||||
# repeated health checks don't spawn a new SubprocessBackend (each spawn
|
||||
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
|
||||
# class to survive registry-sandbox tests that rebind ids transiently.
|
||||
#
|
||||
# It now lives in services.tts_backend — the worker executor needs the same
|
||||
# warm instances and cannot import an API router without inverting the
|
||||
# layering. This name is the SAME dict object, kept so the existing consumers
|
||||
# (engine_memory eviction, model_lifecycle inventory/unload) go on working
|
||||
# unchanged; rebinding it here would fork the cache in two.
|
||||
_ENGINE_INSTANCES: dict[type, object] = tts_backend._ENGINE_INSTANCES
|
||||
_ENGINE_INSTANCES: dict[type, object] = {}
|
||||
|
||||
_get_engine_instance = tts_backend.get_engine_instance
|
||||
|
||||
def _get_engine_instance(cls):
|
||||
"""Return a cached singleton instance of ``cls``.
|
||||
|
||||
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
|
||||
so re-instantiating per request would leak handler entries (and on
|
||||
real engines, additional sidecar processes the first time the lock
|
||||
is acquired). One instance per process is the right move.
|
||||
"""
|
||||
inst = _ENGINE_INSTANCES.get(cls)
|
||||
if inst is None:
|
||||
inst = cls()
|
||||
_ENGINE_INSTANCES[cls] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def _resolve_engine_class(engine_id: str):
|
||||
@@ -572,7 +318,7 @@ def _resolve_engine_class(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/health",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def engine_health(engine_id: str):
|
||||
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
|
||||
@@ -593,9 +339,6 @@ def engine_health(engine_id: str):
|
||||
)
|
||||
|
||||
t0 = perf_counter()
|
||||
# Stable exception class when the probe itself raised, None when it merely
|
||||
# returned not-available. Never the exception text — see the log line below.
|
||||
raised_class: str | None = None
|
||||
if hasattr(cls, "health_check"):
|
||||
# SubprocessBackend path — spawn sidecar (if not running) and ping.
|
||||
# ``health_check`` already swallows its own exceptions per Plan
|
||||
@@ -606,7 +349,6 @@ def engine_health(engine_id: str):
|
||||
ok, msg = instance.health_check()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
raised_class = type(exc).__name__
|
||||
else:
|
||||
# In-process backend — `is_available()` is the classmethod-level
|
||||
# liveness check. Cheap and side-effect-free for every shipping
|
||||
@@ -615,7 +357,6 @@ def engine_health(engine_id: str):
|
||||
ok, msg = cls.is_available()
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
raised_class = type(exc).__name__
|
||||
|
||||
# Engine-owned output can contain much more than shaped HF tokens: local
|
||||
# paths, arbitrary credentials, source lines, or a nested traceback.
|
||||
@@ -623,38 +364,7 @@ def engine_health(engine_id: str):
|
||||
|
||||
latency_ms = (perf_counter() - t0) * 1000.0
|
||||
if not ok:
|
||||
# The response tells the user to "check the backend log for details",
|
||||
# and docs/engines/*.md asks a user diagnosing an unavailable engine to
|
||||
# copy that engine's log lines. The old line named neither the engine
|
||||
# nor anything about the probe, so neither instruction could be
|
||||
# followed (#1866).
|
||||
#
|
||||
# `probe=` reports what the PROBE DID, not what went wrong. It cannot
|
||||
# classify the cause: SubprocessBackend.health_check() swallows its own
|
||||
# exceptions per Plan 02-01's contract, so a dead sidecar and a package
|
||||
# that was never installed both arrive here as `returned-unavailable`.
|
||||
# Separating those needs structured failure metadata from the probes
|
||||
# themselves, which is a wider change than this one.
|
||||
#
|
||||
# Still no diagnostic text and still not the caller-supplied id: the
|
||||
# engine id comes off the resolved registry class and a raised probe
|
||||
# contributes only its exception class, the same shape
|
||||
# core.public_errors.public_failure() logs as `class=`.
|
||||
# tests/test_response_safety.py pins that boundary and passes
|
||||
# unchanged.
|
||||
#
|
||||
# The id is a class attribute off the registry rather than caller
|
||||
# input, but this line is a log-injection surface either way, so it is
|
||||
# flattened to a single token before it goes in.
|
||||
engine_label = str(getattr(cls, "id", None) or cls.__name__)
|
||||
engine_label = "".join(
|
||||
c if (c.isalnum() or c in "-_.") else "-" for c in engine_label
|
||||
)[:64]
|
||||
logger.warning(
|
||||
"Engine health check failed; engine=%s probe=%s, details withheld",
|
||||
engine_label or "unknown",
|
||||
f"raised:{raised_class}" if raised_class else "returned-unavailable",
|
||||
)
|
||||
logger.warning("Engine health check failed; details withheld")
|
||||
return {
|
||||
"id": engine_id,
|
||||
"ok": bool(ok),
|
||||
@@ -682,7 +392,7 @@ def engine_health(engine_id: str):
|
||||
# hanging the Settings panel. The orphaned worker is best-effort daemon.
|
||||
# * A process-wide lock serialises self-tests so a click-storm can't stack
|
||||
# concurrent model loads.
|
||||
# * Only ever on user click (POST) — never on Settings load. Admin-gated.
|
||||
# * Only ever on user click (POST) — never on Settings load. Loopback-gated.
|
||||
|
||||
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
|
||||
# trips the no-hardcoded-CJK guard.
|
||||
@@ -749,7 +459,7 @@ class SelfTestResponse(BaseModel):
|
||||
@router.post(
|
||||
"/engines/{engine_id}/selftest",
|
||||
response_model=SelfTestResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def engine_selftest(engine_id: str):
|
||||
"""Run a bounded, real synthesis on an available in-process TTS engine.
|
||||
@@ -848,11 +558,7 @@ class SelectEngineResponse(BaseModel):
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/select",
|
||||
response_model=SelectEngineResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
@router.post("/engines/select", response_model=SelectEngineResponse)
|
||||
def select_engine(req: SelectEngineRequest):
|
||||
"""Persist a family's engine pick to prefs.json. Refuses unknown backends,
|
||||
backends whose deps aren't installed, AND backends that cannot run on THIS
|
||||
@@ -864,15 +570,7 @@ def select_engine(req: SelectEngineRequest):
|
||||
if not family:
|
||||
raise HTTPException(400, f"Unknown family: {req.family}. Expected one of tts/asr/llm.")
|
||||
module, pref_key = family
|
||||
# MPS intentionally hides the redundant explicit OmniVoice sidecar from
|
||||
# the picker, but existing scripts and saved preferences may still submit
|
||||
# that supported compatibility id directly.
|
||||
rows = (
|
||||
module.list_backends(include_hidden=True)
|
||||
if req.family == "tts"
|
||||
else module.list_backends()
|
||||
)
|
||||
available = {b["id"]: b for b in rows}
|
||||
available = {b["id"]: b for b in module.list_backends()}
|
||||
if req.backend_id not in available:
|
||||
raise HTTPException(400, f"Unknown {req.family} backend: {req.backend_id!r}")
|
||||
entry = available[req.backend_id]
|
||||
@@ -891,7 +589,7 @@ def select_engine(req: SelectEngineRequest):
|
||||
# #981: mlx-audio multiplexes 7+ curated models behind one backend id —
|
||||
# persist the model pick alongside the backend id so the UI can actually
|
||||
# select which curated model gets loaded (previously it always defaulted
|
||||
# to Kokoro no matter what the user downloaded in the engine's Weights list in Model Catalogue).
|
||||
# to Kokoro no matter what the user downloaded in Settings → Models).
|
||||
if req.family == "tts" and req.backend_id == "mlx-audio" and req.model_id is not None:
|
||||
known_keys = tts_backend.MLXAudioBackend.CURATED_MODELS
|
||||
# Accept a curated key OR a raw HF repo id ("owner/name") — the same
|
||||
@@ -906,23 +604,6 @@ def select_engine(req: SelectEngineRequest):
|
||||
"Hugging Face repo ID like 'owner/name'.",
|
||||
)
|
||||
prefs.set_("mlx_audio_model_id", req.model_id)
|
||||
if req.family == "asr" and req.model_id is not None:
|
||||
if req.backend_id not in {"faster-whisper", "faster-whisper-isolated"}:
|
||||
raise HTTPException(400, "This ASR engine does not accept a CTranslate2 model")
|
||||
from api.routers.setup.models import KNOWN_MODELS, is_cached
|
||||
|
||||
model = next((item for item in KNOWN_MODELS if item["repo_id"] == req.model_id), None)
|
||||
compatible = req.model_id.startswith("Systran/faster-") or req.model_id == (
|
||||
"deepdml/faster-whisper-large-v3-turbo-ct2"
|
||||
)
|
||||
if model is None or str(model.get("role", "")).lower() != "asr" or not compatible:
|
||||
raise HTTPException(400, "This model is not compatible with Faster-Whisper")
|
||||
if not is_cached(req.model_id):
|
||||
raise HTTPException(409, "Install this ASR model before selecting it")
|
||||
try:
|
||||
asr_backend.select_faster_whisper_model(req.model_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
prefs.set_(pref_key, req.backend_id)
|
||||
return {
|
||||
"family": req.family,
|
||||
|
||||
+86
-229
@@ -1,24 +1,18 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.audio_validation import resolve_regular_file
|
||||
from core.file_cleanup import FileCleanupError, unlink_if_present
|
||||
from services.ffmpeg_utils import spawn_subprocess
|
||||
|
||||
@@ -366,223 +360,46 @@ async def upload_voice_clip(
|
||||
}
|
||||
|
||||
|
||||
def _stage_profile_audio(source: Path, directory: Path) -> Path:
|
||||
"""Copy an imported clip to a hidden temp file inside ``directory``.
|
||||
|
||||
The temp lives in the destination directory itself so a later
|
||||
``os.replace`` to the final name is an atomic same-filesystem rename —
|
||||
cheap enough to run while holding a DB write lock, unlike the copy.
|
||||
Callers own cleanup of the returned path if they never publish it.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(directory), prefix=".gallery-import-", suffix=".part",
|
||||
)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(source, tmp_name)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
return Path(tmp_name)
|
||||
|
||||
|
||||
def _copy_profile_audio(source: Path, destination: Path) -> None:
|
||||
"""Copy an imported clip without exposing a partial profile audio file."""
|
||||
staged = _stage_profile_audio(source, destination.parent)
|
||||
try:
|
||||
os.replace(staged, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged)
|
||||
raise
|
||||
|
||||
|
||||
def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str:
|
||||
"""Return the canonical, portable filename for a My Imports profile."""
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16]
|
||||
)
|
||||
suffix = source.suffix.lower()
|
||||
if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix):
|
||||
suffix = ".wav"
|
||||
return f"{safe_id}_gallery{suffix}"
|
||||
|
||||
|
||||
def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool:
|
||||
"""Recognize only rows created by this materializer, not identity collisions."""
|
||||
return bool(
|
||||
row["personality"] == f"gallery:{voice['id']}"
|
||||
and row["ref_audio_path"] == audio_filename
|
||||
and row["ref_text"] == ""
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == "Auto"
|
||||
and row["seed"] is None
|
||||
and row["kind"] == "clone"
|
||||
and not row["vd_states"]
|
||||
and row["description"] == (voice.get("description") or "")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and not row["locked_audio_path"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_gallery_profile(conn, voice: dict, source: Path):
|
||||
personality = f"gallery:{voice['id']}"
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
expected = _gallery_profile_audio_filename(row["id"], source)
|
||||
if _is_materialized_gallery_profile(row, voice, expected):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _gallery_profile_audio_is_current(row, source: Path) -> bool:
|
||||
"""Detect missing/replaced copies without re-hashing unchanged imports."""
|
||||
destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"])
|
||||
if destination is None:
|
||||
return False
|
||||
try:
|
||||
source_stat = source.stat()
|
||||
destination_stat = destination.stat()
|
||||
# copy2 preserves mtime; size + nanosecond mtime catches ordinary edits
|
||||
# and partial writes while keeping repeated Use clicks inexpensive.
|
||||
return (
|
||||
source_stat.st_size == destination_stat.st_size
|
||||
and source_stat.st_mtime_ns == destination_stat.st_mtime_ns
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _materialize_gallery_profile(
|
||||
voice_id: str, requested_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Idempotently materialize/heal one My Imports clip as a clone profile."""
|
||||
personality = f"gallery:{voice_id}"
|
||||
copied_path: Optional[Path] = None
|
||||
created = False
|
||||
staged_path: Optional[Path] = None
|
||||
staged_source: Optional[Path] = None
|
||||
try:
|
||||
# Stage the (potentially large) audio copy BEFORE taking SQLite's
|
||||
# write lock: copying inside BEGIN IMMEDIATE would stall every other
|
||||
# backend writer for the whole copy. The staged temp lives in
|
||||
# VOICES_DIR itself, so publishing it inside the transaction is an
|
||||
# atomic same-filesystem os.replace. This pre-read is advisory only —
|
||||
# the locked transaction below re-reads and re-decides everything.
|
||||
copy_needed = False
|
||||
with db_conn() as conn:
|
||||
pre_row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if pre_row is not None:
|
||||
pre_source = Path(pre_row["audio_path"])
|
||||
if pre_source.is_file():
|
||||
pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source)
|
||||
copy_needed = pre_existing is None or not _gallery_profile_audio_is_current(
|
||||
pre_existing, pre_source,
|
||||
)
|
||||
if copy_needed:
|
||||
staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR))
|
||||
staged_source = pre_source
|
||||
|
||||
with db_conn() as conn:
|
||||
# The identity is not globally UNIQUE because personality is shared
|
||||
# with other import mechanisms. Serialize this check+insert in
|
||||
# SQLite so simultaneous Use clicks cannot both create a row.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
source = Path(voice["audio_path"])
|
||||
if not source.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
def _install_audio(destination: Path) -> None:
|
||||
"""Publish the staged copy under the lock via atomic rename."""
|
||||
nonlocal staged_path
|
||||
if staged_path is not None and staged_source == source:
|
||||
os.replace(staged_path, destination)
|
||||
staged_path = None
|
||||
else:
|
||||
# Rare race: the gallery row changed between the advisory
|
||||
# pre-read and taking the lock, so any staged bytes may be
|
||||
# from the wrong source. Fall back to the blocking copy
|
||||
# rather than publish stale audio.
|
||||
_copy_profile_audio(source, destination)
|
||||
|
||||
existing = _existing_gallery_profile(conn, voice, source)
|
||||
if existing is not None:
|
||||
ref_filename = _gallery_profile_audio_filename(existing["id"], source)
|
||||
if not _gallery_profile_audio_is_current(existing, source):
|
||||
ref_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(ref_path)
|
||||
copied_path = ref_path
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', "
|
||||
"language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, "
|
||||
"personality=? WHERE id=?",
|
||||
(
|
||||
ref_filename, voice["description"] or "", personality,
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
result = {"profile_id": existing["id"], "name": existing["name"]}
|
||||
else:
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
profile_name = (requested_name or voice["name"]).strip() or voice["name"]
|
||||
ref_filename = _gallery_profile_audio_filename(profile_id, source)
|
||||
copied_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(copied_path)
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, language, seed,
|
||||
personality, is_locked, locked_audio_path, description, kind,
|
||||
vd_states, created_at)
|
||||
VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""",
|
||||
(
|
||||
profile_id, profile_name, ref_filename, personality,
|
||||
voice["description"] or "", time.time(),
|
||||
),
|
||||
)
|
||||
created = True
|
||||
result = {"profile_id": profile_id, "name": profile_name}
|
||||
except BaseException:
|
||||
if copied_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
copied_path.unlink()
|
||||
raise
|
||||
finally:
|
||||
# Staged but never published (failure, or a concurrent request healed
|
||||
# the profile first) — never leave .part droppings in VOICES_DIR.
|
||||
if staged_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged_path)
|
||||
|
||||
event_bus.emit(
|
||||
"profiles", {"action": "created" if created else "updated", "id": result["profile_id"]},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/save-as-profile")
|
||||
async def save_voice_as_profile(
|
||||
voice_id: str,
|
||||
profile_name: str = Query(..., description="Name for the voice profile"),
|
||||
):
|
||||
"""Save a gallery voice as a voice profile for cloning."""
|
||||
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
|
||||
return {"profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(row["audio_path"])[1]
|
||||
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
||||
shutil.copy(row["audio_path"], new_audio_path)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
profile_name,
|
||||
f"{profile_id}{ext}",
|
||||
row["description"] or "",
|
||||
row["character"] or "",
|
||||
"Auto",
|
||||
None,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
@@ -598,10 +415,22 @@ def preview_voice(voice_id: str):
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
|
||||
if os.path.isabs(audio_path) and os.path.exists(audio_path):
|
||||
# Serve the file from this API route so deployments mounted below a
|
||||
# path prefix do not lose that prefix while following a redirect.
|
||||
return FileResponse(audio_path)
|
||||
# Debug logging
|
||||
is_absolute = os.path.isabs(audio_path)
|
||||
path_exists = os.path.exists(audio_path) if audio_path else False
|
||||
|
||||
# If absolute path, serve directly or redirect
|
||||
if is_absolute and path_exists:
|
||||
# Get just the relative path from outputs dir
|
||||
outputs_path = str(OUTPUTS_DIR)
|
||||
if audio_path.startswith(outputs_path):
|
||||
# Remove outputs_dir prefix to get relative path within outputs
|
||||
rel_path = os.path.relpath(audio_path, outputs_path)
|
||||
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
|
||||
# rel_path becomes: voice_gallery/file.wav
|
||||
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
|
||||
return RedirectResponse(f"/audio/{rel_path}")
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -674,5 +503,33 @@ def batch_delete_voices(body: dict):
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
def voice_to_profile(voice_id: str):
|
||||
"""Create a voice profile from a gallery clip."""
|
||||
result = _materialize_gallery_profile(voice_id)
|
||||
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
# Copy audio to voices dir
|
||||
dest_filename = f"{profile_id}_gallery.wav"
|
||||
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
||||
shutil.copy2(audio_path, dest_path)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
+258
-983
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,13 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_loopback
|
||||
from services import mcp_bindings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/mcp",
|
||||
tags=["mcp"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_loopback
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
|
||||
|
||||
class CustomPathRequest(BaseModel):
|
||||
|
||||
@@ -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,
|
||||
@@ -325,9 +313,10 @@ async def create_speech(req: SpeechRequest):
|
||||
|
||||
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import routing_notice, runtime_compute_profile_async
|
||||
_routing = await runtime_compute_profile_async(
|
||||
backend, detect_host_caps()
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0),
|
||||
)
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
@@ -399,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
|
||||
@@ -435,7 +415,7 @@ async def create_speech(req: SpeechRequest):
|
||||
detail=(
|
||||
f"TTS engine '{backend.id}' did not finish loading within its "
|
||||
f"model-load budget — on a first run this usually means the weight "
|
||||
f"download is slow or stalled (check the engine's Weights list in Model Catalogue for "
|
||||
f"download is slow or stalled (check Settings → Models for "
|
||||
f"progress), not that generation failed. Retry once the model "
|
||||
f"shows as installed."
|
||||
),
|
||||
@@ -474,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
|
||||
@@ -537,10 +517,9 @@ async def create_transcription(
|
||||
):
|
||||
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
load_active_asr_backend,
|
||||
get_active_asr_backend,
|
||||
)
|
||||
|
||||
# TTS-only install: no ASR model on disk → actionable 409, BEFORE any
|
||||
@@ -567,25 +546,18 @@ async def create_transcription(
|
||||
raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")
|
||||
|
||||
try:
|
||||
backend = get_active_asr_backend()
|
||||
|
||||
# Run transcription in the thread pool to avoid blocking the event loop,
|
||||
# bounded so a stuck/starved ASR returns a 504 with guidance instead of
|
||||
# hanging the request forever (see run_transcribe_guarded).
|
||||
from services.asr_backend import run_transcribe_guarded
|
||||
word_ts = response_format == "verbose_json"
|
||||
|
||||
# `load_active_asr_backend`, not `get_active_asr_backend`: the latter is
|
||||
# a pure selector, so a backend whose shallow `is_available()` probe
|
||||
# passes but whose deep import chain is broken (whisperx →
|
||||
# ctranslate2 failing to dlopen on a hardened kernel) reached
|
||||
# `.transcribe()` and 500'd, even with a healthy engine next in line.
|
||||
# The loader does select + ensure_loaded + degrade (#1185). It loads
|
||||
# weights, so it belongs inside the pool with the transcribe call —
|
||||
# never on the event loop.
|
||||
def _run():
|
||||
backend = load_active_asr_backend()
|
||||
return backend.transcribe(tmp_path, word_timestamps=word_ts)
|
||||
|
||||
result = await run_transcribe_guarded(_gpu_pool, _run, what="OpenAI")
|
||||
result = await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
|
||||
what="OpenAI",
|
||||
)
|
||||
|
||||
# Extract the full text from segments
|
||||
segments = result.get("segments", [])
|
||||
@@ -653,14 +625,6 @@ async def create_transcription(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ASRModelMissingError as e:
|
||||
# A degraded-to candidate has no weights on disk. Same typed 409 the
|
||||
# preflight above raises — never a 500, and never a silent multi-GB
|
||||
# auto-download.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
except TimeoutError as e:
|
||||
# ASRTimeoutError (subclass): backend alive, ASR too heavy for compute.
|
||||
logger.warning("OpenAI transcription timed out: %s", e)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"""Keyless portrait search with bounded, normalized, safe thumbnails."""
|
||||
import asyncio
|
||||
import base64
|
||||
import html
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from core.profile_images import MAX_IMAGE_BYTES, normalize_portrait
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def trusted_thumbnail(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
google = parsed.hostname in {
|
||||
f"encrypted-tbn{i}.gstatic.com" for i in range(4)
|
||||
}
|
||||
openverse = (
|
||||
parsed.hostname == "api.openverse.org"
|
||||
and re.fullmatch(r"/v1/images/[0-9a-f-]+/thumb/?", parsed.path) is not None
|
||||
)
|
||||
return (
|
||||
parsed.scheme == "https"
|
||||
and not parsed.username
|
||||
and not parsed.password
|
||||
and parsed.port in (None, 443)
|
||||
and (google or openverse)
|
||||
)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def google_thumbnails(document: str) -> list[tuple[str, str]]:
|
||||
"""Extract result thumbnails only; never download third-party originals."""
|
||||
results = []
|
||||
seen = set()
|
||||
|
||||
def add(title, source):
|
||||
if not isinstance(source, str) or source in seen:
|
||||
return
|
||||
if not (trusted_thumbnail(source) or source.startswith("data:image/jpeg;base64,")):
|
||||
return
|
||||
seen.add(source)
|
||||
if len(results) < 20:
|
||||
results.append((title or "", source))
|
||||
|
||||
class Images(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "img":
|
||||
values = dict(attrs)
|
||||
add(values.get("alt"), values.get("src") or values.get("data-src"))
|
||||
|
||||
Images().feed(document)
|
||||
# Google also assigns thumbnails from script strings after rendering.
|
||||
decoded = html.unescape(document)
|
||||
for escaped, literal in ((r"\u003d", "="), (r"\u0026", "&"), (r"\/", "/")):
|
||||
decoded = decoded.replace(escaped, literal)
|
||||
for match in re.finditer(r'https://encrypted-tbn[0-3]\.gstatic\.com/[^\s"\'<>\\]+|data:image/jpeg;base64,[A-Za-z0-9+/=]+', decoded):
|
||||
add("", match.group())
|
||||
return results
|
||||
|
||||
|
||||
async def openverse_thumbnails(client: httpx.AsyncClient, name: str) -> list[tuple[str, str]]:
|
||||
"""Public-domain/CC portrait fallback when Google returns its JS-only shell.
|
||||
|
||||
Openverse requires no user credential, excludes sensitive results by
|
||||
default, and can restrict results to licenses that allow modification and
|
||||
commercial use. We still fetch only its own thumbnail proxy.
|
||||
"""
|
||||
response = await client.get(
|
||||
"https://api.openverse.org/v1/images/",
|
||||
headers={
|
||||
"User-Agent": "VoiceStudio/0.5 (+https://github.com/debpalash/VoiceStudio)",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
params={
|
||||
"q": name,
|
||||
"page_size": 20,
|
||||
"mature": "false",
|
||||
"extension": "jpg,png",
|
||||
"aspect_ratio": "square",
|
||||
"license_type": "commercial,modification",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > 4 * 1024 * 1024:
|
||||
raise ValueError("Search response too large")
|
||||
payload = response.json()
|
||||
rows = payload.get("results") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("Invalid search response")
|
||||
results = []
|
||||
seen = set()
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
source = row.get("thumbnail")
|
||||
if not isinstance(source, str) or source in seen or not trusted_thumbnail(source):
|
||||
continue
|
||||
seen.add(source)
|
||||
title = str(row.get("title") or name)
|
||||
creator = str(row.get("creator") or "").strip()
|
||||
license_name = str(row.get("license") or "").upper()
|
||||
credit = " · ".join(value for value in (creator, license_name) if value)
|
||||
results.append((f"{title} — {credit}" if credit else title, source))
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/profile-images/search")
|
||||
async def search_profile_images(name: str = Query(min_length=1, max_length=100)):
|
||||
if not name.strip():
|
||||
raise HTTPException(422, detail={"code": "image_search_failed"})
|
||||
async with httpx.AsyncClient(
|
||||
timeout=15,
|
||||
follow_redirects=False,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
) as client:
|
||||
results: list[tuple[str, str]] = []
|
||||
try:
|
||||
async with client.stream("GET", "https://www.google.com/search", params={
|
||||
"q": name.strip(), "udm": "2", "safe": "active", "tbs": "ift:jpg",
|
||||
}) as response:
|
||||
response.raise_for_status()
|
||||
document = bytearray()
|
||||
async for chunk in response.aiter_bytes():
|
||||
document.extend(chunk)
|
||||
if len(document) > 4 * 1024 * 1024:
|
||||
raise ValueError("Search page too large")
|
||||
page = document.decode("utf-8", errors="replace")
|
||||
results = google_thumbnails(page)
|
||||
except (httpx.HTTPError, ValueError, TypeError):
|
||||
# Search providers can change their anonymous HTML or reject a
|
||||
# non-browser request. The fallback below keeps this explicit,
|
||||
# user-triggered feature useful without requiring credentials.
|
||||
results = []
|
||||
|
||||
if not results:
|
||||
try:
|
||||
results = await openverse_thumbnails(client, name.strip())
|
||||
except (httpx.HTTPError, ValueError, TypeError):
|
||||
results = []
|
||||
if not results:
|
||||
raise HTTPException(502, detail={"code": "image_search_failed"})
|
||||
|
||||
async def thumbnail(title, url):
|
||||
try:
|
||||
if url.startswith("data:image/jpeg;base64,"):
|
||||
encoded = url.partition(",")[2]
|
||||
if len(encoded) > MAX_IMAGE_BYTES * 4 // 3 + 4:
|
||||
return None
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
else:
|
||||
if not trusted_thumbnail(url):
|
||||
return None
|
||||
async with client.stream("GET", url) as image:
|
||||
image.raise_for_status()
|
||||
data = bytearray()
|
||||
async for chunk in image.aiter_bytes():
|
||||
data.extend(chunk)
|
||||
if len(data) > MAX_IMAGE_BYTES:
|
||||
return None
|
||||
normalized = await asyncio.to_thread(normalize_portrait, bytes(data))
|
||||
return {"title": title[:200], "data": base64.b64encode(normalized).decode("ascii")}
|
||||
except (httpx.HTTPError, HTTPException, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
images = []
|
||||
for start in range(0, len(results), 5):
|
||||
batch = await asyncio.gather(*(thumbnail(*result) for result in results[start:start + 5]))
|
||||
images.extend(image for image in batch if image)
|
||||
if len(images) >= 5:
|
||||
break
|
||||
return {"images": images[:5]}
|
||||
@@ -1,5 +1,3 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
@@ -16,21 +14,8 @@ 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 core.profile_images import MAX_IMAGE_BYTES, normalize_portrait
|
||||
from starlette.datastructures import UploadFile as StarletteUploadFile
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.profiles")
|
||||
|
||||
|
||||
def _profile_record(row):
|
||||
result = dict(row)
|
||||
image_path = _voices_path(f"{result['id']}.portrait.jpg")
|
||||
result["image_url"] = (
|
||||
f"/profiles/{result['id']}/image?v={os.stat(image_path).st_mtime_ns}"
|
||||
if image_path and os.path.isfile(image_path) else None
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
@@ -50,7 +35,7 @@ def list_personalities():
|
||||
def list_profiles():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM voice_profiles ORDER BY created_at DESC").fetchall()
|
||||
return [_profile_record(r) for r in rows]
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
_DESIGN_SEED = 42 # deterministic sample render, same as archetype previews
|
||||
|
||||
@@ -66,7 +51,6 @@ async def create_profile(
|
||||
personality: str = Form(""),
|
||||
kind: str = Form("clone"),
|
||||
vd_states: Optional[str] = Form(None),
|
||||
image: Optional[UploadFile] = File(None),
|
||||
):
|
||||
"""Create a voice profile (spec: docs/specs/voice-studio-unification.md §5).
|
||||
|
||||
@@ -76,9 +60,6 @@ async def create_profile(
|
||||
archetype materialization) and stores it as the profile's
|
||||
reference so the voice identity is stable across runs.
|
||||
"""
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="A voice profile needs a name.")
|
||||
if kind not in ("clone", "design"):
|
||||
raise HTTPException(status_code=422, detail="kind must be 'clone' or 'design'")
|
||||
if kind == "clone" and ref_audio is None:
|
||||
@@ -125,38 +106,13 @@ async def create_profile(
|
||||
instruct = sanitize_instruct(instruct)
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
portrait = None
|
||||
if isinstance(image, StarletteUploadFile):
|
||||
portrait = normalize_portrait(await image.read(MAX_IMAGE_BYTES + 1))
|
||||
portrait_path = os.path.join(VOICES_DIR, f"{profile_id}.portrait.jpg")
|
||||
|
||||
if kind == "clone":
|
||||
ext = os.path.splitext(ref_audio.filename or ".wav")[1]
|
||||
audio_filename = f"{profile_id}{ext}"
|
||||
audio_path = os.path.join(VOICES_DIR, audio_filename)
|
||||
# Storage can be removed after startup; recover before persisting uploads.
|
||||
os.makedirs(VOICES_DIR, exist_ok=True)
|
||||
with open(audio_path, "wb") as f:
|
||||
f.write(await ref_audio.read())
|
||||
# A matching transcript defines the boundary between the reference and
|
||||
# the requested line. Saving a blank transcript and waiting until the
|
||||
# first generation made that first take depend on the TTS model's
|
||||
# internal ASR fallback; short lines could then start with stray words
|
||||
# from the reference. Resolve it while the profile is being created so
|
||||
# every synthesis, including the first, uses stable conditioning. This
|
||||
# remains best-effort and local-only: transcribe_reference considers
|
||||
# only already-installed ASR/dictation models.
|
||||
if not ref_text.strip():
|
||||
try:
|
||||
from services.asr_backend import transcribe_reference
|
||||
|
||||
ref_text = (
|
||||
await asyncio.to_thread(transcribe_reference, audio_path) or ""
|
||||
).strip()
|
||||
except Exception as exc: # noqa: BLE001 — profile save remains usable
|
||||
logger.warning(
|
||||
"reference transcription during profile save failed: %s", exc
|
||||
)
|
||||
used_seed = seed
|
||||
else:
|
||||
# Saving a design profile is a pure persistence operation — it must not
|
||||
@@ -197,10 +153,6 @@ async def create_profile(
|
||||
used_seed = seed if seed is not None else _DESIGN_SEED
|
||||
|
||||
try:
|
||||
if portrait:
|
||||
os.makedirs(VOICES_DIR, exist_ok=True)
|
||||
with open(portrait_path, "wb") as out:
|
||||
out.write(portrait)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, "
|
||||
@@ -210,14 +162,12 @@ async def create_profile(
|
||||
used_seed, personality, kind, vd_states, time.time())
|
||||
)
|
||||
except Exception:
|
||||
if os.path.exists(portrait_path):
|
||||
os.remove(portrait_path)
|
||||
# Clean up orphaned audio file if DB insert fails
|
||||
if os.path.exists(audio_path):
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return get_profile(profile_id)
|
||||
return {"id": profile_id, "name": name, "kind": kind}
|
||||
|
||||
@router.get("/profiles/{profile_id}")
|
||||
def get_profile(profile_id: str):
|
||||
@@ -231,47 +181,14 @@ def get_profile(profile_id: str):
|
||||
status_code=404,
|
||||
detail="That voice profile doesn't exist. It may have been deleted from another tab.",
|
||||
)
|
||||
return _profile_record(row)
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}/image")
|
||||
def get_profile_image(profile_id: str):
|
||||
get_profile(profile_id)
|
||||
path = _voices_path(f"{profile_id}.portrait.jpg")
|
||||
if not path or not os.path.isfile(path):
|
||||
raise HTTPException(404, "Profile image not found")
|
||||
return FileResponse(path, media_type="image/jpeg", headers={"Cache-Control": "no-cache"})
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}/image")
|
||||
async def update_profile_image(profile_id: str, image: UploadFile = File(...)):
|
||||
get_profile(profile_id)
|
||||
path = _voices_path(f"{profile_id}.portrait.jpg")
|
||||
if path is None:
|
||||
raise HTTPException(404, "Profile not found")
|
||||
portrait = normalize_portrait(await image.read(MAX_IMAGE_BYTES + 1))
|
||||
os.makedirs(VOICES_DIR, exist_ok=True)
|
||||
with open(path, "wb") as out:
|
||||
out.write(portrait)
|
||||
event_bus.emit("profiles", {"action": "updated", "id": profile_id})
|
||||
return get_profile(profile_id)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}")
|
||||
def update_profile(profile_id: str, patch: ProfileUpdate):
|
||||
"""Partial update — only fields set on the payload are changed."""
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT kind FROM voice_profiles WHERE id = ?", (profile_id,),
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="That voice profile doesn't exist. It may have been deleted from another tab.",
|
||||
)
|
||||
fields = []
|
||||
params = []
|
||||
edited_instruct = None
|
||||
for col in ("name", "ref_text", "instruct", "language", "personality"):
|
||||
val = getattr(patch, col)
|
||||
if val is None:
|
||||
@@ -282,22 +199,12 @@ def update_profile(profile_id: str, patch: ProfileUpdate):
|
||||
# Never let an edit persist a validator-rejecting instruct (prose /
|
||||
# "[object Object]"); keep only whitelist tags (#550 #571 #594 #596).
|
||||
val = sanitize_instruct(val)
|
||||
edited_instruct = val
|
||||
fields.append(f"{col} = ?")
|
||||
params.append(val.strip() if col in ("name", "language") else val)
|
||||
if edited_instruct is not None and existing["kind"] == "design":
|
||||
# Keep the complete recipe synchronized with the editable instruct.
|
||||
# Otherwise clients restore a stale vd_states snapshot and a successful
|
||||
# style edit has no effect on the next generation.
|
||||
import json
|
||||
from core.describe_voice import instruct_to_vd_states
|
||||
|
||||
fields.append("vd_states = ?")
|
||||
params.append(json.dumps(instruct_to_vd_states(edited_instruct)))
|
||||
if not fields:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="PUT /profiles/{id} body contained no editable fields. Include at least one of: name, language, ref_text, instruct, personality.",
|
||||
detail="PUT /profiles/{id} body contained no editable fields. Include at least one of: name, language, instruct, description.",
|
||||
)
|
||||
params.append(profile_id)
|
||||
with db_conn() as conn:
|
||||
@@ -314,7 +221,7 @@ def update_profile(profile_id: str, patch: ProfileUpdate):
|
||||
"SELECT * FROM voice_profiles WHERE id = ?", (profile_id,),
|
||||
).fetchone()
|
||||
event_bus.emit("profiles", {"action": "updated", "id": profile_id})
|
||||
return _profile_record(row)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}/usage")
|
||||
@@ -345,14 +252,8 @@ def get_profile_usage(profile_id: str):
|
||||
state = json.loads(r["state_json"] or "{}")
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(state, dict):
|
||||
continue
|
||||
# Current desktop snapshots use dubSegments. An explicit empty list
|
||||
# supersedes legacy segments retained in an older snapshot.
|
||||
segs = state.get("dubSegments", state.get("segments", []))
|
||||
if not isinstance(segs, list):
|
||||
continue
|
||||
n = sum(1 for s in segs if isinstance(s, dict) and s.get("profile_id") == profile_id)
|
||||
segs = state.get("segments") or []
|
||||
n = sum(1 for s in segs if s.get("profile_id") == profile_id)
|
||||
if n:
|
||||
project_hits.append({
|
||||
"project_id": r["id"],
|
||||
@@ -638,9 +539,6 @@ def delete_profile(profile_id: str):
|
||||
path = _voices_path(row[col])
|
||||
if path and os.path.exists(path):
|
||||
os.remove(path)
|
||||
portrait_path = _voices_path(f"{profile_id}.portrait.jpg")
|
||||
if portrait_path and os.path.isfile(portrait_path):
|
||||
os.remove(portrait_path)
|
||||
# Prevent FOREIGN KEY constraint failure
|
||||
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
|
||||
@@ -7,7 +7,7 @@ CRUD for the DB-backed, per-language pronunciation dictionary the
|
||||
before synthesis (see ``services/pronunciation.apply_pronunciation`` and the
|
||||
generate path), so a saved entry actually changes the audio on every engine.
|
||||
|
||||
Endpoints (admin-gated; loopback or authenticated server mode):
|
||||
Endpoints (loopback-only, like the dictation router):
|
||||
GET /pronunciation → list every entry
|
||||
POST /pronunciation → create one entry
|
||||
PUT /pronunciation/{entry_id} → update an entry (partial)
|
||||
@@ -30,16 +30,12 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_loopback
|
||||
from core.db import db_conn
|
||||
from services.pronunciation import (
|
||||
apply_pronunciation,
|
||||
entries_for_language,
|
||||
inert_entries_for_language,
|
||||
)
|
||||
from services.pronunciation import apply_pronunciation, entries_for_language
|
||||
|
||||
logger = logging.getLogger("omnivoice.pronunciation")
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
router = APIRouter()
|
||||
|
||||
_VALID_TYPES = ("respelling", "ipa", "cmu")
|
||||
_ALL_LANG = "*"
|
||||
@@ -137,7 +133,7 @@ class PronImportRequest(BaseModel):
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/pronunciation")
|
||||
@router.get("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
def list_entries():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -147,7 +143,7 @@ def list_entries():
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/pronunciation")
|
||||
@router.post("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
def create_entry(entry: PronEntry):
|
||||
term = entry.term.strip()
|
||||
if not term:
|
||||
@@ -175,7 +171,7 @@ def create_entry(entry: PronEntry):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/pronunciation/{entry_id}")
|
||||
@router.put("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -230,7 +226,7 @@ def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/pronunciation/{entry_id}")
|
||||
@router.delete("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
def delete_entry(entry_id: str):
|
||||
with db_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM pronunciation_entries WHERE id = ?", (entry_id,))
|
||||
@@ -240,7 +236,7 @@ def delete_entry(entry_id: str):
|
||||
# ── Dry-run + import/export ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/pronunciation/test")
|
||||
@router.post("/pronunciation/test", dependencies=[Depends(require_loopback)])
|
||||
def test_substitution(req: PronTestRequest):
|
||||
"""Show the post-substitution text for ``req.text`` — no model call.
|
||||
|
||||
@@ -254,22 +250,15 @@ def test_substitution(req: PronTestRequest):
|
||||
).fetchall()
|
||||
substituted = apply_pronunciation(req.text, rows, req.language)
|
||||
applied = entries_for_language(rows, req.language)
|
||||
# IPA/CMU rows are validated and stored but not applied yet, so a term that
|
||||
# DOES match can still change nothing. Reporting them separately keeps the
|
||||
# dry run honest — otherwise it says "no entries match", which is wrong and
|
||||
# sends the user to re-type an entry that was already correct (#1949).
|
||||
inert = inert_entries_for_language(rows, req.language)
|
||||
return {
|
||||
"input": req.text,
|
||||
"substituted": substituted,
|
||||
"changed": substituted != req.text,
|
||||
"applied_terms": sorted(applied.keys(), key=len, reverse=True),
|
||||
# Present but not honoured: [{term, type}, …]. Empty on the happy path.
|
||||
"inert_entries": inert,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pronunciation/export")
|
||||
@router.get("/pronunciation/export", dependencies=[Depends(require_loopback)])
|
||||
def export_entries():
|
||||
"""Every entry as a JSON-serializable list (round-trips ``/import``)."""
|
||||
with db_conn() as conn:
|
||||
@@ -284,7 +273,7 @@ def export_entries():
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/pronunciation/import")
|
||||
@router.post("/pronunciation/import", dependencies=[Depends(require_loopback)])
|
||||
def import_entries(req: PronImportRequest):
|
||||
"""Bulk-add entries. ``replace=true`` clears the table first.
|
||||
|
||||
|
||||
+19
-162
@@ -20,8 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.logging_utils import log_safe
|
||||
from core.engine_licenses import LICENSE_GATED_ENGINES
|
||||
from api.dependencies import require_admin, require_admin_action
|
||||
from api.dependencies import require_admin
|
||||
|
||||
logger = logging.getLogger("omnivoice.api.settings")
|
||||
|
||||
@@ -36,11 +35,11 @@ class _HFTokenBody(BaseModel):
|
||||
token: str = Field(..., min_length=1, description="HuggingFace access token")
|
||||
|
||||
|
||||
def _state_response(*, validate: bool = False) -> dict:
|
||||
def _state_response() -> dict:
|
||||
"""Return the same shape the React panel renders. Never includes raw token."""
|
||||
from services import token_resolver
|
||||
|
||||
s = token_resolver.state(validate=validate)
|
||||
s = token_resolver.state()
|
||||
return {
|
||||
"active": s["active"],
|
||||
"sources": [asdict(row) for row in s["sources"]],
|
||||
@@ -66,7 +65,8 @@ def save_hf_token(body: _HFTokenBody):
|
||||
|
||||
@router.delete("/hf-token")
|
||||
def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
|
||||
"""Clear the App token and optionally recognized local Hub token files."""
|
||||
"""Clear the App-source token. Optionally also call huggingface_hub.logout
|
||||
to clear the canonical HF file. Returns the updated cascade state."""
|
||||
from services import token_resolver
|
||||
try:
|
||||
token_resolver.clear_app_token(also_clear_hf_cli=also_clear_hf_cli)
|
||||
@@ -82,81 +82,25 @@ def get_hf_token_state(fresh: bool = Query(False)):
|
||||
|
||||
``fresh=1`` drops the resolver's whoami validation cache first so the
|
||||
response re-runs whoami for every source — this is what the panel's
|
||||
"Test now" button sends. Plain GETs only inspect local token presence.
|
||||
"Test now" button sends. Plain GETs (panel mounts) keep the 300s cache
|
||||
so repeat Settings visits don't hammer the HF API.
|
||||
"""
|
||||
from services import token_resolver
|
||||
if fresh:
|
||||
token_resolver.invalidate_cache()
|
||||
return _state_response(validate=fresh)
|
||||
return _state_response()
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
# Threat T-02-04: same admin guard as the hf-token endpoints via the
|
||||
# router-level `require_admin` dep.
|
||||
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
|
||||
# router-level `require_loopback` dep.
|
||||
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
|
||||
from services.performance_profiles import (
|
||||
_PERFORMANCE_PROFILE_KEY, _PERFORMANCE_TIERS, _PERFORMANCE_FAMILIES,
|
||||
activate_performance_tier,
|
||||
profile_state as _performance_profile_state,
|
||||
)
|
||||
|
||||
|
||||
class _PerformanceProfileBody(BaseModel):
|
||||
tier: str = Field(..., description="fast | balanced | quality | max")
|
||||
family: str | None = Field(None, description="Engine family, or null to set the global tier")
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/performance-profile")
|
||||
def get_performance_profile():
|
||||
"""Return the global speed/quality preference and per-engine overrides."""
|
||||
return _performance_profile_state()
|
||||
|
||||
|
||||
@router.put("/performance-profile")
|
||||
def set_performance_profile(body: _PerformanceProfileBody):
|
||||
"""Persist a performance preference and apply installed Max-capacity picks."""
|
||||
from core import prefs
|
||||
|
||||
tier = body.tier.strip().lower()
|
||||
if tier not in _PERFORMANCE_TIERS:
|
||||
raise HTTPException(status_code=400, detail="Unknown performance tier")
|
||||
family = body.family.strip().lower() if body.family else None
|
||||
if family is not None and family not in _PERFORMANCE_FAMILIES:
|
||||
raise HTTPException(status_code=400, detail="Unknown engine family")
|
||||
state = _performance_profile_state()
|
||||
applicable = state["applicable_families"]
|
||||
if (family is not None and family not in applicable) or (family is None and not applicable):
|
||||
raise HTTPException(status_code=409, detail="The selected engines do not support this performance preset")
|
||||
from core import job_store
|
||||
from api.routers.batch import list_batch_jobs
|
||||
if job_store.list_jobs(status="active", limit=1) or list_batch_jobs(status="active", limit=1):
|
||||
raise HTTPException(status_code=409, detail="Wait for queued or running jobs to finish before changing performance presets")
|
||||
try:
|
||||
if family is None:
|
||||
# One atomic write clears family overrides together with the global
|
||||
# choice, so a crash cannot leave half of a global change persisted.
|
||||
prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {"global": tier}, replace=True)
|
||||
else:
|
||||
prefs.update_mapping(_PERFORMANCE_PROFILE_KEY, {family: tier})
|
||||
except Exception:
|
||||
logger.exception("set_performance_profile failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist performance profile")
|
||||
activations = activate_performance_tier(tier, family)
|
||||
result = _performance_profile_state()
|
||||
if activations:
|
||||
result["runtime_activations"] = activations
|
||||
if tier == "max":
|
||||
result["capacity_activations"] = activations
|
||||
return result
|
||||
|
||||
|
||||
class _TorchCompileBody(BaseModel):
|
||||
enabled: bool = Field(..., description="True to disable torch.compile (eager mode) for the engine")
|
||||
enabled: bool = Field(..., description="True to set TORCH_COMPILE_DISABLE=1 on engine subprocesses")
|
||||
|
||||
|
||||
def _torch_compile_state() -> dict:
|
||||
@@ -170,21 +114,15 @@ def _torch_compile_state() -> dict:
|
||||
@router.get("/perf/torch-compile-disabled")
|
||||
def get_torch_compile_disabled():
|
||||
"""Return the current torch.compile-disabled toggle + the runtime platform.
|
||||
|
||||
`platform` is still reported (clients may show it), but since #2135 the
|
||||
toggle is live on every host: it used to be rendered disabled off Windows
|
||||
on the assumption that only #65's Windows OOM needed it, which left the
|
||||
Linux/CUDA reporter of #2135 with no way to switch off the compile that
|
||||
was killing their backend.
|
||||
"""
|
||||
UI uses the platform to render the toggle disabled (with an explainer)
|
||||
on non-Windows hosts, since the OOM is Windows-specific (issue #65)."""
|
||||
return _torch_compile_state()
|
||||
|
||||
|
||||
@router.put("/perf/torch-compile-disabled")
|
||||
def set_torch_compile_disabled(body: _TorchCompileBody):
|
||||
"""Persist the toggle. Honoured by `services.engine_env.build_engine_env()`
|
||||
(subprocess engines) and `services.engine_env.should_torch_compile()`
|
||||
(in-process), on every platform since #2135."""
|
||||
which injects TORCH_COMPILE_DISABLE=1 on Windows when enabled."""
|
||||
from services import settings_store
|
||||
|
||||
try:
|
||||
@@ -195,83 +133,6 @@ def set_torch_compile_disabled(body: _TorchCompileBody):
|
||||
return _torch_compile_state()
|
||||
|
||||
|
||||
# ── Compute-device override (Settings → Performance) ──────────────────────
|
||||
|
||||
|
||||
class _ComputeDeviceBody(BaseModel):
|
||||
value: str = Field(..., description="auto | cuda | rocm | xpu | mps | cpu")
|
||||
|
||||
|
||||
def _compute_device_state() -> dict:
|
||||
"""Everything the Performance panel needs to render the device control:
|
||||
the resolved pick (env > prefs > auto), what this process actually applied
|
||||
at probe time (differs after a change until restart — caps are immutable
|
||||
per process), what auto would pick, and which families exist here."""
|
||||
from core import device_caps
|
||||
|
||||
caps = device_caps.detect_host_caps()
|
||||
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
|
||||
auto_family = next(
|
||||
(f for f in device_caps.ACCELERATOR_PRIORITY if f in caps.available_families),
|
||||
"cpu",
|
||||
)
|
||||
value = device_caps.requested_device_override()
|
||||
return {
|
||||
"value": value,
|
||||
"applied": caps.requested_family,
|
||||
"restart_required": value != caps.requested_family,
|
||||
# The running process asked for a family it doesn't have (env pin on
|
||||
# the wrong machine, hardware removed): auto is in effect, and a
|
||||
# restart would not change that — the panel says so instead of
|
||||
# pretending the pick took.
|
||||
"override_ignored": (
|
||||
caps.requested_family not in ("auto", caps.family)
|
||||
),
|
||||
"effective_family": caps.family,
|
||||
"auto_family": auto_family,
|
||||
"available_families": list(caps.available_families),
|
||||
"env_pinned": env_pin in device_caps.DEVICE_OVERRIDE_CHOICES and env_pin != "",
|
||||
"choices": list(device_caps.DEVICE_OVERRIDE_CHOICES),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compute-device")
|
||||
def get_compute_device():
|
||||
"""Current compute-device override state (Settings → Performance)."""
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
@router.put("/compute-device")
|
||||
def set_compute_device(body: _ComputeDeviceBody):
|
||||
"""Persist the compute-device pick. Applied by the capability probe at
|
||||
the next backend start (host caps are immutable per process — same
|
||||
restart contract as the rest of the Performance tab). ``OMNIVOICE_DEVICE``
|
||||
always wins over this pick; the UI shows the pin instead of pretending."""
|
||||
from core import device_caps, prefs
|
||||
|
||||
value = (body.value or "").strip().lower()
|
||||
if value not in device_caps.DEVICE_OVERRIDE_CHOICES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown device '{value}'. Valid: {', '.join(device_caps.DEVICE_OVERRIDE_CHOICES)}",
|
||||
)
|
||||
caps = device_caps.detect_host_caps()
|
||||
if value not in ("auto", "cpu") and value not in caps.available_families:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"'{value}' is not available on this host "
|
||||
f"(have: {', '.join(caps.available_families)})"
|
||||
),
|
||||
)
|
||||
try:
|
||||
prefs.set_("compute_device", value)
|
||||
except Exception:
|
||||
logger.exception("set_compute_device failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist setting")
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
# ── Generation-history retention (Studio takes rail) ──────────────────────
|
||||
|
||||
|
||||
@@ -620,10 +481,7 @@ def _local_models(base_url: str, api_key: str):
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/llm-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
@router.get("/llm-providers/{provider_id}/models")
|
||||
def list_llm_provider_models(provider_id: str):
|
||||
"""List model ids the provider's key can access (OpenAI-compat /models).
|
||||
|
||||
@@ -717,7 +575,7 @@ def set_llm_skill(skill_id: str, body: _LLMSkillBody):
|
||||
#: Engines that have an in-tree acceptance dialog. Adding a new engine
|
||||
#: here means adding a corresponding frontend dialog + a license URLs
|
||||
#: dict in its constants module. Until that, the API refuses the write.
|
||||
_LICENSE_ALLOWED_ENGINES = LICENSE_GATED_ENGINES
|
||||
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3", "pockettts"})
|
||||
|
||||
|
||||
class _LicenseAcceptBody(BaseModel):
|
||||
@@ -1097,10 +955,9 @@ def set_asr_openai_compat(body: _ASROpenAICompatBody):
|
||||
from services import asr_backend, settings_store
|
||||
|
||||
if body.base_url is not None:
|
||||
try:
|
||||
url = asr_backend.normalize_openai_compat_asr_base_url(body.base_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
url = body.base_url.strip().rstrip("/")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="Base URL must start with http(s)://")
|
||||
settings_store.set_text(asr_backend._ASR_OPENAI_COMPAT_BASE_URL_KEY, url)
|
||||
if body.model is not None:
|
||||
settings_store.set_text(
|
||||
|
||||
@@ -13,8 +13,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -32,7 +30,7 @@ from utils import download_aggregator
|
||||
from .models import ( # noqa: F401
|
||||
KNOWN_MODELS,
|
||||
invalidate_cache,
|
||||
snapshot_is_complete,
|
||||
snapshot_has_weights,
|
||||
disk_space_error,
|
||||
_MIN_WEIGHT_BYTES,
|
||||
_WEIGHT_FLOORS,
|
||||
@@ -43,9 +41,6 @@ router = APIRouter()
|
||||
|
||||
# Cooldown: prevent rapid re-install after a failure. Maps repo_id → last_fail_time.
|
||||
_install_cooldowns: dict[str, float] = {}
|
||||
# Last classified failure per repo. The SSE stream carries the same detail live;
|
||||
# retaining it here keeps recovery useful after navigation or renderer reconnect.
|
||||
_install_failures: dict[str, dict] = {}
|
||||
_COOLDOWN_SECS = 60.0
|
||||
# Evict cooldown entries older than this so the dict can't grow unbounded across
|
||||
# a long-lived process (MM2-06). Anything past the cooldown window is dead state.
|
||||
@@ -58,14 +53,6 @@ def _sweep_cooldowns(now: float) -> None:
|
||||
stale = [k for k, t in _install_cooldowns.items() if (now - t) > _COOLDOWN_TTL_SECS]
|
||||
for k in stale:
|
||||
_install_cooldowns.pop(k, None)
|
||||
_install_failures.pop(k, None)
|
||||
stale_failures = [
|
||||
repo_id
|
||||
for repo_id, failure in _install_failures.items()
|
||||
if (now - float(failure.get("failed_at") or 0)) > _COOLDOWN_TTL_SECS
|
||||
]
|
||||
for repo_id in stale_failures:
|
||||
_install_failures.pop(repo_id, None)
|
||||
|
||||
|
||||
def clear_install_cooldowns() -> None:
|
||||
@@ -75,7 +62,6 @@ def clear_install_cooldowns() -> None:
|
||||
very next action is "retry the failed download on the new mirror", and a
|
||||
429 there would dead-end the wizard's switch-and-retry flow."""
|
||||
_install_cooldowns.clear()
|
||||
_install_failures.clear()
|
||||
|
||||
# Repo_ids the user asked to cancel (FDL-11). Checked between retry attempts.
|
||||
# Note: a single in-flight snapshot_download/Xet fetch is not interruptible
|
||||
@@ -83,14 +69,6 @@ def clear_install_cooldowns() -> None:
|
||||
# cancelled, and clears the cooldown so a cancel isn't rate-limited.
|
||||
_cancelled: set[str] = set()
|
||||
|
||||
# One worker per repo. Repeated clicks and feature-level recovery can converge
|
||||
# on the same install; starting a second snapshot_download against the same HF
|
||||
# cache is wasteful and can corrupt the user-visible progress stream.
|
||||
_active_installs: set[str] = set()
|
||||
_active_installs_lock = threading.Lock()
|
||||
_install_tasks: set[asyncio.Task] = set()
|
||||
_install_tasks_by_repo: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def _download_max_workers() -> int:
|
||||
"""Parallel-FILES worker count for snapshot_download (FDL-02). Default 8 —
|
||||
@@ -193,28 +171,6 @@ def _repo_cancelled(repo_id: str) -> bool:
|
||||
return repo_id in _cancelled
|
||||
|
||||
|
||||
def _create_cache_pointer(blob_path: str, pointer: str) -> None:
|
||||
"""Keep the canonical blob while exposing it from the snapshot tree.
|
||||
|
||||
huggingface_hub's ``new_blob=True`` fallback moves the blob into the
|
||||
snapshot when Windows symlinks are unavailable. The next model load then
|
||||
sees a missing blob and downloads the same multi-gigabyte weight again.
|
||||
NTFS hardlinks preserve both cache paths without doubling disk usage; other
|
||||
filesystems fall back to Hugging Face's copy/symlink path.
|
||||
"""
|
||||
from huggingface_hub.file_download import _create_symlink
|
||||
|
||||
if os.name == "nt":
|
||||
try:
|
||||
os.link(blob_path, pointer)
|
||||
return
|
||||
except FileExistsError:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
_create_symlink(blob_path, pointer, new_blob=False)
|
||||
|
||||
|
||||
def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str) -> str:
|
||||
"""Fetch every file of a repo via the segmented downloader into the HF
|
||||
cache, mirroring hf_hub_download's blob+snapshot+refs layout so the result
|
||||
@@ -225,7 +181,7 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
|
||||
import asyncio as _asyncio
|
||||
from huggingface_hub import HfApi, constants as _C
|
||||
from huggingface_hub.file_download import (
|
||||
hf_hub_url, get_hf_file_metadata, repo_folder_name,
|
||||
hf_hub_url, get_hf_file_metadata, repo_folder_name, _create_symlink,
|
||||
)
|
||||
from services.segmented_download import segmented_download
|
||||
from services.token_resolver import resolve as _resolve_token
|
||||
@@ -264,7 +220,7 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
|
||||
cancel_check=lambda: _repo_cancelled(repo_id),
|
||||
))
|
||||
if not os.path.lexists(pointer):
|
||||
_create_cache_pointer(blob_path, pointer)
|
||||
_create_symlink(blob_path, pointer, new_blob=True)
|
||||
|
||||
# refs/main → commit so scan_cache_dir maps the revision correctly.
|
||||
ref_path = os.path.join(refs_dir, "main")
|
||||
@@ -314,17 +270,10 @@ def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
|
||||
retry loop and the UI's re-download path can deal with it, instead of at
|
||||
first synthesis with an opaque transformers error.
|
||||
|
||||
Delegates to ``models.snapshot_is_complete`` so configuration-only pipeline
|
||||
repositories use their declared required files instead of a weight floor."""
|
||||
model = next((m for m in KNOWN_MODELS if m["repo_id"] == repo_id), {"repo_id": repo_id})
|
||||
if snapshot_is_complete(model, snapshot_path):
|
||||
Delegates the weight check to ``models.snapshot_has_weights`` (single source of
|
||||
the floors); only the install-time error message lives here."""
|
||||
if snapshot_has_weights(snapshot_path):
|
||||
return
|
||||
if model.get("config_only"):
|
||||
required = ", ".join(model.get("config_required_files") or ())
|
||||
raise OSError(f"{repo_id}: download is incomplete; required configuration files: {required}")
|
||||
if model.get("required_files"):
|
||||
required = ", ".join(model["required_files"])
|
||||
raise OSError(f"{repo_id}: required model files are missing or incomplete: {required}")
|
||||
biggest = 0
|
||||
try:
|
||||
for root, _dirs, files in os.walk(snapshot_path, followlinks=True):
|
||||
@@ -339,19 +288,17 @@ def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
|
||||
f"{repo_id}: download finished but no model weights were found in the "
|
||||
"snapshot (largest file "
|
||||
f"{biggest} bytes). The download was likely interrupted — delete the "
|
||||
"model in Model Catalogue and install it again."
|
||||
"model in Settings → Models and install it again."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup/download-stream")
|
||||
async def setup_download_stream(target: str | None = None):
|
||||
async def setup_download_stream():
|
||||
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def listener(event):
|
||||
if target and event.get("target", "local") != target:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(_safe_put, queue, event)
|
||||
except RuntimeError:
|
||||
@@ -385,68 +332,8 @@ async def setup_download_stream(target: str | None = None):
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
@router.get("/models/install/status")
|
||||
def model_install_status():
|
||||
"""Read local and remote jobs after navigation without starting downloads."""
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
|
||||
now = time.time()
|
||||
_sweep_cooldowns(now)
|
||||
with _active_installs_lock:
|
||||
active = tuple(_active_installs)
|
||||
jobs = []
|
||||
for repo_id in active:
|
||||
aggregate = download_aggregator._get(repo_id)
|
||||
jobs.append(
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"target": "local",
|
||||
"state": "downloading",
|
||||
**(aggregate.snapshot() if aggregate else {}),
|
||||
}
|
||||
)
|
||||
detailed = set()
|
||||
for repo_id, failure in tuple(_install_failures.items()):
|
||||
failed_at = float(failure.get("failed_at") or 0)
|
||||
if repo_id in active or now - failed_at >= _COOLDOWN_SECS:
|
||||
continue
|
||||
detailed.add(repo_id)
|
||||
cooldown_at = _install_cooldowns.get(repo_id)
|
||||
retry_after = (
|
||||
max(0, int(_COOLDOWN_SECS - (now - cooldown_at) + 0.999))
|
||||
if cooldown_at is not None
|
||||
else 0
|
||||
)
|
||||
jobs.append(
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"target": "local",
|
||||
"state": "failed",
|
||||
"retry_after_seconds": retry_after,
|
||||
**failure,
|
||||
}
|
||||
)
|
||||
# Preserve status for callers/tests that seed the legacy cooldown map alone.
|
||||
jobs.extend(
|
||||
{
|
||||
"repo_id": repo_id,
|
||||
"target": "local",
|
||||
"state": "failed",
|
||||
"retry_after_seconds": max(
|
||||
0, int(_COOLDOWN_SECS - (now - failed_at) + 0.999)
|
||||
),
|
||||
}
|
||||
for repo_id, failed_at in tuple(_install_cooldowns.items())
|
||||
if repo_id not in active
|
||||
and repo_id not in detailed
|
||||
and now - failed_at < _COOLDOWN_SECS
|
||||
)
|
||||
jobs.extend(gpu_gateway.remote_download_jobs())
|
||||
return {"jobs": jobs}
|
||||
|
||||
|
||||
def _is_retryable_download_error(exc: BaseException) -> bool:
|
||||
"""Whether a failed download attempt is worth retrying.
|
||||
@@ -482,60 +369,11 @@ def _is_retryable_download_error(exc: BaseException) -> bool:
|
||||
return is_hf_connectivity_error(str(exc))
|
||||
|
||||
|
||||
def _segmented_retry_plan(
|
||||
exc: BaseException, attempt: int, max_attempts: int
|
||||
) -> tuple[bool, bool]:
|
||||
"""What to do after the segmented accelerator failed on ``attempt``.
|
||||
|
||||
Returns ``(disable_accelerator, reraise)``.
|
||||
|
||||
A dropped connection is not the accelerator's fault, so the error is
|
||||
re-raised for the outer retry: the next attempt re-enters
|
||||
:func:`_segmented_snapshot`, which resumes from the ``.part`` manifest.
|
||||
Falling straight through to ``snapshot_download`` instead would finish the
|
||||
install from a separate ``.incomplete`` file and strand that manifest — the
|
||||
restart-from-zero this exists to prevent.
|
||||
|
||||
The final attempt is always reserved for the plain path, so the accelerator
|
||||
can never be the reason an install fails outright. The two flags are
|
||||
decoupled for that handover: the attempt that exhausts the accelerator still
|
||||
re-raises, so the plain path starts on the LAST attempt rather than the
|
||||
second-to-last. Disabling and falling through in the same attempt would
|
||||
abandon the resumable manifest one attempt early and restart through a
|
||||
separate file — which is the failure this whole helper exists to avoid.
|
||||
"""
|
||||
if not _is_retryable_download_error(exc):
|
||||
return True, False # the accelerator cannot work here at all
|
||||
if attempt >= max_attempts:
|
||||
# Nothing left to hand over to: take the plain path now rather than
|
||||
# re-raising out of the loop with no fallback ever tried.
|
||||
return True, False
|
||||
return attempt >= max_attempts - 1, True
|
||||
|
||||
|
||||
def _segmented_retry_note(disable: bool, reraise: bool) -> str:
|
||||
"""How to describe the outcome of :func:`_segmented_retry_plan` in the log.
|
||||
|
||||
Three distinct states, and reading only ``disable`` conflates two of them:
|
||||
the attempt that exhausts the accelerator is disabled AND re-raises, so the
|
||||
fallback starts on the NEXT attempt, not this one.
|
||||
"""
|
||||
if not disable:
|
||||
return "kept for the next attempt (resumes from its manifest)"
|
||||
if reraise:
|
||||
return "exhausted — retrying once more, then snapshot_download takes over"
|
||||
return "disabled for this install — falling back to snapshot_download now"
|
||||
|
||||
|
||||
@router.post("/models/install")
|
||||
async def install_model(req: InstallModelRequest):
|
||||
"""Download one HF repo snapshot; progress goes through the shared
|
||||
``/setup/download-stream`` SSE feed."""
|
||||
model_spec = next(
|
||||
(model for model in KNOWN_MODELS if model["repo_id"] == req.repo_id),
|
||||
None,
|
||||
)
|
||||
if model_spec is None:
|
||||
if req.repo_id not in [m["repo_id"] for m in KNOWN_MODELS]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
@@ -543,22 +381,6 @@ async def install_model(req: InstallModelRequest):
|
||||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||||
),
|
||||
)
|
||||
allow_patterns = list(model_spec.get("allow_patterns") or []) or None
|
||||
target = (req.target or "").strip()
|
||||
if target != "local":
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
from worker import routing # noqa: PLC0415
|
||||
|
||||
decision = routing.decide()
|
||||
if target and target != "local" and (
|
||||
not decision.remote or decision.worker_id != target
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="The selected GPU target changed; try again.")
|
||||
if decision.remote:
|
||||
try:
|
||||
return await gpu_gateway.download(req.repo_id, decision=decision)
|
||||
except gpu_gateway.GatewayError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# Cooldown guard — don't retry if the same model just failed.
|
||||
import time as _time_check
|
||||
_sweep_cooldowns(_time_check.time()) # bound the dict (MM2-06)
|
||||
@@ -575,11 +397,8 @@ async def install_model(req: InstallModelRequest):
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _do():
|
||||
# Failure handling must work even when imports, token resolution or
|
||||
# revision lookup fail before the heartbeat thread is started.
|
||||
_resolving = threading.Event()
|
||||
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,
|
||||
@@ -604,12 +423,6 @@ async def install_model(req: InstallModelRequest):
|
||||
"revision": revision_for(req.repo_id),
|
||||
"max_workers": _download_max_workers(),
|
||||
}
|
||||
from services.token_resolver import resolve as resolve_token
|
||||
resolved_token = resolve_token()
|
||||
if resolved_token:
|
||||
dl_kwargs["token"] = resolved_token.token
|
||||
if allow_patterns:
|
||||
dl_kwargs["allow_patterns"] = allow_patterns
|
||||
_tqdm_cls = hf_progress.tracked_tqdm_class()
|
||||
if _tqdm_cls is not None:
|
||||
dl_kwargs["tqdm_class"] = _tqdm_cls
|
||||
@@ -621,7 +434,9 @@ async def install_model(req: InstallModelRequest):
|
||||
|
||||
# Emit a 'resolving' heartbeat every 2s while snapshot_download
|
||||
# resolves repo metadata (before any tqdm bars appear).
|
||||
import threading
|
||||
import time as _t
|
||||
_resolving = threading.Event()
|
||||
|
||||
def _heartbeat():
|
||||
_step = 0
|
||||
@@ -651,26 +466,10 @@ async def install_model(req: InstallModelRequest):
|
||||
"revision": dl_kwargs["revision"],
|
||||
"dry_run": True,
|
||||
}
|
||||
if allow_patterns:
|
||||
_preflight_kwargs["allow_patterns"] = allow_patterns
|
||||
if _endpoint:
|
||||
_preflight_kwargs["endpoint"] = _endpoint
|
||||
if resolved_token:
|
||||
_preflight_kwargs["token"] = resolved_token.token
|
||||
try:
|
||||
_plan = list(snapshot_download(**_preflight_kwargs)) # nosec B615 -- immutable revision_for pin
|
||||
for dependency in model_spec.get("dependencies") or ():
|
||||
if req.repo_id in _cancelled:
|
||||
raise _InstallCancelled()
|
||||
dependency_plan_kwargs = {
|
||||
**_preflight_kwargs,
|
||||
"repo_id": dependency["repo_id"],
|
||||
"revision": revision_for(dependency["repo_id"]),
|
||||
}
|
||||
dependency_plan_kwargs.pop("allow_patterns", None)
|
||||
if dependency.get("allow_patterns"):
|
||||
dependency_plan_kwargs["allow_patterns"] = dependency["allow_patterns"]
|
||||
_plan.extend(snapshot_download(**dependency_plan_kwargs)) # nosec B615 -- immutable revision_for pin
|
||||
_plan = snapshot_download(**_preflight_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
_summary = compute_plan(_plan)
|
||||
# Disk-space guard (before a single byte flows): the preflight
|
||||
# gives an exact "to download" size, so reject an install that
|
||||
@@ -688,18 +487,12 @@ async def install_model(req: InstallModelRequest):
|
||||
"phase": "install_error",
|
||||
"error": _disk_err,
|
||||
})
|
||||
_install_failures[req.repo_id] = {
|
||||
"failed_at": time.time(),
|
||||
"error": _disk_err,
|
||||
"docs_topic": "DISK_SPACE_LOW",
|
||||
}
|
||||
# A disk-full is not a transient network failure — don't set
|
||||
# a cooldown (freeing space, not waiting, is the fix). The
|
||||
# outer finally still cleans up the aggregator + context.
|
||||
return
|
||||
download_aggregator.start(
|
||||
req.repo_id,
|
||||
target=target or "local",
|
||||
total_bytes=_summary["to_download_bytes"],
|
||||
files_total=max(0, _summary["n_files"] - _summary["n_cached"]),
|
||||
)
|
||||
@@ -709,13 +502,11 @@ async def install_model(req: InstallModelRequest):
|
||||
"phase": "install_plan",
|
||||
**_summary,
|
||||
})
|
||||
except _InstallCancelled:
|
||||
raise
|
||||
except Exception as _pf_err:
|
||||
# No preflight (older/gated repo, mirror without dry-run, etc.):
|
||||
# fall back to today's fill-in-as-files-appear behaviour.
|
||||
logger.info("model install %s: preflight unavailable (%s)", req.repo_id, _pf_err)
|
||||
download_aggregator.start(req.repo_id, target=target or "local")
|
||||
download_aggregator.start(req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -729,11 +520,6 @@ async def install_model(req: InstallModelRequest):
|
||||
|
||||
_max_attempts = 5
|
||||
_attempt = 0
|
||||
# The accelerator is retried across attempts so its manifest-based
|
||||
# resume actually gets used; it is disabled for the rest of the
|
||||
# install only when it fails for a reason that is NOT transient
|
||||
# network trouble (i.e. the accelerator itself is unusable here).
|
||||
_segmented_off = False
|
||||
while True:
|
||||
if req.repo_id in _cancelled:
|
||||
raise _InstallCancelled()
|
||||
@@ -741,17 +527,11 @@ async def install_model(req: InstallModelRequest):
|
||||
try:
|
||||
# Segmented accelerator (FDL-09, default ON): parallel
|
||||
# byte-range fetch with real live progress, for the
|
||||
# legacy-LFS path. A failure that is not transient network
|
||||
# trouble falls through to snapshot_download, and so does the
|
||||
# install's last attempt — the accelerator can never
|
||||
# compromise a correct install (see _segmented_retry_plan).
|
||||
# legacy-LFS path. Any failure falls through to
|
||||
# snapshot_download — the accelerator can never compromise a
|
||||
# correct install.
|
||||
_snapshot_path = None
|
||||
if (
|
||||
not _segmented_off
|
||||
and not allow_patterns
|
||||
and _segmented_enabled()
|
||||
and not _xet_active()
|
||||
):
|
||||
if _attempt == 1 and _segmented_enabled() and not _xet_active():
|
||||
try:
|
||||
_snapshot_path = _segmented_snapshot(
|
||||
req.repo_id,
|
||||
@@ -761,16 +541,10 @@ async def install_model(req: InstallModelRequest):
|
||||
except _InstallCancelled:
|
||||
raise
|
||||
except Exception as _seg_err:
|
||||
_segmented_off, _seg_reraise = _segmented_retry_plan(
|
||||
_seg_err, _attempt, _max_attempts
|
||||
)
|
||||
logger.info(
|
||||
"segmented download for %s failed (%s); accelerator %s",
|
||||
"segmented download for %s failed (%s); falling back to snapshot_download",
|
||||
req.repo_id, _seg_err,
|
||||
_segmented_retry_note(_segmented_off, _seg_reraise),
|
||||
)
|
||||
if _seg_reraise:
|
||||
raise
|
||||
_snapshot_path = None
|
||||
if _snapshot_path is None:
|
||||
_snapshot_path = snapshot_download(**dl_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
@@ -778,27 +552,6 @@ async def install_model(req: InstallModelRequest):
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
from services.hf_revisions import remember_revision
|
||||
remember_revision(req.repo_id, dl_kwargs["revision"], HF_HUB_CACHE)
|
||||
# A pipeline config is not a runnable installation by itself.
|
||||
# Download its reviewed dependencies only inside this explicit
|
||||
# install action, retaining the parent cancellation/retry flow.
|
||||
for dependency in model_spec.get("dependencies") or ():
|
||||
if req.repo_id in _cancelled:
|
||||
raise _InstallCancelled()
|
||||
dependency_id = dependency["repo_id"]
|
||||
dependency_kwargs = {
|
||||
**dl_kwargs,
|
||||
"repo_id": dependency_id,
|
||||
"revision": revision_for(dependency_id),
|
||||
}
|
||||
dependency_kwargs.pop("allow_patterns", None)
|
||||
if dependency.get("allow_patterns"):
|
||||
dependency_kwargs["allow_patterns"] = dependency["allow_patterns"]
|
||||
dependency_path = snapshot_download(**dependency_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
if not snapshot_is_complete(dependency, dependency_path):
|
||||
raise OSError(f"{dependency_id}: required model files are missing or incomplete")
|
||||
remember_revision(dependency_id, dependency_kwargs["revision"], HF_HUB_CACHE)
|
||||
if req.repo_id in _cancelled:
|
||||
raise _InstallCancelled()
|
||||
break
|
||||
except Exception as net_err:
|
||||
# #1224: a truncated body ("peer closed connection without
|
||||
@@ -853,7 +606,7 @@ async def install_model(req: InstallModelRequest):
|
||||
# Flush the overall bar to 100% with the true byte total (FDL-06):
|
||||
# under Xet the per-file byte bars don't surface completion, so the
|
||||
# aggregator can sit below 100% even though every file landed.
|
||||
download_aggregator.complete(req.repo_id, target=target or "local")
|
||||
download_aggregator.complete(req.repo_id)
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
@@ -862,28 +615,12 @@ async def install_model(req: InstallModelRequest):
|
||||
"phase": "install_done",
|
||||
})
|
||||
_install_cooldowns.pop(req.repo_id, None) # success clears any cooldown (MM2-06)
|
||||
_install_failures.pop(req.repo_id, None)
|
||||
invalidate_cache()
|
||||
# A saved performance pack owns the desired engine/model policy.
|
||||
# Reconcile after every successful local install so the new model
|
||||
# becomes usable without a restart or a second manual selection.
|
||||
try:
|
||||
from services.performance_profiles import reconcile_active_profile
|
||||
|
||||
activated = reconcile_active_profile()
|
||||
if activated:
|
||||
logger.info("model install activated performance profile: %s", activated)
|
||||
except Exception:
|
||||
# The model is fully installed even if optional preference
|
||||
# reconciliation fails; readiness refresh and manual selection
|
||||
# remain available instead of misreporting the download.
|
||||
logger.exception("performance profile reconciliation failed after model install")
|
||||
except _InstallCancelled:
|
||||
_resolving.set()
|
||||
logger.info("model install cancelled: %s", req.repo_id)
|
||||
# A cancel is user intent, not a failure — don't set a cooldown.
|
||||
_install_cooldowns.pop(req.repo_id, None)
|
||||
_install_failures.pop(req.repo_id, None)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -894,8 +631,7 @@ async def install_model(req: InstallModelRequest):
|
||||
_resolving.set()
|
||||
logger.info("model install failed for %s: %s", req.repo_id, e)
|
||||
import time as _time_fail
|
||||
_failed_at = _time_fail.time()
|
||||
_install_cooldowns[req.repo_id] = _failed_at
|
||||
_install_cooldowns[req.repo_id] = _time_fail.time()
|
||||
# #874: when the install failed because the configured HF mirror is
|
||||
# unreachable, name the mirror + the setting instead of leaking the
|
||||
# raw connectivity error. #959: likewise for the SOCKS-proxy class
|
||||
@@ -904,96 +640,23 @@ async def install_model(req: InstallModelRequest):
|
||||
# class so the wizard can react structurally (HF_MIRROR_UNREACHABLE
|
||||
# raises the inline mirror picker) without string-matching.
|
||||
from core.failure import append_hint, classify
|
||||
_error = append_hint(str(e))
|
||||
_docs_topic = classify(str(e))
|
||||
# Gated catalogue entries own their recovery topic. Hugging Face
|
||||
# uses several exception wordings for the same access verdict, so
|
||||
# the UI must not depend on parsing an English 401/403 message.
|
||||
_catalogue_topic = str(model_spec.get("failure_topic") or "")
|
||||
if _catalogue_topic and _docs_topic in {
|
||||
"",
|
||||
"HF_AUTH_FAILED",
|
||||
"PYANNOTE_LICENSE_REQUIRED",
|
||||
}:
|
||||
_docs_topic = _catalogue_topic
|
||||
# Waiting cannot fix an access/token verdict. Let the user accept
|
||||
# the terms or update the token and retry immediately.
|
||||
if _docs_topic in {
|
||||
"HF_AUTH_FAILED",
|
||||
"PYANNOTE_LICENSE_REQUIRED",
|
||||
"POCKETTTS_GATED_WEIGHTS",
|
||||
}:
|
||||
_install_cooldowns.pop(req.repo_id, None)
|
||||
_install_failures[req.repo_id] = {
|
||||
"failed_at": _failed_at,
|
||||
"error": _error,
|
||||
"docs_topic": _docs_topic,
|
||||
}
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
"downloaded": 0, "total": 0, "pct": 0.0,
|
||||
"phase": "install_error",
|
||||
"error": _error,
|
||||
"docs_topic": _docs_topic,
|
||||
"error": append_hint(str(e)),
|
||||
"docs_topic": classify(str(e)),
|
||||
})
|
||||
finally:
|
||||
_resolving.set()
|
||||
_cancelled.discard(req.repo_id)
|
||||
download_aggregator.finish(req.repo_id, target=target or "local")
|
||||
download_aggregator.finish(req.repo_id)
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
hf_progress.current_target.reset(target_token)
|
||||
with _active_installs_lock:
|
||||
_active_installs.discard(req.repo_id)
|
||||
|
||||
with _active_installs_lock:
|
||||
if req.repo_id in _active_installs:
|
||||
return {"status": "already_running", "repo_id": req.repo_id}
|
||||
_active_installs.add(req.repo_id)
|
||||
# Admission and task publication are one atomic generation boundary:
|
||||
# cancellation can never observe an admitted install without its task.
|
||||
_cancelled.discard(req.repo_id)
|
||||
_install_failures.pop(req.repo_id, None)
|
||||
try:
|
||||
task = loop.create_task(asyncio.to_thread(_do))
|
||||
_install_tasks.add(task)
|
||||
_install_tasks_by_repo[req.repo_id] = task
|
||||
except Exception:
|
||||
_active_installs.discard(req.repo_id)
|
||||
raise
|
||||
|
||||
def install_finished(completed: asyncio.Task) -> None:
|
||||
with _active_installs_lock:
|
||||
_install_tasks.discard(completed)
|
||||
if _install_tasks_by_repo.get(req.repo_id) is completed:
|
||||
_install_tasks_by_repo.pop(req.repo_id, None)
|
||||
|
||||
task.add_done_callback(install_finished)
|
||||
loop.create_task(asyncio.to_thread(_do))
|
||||
return {"status": "install_started", "repo_id": req.repo_id}
|
||||
|
||||
|
||||
async def cancel_install_and_wait(repo_id: str) -> None:
|
||||
"""Request cancellation and retain authority until its thread exits."""
|
||||
from worker.async_utils import drain_task # noqa: PLC0415
|
||||
|
||||
with _active_installs_lock:
|
||||
_cancelled.add(repo_id)
|
||||
_install_cooldowns.pop(repo_id, None)
|
||||
task = _install_tasks_by_repo.get(repo_id)
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
# asyncio.to_thread cannot stop snapshot_download mid-file. Cancelling
|
||||
# its wrapper would only detach the thread, so wait until the blocking
|
||||
# call observes the flag or naturally returns.
|
||||
await drain_task(task)
|
||||
finally:
|
||||
with _active_installs_lock:
|
||||
current = _install_tasks_by_repo.get(repo_id)
|
||||
if current is None or current is task:
|
||||
_cancelled.discard(repo_id)
|
||||
|
||||
|
||||
@router.post("/models/install/cancel")
|
||||
async def cancel_install(req: InstallModelRequest):
|
||||
"""Request cancellation of an in-flight install (FDL-11).
|
||||
@@ -1003,17 +666,8 @@ async def cancel_install(req: InstallModelRequest):
|
||||
in hf_hub 1.7.2, so an already-streaming file finishes; the cancel takes
|
||||
effect at the next retry boundary. Clears the cooldown so the user can
|
||||
immediately restart."""
|
||||
target = (req.target or "local").strip() or "local"
|
||||
if target != "local":
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
|
||||
try:
|
||||
return await gpu_gateway.cancel_download(req.repo_id, target=target)
|
||||
except gpu_gateway.GatewayError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
_cancelled.add(req.repo_id)
|
||||
_install_cooldowns.pop(req.repo_id, None)
|
||||
_install_failures.pop(req.repo_id, None)
|
||||
return {"cancelling": req.repo_id}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter
|
||||
|
||||
logger = logging.getLogger("omnivoice.setup.models")
|
||||
router = APIRouter()
|
||||
@@ -90,36 +90,6 @@ def get_model_catalog() -> ModelCatalog:
|
||||
|
||||
# ── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _target_worker():
|
||||
"""Selected live remote worker, or None when the catalog targets local."""
|
||||
try:
|
||||
from worker import routing, service # noqa: PLC0415
|
||||
|
||||
decision = routing.decide()
|
||||
plane = service.control_plane
|
||||
return plane.pool.get(decision.worker_id) if decision.remote and plane.pool else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _target_host() -> dict | None:
|
||||
"""Selected remote worker host, or None when the catalog targets local."""
|
||||
live = _target_worker()
|
||||
return dict(live.record.host or {}) if live is not None else None
|
||||
|
||||
|
||||
def _target_repo_inventory() -> tuple[str, set[str]] | None:
|
||||
"""Selected worker id and the catalog repositories it reports on disk."""
|
||||
live = _target_worker()
|
||||
if live is None:
|
||||
return None
|
||||
downloaded: set[str] = set()
|
||||
for capability in live.record.capabilities or []:
|
||||
if capability.get("downloaded"):
|
||||
downloaded.update(str(repo) for repo in capability.get("repo_ids") or [])
|
||||
return live.worker_id, downloaded
|
||||
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports.
|
||||
|
||||
@@ -130,25 +100,6 @@ def _current_platform_tags() -> list[str]:
|
||||
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all —
|
||||
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
|
||||
"""
|
||||
target = _target_host()
|
||||
if target is not None:
|
||||
target_os = {"windows": "win32", "darwin": "darwin"}.get(
|
||||
str(target.get("os") or "").lower(), "linux"
|
||||
)
|
||||
arch = str(target.get("arch") or "").lower()
|
||||
arch = {"amd64": "x86_64", "aarch64": "arm64"}.get(arch, arch)
|
||||
tags = [target_os, f"{target_os}-{arch}"]
|
||||
backend = ""
|
||||
if target.get("gpus"):
|
||||
backend = str(target["gpus"][0].get("backend") or "").lower()
|
||||
if backend:
|
||||
tags.append(backend)
|
||||
if backend == "rocm":
|
||||
tags.append("cuda")
|
||||
if not backend and not (target_os == "darwin" and arch == "arm64"):
|
||||
tags.append("cpu")
|
||||
return tags
|
||||
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
@@ -368,44 +319,23 @@ def _snapshot_dirs(repo_id: str) -> list[str]:
|
||||
return dirs
|
||||
|
||||
|
||||
def snapshot_is_complete(model: dict, snapshot_path: str) -> bool:
|
||||
"""Apply the same catalogue requirements during installation and listing."""
|
||||
config_only = bool(model.get("config_only"))
|
||||
required = tuple(str(name) for name in (
|
||||
model.get("config_required_files") if config_only else model.get("required_files")
|
||||
) or ())
|
||||
if config_only and not required:
|
||||
return False
|
||||
try:
|
||||
present = all(
|
||||
os.path.isfile(os.path.join(snapshot_path, name))
|
||||
and os.path.getsize(os.path.join(snapshot_path, name)) >= (
|
||||
1 if config_only else _WEIGHT_FLOORS.get(os.path.splitext(name)[1].lower(), 1)
|
||||
)
|
||||
for name in required
|
||||
)
|
||||
return present and (config_only or snapshot_has_weights(snapshot_path))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cache_is_complete(model: dict) -> bool:
|
||||
"""True when this model's on-disk cache is usable (not a truncated download).
|
||||
|
||||
Config-only repos carry no weight file of their own. Their catalogue entry
|
||||
declares the small files that make the pipeline usable, so a README left by a
|
||||
gated 403 is not mistaken for a completed install. A weight-bearing repo is
|
||||
complete only if at least one snapshot has weights; if no snapshot directory
|
||||
is found, the size-based caller's cached result is preserved.
|
||||
Config-only repos (``config_only: true`` in models.yaml — e.g. pyannote's
|
||||
diarisation pipeline, whose real weights live in referenced sub-repos) carry no
|
||||
weight file of their own, so the weight check would false-positive them as
|
||||
incomplete (#622 caveat). They're exempt: cache presence alone means complete.
|
||||
A weight-bearing repo is complete only if at least one of its snapshots has
|
||||
weights; if no snapshot dir is found on disk we can't prove truncation, so we
|
||||
don't downgrade (the size-based caller already decided it's cached).
|
||||
"""
|
||||
for dependency in model.get("dependencies") or ():
|
||||
snapshots = _snapshot_dirs(dependency["repo_id"])
|
||||
if not any(snapshot_is_complete(dependency, path) for path in snapshots):
|
||||
return False
|
||||
if model.get("config_only"):
|
||||
return True
|
||||
dirs = _snapshot_dirs(model["repo_id"])
|
||||
if not dirs:
|
||||
return True
|
||||
return any(snapshot_is_complete(model, snapshot) for snapshot in dirs)
|
||||
return any(snapshot_has_weights(d) for d in dirs)
|
||||
|
||||
|
||||
def _is_cached_on_disk(repo_id: str) -> bool:
|
||||
@@ -470,17 +400,6 @@ def _scan_cache_on_disk() -> dict[str, dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _cache_dir_missing(exc: Exception) -> bool:
|
||||
"""Whether Hugging Face is reporting the normal empty-cache state.
|
||||
|
||||
``CacheNotFound`` is expected on a clean installation before the first
|
||||
download. Treating it like a damaged Windows cache makes every model probe
|
||||
perform a redundant filesystem fallback and fills the first-run log with
|
||||
warnings. Unexpected scan failures remain visible and recoverable below.
|
||||
"""
|
||||
return type(exc).__name__ == "CacheNotFound"
|
||||
|
||||
|
||||
def is_cached(repo_id: str) -> bool:
|
||||
"""Best-effort check: does HF have this repo in its cache on disk?"""
|
||||
try:
|
||||
@@ -491,8 +410,6 @@ def is_cached(repo_id: str) -> bool:
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
if _cache_dir_missing(e):
|
||||
return False
|
||||
# scan_cache_dir can raise on Windows (WinError 448 'untrusted mount
|
||||
# point'); fall back to a direct disk check so a cached model isn't
|
||||
# mistaken for missing and re-downloaded in a loop (#117/#118). Logged
|
||||
@@ -531,62 +448,6 @@ def invalidate_cache() -> None:
|
||||
|
||||
# ── Endpoints ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/models/access/status")
|
||||
def model_access_status(repo_id: str = Query(...)):
|
||||
"""Check gated Hub access without downloading model files.
|
||||
|
||||
This route runs only after an explicit UI action. It never returns the
|
||||
token or a raw Hub exception; callers need only the per-repository verdict.
|
||||
"""
|
||||
model = _catalog.get(repo_id)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown model")
|
||||
if not model.get("gated"):
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"token_present": False,
|
||||
"ready": True,
|
||||
"repositories": [],
|
||||
}
|
||||
|
||||
from services import token_resolver
|
||||
|
||||
resolved = token_resolver.resolve()
|
||||
repositories = [repo_id]
|
||||
prerequisite = str(model.get("prerequisite_repo_id") or "").strip()
|
||||
if prerequisite:
|
||||
repositories.append(prerequisite)
|
||||
if not resolved:
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"token_present": False,
|
||||
"ready": False,
|
||||
"repositories": [
|
||||
{"repo_id": current, "access": "token_missing"}
|
||||
for current in repositories
|
||||
],
|
||||
}
|
||||
|
||||
from huggingface_hub import get_hf_file_metadata, hf_hub_url
|
||||
|
||||
results = []
|
||||
for current in repositories:
|
||||
try:
|
||||
url = hf_hub_url(current, filename=".gitattributes")
|
||||
get_hf_file_metadata(url, token=resolved.token)
|
||||
access = "granted"
|
||||
except Exception as exc: # Hub exception types vary across releases.
|
||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
access = "required" if status in {401, 403, 404} else "unavailable"
|
||||
results.append({"repo_id": current, "access": access})
|
||||
return {
|
||||
"repo_id": repo_id,
|
||||
"token_present": True,
|
||||
"ready": all(item["access"] == "granted" for item in results),
|
||||
"repositories": results,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
def list_models():
|
||||
"""Catalogue every known model + its on-disk install state.
|
||||
@@ -594,55 +455,35 @@ def list_models():
|
||||
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
||||
walks when the frontend polls.
|
||||
"""
|
||||
platform_tags = _current_platform_tags()
|
||||
remote_inventory = _target_repo_inventory()
|
||||
target_key = remote_inventory[0] if remote_inventory else "local"
|
||||
cache_key = "models:" + target_key + ":" + ",".join(sorted(platform_tags))
|
||||
cached_response = _cached(cache_key)
|
||||
cached_response = _cached("models")
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
cached_by_repo: dict[str, dict] = {}
|
||||
if remote_inventory is not None:
|
||||
for model in KNOWN_MODELS:
|
||||
if model["repo_id"] in remote_inventory[1]:
|
||||
cached_by_repo[model["repo_id"]] = {
|
||||
"size_on_disk": int(float(model.get("size_gb") or 0) * _GIB),
|
||||
"last_accessed": None,
|
||||
"nb_files": 0,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
if _cache_dir_missing(e):
|
||||
cached_by_repo = {}
|
||||
else:
|
||||
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
|
||||
# models still show as installed instead of offering a re-download.
|
||||
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
|
||||
# models still show as installed instead of offering a re-download.
|
||||
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
|
||||
out = []
|
||||
host_tags = set(platform_tags)
|
||||
host_tags = set(_current_platform_tags())
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
on_disk = (
|
||||
m["repo_id"] in remote_inventory[1]
|
||||
if remote_inventory is not None
|
||||
else cached is not None and cached["size_on_disk"] > 0
|
||||
)
|
||||
on_disk = cached is not None and cached["size_on_disk"] > 0
|
||||
# A size-positive cache can still be a truncated download (config landed,
|
||||
# weight shard didn't). Treat that as not-installed + incomplete so the
|
||||
# wizard re-offers the download instead of stranding the user (#622).
|
||||
incomplete = on_disk and remote_inventory is None and not cache_is_complete(m)
|
||||
incomplete = on_disk and not cache_is_complete(m)
|
||||
out.append({
|
||||
**m,
|
||||
"installed": on_disk and not incomplete,
|
||||
@@ -655,17 +496,16 @@ def list_models():
|
||||
"curated": _model_curated(m, host_tags),
|
||||
})
|
||||
response = {
|
||||
"target": target_key,
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": "" if remote_inventory is not None else hf_cache_dir(),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
# Free space on the cache volume, so the Model Store header can warn
|
||||
# BEFORE an "Install all" overruns the disk (pairs with the per-install
|
||||
# disk_space_error guard in setup/download.py).
|
||||
"disk_free_gb": None if remote_inventory is not None else round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": platform_tags,
|
||||
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
_set_cache(cache_key, response)
|
||||
_set_cache("models", response)
|
||||
return response
|
||||
|
||||
|
||||
@@ -678,19 +518,18 @@ def recommendations():
|
||||
TTS model is required; the ASR picks here are the optional "best for your
|
||||
system" set the wizard and Settings surface for on-demand install.
|
||||
"""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
tags = set(_current_platform_tags())
|
||||
target_os = "darwin" if "darwin" in tags else "win32" if "win32" in tags else "linux"
|
||||
target_arch = next((tag.split("-", 1)[1] for tag in tags if tag.startswith(target_os + "-")), _platform.machine())
|
||||
is_mac_arm = target_os == "darwin" and target_arch == "arm64"
|
||||
is_mac_intel = target_os == "darwin" and target_arch == "x86_64"
|
||||
is_linux = target_os == "linux"
|
||||
is_windows = target_os == "win32"
|
||||
has_cuda = "cuda" in tags and "rocm" not in tags
|
||||
has_rocm = "rocm" in tags
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
device_label = f"Apple Silicon ({target_arch})"
|
||||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
@@ -698,7 +537,7 @@ def recommendations():
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
else:
|
||||
device_label = f"{target_os} / {target_arch}"
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
|
||||
# Curated preset for this host, in catalog order (required entries lead).
|
||||
curated = [
|
||||
@@ -717,51 +556,42 @@ def recommendations():
|
||||
rationale = (
|
||||
"NVIDIA preset: VoiceStudio (required) runs standalone. Optional ASR picks "
|
||||
"are CUDA-accelerated via CTranslate2 — Whisper large-v3 for dubbing "
|
||||
"(best word timestamps) and Turbo for 5× faster transcription. Whisper "
|
||||
"Tiny provides broad-language local dictation. KittenTTS adds CPU-realtime English."
|
||||
"(best word timestamps), Turbo for 5× faster transcription, Parakeet TDT "
|
||||
"v3 for live dictation. KittenTTS adds CPU-realtime English."
|
||||
)
|
||||
elif has_rocm:
|
||||
rationale = (
|
||||
"AMD/ROCm preset: VoiceStudio (required) runs standalone. CTranslate2 has "
|
||||
"no ROCm backend, so the PyTorch Whisper large-v3 build is the "
|
||||
"GPU-accelerated ASR route; faster-whisper works on CPU, and Whisper Tiny "
|
||||
"provides broad-language local dictation."
|
||||
"GPU-accelerated ASR route; faster-whisper works on CPU, and Parakeet "
|
||||
"TDT v3 handles live dictation."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"CPU preset: VoiceStudio (required) runs standalone. Optional picks favour "
|
||||
"speed on CPU — Whisper large-v3 (int8) for accuracy, Turbo when speed "
|
||||
"matters, Whisper Tiny (ONNX) for live dictation, KittenTTS for "
|
||||
"matters, Parakeet TDT v3 (int8 ONNX) for live dictation, KittenTTS for "
|
||||
"instant English TTS."
|
||||
)
|
||||
|
||||
remote_inventory = _target_repo_inventory()
|
||||
cached_ids: set[str] = set()
|
||||
if remote_inventory is not None:
|
||||
cached_ids = remote_inventory[1]
|
||||
else:
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception as e:
|
||||
if _cache_dir_missing(e):
|
||||
cached_ids = set()
|
||||
else:
|
||||
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
|
||||
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
|
||||
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
|
||||
entries = []
|
||||
for meta in curated:
|
||||
rid = meta["repo_id"]
|
||||
# Mirror /models: a truncated cache (weights missing) is not installed, so
|
||||
# the wizard counts it toward the remaining download instead of "all set".
|
||||
installed = rid in cached_ids and (
|
||||
remote_inventory is not None or cache_is_complete(meta)
|
||||
)
|
||||
installed = rid in cached_ids and cache_is_complete(meta)
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
@@ -776,10 +606,9 @@ def recommendations():
|
||||
all_installed = all(e["installed"] for e in entries)
|
||||
|
||||
return {
|
||||
"target": remote_inventory[0] if remote_inventory is not None else "local",
|
||||
"device": {
|
||||
"os": target_os,
|
||||
"arch": target_arch,
|
||||
"os": sys.platform,
|
||||
"arch": _platform.machine(),
|
||||
"is_mac_arm": is_mac_arm,
|
||||
"is_mac_intel": is_mac_intel,
|
||||
"is_linux": is_linux,
|
||||
|
||||
@@ -19,7 +19,6 @@ import sys
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.schemas import SetupStatusResponse, PreflightResponse
|
||||
from core.device_caps import KERNEL_RISK_MARKER
|
||||
# MIN_FREE_GB + disk_free_bytes are single-sourced in ``.models`` (the lowest
|
||||
# module in the setup import graph) so the wizard gate, the /models header, and
|
||||
# the per-install disk guard can't drift apart.
|
||||
@@ -63,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]:
|
||||
@@ -175,8 +169,8 @@ def _detect_gpu() -> dict:
|
||||
return info
|
||||
|
||||
|
||||
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 8.0) -> bool:
|
||||
"""Tiny TCP connect test. 8s default — high-latency / China paths often exceed 2–3s."""
|
||||
def _probe_network(host: str = "huggingface.co", port: int = 443, timeout: float = 2.0) -> bool:
|
||||
"""Tiny TCP connect test."""
|
||||
import socket
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
@@ -189,7 +183,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
"""Host/port of the Hugging Face endpoint actually in effect.
|
||||
|
||||
Mirror-aware: restricted-network users (e.g. behind the Great Firewall)
|
||||
point HF_ENDPOINT at a mirror via Settings → Network → Hugging Face
|
||||
point HF_ENDPOINT at a mirror via Settings → Models → Hugging Face
|
||||
mirror. Probing hardcoded huggingface.co would fail them even when their
|
||||
configured mirror works fine.
|
||||
"""
|
||||
@@ -287,7 +281,7 @@ def _network_check() -> dict:
|
||||
"id": "network", "label": "Network (configured endpoint)",
|
||||
"status": "warn",
|
||||
"detail": "The configured Hugging Face endpoint could not be validated.",
|
||||
"fix": "Review the endpoint in Settings → Network, then re-check.",
|
||||
"fix": "Review the endpoint in Settings → Models, then re-check.",
|
||||
"mirror_reachable": False,
|
||||
}
|
||||
net_ok = _probe_network(net_host, net_port)
|
||||
@@ -358,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.",
|
||||
@@ -499,14 +482,10 @@ def preflight():
|
||||
_why = gpu_routing.get("routing_reason")
|
||||
if _rs == "accelerated" and not _why:
|
||||
r_status, r_detail, r_fix = "pass", f"{_eng} → {_dev} (accelerated)", None
|
||||
elif _rs == "accelerated" and KERNEL_RISK_MARKER in (_why or ""):
|
||||
elif _rs == "accelerated": # driver/arch caveat
|
||||
r_status, r_detail, r_fix = "warn", f"{_eng} → {_dev}: {_why}", (
|
||||
"GPU selected but may fail at kernel launch — update drivers / "
|
||||
"reinstall torch for this GPU architecture.")
|
||||
elif _rs == "accelerated": # low-VRAM caveat — not a driver/arch issue
|
||||
r_status, r_detail, r_fix = "warn", f"{_eng} → {_dev}: {_why}", (
|
||||
"Unload other models before generating, keep the text short, "
|
||||
"or pick a lighter engine.")
|
||||
elif _rs == "cpu_fallback":
|
||||
r_status, r_detail, r_fix = "warn", (
|
||||
f"{_eng} runs on CPU here: {_why or 'no GPU path for this host'}"), (
|
||||
@@ -517,10 +496,10 @@ def preflight():
|
||||
elif _rs == "unavailable":
|
||||
r_status, r_detail, r_fix = "fail", (
|
||||
f"{_eng} can't run on this host: {_why or 'needs a GPU this machine lacks'}"), (
|
||||
"Select an engine with a CPU path in Model Catalogue.")
|
||||
"Select an engine with a CPU path in Settings → Engines.")
|
||||
else: # "none" / unknown
|
||||
r_status, r_detail, r_fix = "warn", "No active TTS engine resolved for routing.", (
|
||||
"Pick an engine in Model Catalogue.")
|
||||
"Pick an engine in Settings → Engines.")
|
||||
checks.append({
|
||||
"id": "gpu_routing", "label": "Active engine routing",
|
||||
"status": r_status, "detail": r_detail, "fix": r_fix,
|
||||
|
||||
@@ -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()
|
||||
+60
-439
@@ -11,12 +11,10 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete
|
||||
from services import network_share
|
||||
from services import tailscale as _tailscale
|
||||
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse
|
||||
from api.dependencies import is_loopback, require_admin, require_admin_action
|
||||
from api.dependencies import is_loopback, require_admin
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import torch
|
||||
import shutil
|
||||
import subprocess
|
||||
import shlex
|
||||
|
||||
from core.config import OUTPUTS_DIR, DATA_DIR, CRASH_LOG_PATH, LOG_PATH, IDLE_TIMEOUT_SECONDS
|
||||
from core.version import APP_VERSION
|
||||
@@ -40,10 +38,6 @@ logger = logging.getLogger("omnivoice.api")
|
||||
# Cache device checks at module load — they don't change at runtime
|
||||
_is_mac = hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
||||
_is_cuda = torch.cuda.is_available()
|
||||
try:
|
||||
_is_xpu = hasattr(torch, "xpu") and torch.xpu.is_available()
|
||||
except Exception:
|
||||
_is_xpu = False
|
||||
# Prime psutil's internal CPU counter so the first non-blocking call returns useful data
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
@@ -57,14 +51,6 @@ def _detect_cpu_model() -> str:
|
||||
for line in f:
|
||||
if line.lower().startswith("model name"):
|
||||
return line.split(":", 1)[1].strip()
|
||||
if sys.platform == "win32":
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
) as key:
|
||||
return str(winreg.QueryValueEx(key, "ProcessorNameString")[0]).strip()
|
||||
if sys.platform == "darwin":
|
||||
import subprocess
|
||||
return subprocess.check_output(
|
||||
@@ -75,77 +61,6 @@ def _detect_cpu_model() -> str:
|
||||
return platform.processor() or ""
|
||||
|
||||
|
||||
def _gpu_name_priority(name: str) -> tuple[int, int]:
|
||||
lowered = name.lower()
|
||||
if any(token in lowered for token in ("remote", "virtual", "basic display")):
|
||||
return (-1, len(name))
|
||||
if any(token in lowered for token in ("nvidia", "radeon", "amd", "intel arc")):
|
||||
return (2, len(name))
|
||||
return (1, len(name))
|
||||
|
||||
|
||||
def _detect_os_gpu_name() -> str:
|
||||
"""Best-effort display-adapter identity when the active torch build is CPU-only."""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
executable = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
if not executable:
|
||||
return ""
|
||||
result = subprocess.run(
|
||||
[
|
||||
executable,
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
names = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
return max(names, key=_gpu_name_priority, default="")
|
||||
if sys.platform.startswith("linux"):
|
||||
executable = shutil.which("lspci")
|
||||
if not executable:
|
||||
return ""
|
||||
result = subprocess.run(
|
||||
[executable, "-mm"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
names = []
|
||||
for line in result.stdout.splitlines():
|
||||
parts = shlex.split(line)
|
||||
if len(parts) >= 4 and parts[1] in {"VGA compatible controller", "3D controller"}:
|
||||
names.append(" ".join(parts[2:4]))
|
||||
return max(names, key=_gpu_name_priority, default="")
|
||||
if sys.platform == "darwin":
|
||||
executable = shutil.which("system_profiler")
|
||||
if not executable:
|
||||
return ""
|
||||
result = subprocess.run(
|
||||
[executable, "SPDisplaysDataType"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
check=False,
|
||||
)
|
||||
names = [
|
||||
line.split(":", 1)[1].strip()
|
||||
for line in result.stdout.splitlines()
|
||||
if "Chipset Model:" in line
|
||||
]
|
||||
return max(names, key=_gpu_name_priority, default="")
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _detect_gpu() -> tuple[str, float]:
|
||||
"""(gpu_name, vram_total_gb) — static for the process lifetime.
|
||||
|
||||
@@ -156,15 +71,11 @@ def _detect_gpu() -> tuple[str, float]:
|
||||
if _is_cuda:
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
return torch.cuda.get_device_name(0), round(props.total_memory / (1024 ** 3), 1)
|
||||
if _is_xpu:
|
||||
props = torch.xpu.get_device_properties(0)
|
||||
total_memory = float(getattr(props, "total_memory", 0.0))
|
||||
return torch.xpu.get_device_name(0), round(total_memory / (1024 ** 3), 1)
|
||||
if _is_mac:
|
||||
return "Apple Silicon (MPS)", 0.0
|
||||
except Exception:
|
||||
pass
|
||||
return _detect_os_gpu_name(), 0.0
|
||||
return "", 0.0
|
||||
|
||||
|
||||
# Static hardware facts, captured once — /system/info is hit on every
|
||||
@@ -182,37 +93,6 @@ def _disk_free_gb() -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _nvidia_live_stats() -> tuple[float, float, float] | None:
|
||||
"""Return GPU%, used VRAM GiB, total VRAM GiB without an optional Python dependency."""
|
||||
executable = shutil.which("nvidia-smi")
|
||||
if not executable:
|
||||
return None
|
||||
try:
|
||||
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
||||
result = subprocess.run(
|
||||
[
|
||||
executable,
|
||||
"--query-gpu=utilization.gpu,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
"--id=0",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
check=False,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
values = [float(value.strip()) for value in result.stdout.splitlines()[0].split(",")]
|
||||
if len(values) != 3:
|
||||
return None
|
||||
utilization, used_mib, total_mib = values
|
||||
return utilization, used_mib / 1024, total_mib / 1024
|
||||
except (OSError, ValueError, IndexError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def _ui_port() -> int:
|
||||
"""The Vite UI dev-server port, single-sourced from OMNIVOICE_UI_PORT.
|
||||
|
||||
@@ -305,8 +185,8 @@ def loaded_models():
|
||||
@router.post("/model/unload/{model_id}")
|
||||
async def unload_model(model_id: str):
|
||||
"""Unload a specific model by id (MM2-04). Delegates to model_lifecycle;
|
||||
an unknown id maps to HTTP 400. Supports every id returned by
|
||||
``GET /model/loaded`` plus the aggregate ``sidecars`` id."""
|
||||
an unknown id maps to HTTP 400. ``tts`` | ``diarization`` |
|
||||
``sidecar:<id>`` | ``sidecars``."""
|
||||
from services import model_lifecycle
|
||||
try:
|
||||
return await model_lifecycle.unload(model_id)
|
||||
@@ -323,40 +203,15 @@ def system_info():
|
||||
"""
|
||||
try:
|
||||
_ffmpeg = find_ffmpeg()
|
||||
from services import model_manager as _mm
|
||||
from services import asr_backend as _asr_backend
|
||||
from core import prefs as _prefs_mod
|
||||
_asr_engine = _asr_backend.active_backend_id()
|
||||
_asr_model = (
|
||||
_asr_backend._offline_asr_repo(_asr_engine)
|
||||
or os.environ.get("ASR_MODEL")
|
||||
or _asr_engine
|
||||
)
|
||||
_translation_provider = (
|
||||
os.environ.get("TRANSLATE_PROVIDER")
|
||||
or _prefs_mod.get("translation_backend", "argos")
|
||||
)
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"generate_timeout_s": _mm.GPU_JOB_TIMEOUT_S,
|
||||
"cpu_generate_timeout_s": _mm.CPU_JOB_TIMEOUT_S,
|
||||
# #1787 review fix: a saved prefs.json value for either key can be
|
||||
# silently shadowed by an external env var (os.environ.setdefault
|
||||
# in core.prefs.restore_env is a no-op when one is already
|
||||
# present) — the Settings panel must say so rather than promise a
|
||||
# restart will apply a value that never will.
|
||||
"generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S"),
|
||||
"cpu_generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
|
||||
"OMNIVOICE_CPU_GENERATE_TIMEOUT_S"),
|
||||
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
"idle_timeout_seconds": IDLE_TIMEOUT_SECONDS,
|
||||
"model_checkpoint": resolve_omnivoice_checkpoint(), # #693: show the effective checkpoint, not a leaked raw value
|
||||
"asr_model": _asr_model,
|
||||
"translate_provider": _translation_provider,
|
||||
"asr_model": os.environ.get("ASR_MODEL", "Systran/faster-whisper-large-v3"),
|
||||
"translate_provider": os.environ.get("TRANSLATE_PROVIDER", "google"),
|
||||
"has_hf_token": _has_hf_token(),
|
||||
"fast_download": _fast_download_status(),
|
||||
"device": get_best_device(),
|
||||
@@ -385,11 +240,6 @@ def system_info():
|
||||
logger.exception("system_info failed — returning safe defaults")
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"generate_timeout_s": 300.0,
|
||||
"cpu_generate_timeout_s": 600.0,
|
||||
"generate_timeout_shadowed": False,
|
||||
"cpu_generate_timeout_shadowed": False,
|
||||
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": str(CRASH_LOG_PATH),
|
||||
@@ -428,142 +278,6 @@ def _tail_file(path: str, tail: int):
|
||||
return all_lines[-tail:], len(all_lines)
|
||||
|
||||
|
||||
# Must track main.py's _WindowsSafeRotatingFileHandler(backupCount=3). The
|
||||
# handler rolls omnivoice.log at 2 MB into .1/.2/.3, so up to 6 MB of history
|
||||
# lives in files this module used to ignore entirely.
|
||||
_LOG_BACKUP_COUNT = 3
|
||||
|
||||
|
||||
def _rotated_log_paths(base: str) -> list[str]:
|
||||
"""Existing `<base>.1 … .N`, newest first."""
|
||||
return [p for p in (f"{base}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)) if os.path.exists(p)]
|
||||
|
||||
|
||||
def _tail_rolling(base: str, tail: int):
|
||||
"""Tail `base`, reaching into its rotated siblings when it runs short.
|
||||
|
||||
A rollover leaves omnivoice.log nearly empty, and the Backend tab then
|
||||
showed a handful of lines — or none — while the failure the user was asked
|
||||
to copy sat in omnivoice.log.1. Reading the current file first keeps the
|
||||
common case at one file read; the backups are only touched when they are
|
||||
the only place the requested lines can come from.
|
||||
|
||||
Returns (lines oldest-first, total lines across the files read, paths read
|
||||
oldest-first). The total counts only the files it had to open — it stops as
|
||||
soon as `tail` is satisfied, so it is "how much is behind these lines",
|
||||
not the size of the whole rotation set.
|
||||
"""
|
||||
chunks: list[list[str]] = []
|
||||
paths: list[str] = []
|
||||
total = 0
|
||||
remaining = tail
|
||||
candidates = [p for p in [base, *_rotated_log_paths(base)] if os.path.exists(p)]
|
||||
for path in candidates:
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
lines, count = _tail_file(path, remaining)
|
||||
except FileNotFoundError:
|
||||
# A rollover can rename a candidate between the existence check
|
||||
# above and this open, and the handler holds no lock we can take
|
||||
# from a route. Skip the vanished file rather than 500 the whole
|
||||
# panel over one member of the set — the previous single-file
|
||||
# version failed the request outright in the same situation.
|
||||
#
|
||||
# A roll landing mid-walk can also shift which chunk a file holds,
|
||||
# so a tail taken at that instant may repeat or miss a block. The
|
||||
# panel re-polls every 5s and the next read is clean; buying strict
|
||||
# consistency here would mean reaching into logging's internals.
|
||||
continue
|
||||
except PermissionError as exc:
|
||||
# Windows only, and only the sharing violation: the handler still
|
||||
# holds the file it is rolling. Any other permission failure is a
|
||||
# real misconfiguration and must not be hidden.
|
||||
if os.name == "nt" and getattr(exc, "winerror", None) == 32:
|
||||
continue
|
||||
raise
|
||||
if count == 0:
|
||||
continue
|
||||
chunks.append(lines)
|
||||
paths.append(path)
|
||||
total += count
|
||||
remaining -= len(lines)
|
||||
# Files were visited newest-first; the reader wants oldest-first.
|
||||
out: list[str] = []
|
||||
for chunk in reversed(chunks):
|
||||
out.extend(chunk)
|
||||
return out, total, list(reversed(paths))
|
||||
|
||||
def _tauri_plugin_log_candidates():
|
||||
"""The `tauri-plugin-log` files — the shell's own log, and the only thing
|
||||
the Tauri tab actually displays.
|
||||
|
||||
Split out from :func:`_tauri_log_candidates` so Clear can touch these and
|
||||
leave the backend stdout/stderr redirect alone. See
|
||||
:func:`clear_tauri_logs`.
|
||||
"""
|
||||
home = os.path.expanduser("~")
|
||||
bid = "com.debpalash.omnivoice-studio"
|
||||
if sys.platform == "darwin":
|
||||
return [
|
||||
os.path.join(home, "Library/Logs", bid, "tauri.log"),
|
||||
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
|
||||
]
|
||||
if sys.platform.startswith("linux"):
|
||||
data_dir = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local/share")
|
||||
return [
|
||||
os.path.join(data_dir, bid, "logs", "tauri.log"),
|
||||
os.path.join(home, ".config", bid, "logs", "tauri.log"),
|
||||
]
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA", home)
|
||||
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
return [
|
||||
os.path.join(localappdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(appdata, bid, "logs", "tauri.log"),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _backend_redirect_log_candidates():
|
||||
"""`backend.log` / `backend_err.log` — the spawned backend's stdout and
|
||||
stderr, written by `src-tauri/src/backend.rs::backend_log_path()`.
|
||||
|
||||
Deliberately NOT cleared by the Tauri tab's Clear button.
|
||||
`open_err_log_for_run()` opens `backend_err.log` **append-only** so "a
|
||||
respawn must not destroy the previous run's evidence" (#1510), rotates it
|
||||
to `.1` rather than truncating, and its spawn diagnostics are described
|
||||
there as "retained in backend_err.log across runs and lands verbatim in bug
|
||||
reports". A native death (a Windows access violation, a SIGSEGV) writes
|
||||
nothing to the Python log by construction, so this file is the only record
|
||||
of it.
|
||||
|
||||
`OMNIVOICE_LOG_DIR` is honoured first, in the same precedence
|
||||
`backend_log_path()` uses. The backend is a child of the shell, so an
|
||||
ambient override reaches both — and a resolver that ignored it would look
|
||||
in the per-OS default while the writer wrote somewhere else, which is the
|
||||
divergence class this file already has one of (see #1782).
|
||||
"""
|
||||
override = (os.environ.get("OMNIVOICE_LOG_DIR") or "").strip()
|
||||
if override:
|
||||
return [
|
||||
os.path.join(override, "backend.log"),
|
||||
os.path.join(override, "backend_err.log"),
|
||||
]
|
||||
home = os.path.expanduser("~")
|
||||
if sys.platform == "darwin":
|
||||
base = os.path.join(home, "Library/Logs/OmniVoice")
|
||||
elif sys.platform.startswith("linux"):
|
||||
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
|
||||
base = os.path.join(state_dir, "OmniVoice")
|
||||
elif sys.platform.startswith("win"):
|
||||
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
base = os.path.join(localappdata, "OmniVoice", "Logs")
|
||||
else:
|
||||
return []
|
||||
return [os.path.join(base, "backend.log"), os.path.join(base, "backend_err.log")]
|
||||
|
||||
|
||||
def _tauri_log_candidates():
|
||||
"""Likely paths for Tauri-side logs, most useful first.
|
||||
|
||||
@@ -575,15 +289,40 @@ def _tauri_log_candidates():
|
||||
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
|
||||
- backend.rs::backend_log_path() redirects the spawned backend's
|
||||
stdout/stderr to `backend.log` / `backend_err.log` under
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/VoiceStudio` falling
|
||||
back to `~/.local/state/OmniVoice` (Linux), and
|
||||
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
|
||||
startup banners and hard-crash tracebacks land — keep all three OS
|
||||
shapes listed or sidecar crashes become invisible off-macOS.
|
||||
"""
|
||||
# Composed from the two halves so the read path keeps seeing every file
|
||||
# while Clear can be narrowed to the shell's own log.
|
||||
return _tauri_plugin_log_candidates() + _backend_redirect_log_candidates()
|
||||
home = os.path.expanduser("~")
|
||||
bid = "com.debpalash.omnivoice-studio"
|
||||
if sys.platform == "darwin":
|
||||
return [
|
||||
os.path.join(home, "Library/Logs", bid, "tauri.log"),
|
||||
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend.log"),
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
|
||||
]
|
||||
if sys.platform.startswith("linux"):
|
||||
data_dir = os.environ.get("XDG_DATA_HOME") or os.path.join(home, ".local/share")
|
||||
state_dir = os.environ.get("XDG_STATE_HOME") or os.path.join(home, ".local/state")
|
||||
return [
|
||||
os.path.join(data_dir, bid, "logs", "tauri.log"),
|
||||
os.path.join(home, ".config", bid, "logs", "tauri.log"),
|
||||
os.path.join(state_dir, "OmniVoice", "backend.log"),
|
||||
os.path.join(state_dir, "OmniVoice", "backend_err.log"),
|
||||
]
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA", home)
|
||||
localappdata = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
return [
|
||||
os.path.join(localappdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(appdata, bid, "logs", "tauri.log"),
|
||||
os.path.join(localappdata, "OmniVoice", "Logs", "backend.log"),
|
||||
os.path.join(localappdata, "OmniVoice", "Logs", "backend_err.log"),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/system/logs")
|
||||
@@ -598,24 +337,12 @@ async def system_logs(tail: int = 200):
|
||||
except Exception:
|
||||
tail = 200
|
||||
|
||||
if os.path.exists(LOG_PATH) or _rotated_log_paths(LOG_PATH):
|
||||
base = LOG_PATH
|
||||
else:
|
||||
base = CRASH_LOG_PATH
|
||||
if not os.path.exists(base) and not _rotated_log_paths(base):
|
||||
path = LOG_PATH if os.path.exists(LOG_PATH) else CRASH_LOG_PATH
|
||||
if not os.path.exists(path):
|
||||
return {"lines": [], "path": LOG_PATH, "exists": False}
|
||||
path = base
|
||||
try:
|
||||
lines, total, paths = await asyncio.to_thread(_tail_rolling, base, tail)
|
||||
return {
|
||||
"lines": lines,
|
||||
"path": path,
|
||||
"exists": True,
|
||||
"total_lines": total,
|
||||
# Which files the tail actually came from, oldest first. A bug
|
||||
# report can then say whether it crossed a rollover.
|
||||
"paths": paths,
|
||||
}
|
||||
lines, total = await asyncio.to_thread(_tail_file, path, tail)
|
||||
return {"lines": lines, "path": path, "exists": True, "total_lines": total}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -721,23 +448,9 @@ def _read_from_pos(path: str, pos: int) -> list[str]:
|
||||
|
||||
@router.post("/system/logs/clear")
|
||||
async def clear_system_logs():
|
||||
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads).
|
||||
|
||||
Includes the rotated siblings. Truncating only omnivoice.log left up to
|
||||
6 MB in .1/.2/.3, so Clear freed almost nothing and — now that the tail
|
||||
reaches into those files — would have looked like it did nothing at all.
|
||||
"""
|
||||
"""Truncate the rolling runtime log and the crash log (what the Backend tab reads)."""
|
||||
cleared_any = False
|
||||
# The full fixed name set rather than a snapshot of what exists: enumerating
|
||||
# first leaves a window where a rollover creates a backup after the scan and
|
||||
# its history survives a Clear that reported success. Names the handler can
|
||||
# ever write are known up front, so there is nothing to enumerate.
|
||||
targets = [
|
||||
LOG_PATH,
|
||||
*(f"{LOG_PATH}.{i}" for i in range(1, _LOG_BACKUP_COUNT + 1)),
|
||||
CRASH_LOG_PATH,
|
||||
]
|
||||
for p in targets:
|
||||
for p in (LOG_PATH, CRASH_LOG_PATH):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
@@ -770,20 +483,10 @@ def _truncate_file(path: str):
|
||||
|
||||
@router.post("/system/logs/tauri/clear")
|
||||
async def clear_tauri_logs():
|
||||
"""Truncate the shell's own log files. OS-level rotation may recreate them.
|
||||
|
||||
The backend stdout/stderr redirect is deliberately excluded. This button
|
||||
lives on a tab that shows `tauri.log`, and truncating `backend_err.log`
|
||||
from it destroyed evidence the user was never shown — the one record of a
|
||||
native death, which writes nothing to the Python log. `backend.rs`'s
|
||||
`open_err_log_for_run()` opens that file append-only precisely so "a
|
||||
respawn must not destroy the previous run's evidence" (#1510) and rotates
|
||||
it to `.1` instead of truncating, so it manages its own size and does not
|
||||
need clearing from here.
|
||||
"""
|
||||
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
|
||||
cleared = []
|
||||
failed = 0
|
||||
for p in _tauri_plugin_log_candidates():
|
||||
for p in _tauri_log_candidates():
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
@@ -800,7 +503,6 @@ async def clear_tauri_logs():
|
||||
@router.get("/sysinfo", response_model=SysinfoResponse)
|
||||
def get_sys_info():
|
||||
vram = 0.0
|
||||
total_vram = 0.0
|
||||
gpu_active = False
|
||||
|
||||
try:
|
||||
@@ -813,38 +515,18 @@ def get_sys_info():
|
||||
vram = alloc() / (1024**3)
|
||||
elif _is_cuda:
|
||||
vram = torch.cuda.memory_allocated() / (1024**3)
|
||||
total_vram = torch.cuda.get_device_properties(torch.cuda.current_device()).total_memory / (1024**3)
|
||||
elif _is_xpu:
|
||||
vram = torch.xpu.memory_allocated() / (1024**3)
|
||||
total_vram = float(
|
||||
getattr(torch.xpu.get_device_properties(0), "total_memory", 0.0)
|
||||
) / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if vram > 0.01:
|
||||
gpu_active = True
|
||||
|
||||
gpu_utilization = None
|
||||
nvidia_stats = _nvidia_live_stats() if _is_cuda else None
|
||||
if nvidia_stats:
|
||||
gpu_utilization, vram, total_vram = nvidia_stats
|
||||
gpu_active = gpu_active or gpu_utilization > 0 or vram > 0.01
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
cpu_frequency = psutil.cpu_freq()
|
||||
return {
|
||||
"cpu": psutil.cpu_percent(interval=None),
|
||||
"cpu_model": _CPU_MODEL,
|
||||
"cpu_physical_cores": psutil.cpu_count(logical=False) or 0,
|
||||
"cpu_logical_cores": psutil.cpu_count(logical=True) or 0,
|
||||
"cpu_frequency_ghz": round((cpu_frequency.current if cpu_frequency else 0.0) / 1000, 2),
|
||||
"ram": vm.used / (1024**3),
|
||||
"total_ram": vm.total / (1024**3),
|
||||
"gpu_name": _GPU_NAME,
|
||||
"gpu_utilization": gpu_utilization,
|
||||
"vram": round(vram, 2),
|
||||
"total_vram": round(total_vram, 2),
|
||||
"gpu_active": gpu_active
|
||||
}
|
||||
|
||||
@@ -860,18 +542,11 @@ async def flush_memory(unload_model: bool = False):
|
||||
|
||||
freed_model = False
|
||||
if unload_model:
|
||||
from services import model_lifecycle
|
||||
|
||||
# The user-facing action has always promised "Unload all". Route it
|
||||
# through the lifecycle facade so alternate TTS engines, dictation,
|
||||
# diarisation, translation and sidecars are released as well as the
|
||||
# shared OmniVoice model. Individual runtimes still decline while
|
||||
# leased by active work.
|
||||
released = await model_lifecycle.unload_all()
|
||||
freed_model = any(
|
||||
bool(result.get("success"))
|
||||
for result in released.get("results", {}).values()
|
||||
)
|
||||
import services.model_manager as mm
|
||||
async with mm._model_lock:
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
freed_model = True
|
||||
|
||||
# Multi-pass GC to break reference cycles
|
||||
gc.collect(generation=2)
|
||||
@@ -880,25 +555,15 @@ async def flush_memory(unload_model: bool = False):
|
||||
|
||||
free_vram()
|
||||
|
||||
# Snapshot after flush. Two numbers, because one of them is a lie by
|
||||
# omission: `memory_allocated` counts live tensors only, so it reads ~0
|
||||
# after an unload while nvidia-smi still shows gigabytes — which is exactly
|
||||
# the report we keep getting ("flush says it worked, the GPU says it
|
||||
# didn't"). `memory_reserved` is what the caching allocator holds from the
|
||||
# driver, and the gap between reserved and the driver's own figure is the
|
||||
# CUDA context plus kernel workspaces, which no in-process call can return.
|
||||
# Snapshot after flush
|
||||
vram_after = 0.0
|
||||
vram_reserved = 0.0
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
driver = getattr(torch.mps, "driver_allocated_memory", None)
|
||||
if driver:
|
||||
vram_after = driver() / (1024**3)
|
||||
current = getattr(torch.mps, "current_allocated_memory", None)
|
||||
vram_reserved = (current() / (1024**3)) if current else vram_after
|
||||
elif torch.cuda.is_available():
|
||||
vram_after = torch.cuda.memory_allocated() / (1024**3)
|
||||
vram_reserved = torch.cuda.memory_reserved() / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -909,7 +574,6 @@ async def flush_memory(unload_model: bool = False):
|
||||
"unloaded_model": freed_model,
|
||||
"ram_after": round(ram_after, 2),
|
||||
"vram_after": round(vram_after, 2),
|
||||
"vram_reserved": round(vram_reserved, 2),
|
||||
}
|
||||
|
||||
|
||||
@@ -1041,7 +705,7 @@ def system_notifications():
|
||||
from core import run_sentinel
|
||||
|
||||
rec = run_sentinel.newest_record()
|
||||
if rec is not None and not rec[1] and run_sentinel.warrants_user_notice(rec[0]):
|
||||
if rec is not None and not rec[1]:
|
||||
record = rec[0]
|
||||
last = record.get("last_activity") or {}
|
||||
doing = f" Last activity: {last.get('kind')}." if last.get("kind") else ""
|
||||
@@ -1172,14 +836,6 @@ PERSISTENT_KEYS = {
|
||||
# the Rust sidecar reads OMNIVOICE_PORT at startup and the backend derives
|
||||
# the LAN-share/UI ports from the others.
|
||||
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
|
||||
# Per-job compute-time budgets (#1787). Both are captured at import time
|
||||
# by services/model_manager.py (GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S), so
|
||||
# a value saved here takes effect on the NEXT backend restart — same
|
||||
# contract as OMNIVOICE_PORT above. Restored into os.environ during the
|
||||
# "env_prefs" startup step (main.py), which runs before model_manager is
|
||||
# first imported ("ml_imports"), so the restored value is what the module
|
||||
# captures. The Settings UI must say so (RestartBadge).
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S",
|
||||
}
|
||||
|
||||
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
|
||||
@@ -1197,16 +853,6 @@ except Exception: # pragma: no cover — defensive: env panel > installer wirin
|
||||
# being set so a bad value never reaches uvicorn / the share listener.
|
||||
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
|
||||
|
||||
# Keys whose value is a wall-clock compute-time budget in seconds (#1787).
|
||||
# Validated the same way as _PORT_KEYS: reject anything that isn't a
|
||||
# positive number before it reaches services/model_manager.py. Upper bound is
|
||||
# generous — long enough that a legitimate multi-hour, audiobook-length CPU
|
||||
# render is never blocked — but still bounded, so a fat-fingered extra digit
|
||||
# (300 -> 3000000) can't turn a wedged job into one that silently occupies a
|
||||
# worker for days before the guard ever fires.
|
||||
_TIMEOUT_KEYS = {"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S"}
|
||||
_MAX_GENERATE_TIMEOUT_S = 21600.0 # 6 hours
|
||||
|
||||
|
||||
@router.post("/system/set-env")
|
||||
async def set_env_var(body: dict):
|
||||
@@ -1215,7 +861,7 @@ async def set_env_var(body: dict):
|
||||
Persistent keys (proxy, FFMPEG_PATH, translation provider keys, …) are
|
||||
saved to ``prefs.json`` so they survive backend restarts (restored at
|
||||
startup in ``main.py``). HF_TOKEN is persisted via
|
||||
``huggingface_hub.login()`` (and cleared with the shared token-file helper). Other keys
|
||||
``huggingface_hub.login()`` (and cleared via ``logout()``). Other keys
|
||||
are set on ``os.environ`` for the running process.
|
||||
|
||||
The loopback-origin gate that previously lived inline here is now applied
|
||||
@@ -1250,22 +896,6 @@ async def set_env_var(body: dict):
|
||||
status_code=400,
|
||||
detail=f"Invalid port for {key}: must be between 1024 and 65535.",
|
||||
)
|
||||
if key in _TIMEOUT_KEYS:
|
||||
try:
|
||||
timeout_n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid timeout for {key}: '{value}' is not a number.",
|
||||
)
|
||||
if not (0 < timeout_n <= _MAX_GENERATE_TIMEOUT_S):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Invalid timeout for {key}: must be greater than 0 "
|
||||
f"and at most {_MAX_GENERATE_TIMEOUT_S:.0f} seconds."
|
||||
),
|
||||
)
|
||||
os.environ[key] = value
|
||||
logger.info("Environment variable set (length=%d)", len(value))
|
||||
|
||||
@@ -1290,14 +920,14 @@ async def set_env_var(body: dict):
|
||||
# Mirror the persistence on clear — wipe the saved token file too.
|
||||
if key == "HF_TOKEN":
|
||||
try:
|
||||
from services.token_resolver import clear_hf_cli_tokens
|
||||
clear_hf_cli_tokens()
|
||||
logger.info("Local Hugging Face token files cleared")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Could not clear local Hugging Face token files") from None
|
||||
from huggingface_hub import logout as _hf_logout
|
||||
_hf_logout()
|
||||
logger.info("HF token cleared from $HF_HOME/token via logout()")
|
||||
except Exception as e:
|
||||
logger.warning("Could not clear HF token file: %s", e)
|
||||
|
||||
# HF_TOKEN persistence is handled above via huggingface_hub.login()/
|
||||
# clear_hf_cli_tokens() — it never touches prefs.json. Everything else in
|
||||
# logout() — it never touches prefs.json. Everything else in
|
||||
# PERSISTENT_KEYS (proxy, FFMPEG_PATH, translation provider keys, …) is
|
||||
# saved to prefs.json so it survives backend restarts (restored at
|
||||
# startup in main.py). Non-persistent keys stay process-local.
|
||||
@@ -1308,13 +938,7 @@ async def set_env_var(body: dict):
|
||||
else:
|
||||
prefs_delete(prefs_key)
|
||||
|
||||
# #1787 review fix: tell the caller up front when the value just saved is
|
||||
# being shadowed by an external env var — set at THIS process's startup,
|
||||
# before our own prefs restore ran, so it predicts the next restart too.
|
||||
# A response that just said {"set": True} let the Settings panel promise
|
||||
# a restart would apply a value that never will.
|
||||
from core.prefs import is_env_shadowed
|
||||
return {"key": key, "set": bool(value), "shadowed": is_env_shadowed(key)}
|
||||
return {"key": key, "set": bool(value)}
|
||||
|
||||
|
||||
@router.post("/clean-audio")
|
||||
@@ -1405,7 +1029,7 @@ def asr_backends():
|
||||
def hf_token_state():
|
||||
"""Return the 3-source HF token cascade state for the Settings UI
|
||||
(Wave 2 React panel consumes this). Never returns the raw token —
|
||||
only a masked preview and local presence; no outbound validation.
|
||||
only a masked preview, whoami username, and per-source validity.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
from services import token_resolver
|
||||
@@ -1453,10 +1077,7 @@ async def diagnostic_bundle(network: bool = Query(False, description="Include th
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/diagnose",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
@router.get("/system/diagnose")
|
||||
async def system_diagnose(
|
||||
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
|
||||
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
|
||||
|
||||
@@ -10,8 +10,7 @@ as they're generated. This unlocks:
|
||||
Protocol:
|
||||
→ Client sends JSON: {"text": "...", "voice": "profile_id", ...}
|
||||
← Server sends binary audio chunks (PCM16 @ 24kHz mono) as generated
|
||||
← Server sends JSON: {"type": "done", "duration_s": 4.2,
|
||||
"gen_time_s": 1.1, "ttfa_ms": 180.0, "rtf": 0.262}
|
||||
← Server sends JSON: {"type": "done", "duration_s": 4.2, "gen_time_s": 1.1}
|
||||
← Server sends JSON: {"type": "error", "detail": "..."}
|
||||
|
||||
The chunked delivery targets <100ms time-to-first-audio (TTFA) on warm models.
|
||||
@@ -34,30 +33,6 @@ logger = logging.getLogger("omnivoice.tts_stream")
|
||||
# Smaller chunks = lower latency but more WebSocket overhead.
|
||||
CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))
|
||||
|
||||
# Module seam for deterministic latency-contract tests. Keep every timing
|
||||
# sample on the same monotonic clock.
|
||||
_perf_counter = time.perf_counter
|
||||
|
||||
|
||||
async def _resolve_stream_backend(engine_id: str | None):
|
||||
"""Resolve the live-stream engine without bypassing host isolation."""
|
||||
from services.tts_backend import (
|
||||
OmniVoiceBackend,
|
||||
active_backend_id,
|
||||
get_active_tts_backend,
|
||||
get_backend_class,
|
||||
)
|
||||
|
||||
if engine_id:
|
||||
return get_backend_class(engine_id)()
|
||||
|
||||
cls = get_backend_class(active_backend_id())
|
||||
if cls is OmniVoiceBackend:
|
||||
from services.model_manager import get_model
|
||||
|
||||
return get_active_tts_backend(model=await get_model())
|
||||
return get_active_tts_backend()
|
||||
|
||||
|
||||
class StreamTTSRequest(BaseModel):
|
||||
"""Client request for streaming TTS."""
|
||||
@@ -87,11 +62,6 @@ async def ws_tts(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.info("TTS streaming WebSocket connected")
|
||||
|
||||
# Said once per socket, not once per utterance: a conversational client
|
||||
# sends many requests down one connection and a repeated notice would be
|
||||
# noise. See `_announce_local_only`.
|
||||
announced_local_only = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for a text request from the client
|
||||
@@ -110,48 +80,15 @@ async def ws_tts(websocket: WebSocket):
|
||||
})
|
||||
continue
|
||||
|
||||
t0 = _perf_counter()
|
||||
t0 = time.perf_counter()
|
||||
text = data["text"]
|
||||
|
||||
# Remote GPU: this socket stays on this machine, and says so.
|
||||
#
|
||||
# /generate's port trades progressive playback for the remote
|
||||
# render — the classic path was always a single wait, so spending
|
||||
# it on a faster GPU is a straight win. This route is the opposite
|
||||
# shape: it exists to put audio in the user's ear before the
|
||||
# sentence has finished synthesizing, and sending each utterance to
|
||||
# a worker would pay queue admission, a round trip and cold-load
|
||||
# risk per utterance, for the one surface where latency IS the
|
||||
# feature.
|
||||
#
|
||||
# Silence would be worse than the limitation: the header badge
|
||||
# would read "gpu2" while this machine does 100% of the work, the
|
||||
# same class of lie the op-aware picker exists to stop. Said once
|
||||
# per socket — a conversational client sends many requests down one
|
||||
# connection — and BEFORE engine resolution, so an engine that
|
||||
# cannot load still tells the user where it would have run.
|
||||
if not announced_local_only:
|
||||
announced_local_only = True
|
||||
try:
|
||||
from worker import routing as worker_routing
|
||||
|
||||
target = worker_routing.decide(op="tts")
|
||||
except Exception: # noqa: BLE001 — advisory; never break audio
|
||||
target = None
|
||||
if target is not None and target.remote:
|
||||
from core.scrub import scrub_text as _scrub
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "routing",
|
||||
"status": "local_stream",
|
||||
"reason": _scrub(
|
||||
f"{target.label} is your GPU target, but live "
|
||||
f"streaming runs on this machine"
|
||||
),
|
||||
})
|
||||
|
||||
try:
|
||||
# Resolve engine
|
||||
from services.tts_backend import (
|
||||
get_active_tts_backend,
|
||||
get_backend_class,
|
||||
)
|
||||
engine_id = data.get("engine")
|
||||
# #1224: leave a breadcrumb when memory is already tight before
|
||||
# a heavy load. /generate has done this since the 16 GB-Mac
|
||||
@@ -167,21 +104,24 @@ 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 +
|
||||
# close on `unavailable`, a one-time `routing` frame on
|
||||
# cpu_fallback / accelerated-with-caveat (before any audio).
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import (
|
||||
routing_notice,
|
||||
runtime_compute_profile_async,
|
||||
)
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
from core.scrub import scrub_text
|
||||
_routing = await runtime_compute_profile_async(
|
||||
backend, detect_host_caps()
|
||||
)
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0))
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
@@ -276,11 +216,6 @@ async def ws_tts(websocket: WebSocket):
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
|
||||
def _generate(sentence_text):
|
||||
# Timed INSIDE the pool worker: the guarded dispatch below
|
||||
# can queue behind other jobs, and queue wait is not
|
||||
# synthesis (review on #1620) — under contention it would
|
||||
# inflate rtf without the engine slowing at all.
|
||||
_synth_t0 = _perf_counter()
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.watermark import mark_synthetic
|
||||
wav = backend.generate(sentence_text, **kw)
|
||||
@@ -302,19 +237,12 @@ async def ws_tts(websocket: WebSocket):
|
||||
# watermark._iter_chunks), which is inherent to marking
|
||||
# ultra-short clips, not a coverage gap.
|
||||
wav = mark_synthetic(wav, sr_actual, context="tts_stream.sentence")
|
||||
return wav, sr_actual, _perf_counter() - _synth_t0
|
||||
return wav, sr_actual
|
||||
|
||||
import torch
|
||||
total_samples = 0
|
||||
sr = backend.sample_rate
|
||||
started = False
|
||||
first_audio_at: float | None = None
|
||||
# Synthesis time only. The wall clock below also carries socket
|
||||
# delivery and the per-chunk event-loop yields, so deriving RTF
|
||||
# from it reports "how slow was the client" as if it were engine
|
||||
# throughput — on a slow consumer that inflates RTF without the
|
||||
# engine having changed at all.
|
||||
synth_time = 0.0
|
||||
|
||||
for sentence in sentences:
|
||||
# Bounded + pool-reset on hang so a wedged generate can't
|
||||
@@ -324,12 +252,11 @@ async def ws_tts(websocket: WebSocket):
|
||||
# Length-scaled budget per sentence (#1190) — the flat 300s
|
||||
# default is gone from every dispatch.
|
||||
from services.model_manager import generate_timeout_s
|
||||
wav_tensor, sr, sentence_synth_s = await run_on_gpu_pool_guarded(
|
||||
wav_tensor, sr = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_generate, sentence),
|
||||
what="TTS generate",
|
||||
timeout=generate_timeout_s(sentence, engine=backend),
|
||||
timeout=generate_timeout_s(sentence),
|
||||
)
|
||||
synth_time += sentence_synth_s
|
||||
|
||||
if not started:
|
||||
# Send metadata after the first generation so
|
||||
@@ -356,49 +283,25 @@ async def ws_tts(websocket: WebSocket):
|
||||
end = min(sent_samples + CHUNK_SAMPLES, n_samples)
|
||||
chunk = pcm_bytes[sent_samples * 2: end * 2]
|
||||
await websocket.send_bytes(chunk)
|
||||
if first_audio_at is None:
|
||||
# TTFA ends when the first audio bytes have been
|
||||
# handed to the socket. The previous log used the
|
||||
# whole-render duration and called it TTFA.
|
||||
first_audio_at = _perf_counter()
|
||||
sent_samples = end
|
||||
# Yield to event loop between chunks for responsiveness
|
||||
await asyncio.sleep(0)
|
||||
total_samples += n_samples
|
||||
|
||||
finished_at = _perf_counter()
|
||||
wall_time_raw = max(0.0, finished_at - t0)
|
||||
synth_time_raw = max(0.0, synth_time)
|
||||
gen_time = round(wall_time_raw, 3)
|
||||
gen_time = round(time.perf_counter() - t0, 3)
|
||||
duration = round(total_samples / sr, 3)
|
||||
ttfa_ms = (
|
||||
round(max(0.0, first_audio_at - t0) * 1000.0, 1)
|
||||
if first_audio_at is not None
|
||||
else None
|
||||
)
|
||||
# RTF is a render metric: synthesis seconds per audio second.
|
||||
rtf = (
|
||||
round(synth_time_raw / (total_samples / sr), 3)
|
||||
if total_samples > 0
|
||||
else None
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "done",
|
||||
"duration_s": duration,
|
||||
"gen_time_s": gen_time,
|
||||
"ttfa_ms": ttfa_ms,
|
||||
"rtf": rtf,
|
||||
"samples": total_samples,
|
||||
"sample_rate": sr,
|
||||
"engine": backend.id,
|
||||
})
|
||||
logger.info(
|
||||
"TTS stream: %.1fs audio in %.1fs (TTFA=%s, RTF=%s)",
|
||||
duration,
|
||||
gen_time,
|
||||
f"{ttfa_ms:.0f}ms" if ttfa_ms is not None else "n/a",
|
||||
f"{rtf:.3f}" if rtf is not None else "n/a",
|
||||
"TTS stream: %.1fs audio in %.1fs (TTFA=%.0fms)",
|
||||
duration, gen_time, gen_time * 1000,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
"""Speech-to-speech voice changer — Studio's Convert method (POST /convert).
|
||||
|
||||
The user drops (or records) a source clip, picks an existing voice profile,
|
||||
and gets the same words back in that profile's voice: the active ASR backend
|
||||
transcribes the clip (no word timestamps — the text is all we need), the
|
||||
active TTS engine re-synthesizes it conditioned on the profile's reference
|
||||
audio, and — by default — the take is pitch-preservingly time-stretched
|
||||
(ffmpeg atempo, clamped to one well-behaved 0.5–2.0 stage) so it lands near
|
||||
the source clip's duration.
|
||||
|
||||
Deliberately reuses the /generate choke points instead of re-deriving them:
|
||||
|
||||
* profile row → conditioning via ``generation._resolve_profile_conditioning``
|
||||
(lock wins, ``kind`` authoritative, #533 language fill),
|
||||
* engine resolution via ``services.tts_backend.resolve_generation_backend``
|
||||
(never a silent OmniVoice fallback; ``require_cloning=True`` refuses
|
||||
clone-less engines with the actionable switch-engine message),
|
||||
* synthesis via ``generation._run_backend_inference`` on the guarded GPU
|
||||
pool (#730 bound + reset; busy/timeout → retryable 503),
|
||||
* provenance + persistence via ``services.watermark.mark_synthetic_async``
|
||||
and ``generation._finalize_generation`` (watermark → WAV in OUTPUTS_DIR →
|
||||
history row → retention prune), marked AFTER the stretch so the take users
|
||||
keep carries exactly one whole-take mark.
|
||||
|
||||
Local-first: no network calls; ASR-model-less installs get the same typed
|
||||
409 download CTA as /transcribe; a backend mid-shutdown surfaces the global
|
||||
503 ``[shutting_down]`` (ModelLoadInterruptedByShutdown → main.py handler).
|
||||
Reachability matches /generate: loopback bind by default, with the shared
|
||||
network-share PIN / API-key middleware gating any non-loopback exposure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.convert")
|
||||
|
||||
#: ffmpeg's atempo filter is well-behaved in [0.5, 2.0] per stage. Convert
|
||||
#: clamps to ONE stage by design: needing more than 2× either way means the
|
||||
#: synthesized speech differs so much from the source that "matching" it
|
||||
#: would produce chipmunk/slow-motion artifacts worse than the mismatch.
|
||||
ATEMPO_MIN = 0.5
|
||||
ATEMPO_MAX = 2.0
|
||||
|
||||
#: Within this relative tolerance the durations already match — stretching
|
||||
#: would resample the whole take for an inaudible gain.
|
||||
_MATCH_TOLERANCE = 0.02
|
||||
|
||||
#: Convert clips are short conversational inputs, not long-form media. Stream
|
||||
#: them to disk in bounded chunks so a network-share client cannot make the
|
||||
#: backend materialize an arbitrarily large multipart upload in memory.
|
||||
_MAX_SOURCE_AUDIO_BYTES = 64 * 1024 * 1024
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _copy_source_upload(audio: UploadFile, destination) -> int:
|
||||
"""Stream ``audio`` into ``destination`` with the Convert upload cap."""
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await audio.read(_UPLOAD_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
return total
|
||||
total += len(chunk)
|
||||
if total > _MAX_SOURCE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail="Source audio is too large (maximum 64 MB).",
|
||||
)
|
||||
destination.write(chunk)
|
||||
|
||||
|
||||
def _clamped_tempo_ratio(tts_duration_s: float, source_duration_s: float) -> "float | None":
|
||||
"""The atempo ratio that fits the take into the source duration, or None.
|
||||
|
||||
ratio > 1 speeds the take up (it came out longer than the source),
|
||||
ratio < 1 slows it down. Clamped to a single atempo stage's [0.5, 2.0];
|
||||
None when either duration is unusable or they already match.
|
||||
"""
|
||||
if not source_duration_s or source_duration_s <= 0:
|
||||
return None
|
||||
if not tts_duration_s or tts_duration_s <= 0:
|
||||
return None
|
||||
ratio = tts_duration_s / source_duration_s
|
||||
if abs(ratio - 1.0) <= _MATCH_TOLERANCE:
|
||||
return None
|
||||
return min(ATEMPO_MAX, max(ATEMPO_MIN, ratio))
|
||||
|
||||
|
||||
async def _match_source_duration(audio_tensor, sample_rate: int, source_duration_s: float):
|
||||
"""Best-effort pitch-preserving stretch of the take toward the source
|
||||
clip's duration. Returns the input unchanged when no stretch is needed
|
||||
or ffmpeg fails — a duration mismatch is better than a failed convert."""
|
||||
n_samples = int(audio_tensor.shape[-1])
|
||||
ratio = _clamped_tempo_ratio(n_samples / sample_rate, source_duration_s)
|
||||
if ratio is None:
|
||||
return audio_tensor
|
||||
target_samples = max(1, int(round(n_samples / ratio)))
|
||||
from services.ffmpeg_utils import _pitch_preserving_stretch
|
||||
try:
|
||||
return await _pitch_preserving_stretch(audio_tensor, target_samples, sample_rate)
|
||||
except Exception as e: # noqa: BLE001 — stretch is opt-in polish, never fatal
|
||||
logger.warning("duration match skipped — atempo stretch failed: %s", e)
|
||||
return audio_tensor
|
||||
|
||||
|
||||
async def _transcribe_source(tmp_path: str, *, source_lease=None) -> dict:
|
||||
"""Active-ASR transcription of the uploaded clip (no word timestamps).
|
||||
|
||||
Mirrors POST /transcribe: typed 409 + download CTA before any backend
|
||||
is constructed (never a silent multi-GB auto-download), the guarded GPU
|
||||
pool dispatch (#730), 504 on timeout, and the same 409 when the loader
|
||||
degrades onto an engine with no weights on disk (#1185).
|
||||
"""
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
|
||||
missing = await asyncio.to_thread(asr_model_missing_error, purpose="transcribe")
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
|
||||
def _run():
|
||||
# `load_*`, not `get_*`: the loader runs ensure_loaded() and degrades
|
||||
# past an engine whose deep import chain is broken (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
return backend.transcribe(tmp_path, word_timestamps=False)
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
release = source_lease.acquire() if source_lease is not None else None
|
||||
abandoned = False
|
||||
try:
|
||||
return await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
_run,
|
||||
what="Voice convert",
|
||||
on_abandon=release,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# The guard now owns the lease token until the native worker drains.
|
||||
abandoned = True
|
||||
raise
|
||||
except ASRTimeoutError as e:
|
||||
abandoned = True
|
||||
logger.warning("Convert transcription timed out: %s", e)
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
finally:
|
||||
if release is not None and not abandoned:
|
||||
release()
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_speech(
|
||||
audio: UploadFile = File(...),
|
||||
profile_id: str = Form(...),
|
||||
match_duration: bool = Form(True),
|
||||
):
|
||||
"""Convert a spoken clip into an existing voice profile's voice.
|
||||
|
||||
Multipart form: ``audio`` (the source clip), ``profile_id`` (an existing
|
||||
voice profile), optional ``match_duration`` (default on — atempo the take
|
||||
toward the source clip's length, clamped to 0.5–2.0×).
|
||||
|
||||
Returns JSON ``{audio_url, text, duration_s, id}`` — the take is saved to
|
||||
OUTPUTS_DIR and served from the ``/audio`` mount like every other take.
|
||||
"""
|
||||
from core.db import db_conn
|
||||
from api.routers.generation import _resolve_profile_conditioning, _TempReferenceLease
|
||||
|
||||
# ── Profile first: strict 404, unlike /generate's silent skip — Convert
|
||||
# has no meaning without a target voice.
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (profile_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="That voice profile doesn't exist. It may have been deleted from another tab.",
|
||||
)
|
||||
cond = _resolve_profile_conditioning(row)
|
||||
|
||||
# ── Save the upload before loading an engine. Every ASR backend (and
|
||||
# ffprobe) needs a file path; the bounded streaming copy rejects oversized
|
||||
# network-share requests without materializing them in process memory or
|
||||
# starting heavyweight model work.
|
||||
ext = os.path.splitext(audio.filename or "audio.wav")[1] or ".wav"
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
|
||||
source_lease = None
|
||||
try:
|
||||
try:
|
||||
await _copy_source_upload(audio, tmp)
|
||||
finally:
|
||||
tmp.close()
|
||||
source_lease = _TempReferenceLease(tmp.name)
|
||||
|
||||
# ── Engine gate before ASR/TTS work: the shared resolver refuses a
|
||||
# clone-less engine with the actionable switch-engine message (→ 400),
|
||||
# and a backend mid-shutdown raises ModelLoadInterruptedByShutdown out
|
||||
# of the model load → the global 503 [shutting_down] handler.
|
||||
from services.tts_backend import active_backend_id, resolve_generation_backend
|
||||
try:
|
||||
backend = await resolve_generation_backend(
|
||||
require_cloning=True, cloning_purpose="voice conversion",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
result = await _transcribe_source(tmp.name, source_lease=source_lease)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
text = result.get("text", "")
|
||||
if not text and segments:
|
||||
text = " ".join(s.get("text", "") for s in segments).strip()
|
||||
# Same final-text hygiene as /transcribe: strip Whisper hallucination
|
||||
# loops, then deterministic polish (leading capital + terminal
|
||||
# punctuation) so the TTS input reads as typed text.
|
||||
from services.refinement import collapse_repetitive_artifacts
|
||||
from services.text_polish import polish_text
|
||||
text = polish_text(collapse_repetitive_artifacts(text))
|
||||
if not text or not text.strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"No speech was recognized in the source clip, so there is "
|
||||
"nothing to convert. Record or drop a clip with clear, "
|
||||
"audible speech and try again."
|
||||
),
|
||||
)
|
||||
|
||||
# #308/#1032 parity with /generate: a clone profile saved without a
|
||||
# transcript conditions better when its reference clip is transcribed,
|
||||
# and that transcript is cached onto the row so it happens ONCE, not
|
||||
# per convert. Best-effort exactly like /generate — a timeout/failure
|
||||
# degrades to ref_text=None and the engine's own fallback. The ASR
|
||||
# model is already warm here (the source transcribe above just used it).
|
||||
if cond["ref_audio_path"] and not cond["ref_text"]:
|
||||
from api.routers.generation import (
|
||||
_generate_timeout_s,
|
||||
_persist_profile_ref_text,
|
||||
)
|
||||
from services.asr_backend import transcribe_reference
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
try:
|
||||
cond["ref_text"] = await run_on_gpu_pool_guarded(
|
||||
functools.partial(transcribe_reference, cond["ref_audio_path"]),
|
||||
what="Reference transcribe",
|
||||
timeout=_generate_timeout_s(""),
|
||||
)
|
||||
except TimeoutError as e:
|
||||
logger.warning(
|
||||
"reference transcribe hung (%s); using engine ASR fallback", e,
|
||||
)
|
||||
cond["ref_text"] = None
|
||||
if cond["ref_text"] and cond["persist_ref_text"]:
|
||||
_persist_profile_ref_text(profile_id, cond["ref_text"])
|
||||
|
||||
# Source duration for the optional match: the container's own length
|
||||
# (ffprobe), falling back to the last ASR segment end. Best-effort —
|
||||
# None just skips the stretch.
|
||||
source_duration_s = None
|
||||
if match_duration:
|
||||
from services.ffmpeg_utils import probe_duration
|
||||
source_duration_s = await probe_duration(
|
||||
tmp.name, allowed_root=os.path.dirname(tmp.name),
|
||||
)
|
||||
if not source_duration_s and segments:
|
||||
source_duration_s = max((s.get("end", 0) or 0) for s in segments) or None
|
||||
|
||||
# ── Same text choke point as /generate: engine-agnostic normalization
|
||||
# (numbers→words, junk strip) on the fully resolved language.
|
||||
from services.text_normalization import normalize_for_tts
|
||||
language = cond["language"]
|
||||
text = normalize_for_tts(text, language)
|
||||
|
||||
used_seed = cond["seed"]
|
||||
if used_seed is None:
|
||||
import random
|
||||
used_seed = random.randint(0, 2**31 - 1)
|
||||
|
||||
from api.routers.generation import (
|
||||
_finalize_generation,
|
||||
_generate_timeout_s,
|
||||
_run_backend_inference,
|
||||
)
|
||||
from services.model_manager import (
|
||||
GpuJobTimeoutError,
|
||||
GpuPoolBusyError,
|
||||
run_on_gpu_pool_guarded,
|
||||
)
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import runtime_compute_profile_async
|
||||
compute_profile = await runtime_compute_profile_async(
|
||||
backend, detect_host_caps()
|
||||
)
|
||||
if compute_profile["routing_status"] == "unavailable":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=compute_profile["routing_reason"],
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
from services.performance_profiles import tts_defaults
|
||||
_profile_defaults = tts_defaults(active_backend_id())
|
||||
_render = functools.partial(
|
||||
_run_backend_inference,
|
||||
backend, text, language, cond["ref_audio_path"], cond["ref_text"],
|
||||
cond["instruct"],
|
||||
None, # duration — the model picks; match_duration owns pacing
|
||||
_profile_defaults.get("num_step", 16), 2.0,
|
||||
1.0, # speed
|
||||
True, _profile_defaults.get("postprocess_output", True),
|
||||
used_seed,
|
||||
)
|
||||
try:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_render,
|
||||
what="Voice convert",
|
||||
timeout=_generate_timeout_s(
|
||||
text,
|
||||
execution_device=compute_profile["effective_device"],
|
||||
min_vram_gb=compute_profile["min_vram_gb"],
|
||||
hardware_family=compute_profile.get("runtime_hardware_family"),
|
||||
vram_gb=compute_profile.get("runtime_vram_gb"),
|
||||
),
|
||||
min_vram_gb=compute_profile["min_vram_gb"],
|
||||
)
|
||||
except GpuPoolBusyError as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail=str(e),
|
||||
headers={"Retry-After": str(e.retry_after),
|
||||
"X-OmniVoice-Retryable": "true"},
|
||||
) from e
|
||||
except GpuJobTimeoutError as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail=str(e),
|
||||
headers={"Retry-After": "30", "X-OmniVoice-Retryable": "true"},
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
sample_rate = backend.sample_rate
|
||||
|
||||
if match_duration and source_duration_s:
|
||||
audio_tensor = await _match_source_duration(
|
||||
audio_tensor, sample_rate, source_duration_s,
|
||||
)
|
||||
|
||||
# Provenance mark AFTER the stretch (one whole-take mark on the audio
|
||||
# the user actually keeps), then the shared finalize tail — WAV in
|
||||
# OUTPUTS_DIR, self-healing history row, retention prune, event emit.
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, sample_rate, context="convert.finalize",
|
||||
)
|
||||
_, meta = await _finalize_generation(
|
||||
audio_tensor, sample_rate, text=text, history_mode="convert",
|
||||
ref_audio_path=cond["ref_audio_path"], language=language,
|
||||
instruct=cond["instruct"], resolved_profile_id=profile_id,
|
||||
used_seed=used_seed, start_time=start_time,
|
||||
already_marked=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": meta["id"],
|
||||
"audio_url": f"/audio/{meta['filename']}",
|
||||
"text": text,
|
||||
"duration_s": meta["duration"],
|
||||
"gen_time_s": meta["gen_time"],
|
||||
}
|
||||
finally:
|
||||
if source_lease is not None:
|
||||
source_lease.finish_request()
|
||||
else:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1,831 +0,0 @@
|
||||
"""Remote worker management API.
|
||||
|
||||
Deliberately small. The council's warning about the original design was that
|
||||
seven strategies times three execution modes times priorities times weights
|
||||
times per-model concurrency is a configuration surface nobody can test and
|
||||
every knob is a compatibility promise forever. So this exposes what a user
|
||||
actually needs to run their other GPU: see workers, add one, name it, prefer
|
||||
one, pause one, remove one.
|
||||
|
||||
Two things here are not conveniences and must not be softened:
|
||||
|
||||
* **Consent is explicit and per worker.** Audio, reference voices, and text
|
||||
leave the machine for a worker, so each one is approved individually. There
|
||||
is no global "trust all workers".
|
||||
* **A token is shown exactly once.** Only its hash is stored, so it cannot be
|
||||
re-displayed — which is the point.
|
||||
|
||||
One endpoint here is not part of that surface: `POST /workers/tasks` submits a
|
||||
single task and waits for it, and exists only because the scheduler otherwise
|
||||
has no caller at all outside the tests. It is marked dev-only everywhere it
|
||||
appears and is replaced by the GPU gateway.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from worker import registry, routing, service
|
||||
from worker.async_utils import drain_task, to_thread_and_defer_cancellation
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
|
||||
# How often an awaiting request checks whether its caller is still there.
|
||||
# Starlette does not cancel a handler when the client hangs up, so polling is
|
||||
# the only way the "cancel what nobody is waiting for" rule can fire before
|
||||
# the task's own deadline does.
|
||||
_DISCONNECT_POLL_SECONDS = 1.0
|
||||
|
||||
# Management is admin-gated: these endpoints mint join tokens and revoke
|
||||
# machines, so Docker writes require the API key while desktop stays loopback.
|
||||
router = APIRouter(prefix="/workers", tags=["workers"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class EnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
label: str = Field("", max_length=120)
|
||||
endpoint: str = Field("", max_length=256)
|
||||
ttl_seconds: int = Field(900, ge=60, le=24 * 3600)
|
||||
|
||||
|
||||
class JoinRequest(BaseModel):
|
||||
"""A join code, as pasted (or scanned) from the control plane."""
|
||||
|
||||
token: str = Field(..., max_length=4096)
|
||||
|
||||
|
||||
class TargetRequest(BaseModel):
|
||||
"""`local`, or the id of an enrolled worker."""
|
||||
|
||||
target: str = Field(..., max_length=64)
|
||||
|
||||
|
||||
class WorkerUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=120)
|
||||
enabled: bool | None = None
|
||||
priority: int | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class SubmitTaskRequest(BaseModel):
|
||||
"""One unit of work for a remote worker. **Dev only** — see `submit_task`."""
|
||||
|
||||
engine: str = Field(..., max_length=64)
|
||||
operation: str = Field("tts", max_length=32)
|
||||
model_id: str = Field("", max_length=128)
|
||||
params: dict = Field(default_factory=dict)
|
||||
# Mandatory, and deliberately without a default: the sweeper fails a task
|
||||
# on its deadline only while it is QUEUED, so one submitted without a
|
||||
# deadline while no worker is online waits forever with nothing left in
|
||||
# the system that would ever time it out.
|
||||
deadline_seconds: float = Field(..., gt=0, le=6 * 3600)
|
||||
idempotency_key: str | None = Field(None, max_length=128)
|
||||
|
||||
|
||||
class _ClientGone(Exception):
|
||||
"""The caller hung up while its task was still running."""
|
||||
|
||||
|
||||
class _WaitExpired(Exception):
|
||||
"""The task did not reach a terminal state inside its deadline."""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_workers() -> dict:
|
||||
"""Everything the workers panel renders, in one call."""
|
||||
return service.control_plane.snapshot()
|
||||
|
||||
|
||||
@router.get("/target")
|
||||
def get_target(op: str = "") -> dict:
|
||||
"""What the GPU picker shows: the choice, the resolved answer, the options.
|
||||
|
||||
`active` is the same answer the generation path uses, so the badge cannot
|
||||
claim work goes somewhere the router will not send it. Pass `op` for the
|
||||
surface being rendered — omitting it answers for the target as a whole,
|
||||
which is what the picker's own menu asks.
|
||||
"""
|
||||
return routing.status(op=op.strip() or None)
|
||||
|
||||
|
||||
@router.get("/runtime")
|
||||
async def get_runtime(engine: str = "", op: str = "tts") -> dict:
|
||||
"""Runtime/model facts for the machine that will execute this operation."""
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
|
||||
return await gpu_gateway.status(
|
||||
engine=engine.strip() or None,
|
||||
op=op.strip() or "tts",
|
||||
control_plane=service.control_plane,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/target")
|
||||
def set_target(request: TargetRequest) -> dict:
|
||||
"""Choose where work runs. Exactly one target is active at a time."""
|
||||
chosen = request.target.strip() or routing.LOCAL
|
||||
if chosen != routing.LOCAL:
|
||||
worker = registry.get(chosen)
|
||||
if worker is None or worker.revoked:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
routing.set_target_id(chosen)
|
||||
return routing.status()
|
||||
|
||||
|
||||
@router.post("/enabled")
|
||||
async def set_enabled(request: EnableRequest) -> dict:
|
||||
"""Turn the feature on or off.
|
||||
|
||||
Off means off: the control plane stops, the listening socket closes, and
|
||||
the app is exactly what it was before the toggle existed.
|
||||
"""
|
||||
service.set_remote_workers_enabled(request.enabled)
|
||||
if request.enabled:
|
||||
try:
|
||||
await service.control_plane.start()
|
||||
except Exception as exc:
|
||||
service.control_plane.startup_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
else:
|
||||
await service.control_plane.stop()
|
||||
return service.control_plane.snapshot()
|
||||
|
||||
|
||||
@router.get("/agent")
|
||||
def agent_status() -> dict:
|
||||
"""The other side of the same feature: is THIS machine lending its GPU?
|
||||
|
||||
Separate from `GET /workers`, which answers for the control plane. A
|
||||
machine can legitimately be both — a desktop that borrows a laptop's GPU
|
||||
and lends its own to a colleague — so neither status can stand in for the
|
||||
other.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.get("/agent/readiness", include_in_schema=False, response_model=None)
|
||||
def agent_readiness() -> JSONResponse:
|
||||
"""Container readiness: 200 only after this process registered as a worker."""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
readiness = worker_agent.agent.readiness()
|
||||
return JSONResponse(
|
||||
status_code=200 if readiness["ready"] else 503,
|
||||
content=readiness,
|
||||
headers={} if readiness["ready"] else {"Retry-After": "2"},
|
||||
)
|
||||
|
||||
|
||||
def _refuse_when_env_pinned(worker_agent) -> None:
|
||||
"""OMNIVOICE_WORKER_MODE wins over the setting everywhere else.
|
||||
|
||||
`worker_mode_enabled()` reads the variable first and `status()` reports the
|
||||
machine as env-pinned, so a route that changed worker mode anyway would
|
||||
contradict both: it writes a setting nothing consults, and the next restart
|
||||
undoes whatever the user just saw happen.
|
||||
"""
|
||||
if worker_agent.agent.status()["env_pinned"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"OMNIVOICE_WORKER_MODE controls this machine's worker mode. Unset it "
|
||||
"and restart VoiceStudio to manage it from here."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _finish_cleanup(awaitable):
|
||||
"""Run rollback to completion even if its HTTP task was cancelled."""
|
||||
task = asyncio.create_task(awaitable)
|
||||
await drain_task(task)
|
||||
return task.result()
|
||||
|
||||
|
||||
async def _set_worker_mode(worker_agent, enabled: bool) -> None:
|
||||
_result, cancelled = await to_thread_and_defer_cancellation(
|
||||
worker_agent.set_worker_mode_enabled, enabled
|
||||
)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
|
||||
async def _restore_agent_transaction(
|
||||
worker_agent, previous: dict, *, was_running: bool
|
||||
) -> None:
|
||||
"""Restore durable enrollment/settings and the exact prior live state."""
|
||||
try:
|
||||
await _finish_cleanup(worker_agent.agent.stop())
|
||||
await _finish_cleanup(worker_agent.restore_enrollment(previous))
|
||||
if was_running and not worker_agent.agent.running:
|
||||
await _finish_cleanup(worker_agent.agent.start())
|
||||
elif not was_running and worker_agent.agent.running:
|
||||
await _finish_cleanup(worker_agent.agent.stop())
|
||||
except worker_agent.EnrollmentRollbackError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
message = (
|
||||
"The previous worker state could not be restored safely. "
|
||||
"Worker mode remains stopped; fix its enrollment/settings storage, then retry."
|
||||
)
|
||||
with contextlib.suppress(BaseException):
|
||||
await _finish_cleanup(worker_agent.agent.stop())
|
||||
worker_agent.agent.last_error = message
|
||||
raise worker_agent.EnrollmentRollbackError(message) from exc
|
||||
|
||||
|
||||
def _raise_agent_transaction_failure(
|
||||
worker_agent, operation: BaseException, rollback: BaseException | None
|
||||
) -> None:
|
||||
if isinstance(operation, asyncio.CancelledError):
|
||||
if rollback is not None:
|
||||
logger.error(
|
||||
"Worker rollback failed during request cancellation",
|
||||
exc_info=(type(rollback), rollback, rollback.__traceback__),
|
||||
)
|
||||
raise operation
|
||||
if rollback is not None:
|
||||
raise HTTPException(status_code=409, detail=str(rollback)) from rollback
|
||||
if isinstance(operation, Exception):
|
||||
worker_agent.agent.last_error = str(operation)
|
||||
raise HTTPException(status_code=409, detail=str(operation)) from operation
|
||||
raise operation
|
||||
|
||||
|
||||
@router.post("/agent/join")
|
||||
async def join_control_plane(request: JoinRequest) -> dict:
|
||||
"""Redeem a join code and start working for that control plane.
|
||||
|
||||
This is the endpoint that makes the feature reachable. Joining used to mean
|
||||
setting OMNIVOICE_WORKER_MODE and OMNIVOICE_WORKER_TOKEN in the environment
|
||||
and relaunching the app — a step most users will never take, on the machine
|
||||
that is usually the least convenient to configure by hand.
|
||||
|
||||
The code is single-use and short-lived, so a failure here is nearly always
|
||||
"expired" or "wrong address"; it is returned verbatim rather than as a bare
|
||||
409, because the user's next action depends on which one it was.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
token = request.token.strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=422, detail="Paste the join code first.")
|
||||
# Same rule as the toggle below: joining ENABLES worker mode, so under
|
||||
# OMNIVOICE_WORKER_MODE it would write a setting the rest of the app
|
||||
# ignores — and with the variable set to 0, hand the user a machine that
|
||||
# says it joined and never lends anything (CodeRabbit).
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
try:
|
||||
previous, cancelled = await to_thread_and_defer_cancellation(
|
||||
worker_agent.snapshot_enrollment
|
||||
)
|
||||
except worker_agent.EnrollmentStateError as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
was_running = worker_agent.agent.running
|
||||
|
||||
# A rejoin stops a working agent before the replacement is accepted.
|
||||
# Stop, acceptance and the durable setting are one transaction: every
|
||||
# failure, including cancellation, restores both trust and live state.
|
||||
try:
|
||||
await worker_agent.agent.stop()
|
||||
await worker_agent.agent.start(token_text=token)
|
||||
# Success is the control plane ACCEPTING this worker, not the
|
||||
# connection being scheduled — see wait_until_registered.
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
await _set_worker_mode(worker_agent, True)
|
||||
except BaseException as exc:
|
||||
rollback_exc = None
|
||||
try:
|
||||
await _restore_agent_transaction(
|
||||
worker_agent, previous, was_running=was_running
|
||||
)
|
||||
except BaseException as rollback_error:
|
||||
rollback_exc = rollback_error
|
||||
_raise_agent_transaction_failure(worker_agent, exc, rollback_exc)
|
||||
worker_agent.agent.last_error = ""
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.post("/agent/enabled")
|
||||
async def set_agent_enabled(request: EnableRequest) -> dict:
|
||||
"""Start or stop lending this machine, without forgetting the enrollment.
|
||||
|
||||
Off stops the agent and clears the setting, so nothing dials out; the
|
||||
pinned certificate stays, which is what lets "on" resume without asking for
|
||||
another code.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
try:
|
||||
previous, cancelled = await to_thread_and_defer_cancellation(
|
||||
worker_agent.snapshot_enrollment
|
||||
)
|
||||
except worker_agent.EnrollmentStateError as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
was_running = worker_agent.agent.running
|
||||
|
||||
try:
|
||||
if request.enabled:
|
||||
await worker_agent.agent.start()
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
await _set_worker_mode(worker_agent, True)
|
||||
else:
|
||||
await worker_agent.agent.stop()
|
||||
await _set_worker_mode(worker_agent, False)
|
||||
except BaseException as exc:
|
||||
rollback_exc = None
|
||||
try:
|
||||
await _restore_agent_transaction(
|
||||
worker_agent, previous, was_running=was_running
|
||||
)
|
||||
except BaseException as rollback_error:
|
||||
rollback_exc = rollback_error
|
||||
_raise_agent_transaction_failure(worker_agent, exc, rollback_exc)
|
||||
worker_agent.agent.last_error = ""
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.post("/enrollments")
|
||||
def create_enrollment(request: EnrollRequest) -> dict:
|
||||
"""Mint a single-use join token.
|
||||
|
||||
The plaintext is returned once and never stored — the response is the only
|
||||
time it exists outside the worker that redeems it.
|
||||
"""
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Remote workers are turned off. Enable them in Settings → System → Remote workers first.",
|
||||
)
|
||||
try:
|
||||
token = service.control_plane.create_enrollment(
|
||||
endpoint=request.endpoint,
|
||||
label=request.label,
|
||||
ttl_seconds=request.ttl_seconds,
|
||||
)
|
||||
except service.EndpointCertificateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"token": token.encode(),
|
||||
"endpoint": token.endpoint,
|
||||
"fingerprint": token.cert_fingerprint,
|
||||
"expires_at": token.expires_at,
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
def _persist_worker_update(
|
||||
worker_id: str, request: WorkerUpdate
|
||||
):
|
||||
"""Write policy on a worker thread; live publication stays loop-owned."""
|
||||
return registry.update_policy(
|
||||
worker_id,
|
||||
name=request.name,
|
||||
enabled=request.enabled,
|
||||
priority=request.priority,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{worker_id}")
|
||||
async def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
|
||||
pool = service.control_plane.pool if service.control_plane.running else None
|
||||
live = None
|
||||
was_pending = False
|
||||
if pool is not None:
|
||||
# Quiesce dispatch before releasing authority for the SQLite write.
|
||||
# The publication after the await restores the exact prior state, so a
|
||||
# concurrent registration handoff remains quiesced for its own reason.
|
||||
with registry.authority_guard():
|
||||
live = pool.get(worker_id)
|
||||
if live is not None:
|
||||
was_pending = live.registration_pending
|
||||
live.registration_pending = True
|
||||
updated = None
|
||||
cancelled = False
|
||||
try:
|
||||
updated, cancelled = await to_thread_and_defer_cancellation(
|
||||
_persist_worker_update, worker_id, request
|
||||
)
|
||||
finally:
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
if updated is not None:
|
||||
# Pool state, including the cached record the scheduler
|
||||
# reads, belongs to the app's event loop.
|
||||
pool.refresh_record(updated)
|
||||
current = pool.get(worker_id)
|
||||
if current is live:
|
||||
current.registration_pending = was_pending
|
||||
if updated is None:
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return updated.to_dict()
|
||||
|
||||
|
||||
@router.post("/{worker_id}/consent")
|
||||
def grant_consent(worker_id: str) -> dict:
|
||||
"""Record the user's explicit yes to sending their audio to this machine."""
|
||||
if registry.get(worker_id) is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
registry.grant_consent(worker_id)
|
||||
worker = registry.get(worker_id)
|
||||
return worker.to_dict() if worker else {}
|
||||
|
||||
|
||||
@router.post("/{worker_id}/resume")
|
||||
async def clear_breaker(worker_id: str) -> dict:
|
||||
"""Clear a paused worker's circuit breakers.
|
||||
|
||||
The user fixed the machine and knows it — a breaker with no manual clear is
|
||||
the quarantine trap the reputation system had.
|
||||
"""
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
breakers = service.control_plane.pool.breakers
|
||||
for breaker in breakers.open_breakers(worker_id):
|
||||
breaker.force_close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{worker_id}")
|
||||
async def revoke_worker(worker_id: str) -> dict:
|
||||
"""Remove a worker — which means revoke its key, not hide the row.
|
||||
|
||||
Its in-flight work is released so it can be retried elsewhere rather than
|
||||
waiting out a lease on a machine that will never answer again.
|
||||
"""
|
||||
pool = service.control_plane.pool if service.control_plane.running else None
|
||||
live = None
|
||||
was_pending = False
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
live = pool.get(worker_id)
|
||||
if live is not None:
|
||||
was_pending = live.registration_pending
|
||||
live.registration_pending = True
|
||||
try:
|
||||
revoked, cancelled = await to_thread_and_defer_cancellation(
|
||||
registry.revoke, worker_id
|
||||
)
|
||||
except BaseException:
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
current = pool.get(worker_id)
|
||||
if current is live:
|
||||
current.registration_pending = was_pending
|
||||
raise
|
||||
if not revoked:
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
current = pool.get(worker_id)
|
||||
if current is live:
|
||||
current.registration_pending = was_pending
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
|
||||
# The tombstone committed before any egress/session mutation. Everything
|
||||
# below is loop-owned and published under the same scheduler authority read
|
||||
# used by next_assignment(), so no task can bind in the handoff window.
|
||||
with registry.authority_guard():
|
||||
if service.control_plane.running:
|
||||
if service.control_plane.servicer is not None:
|
||||
service.control_plane.servicer.revoke_worker_sessions(worker_id)
|
||||
service.control_plane.scheduler.on_disconnected(worker_id)
|
||||
service.control_plane.pool.breakers.forget_worker(worker_id)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return {"ok": True, "revoked": worker_id}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(limit: int = 50) -> dict:
|
||||
"""Recent remote tasks, for the queue view."""
|
||||
if not service.control_plane.running:
|
||||
return {"tasks": [], "queue_depth": 0}
|
||||
from worker import task_store # noqa: PLC0415
|
||||
|
||||
return {
|
||||
"queue_depth": service.control_plane.scheduler.queue_depth,
|
||||
"tasks": [t.to_dict() for t in task_store.list_tasks(limit=min(200, max(1, limit)))],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
|
||||
"""Run one task on a remote worker and wait for it. **DEV ONLY.**
|
||||
|
||||
This is the producer the remote pipeline never had: until it existed the
|
||||
scheduler had no caller outside the test suite, so picking a remote GPU
|
||||
changed the badge and nothing else — every job still ran locally. It is
|
||||
the smallest thing that makes remote execution observable end to end, not
|
||||
the shipping surface: the GPU gateway takes over routing real generation
|
||||
and this endpoint goes with it.
|
||||
|
||||
Loopback-only and behind the same opt-in as the rest of the feature, so a
|
||||
user who never enabled remote workers cannot reach it at all.
|
||||
"""
|
||||
from worker.lifecycle import TaskState # noqa: PLC0415
|
||||
from worker.scheduler import QueueFull, SchedulerStopped # noqa: PLC0415
|
||||
|
||||
if not service.remote_workers_enabled() or not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
if not routing.supports_operation(body.operation):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"'{body.operation}' does not run on a remote worker yet.",
|
||||
)
|
||||
|
||||
scheduler = service.control_plane.scheduler
|
||||
try:
|
||||
submit = getattr(scheduler, "submit_async", None)
|
||||
submit = submit if callable(submit) else scheduler.submit
|
||||
submitted = submit(
|
||||
operation=body.operation,
|
||||
engine=body.engine,
|
||||
model_id=body.model_id,
|
||||
params=body.params,
|
||||
idempotency_key=body.idempotency_key or None,
|
||||
deadline_seconds=body.deadline_seconds,
|
||||
pinned_worker_id=routing.decide().worker_id or None,
|
||||
)
|
||||
task = await submitted if asyncio.iscoroutine(submitted) else submitted
|
||||
except QueueFull as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
|
||||
settled = None
|
||||
reason = "the request was interrupted"
|
||||
try:
|
||||
settled = await _await_terminal(
|
||||
request, scheduler, task.task_id, timeout=body.deadline_seconds
|
||||
)
|
||||
except _ClientGone:
|
||||
reason = "the client disconnected"
|
||||
raise HTTPException(status_code=499, detail="The client stopped waiting.") from None
|
||||
except _WaitExpired:
|
||||
reason = "the task passed its deadline"
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"The task did not finish within {body.deadline_seconds:g}s.",
|
||||
) from None
|
||||
except SchedulerStopped as exc:
|
||||
# Deliberately no cancel: the worker was never told to stop and may
|
||||
# still be rendering, so claiming the task is cancelled would be a
|
||||
# statement about someone else's GPU that we cannot make.
|
||||
reason = None
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
finally:
|
||||
# Nothing else will stop it: a worker holds its slot — often its only
|
||||
# one — until the control plane says otherwise, and the sweeper only
|
||||
# enforces deadlines on tasks that are still queued. Swallowed because
|
||||
# a failure here would replace the caller's real error with a 500.
|
||||
if settled is None and reason is not None:
|
||||
try:
|
||||
await service.control_plane.cancel(task.task_id, reason=reason)
|
||||
except Exception:
|
||||
logger.exception("Could not cancel abandoned remote task %s", task.task_id)
|
||||
|
||||
payload = settled.to_dict()
|
||||
if settled.state is TaskState.COMPLETED:
|
||||
return payload
|
||||
# A failure that answered 200 would be indistinguishable from success to
|
||||
# anything that does not read `state` — which is the whole point of this
|
||||
# endpoint existing before the gateway does.
|
||||
raise HTTPException(
|
||||
status_code=409 if settled.state is TaskState.CANCELLED else 502, detail=payload
|
||||
)
|
||||
|
||||
|
||||
async def _await_terminal(request: Request, scheduler, task_id: str, *, timeout: float):
|
||||
"""Wait for a terminal task, giving up if the caller does first."""
|
||||
waiter = asyncio.ensure_future(scheduler.wait(task_id, timeout=timeout))
|
||||
while True:
|
||||
done, _pending = await asyncio.wait({waiter}, timeout=_DISCONNECT_POLL_SECONDS)
|
||||
if done:
|
||||
try:
|
||||
settled = waiter.result()
|
||||
except (asyncio.TimeoutError, TimeoutError) as exc:
|
||||
raise _WaitExpired() from exc
|
||||
if settled is None or not settled.state.terminal:
|
||||
raise _WaitExpired()
|
||||
return settled
|
||||
if await request.is_disconnected():
|
||||
waiter.cancel()
|
||||
raise _ClientGone()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str) -> dict:
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
cancelled = await service.control_plane.cancel(task_id, reason="cancelled by user")
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=404, detail="No such active task.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Inbound mode ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# The other direction: this machine accepts connections from panels, or dials
|
||||
# out to nodes that do. Outbound enrollment above is unchanged and remains the
|
||||
# default — see docs/adr/inbound-node-mode.md for why this exists alongside it
|
||||
# rather than replacing it.
|
||||
|
||||
|
||||
class InboundEnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
# Widening the bind is a separate decision from turning the feature on,
|
||||
# so it is a separate field with a safe default rather than a flag that
|
||||
# rides along with `enabled`.
|
||||
bind: str = ""
|
||||
port: int = 0
|
||||
|
||||
|
||||
class IssueKeyRequest(BaseModel):
|
||||
label: str = Field(default="", max_length=64)
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
connection_string: str = Field(min_length=1, max_length=512)
|
||||
|
||||
|
||||
@router.get("/inbound")
|
||||
def inbound_status() -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
return {
|
||||
**inbound_service.node.snapshot(),
|
||||
"connections": inbound_service.outbound.snapshot(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/inbound/enabled")
|
||||
async def set_inbound_enabled(request: InboundEnableRequest) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if inbound_service.enabled_override() is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Accept connections is controlled by OMNIVOICE_INBOUND_NODE on this "
|
||||
"machine. Change that environment setting and restart VoiceStudio."
|
||||
),
|
||||
)
|
||||
|
||||
requested_bind = (
|
||||
inbound_service.normalise_bind_host(request.bind)
|
||||
if request.bind
|
||||
else inbound_service.bind_host()
|
||||
)
|
||||
requested_port = request.port or inbound_service.bind_port()
|
||||
if (
|
||||
request.enabled
|
||||
and inbound_service.node.running
|
||||
and (
|
||||
requested_bind != inbound_service.bind_host()
|
||||
or requested_port != inbound_service.node.port
|
||||
)
|
||||
):
|
||||
# start() is intentionally idempotent while a listener owns its
|
||||
# socket. Persisting a new endpoint here would make the UI report a
|
||||
# narrower/different bind while the original socket stayed live.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Turn off Accept connections before changing its bind address "
|
||||
"or port."
|
||||
),
|
||||
)
|
||||
if request.bind:
|
||||
inbound_service.set_bind_host(requested_bind)
|
||||
if request.port:
|
||||
inbound_service.set_bind_port(request.port)
|
||||
inbound_service.set_enabled(request.enabled)
|
||||
|
||||
if inbound_service.enabled():
|
||||
await inbound_service.node.start()
|
||||
if inbound_service.node.startup_error:
|
||||
logger.error("Inbound worker listener failed to start; details withheld.")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"The inbound worker listener could not start; "
|
||||
"check the backend log for details."
|
||||
),
|
||||
)
|
||||
else:
|
||||
await inbound_service.node.stop()
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/keys")
|
||||
def issue_inbound_key(request: IssueKeyRequest) -> dict:
|
||||
"""Mint one panel's key and return the string it pastes.
|
||||
|
||||
The secret is in this response and nowhere else afterwards — only its hash
|
||||
is stored, so it cannot be shown again, only replaced.
|
||||
"""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.keys import KeyLimitExceeded # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"This machine is not accepting connections yet. Turn on "
|
||||
"Settings → System → Remote workers → Accept connections first."
|
||||
),
|
||||
)
|
||||
try:
|
||||
issued = inbound_service.node.keys.issue(request.label)
|
||||
except KeyLimitExceeded as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"key_id": issued.key.key_id,
|
||||
"label": issued.key.label,
|
||||
"connection_string": inbound_service.node.connection_string(issued.secret),
|
||||
"exposed": inbound_service.is_exposed(),
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/inbound/keys/{key_id}")
|
||||
async def revoke_inbound_key(key_id: str) -> dict:
|
||||
"""Revoke one panel. Everyone else stays connected — the whole reason keys
|
||||
are per panel rather than one shared node key."""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not await inbound_service.node.revoke_key(key_id):
|
||||
raise HTTPException(status_code=404, detail="No such key.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/sessions/{session_id}/disconnect")
|
||||
def disconnect_inbound_session(session_id: str) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.log.kick(session_id):
|
||||
raise HTTPException(status_code=404, detail="That connection has already ended.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/connections")
|
||||
async def add_inbound_connection(request: ConnectRequest) -> dict:
|
||||
"""Paste a connection string from a GPU machine and dial it."""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.connection_string import InvalidConnectionString # noqa: PLC0415
|
||||
from worker.inbound.connector import InboundConnectionError # noqa: PLC0415
|
||||
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Remote workers are turned off. Enable them in "
|
||||
"Settings → System → Remote workers first."
|
||||
),
|
||||
)
|
||||
try:
|
||||
connection = await inbound_service.outbound.add(
|
||||
request.connection_string, service.control_plane.servicer
|
||||
)
|
||||
except InvalidConnectionString as exc:
|
||||
# 400 with the parser's own words: every one of these otherwise
|
||||
# surfaces as "cannot connect", which is what a firewall, a wrong port
|
||||
# and a dead node all say too.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except InboundConnectionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"endpoint": connection.endpoint, "connections": inbound_service.outbound.snapshot()}
|
||||
|
||||
|
||||
@router.delete("/inbound/connections/{endpoint}")
|
||||
async def remove_inbound_connection(endpoint: str) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.connector import InboundConnectionError # noqa: PLC0415
|
||||
|
||||
try:
|
||||
await inbound_service.outbound.remove(endpoint)
|
||||
except InboundConnectionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"connections": inbound_service.outbound.snapshot()}
|
||||
+1
-23
@@ -15,16 +15,9 @@ class SysinfoResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cpu: float = Field(description="CPU usage percentage (0–100)")
|
||||
cpu_model: str = ""
|
||||
cpu_physical_cores: int = 0
|
||||
cpu_logical_cores: int = 0
|
||||
cpu_frequency_ghz: float = 0.0
|
||||
ram: float = Field(description="Used RAM in GiB")
|
||||
total_ram: float = Field(description="Total RAM in GiB")
|
||||
gpu_name: str = ""
|
||||
gpu_utilization: float | None = None
|
||||
vram: float = Field(0.0, description="Used VRAM in GiB")
|
||||
total_vram: float = Field(0.0, description="Total VRAM in GiB when reported by the runtime")
|
||||
gpu_active: bool = Field(False, description="Whether a GPU is actively used")
|
||||
|
||||
|
||||
@@ -33,21 +26,6 @@ class SystemInfoResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
app_version: str = ""
|
||||
# Effective compute-time budgets (seconds) for one synthesis job — the
|
||||
# values services/model_manager.py's GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S
|
||||
# captured at backend import time (#1787). A value just saved via
|
||||
# /system/set-env is NOT reflected here until the next restart.
|
||||
generate_timeout_s: float = 300.0
|
||||
cpu_generate_timeout_s: float = 600.0
|
||||
# True when an external env var (shell, `.env`, Docker, …) is currently
|
||||
# shadowing a prefs.json save for this key — see core.prefs.is_env_shadowed.
|
||||
generate_timeout_shadowed: bool = False
|
||||
cpu_generate_timeout_shadowed: bool = False
|
||||
# #1770: the desktop attach handshake's code fingerprint — whatever
|
||||
# Tauri set OMNIVOICE_BUILD_FINGERPRINT to when it spawned this process,
|
||||
# echoed back verbatim. Blank when unset (dev mode, a manually started
|
||||
# backend). See frontend/src-tauri/src/backend.rs::code_fingerprint_is_current.
|
||||
code_fingerprint: str = ""
|
||||
data_dir: str
|
||||
outputs_dir: str
|
||||
crash_log_path: str
|
||||
@@ -89,7 +67,7 @@ class ModelStatusResponse(BaseModel):
|
||||
status: str = Field(description="idle | loading | ready")
|
||||
checkpoint: str | None = None
|
||||
loaded_at: str | None = None
|
||||
sub_stage: str | None = Field(None, description="Current TTS loading sub-stage: importing | loading_weights | compiling | ready | error")
|
||||
sub_stage: str | None = Field(None, description="Current loading sub-stage: importing | loading_weights | loading_asr | compiling | ready | error")
|
||||
detail: str | None = Field(None, description="Human-readable detail of current loading phase")
|
||||
error: str | None = Field(None, description="Error message if loading failed")
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,720
|
||||
VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:15,000
|
||||
VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:16,560
|
||||
VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,200
|
||||
VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"rendered_by": "omnivoice engine + ffmpeg showwaves",
|
||||
"rendered_at": "2026-08-12T19:47:29Z",
|
||||
"license": "MIT (synthetic, no third-party IP)",
|
||||
"source": {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
"video": "source.mp4",
|
||||
"srt": "source.srt",
|
||||
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
},
|
||||
"dubbed": [
|
||||
{
|
||||
"code": "es",
|
||||
"label": "Español",
|
||||
"video": "dubbed_es.mp4",
|
||||
"srt": "dubbed_es.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"label": "Français",
|
||||
"video": "dubbed_fr.mp4",
|
||||
"srt": "dubbed_fr.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"label": "中文",
|
||||
"video": "dubbed_zh.mp4",
|
||||
"srt": "dubbed_zh.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"label": "日本語",
|
||||
"video": "dubbed_ja.mp4",
|
||||
"srt": "dubbed_ja.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:11,400
|
||||
VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+12
-107
@@ -8,9 +8,8 @@
|
||||
#
|
||||
# Fields:
|
||||
# repo_id (required) — HuggingFace repository ID
|
||||
# engines (required) — backend ids that load this repo; [] for pipeline weights no single engine owns (they list under "Other weights")
|
||||
# label (required) — Human-readable display name
|
||||
# role (required) — TTS | ASR | Translation | Diarisation
|
||||
# role (required) — TTS | ASR | Diarisation
|
||||
# size_gb (required) — Approximate download size in GiB
|
||||
# required (optional) — true if the app needs this model to function.
|
||||
# Only the TTS model is required: the app boots and
|
||||
@@ -29,9 +28,6 @@
|
||||
# their own (weights live in referenced sub-repos). Such
|
||||
# a cache is legitimately tiny, so the truncated-download
|
||||
# (weights-missing) detector must NOT flag it incomplete.
|
||||
# allow_patterns (optional) — restrict installation to these repository paths.
|
||||
# Use for multi-package repos so an explicit install
|
||||
# never downloads unrelated model variants.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
models:
|
||||
@@ -40,25 +36,10 @@ models:
|
||||
- repo_id: "k2-fsa/OmniVoice"
|
||||
label: "VoiceStudio TTS (k2-fsa/OmniVoice, 600+ languages, zero-shot)"
|
||||
role: TTS
|
||||
engines: [omnivoice, omnivoice-subprocess]
|
||||
size_gb: 2.4
|
||||
required: true
|
||||
curated_on: [all]
|
||||
|
||||
- repo_id: "audio-cpp/audio.cpp-gguf"
|
||||
label: "audio.cpp native bundle (Breeze-TTS-2 + Sortformer diarisation)"
|
||||
role: TTS
|
||||
engines: [audiocpp]
|
||||
families: [tts, diarisation]
|
||||
size_gb: 4.98
|
||||
required_files:
|
||||
- "Breeze-TTS-2-GGUF/breeze-tts-2-q8_0.gguf"
|
||||
- "Sortformer-Diar-4spk-v1-GGUF/sortformer-diar-4spk-v1-q8_0.gguf"
|
||||
allow_patterns:
|
||||
- "Breeze-TTS-2-GGUF/breeze-tts-2-q8_0.gguf"
|
||||
- "Sortformer-Diar-4spk-v1-GGUF/sortformer-diar-4spk-v1-q8_0.gguf"
|
||||
note: "Optional audio.cpp bundle for native voice cloning and up-to-four-speaker diarisation. Research/non-commercial weights and self-hosted outputs; install only after reviewing the licenses."
|
||||
|
||||
# ── ASR (optional — curated per platform) ─────────────────────────────
|
||||
# No ASR model is required to boot: TTS-only installs work. Dubbing,
|
||||
# dictation, and clone-reference transcription prompt for the curated
|
||||
@@ -67,7 +48,6 @@ models:
|
||||
- repo_id: "Systran/faster-whisper-large-v3"
|
||||
label: "Whisper large-v3 (faster-whisper — cross-platform, 99 langs)"
|
||||
role: ASR
|
||||
engines: [faster-whisper, faster-whisper-isolated, whisperx]
|
||||
size_gb: 2.9
|
||||
curated_on: [cuda, rocm, cpu, darwin-x86_64]
|
||||
note: "The universal pick: best word-timestamp robustness for dubbing, runs on CUDA and CPU everywhere. On Apple Silicon prefer the MLX build."
|
||||
@@ -75,7 +55,6 @@ models:
|
||||
- repo_id: "mlx-community/whisper-large-v3-mlx"
|
||||
label: "Whisper large-v3 (MLX — best for Apple Silicon)"
|
||||
role: ASR
|
||||
engines: [mlx-whisper]
|
||||
size_gb: 3.0
|
||||
platforms: [darwin-arm64]
|
||||
curated_on: [darwin-arm64]
|
||||
@@ -84,7 +63,6 @@ models:
|
||||
- repo_id: "mlx-community/whisper-large-v3-turbo"
|
||||
label: "Whisper large-v3 Turbo (MLX — fastest dictation)"
|
||||
role: ASR
|
||||
engines: [mlx-whisper]
|
||||
size_gb: 1.6
|
||||
platforms: [darwin-arm64]
|
||||
curated_on: [darwin-arm64]
|
||||
@@ -93,7 +71,6 @@ models:
|
||||
- repo_id: "openai/whisper-large-v3"
|
||||
label: "Whisper large-v3 (PyTorch — GPU path for AMD/ROCm)"
|
||||
role: ASR
|
||||
engines: [pytorch-whisper]
|
||||
size_gb: 3.1
|
||||
platforms: [cuda, rocm]
|
||||
curated_on: [rocm]
|
||||
@@ -102,14 +79,12 @@ models:
|
||||
- repo_id: "mlx-community/whisper-tiny-mlx"
|
||||
label: "Whisper tiny (MLX ASR — fast fallback)"
|
||||
role: ASR
|
||||
engines: [mlx-whisper]
|
||||
size_gb: 0.08
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
- repo_id: "deepdml/faster-whisper-large-v3-turbo-ct2"
|
||||
label: "Whisper large-v3 Turbo (5× faster, 0.8B)"
|
||||
role: ASR
|
||||
engines: [faster-whisper, faster-whisper-isolated, whisperx]
|
||||
size_gb: 1.6
|
||||
curated_on: [cuda, cpu]
|
||||
note: "Best speed/quality tradeoff. 5× faster than large-v3 with minimal WER loss. Community CTranslate2 conversion (no official Systran/OpenAI turbo repo) — re-verify availability on catalog audits."
|
||||
@@ -117,28 +92,24 @@ models:
|
||||
- repo_id: "Systran/faster-distil-whisper-large-v3"
|
||||
label: "Distil-Whisper large-v3 (distilled, fast)"
|
||||
role: ASR
|
||||
engines: [faster-whisper, faster-whisper-isolated, whisperx]
|
||||
size_gb: 1.5
|
||||
note: "Knowledge-distilled from large-v3. Good accuracy at higher speed."
|
||||
|
||||
- repo_id: "Systran/faster-whisper-medium"
|
||||
label: "Whisper medium (balanced, lower VRAM)"
|
||||
role: ASR
|
||||
engines: [faster-whisper, faster-whisper-isolated, whisperx]
|
||||
size_gb: 1.5
|
||||
note: "Good balance of speed and accuracy. Half the VRAM of large-v3."
|
||||
|
||||
- repo_id: "Systran/faster-whisper-small"
|
||||
label: "Whisper small (fast preview, low VRAM)"
|
||||
role: ASR
|
||||
engines: [faster-whisper, faster-whisper-isolated, whisperx]
|
||||
size_gb: 0.5
|
||||
note: "Quick previews and testing. ~2× faster than medium."
|
||||
|
||||
- repo_id: "Systran/faster-whisper-base"
|
||||
label: "Whisper base (minimal, fastest Whisper)"
|
||||
role: ASR
|
||||
engines: [faster-whisper, faster-whisper-isolated, whisperx]
|
||||
size_gb: 0.15
|
||||
note: "Lowest accuracy but near-instant. Good for rapid iteration."
|
||||
|
||||
@@ -147,7 +118,6 @@ models:
|
||||
- repo_id: "nvidia/parakeet-tdt-0.6b-v3"
|
||||
label: "Parakeet TDT 0.6B v3 (NVIDIA — SOTA, 25+ langs)"
|
||||
role: ASR
|
||||
engines: [nemo-parakeet]
|
||||
size_gb: 1.2
|
||||
platforms: [cuda]
|
||||
note: "Beats Whisper large-v3 on English benchmarks. Requires nemo_toolkit[asr]."
|
||||
@@ -155,7 +125,6 @@ models:
|
||||
- repo_id: "nvidia/parakeet-tdt-0.6b-v2"
|
||||
label: "Parakeet TDT 0.6B v2 (NVIDIA — English + punctuation)"
|
||||
role: ASR
|
||||
engines: [nemo-parakeet]
|
||||
size_gb: 1.2
|
||||
platforms: [cuda]
|
||||
note: "English-optimized with punctuation/capitalization. Requires nemo_toolkit[asr]."
|
||||
@@ -163,7 +132,6 @@ models:
|
||||
- repo_id: "mlx-community/parakeet-tdt-0.6b-v3"
|
||||
label: "Parakeet TDT 0.6B v3 (MLX — Apple Silicon, 25 EU langs)"
|
||||
role: ASR
|
||||
engines: [parakeet-mlx]
|
||||
size_gb: 1.2
|
||||
platforms: [darwin-arm64]
|
||||
curated_on: [darwin-arm64]
|
||||
@@ -172,14 +140,12 @@ models:
|
||||
- repo_id: "UsefulSensors/moonshine-base"
|
||||
label: "Moonshine base (edge-optimized, 61M, ONNX)"
|
||||
role: ASR
|
||||
engines: [moonshine]
|
||||
size_gb: 0.12
|
||||
note: "Variable-length processing, sub-200ms latency. Great for CPU/edge. Requires moonshine-onnx."
|
||||
|
||||
- repo_id: "UsefulSensors/moonshine-tiny"
|
||||
label: "Moonshine tiny (edge-optimized, 27M, ONNX)"
|
||||
role: ASR
|
||||
engines: [moonshine]
|
||||
size_gb: 0.05
|
||||
note: "Smallest/fastest Moonshine, sub-200ms latency. Lower accuracy than base. Requires moonshine-onnx."
|
||||
|
||||
@@ -193,18 +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
|
||||
engines: [sherpa-onnx-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
|
||||
engines: [sherpa-onnx-asr]
|
||||
size_gb: 0.66
|
||||
size_gb: 0.17
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-parakeet-tdt-v2
|
||||
tag: offline
|
||||
@@ -213,8 +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
|
||||
engines: [sherpa-onnx-asr]
|
||||
size_gb: 0.2
|
||||
size_gb: 0.13
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-bilingual-zh-en
|
||||
tag: streaming
|
||||
@@ -223,8 +187,7 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-streaming-paraformer-bilingual-zh-en"
|
||||
label: "Paraformer Bilingual (sherpa-onnx — streaming, zh+en)"
|
||||
role: ASR
|
||||
engines: [sherpa-onnx-asr]
|
||||
size_gb: 0.24
|
||||
size_gb: 0.115
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-paraformer-bilingual-zh-en
|
||||
tag: streaming
|
||||
@@ -233,8 +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
|
||||
engines: [sherpa-onnx-asr]
|
||||
size_gb: 0.044
|
||||
size_gb: 0.128
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-en-20m
|
||||
tag: streaming
|
||||
@@ -243,8 +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
|
||||
engines: [sherpa-onnx-asr]
|
||||
size_gb: 0.025
|
||||
size_gb: 0.074
|
||||
engine: sherpa-onnx
|
||||
dictation_id: sherpa-zipformer-zh-14m
|
||||
tag: streaming
|
||||
@@ -253,89 +214,39 @@ models:
|
||||
- repo_id: "csukuangfj/sherpa-onnx-whisper-tiny"
|
||||
label: "Whisper Tiny (sherpa-onnx — dictation, 90+ langs)"
|
||||
role: ASR
|
||||
engines: [sherpa-onnx-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."
|
||||
|
||||
# ── Translation ──────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "facebook/nllb-200-distilled-600M"
|
||||
label: "NLLB-200 distilled 600M (local, 200 languages)"
|
||||
role: Translation
|
||||
engines: []
|
||||
size_gb: 2.4
|
||||
note: "Best fully-local translation quality. Install explicitly before selecting NLLB; translation never downloads these weights in the background."
|
||||
note: "Multilingual offline dictation (auto-detect). CPU, int8 ONNX. Requires sherpa-onnx."
|
||||
|
||||
# ── Diarisation ───────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "pyannote/speaker-diarization-3.1"
|
||||
label: "pyannote speaker diarisation (multi-speaker videos)"
|
||||
role: Diarisation
|
||||
engines: []
|
||||
size_gb: 0.8
|
||||
config_only: true # pipeline repo; real weights live in referenced sub-repos
|
||||
config_required_files: ["config.yaml"]
|
||||
dependencies:
|
||||
- repo_id: "pyannote/segmentation-3.0"
|
||||
required_files: ["pytorch_model.bin"]
|
||||
allow_patterns: ["config.yaml", "pytorch_model.bin"]
|
||||
- repo_id: "pyannote/wespeaker-voxceleb-resnet34-LM"
|
||||
required_files: ["pytorch_model.bin"]
|
||||
allow_patterns: ["config.yaml", "pytorch_model.bin"]
|
||||
gated: true
|
||||
requires_hf_token: true
|
||||
access_url: "https://huggingface.co/pyannote/speaker-diarization-3.1"
|
||||
prerequisite_repo_id: "pyannote/segmentation-3.0"
|
||||
prerequisite_access_url: "https://huggingface.co/pyannote/segmentation-3.0"
|
||||
failure_topic: "PYANNOTE_LICENSE_REQUIRED"
|
||||
note: "Requires access to both pyannote repositories and an HF token."
|
||||
note: "Needs an HF_TOKEN with license accepted."
|
||||
|
||||
# ── Optional TTS ──────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "OpenMOSS-Team/MOSS-TTS-Nano-100M"
|
||||
label: "MOSS-TTS-Nano 100M (20 langs, CPU-realtime)"
|
||||
role: TTS
|
||||
engines: [moss-tts-nano]
|
||||
size_gb: 0.4
|
||||
|
||||
- repo_id: "KittenML/kitten-tts-mini-0.8"
|
||||
label: "KittenTTS (English, 8 preset voices, CPU realtime)"
|
||||
role: TTS
|
||||
engines: [kittentts]
|
||||
size_gb: 0.08
|
||||
curated_on: [all]
|
||||
|
||||
- repo_id: "openbmb/VoxCPM2"
|
||||
label: "VoxCPM2 (30 languages, voice cloning and design)"
|
||||
role: TTS
|
||||
engines: [voxcpm2]
|
||||
size_gb: 5.0
|
||||
curated_on: [cuda]
|
||||
|
||||
- repo_id: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
|
||||
label: "CosyVoice 3 0.5B (multilingual zero-shot)"
|
||||
role: TTS
|
||||
engines: [cosyvoice]
|
||||
size_gb: 9.8
|
||||
curated_on: [cuda]
|
||||
|
||||
- repo_id: "lj1995/GPT-SoVITS"
|
||||
label: "GPT-SoVITS pretrained weights"
|
||||
role: TTS
|
||||
engines: [gpt-sovits]
|
||||
size_gb: 2.0
|
||||
curated_on: [cuda]
|
||||
|
||||
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/Kokoro-82M-bf16"
|
||||
label: "Kokoro 82M (8 langs, small, mlx-audio default)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 0.15
|
||||
curated_on: [darwin-arm64]
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
@@ -344,7 +255,6 @@ models:
|
||||
- repo_id: "mlx-community/csm-1b-8bit"
|
||||
label: "CSM 1B (voice cloning, mlx-audio)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 1.1
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -352,7 +262,6 @@ models:
|
||||
- repo_id: "mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit"
|
||||
label: "Qwen3-TTS 1.7B 4bit (voice design, mlx-audio)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 1.4
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -360,7 +269,6 @@ models:
|
||||
- repo_id: "mlx-community/Dia-1.6B"
|
||||
label: "Dia 1.6B (expressive, mlx-audio)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 3.2
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -368,7 +276,6 @@ models:
|
||||
- repo_id: "mlx-community/Llama-OuteTTS-1.0-1B-4bit"
|
||||
label: "Llama-OuteTTS 1.0 1B 4bit (voice clone, mlx-audio)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 0.8
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -376,7 +283,6 @@ models:
|
||||
- repo_id: "mlx-community/Chatterbox-TTS-4bit"
|
||||
label: "Chatterbox TTS 4bit (mlx-audio)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 0.5
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
@@ -384,7 +290,6 @@ models:
|
||||
- repo_id: "mlx-community/MeloTTS-English-v3-MLX"
|
||||
label: "MeloTTS English v3 (mlx-audio)"
|
||||
role: TTS
|
||||
engines: [mlx-audio]
|
||||
size_gb: 0.2
|
||||
note: "Apple Silicon only — via mlx-audio backend."
|
||||
platforms: [darwin-arm64]
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Lightweight validation for persisted profile WAV references.
|
||||
|
||||
This module deliberately uses only the standard library. Gallery routers import
|
||||
it during startup, so pulling in torch/torchaudio merely to validate a cached
|
||||
file would make every Gallery open pay the model stack's import cost.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.path_security import UnsafePath, resolve_within, safe_filename
|
||||
|
||||
_READ_CHUNK_BYTES = 1 << 20
|
||||
_MAX_CHANNELS = 64
|
||||
_MAX_SAMPLE_RATE = 768_000
|
||||
_MAX_SAMPLE_WIDTH = 8
|
||||
|
||||
|
||||
def resolve_regular_file(root: os.PathLike[str] | str, value: object) -> Optional[Path]:
|
||||
"""Resolve a portable bare filename inside *root*, rejecting symlinks."""
|
||||
try:
|
||||
name = safe_filename(value)
|
||||
unresolved = Path(root).resolve(strict=False) / name
|
||||
if unresolved.is_symlink():
|
||||
return None
|
||||
return resolve_within(root, name)
|
||||
except (OSError, UnsafePath):
|
||||
return None
|
||||
|
||||
|
||||
def is_playable_wav(path: Optional[Path]) -> bool:
|
||||
"""Return true only for a regular, decodable WAV with audio frames."""
|
||||
if path is None:
|
||||
return False
|
||||
try:
|
||||
if not path.is_file() or path.is_symlink():
|
||||
return False
|
||||
file_size = path.stat().st_size
|
||||
with wave.open(str(path), "rb") as wav:
|
||||
channels = wav.getnchannels()
|
||||
sample_rate = wav.getframerate()
|
||||
sample_width = wav.getsampwidth()
|
||||
frame_count = wav.getnframes()
|
||||
if (
|
||||
not 0 < channels <= _MAX_CHANNELS
|
||||
or not 0 < sample_rate <= _MAX_SAMPLE_RATE
|
||||
or not 0 < sample_width <= _MAX_SAMPLE_WIDTH
|
||||
or frame_count <= 0
|
||||
):
|
||||
return False
|
||||
# ``wave.getnframes`` trusts the header. Read through the declared
|
||||
# payload so an interrupted write with a complete header but a
|
||||
# truncated data chunk cannot masquerade as playable audio.
|
||||
frame_size = channels * sample_width
|
||||
expected_bytes = frame_count * frame_size
|
||||
# A PCM payload cannot be larger than the containing file. Check
|
||||
# before calling ``readframes`` so hostile header values cannot
|
||||
# turn a tiny file into a multi-gigabyte allocation request.
|
||||
if expected_bytes > file_size:
|
||||
return False
|
||||
read_bytes = 0
|
||||
chunk_frames = max(1, min(frame_count, _READ_CHUNK_BYTES // frame_size))
|
||||
while read_bytes < expected_bytes:
|
||||
chunk = wav.readframes(chunk_frames)
|
||||
if not chunk or len(chunk) % frame_size:
|
||||
return False
|
||||
read_bytes += len(chunk)
|
||||
return read_bytes == expected_bytes
|
||||
except (MemoryError, OSError, EOFError, OverflowError, wave.Error):
|
||||
# Python 3.11's wave module rejects valid IEEE-float/WAVE_EXTENSIBLE
|
||||
# files. SoundFile is already a runtime dependency and recognizes those
|
||||
# containers; import it only on the uncommon fallback path.
|
||||
try:
|
||||
import soundfile as sf
|
||||
|
||||
with sf.SoundFile(str(path)) as audio:
|
||||
if (
|
||||
audio.format != "WAV"
|
||||
or not 0 < audio.channels <= _MAX_CHANNELS
|
||||
or not 0 < audio.samplerate <= _MAX_SAMPLE_RATE
|
||||
or len(audio) <= 0
|
||||
):
|
||||
return False
|
||||
remaining = len(audio)
|
||||
# Decode through the declared payload in byte-bounded chunks;
|
||||
# ``sf.info`` alone also trusts a truncated file's header.
|
||||
chunk_frames = max(
|
||||
1, _READ_CHUNK_BYTES // (audio.channels * 4),
|
||||
)
|
||||
while remaining:
|
||||
frames = audio.read(
|
||||
min(remaining, chunk_frames), dtype="float32", always_2d=True,
|
||||
)
|
||||
count = len(frames)
|
||||
if count <= 0:
|
||||
return False
|
||||
remaining -= count
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["is_playable_wav", "resolve_regular_file"]
|
||||
@@ -1,421 +0,0 @@
|
||||
"""Canonical authentication identity for HTTP and WebSocket connections.
|
||||
|
||||
Transport parsing belongs here; authorization remains in FastAPI dependencies.
|
||||
Each ASGI scope receives exactly one secret-free :class:`AuthPrincipal` so
|
||||
middleware and route guards cannot disagree about credential precedence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import importlib
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from services.admin_sessions import (
|
||||
AdminSessionStore,
|
||||
)
|
||||
|
||||
|
||||
_AUTH_STATE_KEY = "auth_principal"
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
CONSUME_CAPABILITIES = frozenset({"consume"})
|
||||
ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
LOOPBACK_CAPABILITIES = frozenset({"consume", "admin", "native"})
|
||||
|
||||
|
||||
class PrincipalKind(str, Enum):
|
||||
ANONYMOUS = "anonymous"
|
||||
LOOPBACK = "loopback"
|
||||
TRUSTED_NETWORK = "trusted_network"
|
||||
PIN = "pin"
|
||||
API_KEY = "api_key"
|
||||
ADMIN_SESSION = "admin_session"
|
||||
|
||||
|
||||
class CredentialTransport(str, Enum):
|
||||
NONE = "none"
|
||||
HEADER = "header"
|
||||
QUERY = "query"
|
||||
COOKIE = "cookie"
|
||||
LEGACY_COOKIE = "legacy_cookie"
|
||||
WS_TICKET = "ws_ticket"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthPrincipal:
|
||||
kind: PrincipalKind
|
||||
capabilities: frozenset[str]
|
||||
credential_id: str | None = None
|
||||
transport: CredentialTransport = CredentialTransport.NONE
|
||||
|
||||
def allows(self, capability: str) -> bool:
|
||||
return capability in self.capabilities
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CredentialCandidate:
|
||||
value: str = field(repr=False)
|
||||
transport: CredentialTransport
|
||||
allow_master: bool = False
|
||||
allow_session: bool = False
|
||||
allow_ticket: bool = False
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""Normalized remote operator key, read dynamically for rotation support."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def credential_matches(supplied: str | None, configured: str | None) -> bool:
|
||||
"""Constant-time credential comparison that accepts the full Unicode range."""
|
||||
if not supplied or not configured:
|
||||
return False
|
||||
return secrets.compare_digest(
|
||||
supplied.encode("utf-8", errors="surrogatepass"),
|
||||
configured.encode("utf-8", errors="surrogatepass"),
|
||||
)
|
||||
|
||||
|
||||
def _active_admin_session_store() -> AdminSessionStore:
|
||||
"""Resolve mutable process state at call time so app reloads cannot split it."""
|
||||
module = importlib.import_module("services.admin_sessions")
|
||||
return module.admin_session_store
|
||||
|
||||
|
||||
def _trusted_networks() -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
|
||||
networks = []
|
||||
for value in os.environ.get("OMNIVOICE_TRUSTED_NETWORKS", "").split(","):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
networks.append(ipaddress.ip_network(value, strict=False))
|
||||
except ValueError:
|
||||
# Invalid configuration never makes the gate fail open or wedge the
|
||||
# backend. It simply contributes no trusted range.
|
||||
continue
|
||||
return tuple(networks)
|
||||
|
||||
|
||||
def is_loopback(host: str | None) -> bool:
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def is_local_host(host: str | None) -> bool:
|
||||
if is_loopback(host):
|
||||
return True
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if getattr(address, "ipv4_mapped", None):
|
||||
address = address.ipv4_mapped
|
||||
return any(address in network for network in _trusted_networks())
|
||||
|
||||
|
||||
def _mapping_get(mapping: Mapping[str, str] | object, name: str) -> str:
|
||||
if not mapping:
|
||||
return ""
|
||||
getter = getattr(mapping, "get", None)
|
||||
if callable(getter):
|
||||
value = getter(name, "")
|
||||
if value:
|
||||
return str(value)
|
||||
# Real Starlette Headers are case-insensitive. This small fallback keeps
|
||||
# minimal request stubs and non-Starlette callers correct too.
|
||||
items = getattr(mapping, "items", None)
|
||||
if callable(items):
|
||||
for key, value in items():
|
||||
if str(key).lower() == name.lower():
|
||||
return str(value or "")
|
||||
return ""
|
||||
|
||||
|
||||
def _scope_type(connection) -> str:
|
||||
scope = getattr(connection, "scope", None)
|
||||
return str(scope.get("type", "http")) if isinstance(scope, dict) else "http"
|
||||
|
||||
|
||||
def _path(connection) -> str:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict):
|
||||
return str(scope.get("path", ""))
|
||||
return str(getattr(connection, "url", "") or "")
|
||||
|
||||
|
||||
def _canonical_websocket_path(connection) -> str:
|
||||
"""Remove only the ASGI-configured deployment prefix from a WS path."""
|
||||
path = _path(connection)
|
||||
scope = getattr(connection, "scope", None)
|
||||
if not isinstance(scope, dict):
|
||||
return path
|
||||
root_path = str(scope.get("root_path", "") or "").rstrip("/")
|
||||
if not root_path or root_path == "/":
|
||||
return path
|
||||
root_path = "/" + root_path.lstrip("/")
|
||||
if path.startswith(root_path + "/"):
|
||||
return path[len(root_path) :]
|
||||
return path
|
||||
|
||||
|
||||
def _client_host(connection) -> str | None:
|
||||
client = getattr(connection, "client", None)
|
||||
if client is not None:
|
||||
return getattr(client, "host", None)
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict) and scope.get("client"):
|
||||
return scope["client"][0]
|
||||
return None
|
||||
|
||||
|
||||
def _credential_candidate(connection) -> _CredentialCandidate | None:
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
|
||||
raw_authorization = authorization_header(connection)
|
||||
authorization = raw_authorization.strip()
|
||||
if raw_authorization.lower().startswith("bearer "):
|
||||
value = raw_authorization[7:].strip()
|
||||
if value:
|
||||
return _CredentialCandidate(
|
||||
value=value,
|
||||
transport=CredentialTransport.HEADER,
|
||||
allow_master=True,
|
||||
allow_session=True,
|
||||
)
|
||||
# Preserve the legacy normalization contract: ``Bearer`` followed
|
||||
# only by whitespace is equivalent to an empty credential channel.
|
||||
elif authorization:
|
||||
# Any non-empty explicit Authorization value is authoritative, even
|
||||
# when its scheme is unsupported or its Bearer payload is missing.
|
||||
# It must never fall through to a stale ambient cookie.
|
||||
return _CredentialCandidate(
|
||||
value=authorization,
|
||||
transport=CredentialTransport.HEADER,
|
||||
)
|
||||
|
||||
if _scope_type(connection) == "websocket":
|
||||
ticket = _mapping_get(query, "ws_ticket").strip()
|
||||
if ticket:
|
||||
return _CredentialCandidate(
|
||||
value=ticket,
|
||||
transport=CredentialTransport.WS_TICKET,
|
||||
allow_ticket=True,
|
||||
)
|
||||
|
||||
query_key = _mapping_get(query, "api_key").strip()
|
||||
if query_key:
|
||||
return _CredentialCandidate(
|
||||
value=query_key,
|
||||
transport=CredentialTransport.QUERY,
|
||||
allow_master=True,
|
||||
)
|
||||
|
||||
session = _mapping_get(cookies, "ov_session").strip()
|
||||
if session:
|
||||
return _CredentialCandidate(
|
||||
value=session,
|
||||
transport=CredentialTransport.COOKIE,
|
||||
allow_session=True,
|
||||
)
|
||||
|
||||
legacy_key = _mapping_get(cookies, "ov_key").strip()
|
||||
if legacy_key:
|
||||
return _CredentialCandidate(
|
||||
value=legacy_key,
|
||||
transport=CredentialTransport.LEGACY_COOKIE,
|
||||
allow_master=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def presented_api_key(connection) -> str:
|
||||
"""Compatibility extractor for the durable API-key transports only."""
|
||||
candidate = _credential_candidate(connection)
|
||||
if candidate is None or not candidate.allow_master:
|
||||
return ""
|
||||
return candidate.value
|
||||
|
||||
|
||||
def authorization_header(connection) -> str:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
return _mapping_get(headers, "authorization")
|
||||
|
||||
|
||||
def authorization_credential_present(connection) -> bool:
|
||||
"""Whether Authorization contains an authoritative credential channel.
|
||||
|
||||
This deliberately mirrors :func:`_credential_candidate`: whitespace and
|
||||
``Bearer`` followed only by spaces are empty channels that may fall back to
|
||||
legacy migration state. Unsupported schemes and ``Bearer`` without the
|
||||
required separating space remain explicit invalid credentials.
|
||||
"""
|
||||
authorization = authorization_header(connection)
|
||||
if authorization.lower().startswith("bearer ") and not authorization[7:].strip():
|
||||
return False
|
||||
return bool(authorization.strip())
|
||||
|
||||
|
||||
def bearer_header_value(connection) -> str:
|
||||
authorization = authorization_header(connection)
|
||||
if not authorization.lower().startswith("bearer "):
|
||||
return ""
|
||||
return authorization[7:].strip()
|
||||
|
||||
|
||||
def legacy_master_cookie_valid(connection) -> bool:
|
||||
configured = remote_api_key()
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
supplied = _mapping_get(cookies, "ov_key").strip()
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def master_header_valid(connection) -> bool:
|
||||
configured = remote_api_key()
|
||||
supplied = bearer_header_value(connection)
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def _configured_pin(connection) -> str | None:
|
||||
app = getattr(connection, "app", None)
|
||||
state = getattr(app, "state", None) if app is not None else None
|
||||
network_share = getattr(state, "network_share", None) if state is not None else None
|
||||
pin = getattr(network_share, "pin", None) if network_share is not None else None
|
||||
return str(pin) if pin else None
|
||||
|
||||
|
||||
def _valid_pin(connection) -> bool:
|
||||
configured = _configured_pin(connection)
|
||||
if not configured:
|
||||
return False
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
supplied = (
|
||||
_mapping_get(headers, "x-omnivoice-pin").strip()
|
||||
or _mapping_get(query, "pin").strip()
|
||||
or _mapping_get(cookies, "ov_pin").strip()
|
||||
)
|
||||
return credential_matches(supplied, configured)
|
||||
|
||||
|
||||
def _attached_principal(connection) -> AuthPrincipal | None:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if not isinstance(scope, dict):
|
||||
return None
|
||||
state = scope.get("state")
|
||||
if isinstance(state, dict):
|
||||
principal = state.get(_AUTH_STATE_KEY)
|
||||
return principal if isinstance(principal, AuthPrincipal) else None
|
||||
return None
|
||||
|
||||
|
||||
def _attach_principal(connection, principal: AuthPrincipal) -> AuthPrincipal:
|
||||
scope = getattr(connection, "scope", None)
|
||||
if isinstance(scope, dict):
|
||||
state = scope.setdefault("state", {})
|
||||
if isinstance(state, dict):
|
||||
state[_AUTH_STATE_KEY] = principal
|
||||
return principal
|
||||
|
||||
|
||||
def resolve_principal(
|
||||
connection,
|
||||
*,
|
||||
store: AdminSessionStore | None = None,
|
||||
) -> AuthPrincipal:
|
||||
"""Resolve and attach the single authentication decision for one scope."""
|
||||
attached = _attached_principal(connection)
|
||||
if attached is not None:
|
||||
return attached
|
||||
if store is None:
|
||||
store = _active_admin_session_store()
|
||||
|
||||
host = _client_host(connection)
|
||||
if is_loopback(host):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.LOOPBACK, LOOPBACK_CAPABILITIES),
|
||||
)
|
||||
|
||||
candidate = _credential_candidate(connection)
|
||||
configured_key = remote_api_key()
|
||||
if candidate is not None:
|
||||
principal: AuthPrincipal | None = None
|
||||
if (
|
||||
candidate.allow_master
|
||||
and credential_matches(candidate.value, configured_key)
|
||||
):
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.API_KEY,
|
||||
ADMIN_CAPABILITIES,
|
||||
credential_id="api-key",
|
||||
transport=candidate.transport,
|
||||
)
|
||||
elif candidate.allow_session:
|
||||
session = store.resolve(candidate.value, configured_key)
|
||||
if session is not None:
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
session.capabilities,
|
||||
credential_id=session.credential_id,
|
||||
transport=candidate.transport,
|
||||
)
|
||||
elif candidate.allow_ticket:
|
||||
session = store.consume_ws_ticket(
|
||||
candidate.value,
|
||||
_canonical_websocket_path(connection),
|
||||
configured_key,
|
||||
)
|
||||
if session is not None:
|
||||
principal = AuthPrincipal(
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
session.capabilities,
|
||||
credential_id=session.credential_id,
|
||||
transport=candidate.transport,
|
||||
)
|
||||
if principal is not None:
|
||||
return _attach_principal(connection, principal)
|
||||
# An explicit, non-empty credential is authoritative. Do not silently
|
||||
# fall back to network or PIN trust after an invalid higher-priority
|
||||
# credential was presented.
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(
|
||||
PrincipalKind.ANONYMOUS,
|
||||
frozenset(),
|
||||
transport=candidate.transport,
|
||||
),
|
||||
)
|
||||
|
||||
if is_local_host(host):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.TRUSTED_NETWORK, CONSUME_CAPABILITIES),
|
||||
)
|
||||
if _valid_pin(connection):
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(
|
||||
PrincipalKind.PIN,
|
||||
CONSUME_CAPABILITIES,
|
||||
transport=CredentialTransport.HEADER,
|
||||
),
|
||||
)
|
||||
return _attach_principal(
|
||||
connection,
|
||||
AuthPrincipal(PrincipalKind.ANONYMOUS, frozenset()),
|
||||
)
|
||||
|
||||
|
||||
def principal_for(
|
||||
connection,
|
||||
*,
|
||||
store: AdminSessionStore | None = None,
|
||||
) -> AuthPrincipal:
|
||||
return _attached_principal(connection) or resolve_principal(connection, store=store)
|
||||
@@ -15,18 +15,6 @@ def get_app_data_dir():
|
||||
return os.path.expanduser("~/.omnivoice")
|
||||
|
||||
|
||||
def _configured_hf_token_path():
|
||||
"""Match Hub's token location without importing or refreshing credentials."""
|
||||
default_cache = os.path.join(os.path.expanduser("~"), ".cache")
|
||||
hf_home = os.environ.get("HF_HOME", os.path.join(os.environ.get("XDG_CACHE_HOME", default_cache), "huggingface"))
|
||||
return os.path.expandvars(os.path.expanduser(os.environ.get("HF_TOKEN_PATH", os.path.join(hf_home, "token"))))
|
||||
|
||||
|
||||
# Snapshot recognized locations before automatic model-cache redirection.
|
||||
# Explicit cache/token overrides restrict clearing to their selected location.
|
||||
HF_CLI_TOKEN_PATHS = (_configured_hf_token_path(),)
|
||||
|
||||
|
||||
def _ensure_short_hf_cache_on_windows():
|
||||
"""Redirect HuggingFace cache to a short path on Windows.
|
||||
|
||||
@@ -50,14 +38,6 @@ def _ensure_short_hf_cache_on_windows():
|
||||
return
|
||||
short_cache = os.path.join(local_app, "OmniVoice", "hf_cache")
|
||||
os.makedirs(short_cache, exist_ok=True)
|
||||
if "HF_TOKEN_PATH" not in os.environ:
|
||||
global HF_CLI_TOKEN_PATHS
|
||||
canonical = HF_CLI_TOKEN_PATHS[0]
|
||||
legacy = os.path.join(short_cache, "token")
|
||||
HF_CLI_TOKEN_PATHS = tuple(dict.fromkeys((canonical, legacy)))
|
||||
# Keep existing app-written logins usable without copying credentials.
|
||||
selected = canonical if os.path.exists(canonical) or not os.path.exists(legacy) else legacy
|
||||
os.environ.setdefault("HF_TOKEN_PATH", selected)
|
||||
os.environ["HF_HOME"] = short_cache
|
||||
os.environ["HF_HUB_CACHE"] = short_cache
|
||||
|
||||
|
||||
@@ -1,716 +0,0 @@
|
||||
"""Nested subprocess ownership for desktop-managed backend operations.
|
||||
|
||||
The desktop owns the backend with an OS process group/Job. Engine and
|
||||
installer operations also need an independently terminable subtree: killing
|
||||
only their direct child on a timeout leaves uv/git/model workers holding pipes
|
||||
and mutating files.
|
||||
|
||||
On POSIX a small supervisor is the unreaped leader of a nested process group.
|
||||
A control-pipe EOF (including kernel EOF when the backend dies) kills that
|
||||
group; the parent also drains the group before reaping its stable leader. On
|
||||
Windows the backend retains a nested kill-on-close Job directly and assigns
|
||||
the suspended operation before resuming it. The outer desktop Job remains the
|
||||
terminal fallback.
|
||||
|
||||
Standalone/server launches use the same nested owner, preserving their
|
||||
independently terminable subtree without relying on ``taskkill`` or discovery.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
_RESULT = struct.Struct("!i")
|
||||
_DESKTOP_MARKER = "OMNIVOICE_DESKTOP_CONTAINED"
|
||||
_DRAIN_FD_ENV = "OMNIVOICE_DESKTOP_DRAIN_FD"
|
||||
|
||||
|
||||
def backend_drain_fd(*, required: bool = False) -> Optional[int]:
|
||||
"""Validated Rust-owned drain writer inherited by the desktop backend."""
|
||||
if os.name != "posix" or os.environ.get(_DESKTOP_MARKER) != "1":
|
||||
return None
|
||||
try:
|
||||
fd = int(os.environ[_DRAIN_FD_ENV])
|
||||
os.fstat(fd)
|
||||
except (KeyError, ValueError, OSError) as exc:
|
||||
if required:
|
||||
raise RuntimeError(
|
||||
"desktop backend is missing its live nested-operation drain descriptor"
|
||||
) from exc
|
||||
return None
|
||||
return fd
|
||||
|
||||
|
||||
def secure_backend_drain_fd() -> None:
|
||||
"""Restore CLOEXEC after Rust's one intentional backend inheritance."""
|
||||
fd = backend_drain_fd(required=True)
|
||||
if fd is not None:
|
||||
os.set_inheritable(fd, False)
|
||||
|
||||
|
||||
class OwnedPopen:
|
||||
"""Popen-compatible handle for a desktop-owned nested operation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
proc: subprocess.Popen,
|
||||
control_fd: int,
|
||||
result_fd: int,
|
||||
) -> None:
|
||||
self._proc = proc
|
||||
self._control_fd: Optional[int] = control_fd
|
||||
self._result_fd: Optional[int] = result_fd
|
||||
self._returncode: Optional[int] = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
# Popen callers use these directly (protocol pipes and log drains).
|
||||
self.stdin = proc.stdin
|
||||
self.stdout = proc.stdout
|
||||
self.stderr = proc.stderr
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
return self._proc.pid
|
||||
|
||||
@property
|
||||
def args(self) -> Any:
|
||||
return self._proc.args
|
||||
|
||||
@property
|
||||
def returncode(self) -> Optional[int]:
|
||||
return self._returncode
|
||||
|
||||
def _close_control(self) -> None:
|
||||
fd, self._control_fd = self._control_fd, None
|
||||
if fd is not None:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
# Cleanup is idempotent; another teardown path already closed it.
|
||||
pass
|
||||
|
||||
def _read_result(self, fallback: int) -> int:
|
||||
fd, self._result_fd = self._result_fd, None
|
||||
if fd is None:
|
||||
return fallback
|
||||
try:
|
||||
payload = b""
|
||||
while len(payload) < _RESULT.size:
|
||||
chunk = os.read(fd, _RESULT.size - len(payload))
|
||||
if not chunk:
|
||||
break
|
||||
payload += chunk
|
||||
return _RESULT.unpack(payload)[0] if len(payload) == _RESULT.size else fallback
|
||||
except OSError:
|
||||
return fallback
|
||||
finally:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
# The descriptor may have been closed by cancellation cleanup.
|
||||
pass
|
||||
|
||||
def _posix_exited_unreaped(self) -> bool:
|
||||
flags = os.WEXITED | os.WNOHANG | os.WNOWAIT
|
||||
info = os.waitid(os.P_PID, self.pid, flags)
|
||||
return info is not None and info.si_pid != 0
|
||||
|
||||
def _posix_exited_reaping(self) -> Optional[int]:
|
||||
"""macOS fallback for :meth:`_posix_exited_unreaped` (#1656).
|
||||
|
||||
CPython on macOS does not expose ``os.waitid`` (HAVE_WAITID is not set
|
||||
in its build), so the WNOWAIT probe is unavailable there. This
|
||||
fallback *reaps* the wrapper with ``waitpid(WNOHANG)``: it returns
|
||||
the wrapper's exit code once it has exited, None while it is still
|
||||
running, and raises ``ChildProcessError`` when another owner already
|
||||
reaped it (the same refusal the waitid probe gives).
|
||||
|
||||
Reaping earlier than the WNOWAIT dance loses the pre-reap group kill
|
||||
in :meth:`poll`; that is safe because the supervisor's control-pipe
|
||||
EOF already terminates the whole nested group (#1635 design).
|
||||
"""
|
||||
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
||||
if pid != self.pid:
|
||||
return None
|
||||
rc = os.waitstatus_to_exitcode(status)
|
||||
# Publish on the underlying Popen so its own wait()/poll() no-op.
|
||||
self._proc.returncode = rc
|
||||
return rc
|
||||
|
||||
def _posix_exit_state_reaping(self) -> Optional[int]:
|
||||
""":meth:`_posix_exited_reaping` plus one concession: if the leader
|
||||
was already reaped through *this* Popen (``_proc.returncode`` known),
|
||||
report that code rather than refusing — reaping by our own handle is
|
||||
not the foreign reaper the ECHILD refusal exists for."""
|
||||
try:
|
||||
return self._posix_exited_reaping()
|
||||
except ChildProcessError:
|
||||
return self._proc.returncode
|
||||
|
||||
def _signal_owned_group(self, sig: int) -> None:
|
||||
# The numeric group is safe only while its direct-child leader remains
|
||||
# ours and unreaped. ECHILD therefore refuses rather than guessing.
|
||||
try:
|
||||
os.waitid(os.P_PID, self.pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
|
||||
except ChildProcessError:
|
||||
return
|
||||
except AttributeError:
|
||||
# macOS CPython has no os.waitid (#1656). waitpid still proves
|
||||
# that this exact numeric pid is our live child: ECHILD refuses a
|
||||
# foreign-reaped/reused pid, while pid == self.pid records an exit
|
||||
# without ever signalling the now-unowned process-group number.
|
||||
try:
|
||||
pid, status = os.waitpid(self.pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
return
|
||||
if pid == self.pid:
|
||||
self._proc.returncode = os.waitstatus_to_exitcode(status)
|
||||
return
|
||||
try:
|
||||
os.killpg(self.pid, sig)
|
||||
except ProcessLookupError:
|
||||
# The owned group exited between the waitid probe and the signal.
|
||||
pass
|
||||
|
||||
def poll(self) -> Optional[int]:
|
||||
with self._lock:
|
||||
if self._returncode is not None:
|
||||
return self._returncode
|
||||
if os.name == "posix":
|
||||
try:
|
||||
if hasattr(os, "waitid"):
|
||||
if not self._posix_exited_unreaped():
|
||||
return None
|
||||
self._signal_owned_group(signal.SIGKILL)
|
||||
wrapper_rc = self._proc.wait()
|
||||
else:
|
||||
# macOS CPython: no os.waitid (#1656) — the reaping
|
||||
# probe already terminated/killed nothing; the group
|
||||
# is torn down by the control-pipe EOF in _close_control.
|
||||
wrapper_rc = self._posix_exit_state_reaping()
|
||||
if wrapper_rc is None:
|
||||
return None
|
||||
except ChildProcessError:
|
||||
# Never signal a potentially reused group after another
|
||||
# owner reaped the stable leader.
|
||||
return None
|
||||
else:
|
||||
wrapper_rc = self._proc.poll()
|
||||
if wrapper_rc is None:
|
||||
return None
|
||||
self._close_control()
|
||||
self._returncode = self._read_result(wrapper_rc)
|
||||
return self._returncode
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> int:
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
while True:
|
||||
rc = self.poll()
|
||||
if rc is not None:
|
||||
return rc
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise subprocess.TimeoutExpired(self.args, timeout)
|
||||
time.sleep(0.01)
|
||||
|
||||
def terminate(self) -> None:
|
||||
with self._lock:
|
||||
if self._returncode is not None:
|
||||
return
|
||||
self._close_control()
|
||||
if os.name == "posix":
|
||||
self._signal_owned_group(signal.SIGTERM)
|
||||
else:
|
||||
# Closing the control pipe asks the supervisor to terminate
|
||||
# its nested Job. The stable wrapper handle is a fallback.
|
||||
try:
|
||||
self._proc.terminate()
|
||||
except OSError:
|
||||
# The wrapper exited after the return-code check.
|
||||
pass
|
||||
|
||||
def kill(self) -> None:
|
||||
with self._lock:
|
||||
if self._returncode is not None:
|
||||
return
|
||||
self._close_control()
|
||||
if os.name == "posix":
|
||||
self._signal_owned_group(signal.SIGKILL)
|
||||
else:
|
||||
try:
|
||||
self._proc.kill()
|
||||
except OSError:
|
||||
# The wrapper exited after the return-code check.
|
||||
pass
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._proc, name)
|
||||
|
||||
def __del__(self) -> None:
|
||||
self._close_control()
|
||||
fd, self._result_fd = self._result_fd, None
|
||||
if fd is not None:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
# Finalization may race explicit wait or cancellation cleanup.
|
||||
pass
|
||||
|
||||
|
||||
class WindowsJobPopen:
|
||||
"""Popen-compatible handle whose child tree lives in a retained Job.
|
||||
|
||||
Windows Job handles already provide the stable ownership that POSIX needs
|
||||
a supervisor process group for. Keeping the handle in the backend means an
|
||||
abrupt backend exit closes it in the kernel and kills the whole operation
|
||||
tree, without inserting a second Python process in the sidecar loader path
|
||||
(#1734).
|
||||
"""
|
||||
|
||||
def __init__(self, proc: subprocess.Popen, job: Any, kernel32: Any) -> None:
|
||||
self._proc = proc
|
||||
self._job = job
|
||||
self._kernel32 = kernel32
|
||||
self._lock = threading.RLock()
|
||||
self.stdin = proc.stdin
|
||||
self.stdout = proc.stdout
|
||||
self.stderr = proc.stderr
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
return self._proc.pid
|
||||
|
||||
@property
|
||||
def args(self) -> Any:
|
||||
return self._proc.args
|
||||
|
||||
@property
|
||||
def returncode(self) -> Optional[int]:
|
||||
return self._proc.returncode
|
||||
|
||||
def _close_job(self, *, terminate: bool) -> None:
|
||||
job, self._job = self._job, None
|
||||
if job is None:
|
||||
return
|
||||
try:
|
||||
if terminate:
|
||||
self._kernel32.TerminateJobObject(job, 1)
|
||||
finally:
|
||||
self._kernel32.CloseHandle(job)
|
||||
|
||||
def poll(self) -> Optional[int]:
|
||||
with self._lock:
|
||||
rc = self._proc.poll()
|
||||
if rc is None:
|
||||
return None
|
||||
# A successful direct child may leave helpers behind. Match the
|
||||
# supervisor contract by draining the retained Job before return.
|
||||
self._close_job(terminate=True)
|
||||
return rc
|
||||
|
||||
def wait(self, timeout: Optional[float] = None) -> int:
|
||||
try:
|
||||
rc = self._proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise
|
||||
with self._lock:
|
||||
self._close_job(terminate=True)
|
||||
return rc
|
||||
|
||||
def terminate(self) -> None:
|
||||
with self._lock:
|
||||
self._close_job(terminate=True)
|
||||
|
||||
def kill(self) -> None:
|
||||
self.terminate()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._proc, name)
|
||||
|
||||
def __del__(self) -> None:
|
||||
try:
|
||||
self._close_job(terminate=True)
|
||||
except Exception:
|
||||
pass # interpreter shutdown; closing the OS handle is best-effort
|
||||
|
||||
|
||||
def _spawn_windows_owned(argv: list[str], kwargs: dict[str, Any]) -> WindowsJobPopen:
|
||||
"""Start *argv* suspended, assign its tree to a Job, then resume it."""
|
||||
import ctypes
|
||||
|
||||
job, kernel32, wintypes = _windows_job()
|
||||
child: Optional[subprocess.Popen] = None
|
||||
popen_kwargs = dict(kwargs)
|
||||
supplied_env = popen_kwargs.get("env")
|
||||
operation_env = dict(os.environ if supplied_env is None else supplied_env)
|
||||
operation_env.pop(_DRAIN_FD_ENV, None)
|
||||
operation_env.pop(_DESKTOP_MARKER, None)
|
||||
popen_kwargs["env"] = operation_env
|
||||
supplied_flags = int(popen_kwargs.pop("creationflags", 0))
|
||||
popen_kwargs["creationflags"] = supplied_flags | 0x08000000 | 0x00000004
|
||||
try:
|
||||
child = subprocess.Popen(argv, **popen_kwargs)
|
||||
assign = kernel32.AssignProcessToJobObject
|
||||
assign.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
||||
assign.restype = wintypes.BOOL
|
||||
if not assign(job, wintypes.HANDLE(child._handle)):
|
||||
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject")
|
||||
_resume_windows_process(kernel32, wintypes, child.pid)
|
||||
return WindowsJobPopen(child, job, kernel32)
|
||||
except BaseException:
|
||||
kernel32.TerminateJobObject(job, 1)
|
||||
if child is not None:
|
||||
try:
|
||||
child.kill()
|
||||
except OSError:
|
||||
pass # the suspended child may already have exited
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass # Job termination remains the authoritative cleanup
|
||||
kernel32.CloseHandle(job)
|
||||
raise
|
||||
|
||||
|
||||
def spawn_owned(
|
||||
argv: list[str], **kwargs: Any
|
||||
) -> "subprocess.Popen | OwnedPopen | WindowsJobPopen":
|
||||
"""Spawn an operation with a stable, independently terminable owner."""
|
||||
|
||||
if os.name == "nt":
|
||||
return _spawn_windows_owned(argv, kwargs)
|
||||
|
||||
drain_fd = backend_drain_fd(required=True)
|
||||
control_read, control_write = os.pipe()
|
||||
result_read, result_write = os.pipe()
|
||||
wrapper_argv = _supervisor_argv(
|
||||
control_read,
|
||||
result_write,
|
||||
argv,
|
||||
)
|
||||
wrapper_kwargs = dict(kwargs)
|
||||
wrapper_kwargs["start_new_session"] = True
|
||||
pass_fds = [control_read, result_write]
|
||||
if drain_fd is not None:
|
||||
pass_fds.append(drain_fd)
|
||||
if wrapper_kwargs.get("env") is not None:
|
||||
wrapper_env = dict(wrapper_kwargs["env"])
|
||||
wrapper_env[_DESKTOP_MARKER] = "1"
|
||||
wrapper_env[_DRAIN_FD_ENV] = str(drain_fd)
|
||||
wrapper_kwargs["env"] = wrapper_env
|
||||
wrapper_kwargs["pass_fds"] = tuple(pass_fds)
|
||||
try:
|
||||
proc = subprocess.Popen(wrapper_argv, **wrapper_kwargs)
|
||||
except BaseException:
|
||||
# The finally block exclusively owns the child-side endpoints. Closing
|
||||
# them here as well risks closing a reused descriptor in another thread.
|
||||
for fd in (control_write, result_read):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
# A partial spawn may already have closed a parent-side endpoint.
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
for fd in (control_read, result_write):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
# Popen may have consumed an inherited child-side endpoint.
|
||||
pass
|
||||
return OwnedPopen(proc, control_write, result_read)
|
||||
|
||||
|
||||
def _supervisor_argv(
|
||||
control_token: int,
|
||||
result_token: int,
|
||||
argv: list[str],
|
||||
) -> list[str]:
|
||||
prefix = [sys.executable]
|
||||
if not getattr(sys, "frozen", False):
|
||||
prefix.append(str(Path(__file__).resolve().parents[1] / "main.py"))
|
||||
return [
|
||||
*prefix,
|
||||
"--supervise",
|
||||
str(control_token),
|
||||
str(result_token),
|
||||
"--",
|
||||
*map(str, argv),
|
||||
]
|
||||
|
||||
|
||||
def _write_result(fd: int, returncode: int) -> None:
|
||||
try:
|
||||
os.write(fd, _RESULT.pack(int(returncode)))
|
||||
except OSError:
|
||||
# The caller may have cancelled and closed its result reader.
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
# Writing or cancellation may already have closed the descriptor.
|
||||
pass
|
||||
|
||||
|
||||
def _operation_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
# The operation intentionally does not own the Rust drain writer. Avoid
|
||||
# exposing a stale numeric token which nested code could mistake as valid.
|
||||
env.pop(_DRAIN_FD_ENV, None)
|
||||
env.pop(_DESKTOP_MARKER, None)
|
||||
return env
|
||||
|
||||
|
||||
def _supervise_posix(control_fd: int, result_fd: int, argv: list[str]) -> int:
|
||||
def cancel_on_eof() -> None:
|
||||
try:
|
||||
while os.read(control_fd, 1):
|
||||
pass
|
||||
except OSError:
|
||||
# Closing the control descriptor is itself a cancellation signal.
|
||||
pass
|
||||
os.killpg(os.getpgrp(), signal.SIGKILL)
|
||||
|
||||
threading.Thread(target=cancel_on_eof, daemon=True).start()
|
||||
try:
|
||||
child = subprocess.Popen(argv, close_fds=True, env=_operation_env())
|
||||
rc = child.wait()
|
||||
except OSError:
|
||||
rc = 127
|
||||
_write_result(result_fd, rc)
|
||||
# Drain children which outlived the operation before the stable group
|
||||
# leader exits. SIGKILL intentionally includes this supervisor.
|
||||
os.killpg(os.getpgrp(), signal.SIGKILL)
|
||||
return rc # unreachable
|
||||
|
||||
|
||||
def _windows_job() -> tuple[Any, Any, Any]:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
kernel32.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT)
|
||||
kernel32.TerminateJobObject.restype = wintypes.BOOL
|
||||
kernel32.ReadFile.argtypes = (
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
ctypes.c_void_p,
|
||||
)
|
||||
kernel32.ReadFile.restype = wintypes.BOOL
|
||||
kernel32.WriteFile.argtypes = (
|
||||
wintypes.HANDLE,
|
||||
ctypes.c_void_p,
|
||||
wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
ctypes.c_void_p,
|
||||
)
|
||||
kernel32.WriteFile.restype = wintypes.BOOL
|
||||
create = kernel32.CreateJobObjectW
|
||||
create.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR)
|
||||
create.restype = wintypes.HANDLE
|
||||
job = create(None, None)
|
||||
if not job:
|
||||
raise OSError(ctypes.get_last_error(), "CreateJobObjectW")
|
||||
|
||||
class BasicLimits(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
||||
("PerJobUserTimeLimit", ctypes.c_longlong),
|
||||
("LimitFlags", wintypes.DWORD),
|
||||
("MinimumWorkingSetSize", ctypes.c_size_t),
|
||||
("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||
("ActiveProcessLimit", wintypes.DWORD),
|
||||
("Affinity", ctypes.c_size_t),
|
||||
("PriorityClass", wintypes.DWORD),
|
||||
("SchedulingClass", wintypes.DWORD),
|
||||
]
|
||||
|
||||
class IoCounters(ctypes.Structure):
|
||||
_fields_ = [(name, ctypes.c_ulonglong) for name in (
|
||||
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
||||
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount",
|
||||
)]
|
||||
|
||||
class ExtendedLimits(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("BasicLimitInformation", BasicLimits),
|
||||
("IoInfo", IoCounters),
|
||||
("ProcessMemoryLimit", ctypes.c_size_t),
|
||||
("JobMemoryLimit", ctypes.c_size_t),
|
||||
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
||||
("PeakJobMemoryUsed", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
info = ExtendedLimits()
|
||||
info.BasicLimitInformation.LimitFlags = 0x00002000 # KILL_ON_JOB_CLOSE
|
||||
set_info = kernel32.SetInformationJobObject
|
||||
set_info.argtypes = (wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD)
|
||||
set_info.restype = wintypes.BOOL
|
||||
if not set_info(job, 9, ctypes.byref(info), ctypes.sizeof(info)):
|
||||
error = ctypes.get_last_error()
|
||||
kernel32.CloseHandle(job)
|
||||
raise OSError(error, "SetInformationJobObject")
|
||||
return job, kernel32, wintypes
|
||||
|
||||
|
||||
def _resume_windows_process(kernel32: Any, wintypes: Any, pid: int) -> None:
|
||||
import ctypes
|
||||
|
||||
class ThreadEntry(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", wintypes.DWORD),
|
||||
("cntUsage", wintypes.DWORD),
|
||||
("th32ThreadID", wintypes.DWORD),
|
||||
("th32OwnerProcessID", wintypes.DWORD),
|
||||
("tpBasePri", wintypes.LONG),
|
||||
("tpDeltaPri", wintypes.LONG),
|
||||
("dwFlags", wintypes.DWORD),
|
||||
]
|
||||
|
||||
kernel32.CreateToolhelp32Snapshot.argtypes = (wintypes.DWORD, wintypes.DWORD)
|
||||
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
||||
kernel32.Thread32First.argtypes = (wintypes.HANDLE, ctypes.POINTER(ThreadEntry))
|
||||
kernel32.Thread32First.restype = wintypes.BOOL
|
||||
kernel32.Thread32Next.argtypes = (wintypes.HANDLE, ctypes.POINTER(ThreadEntry))
|
||||
kernel32.Thread32Next.restype = wintypes.BOOL
|
||||
kernel32.OpenThread.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
kernel32.OpenThread.restype = wintypes.HANDLE
|
||||
kernel32.ResumeThread.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.ResumeThread.restype = wintypes.DWORD
|
||||
|
||||
snapshot = kernel32.CreateToolhelp32Snapshot(0x00000004, 0)
|
||||
invalid = ctypes.c_void_p(-1).value
|
||||
if snapshot == invalid:
|
||||
raise OSError(ctypes.get_last_error(), "CreateToolhelp32Snapshot")
|
||||
try:
|
||||
entry = ThreadEntry(dwSize=ctypes.sizeof(ThreadEntry))
|
||||
found = kernel32.Thread32First(snapshot, ctypes.byref(entry))
|
||||
while found:
|
||||
if entry.th32OwnerProcessID == pid:
|
||||
thread = kernel32.OpenThread(0x0002, False, entry.th32ThreadID)
|
||||
if not thread:
|
||||
raise OSError(ctypes.get_last_error(), "OpenThread")
|
||||
try:
|
||||
if kernel32.ResumeThread(thread) == 0xFFFFFFFF:
|
||||
raise OSError(ctypes.get_last_error(), "ResumeThread")
|
||||
return
|
||||
finally:
|
||||
kernel32.CloseHandle(thread)
|
||||
found = kernel32.Thread32Next(snapshot, ctypes.byref(entry))
|
||||
finally:
|
||||
kernel32.CloseHandle(snapshot)
|
||||
raise OSError("suspended operation thread was not found")
|
||||
|
||||
|
||||
def _supervise_windows(control_fd: int, result_fd: int, argv: list[str]) -> int:
|
||||
import ctypes
|
||||
|
||||
job, kernel32, wintypes = _windows_job()
|
||||
cancelled = threading.Event()
|
||||
job_lock = threading.Lock()
|
||||
job_open = True
|
||||
|
||||
def terminate_job() -> None:
|
||||
with job_lock:
|
||||
if job_open:
|
||||
kernel32.TerminateJobObject(job, 1)
|
||||
|
||||
def cancel_on_eof() -> None:
|
||||
byte = ctypes.create_string_buffer(1)
|
||||
count = wintypes.DWORD()
|
||||
while kernel32.ReadFile(
|
||||
wintypes.HANDLE(control_fd), byte, 1, ctypes.byref(count), None
|
||||
) and count.value:
|
||||
pass
|
||||
kernel32.CloseHandle(wintypes.HANDLE(control_fd))
|
||||
cancelled.set()
|
||||
terminate_job()
|
||||
|
||||
threading.Thread(target=cancel_on_eof, daemon=True).start()
|
||||
child: Optional[subprocess.Popen] = None
|
||||
rc = 127
|
||||
try:
|
||||
child = subprocess.Popen(
|
||||
argv,
|
||||
close_fds=True,
|
||||
env=_operation_env(),
|
||||
creationflags=0x08000000 | 0x00000004, # NO_WINDOW | SUSPENDED
|
||||
)
|
||||
assign = kernel32.AssignProcessToJobObject
|
||||
assign.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
|
||||
assign.restype = wintypes.BOOL
|
||||
if not assign(job, wintypes.HANDLE(child._handle)):
|
||||
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject")
|
||||
if cancelled.is_set():
|
||||
terminate_job()
|
||||
else:
|
||||
_resume_windows_process(kernel32, wintypes, child.pid)
|
||||
rc = child.wait()
|
||||
# A successful direct child may leave helpers behind; terminate the
|
||||
# nested stable Job before reporting completion.
|
||||
terminate_job()
|
||||
except OSError:
|
||||
terminate_job()
|
||||
if child is not None:
|
||||
try:
|
||||
# Assignment itself may have failed, leaving this suspended
|
||||
# process outside the nested Job. Terminate it through its
|
||||
# stable process handle before waiting; never strand an
|
||||
# unassigned operation or rely on the outer desktop Job.
|
||||
child.kill()
|
||||
except OSError:
|
||||
# The suspended child may have exited during Job teardown.
|
||||
pass
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
# The outer desktop Job remains the terminal containment fallback.
|
||||
pass
|
||||
finally:
|
||||
payload = _RESULT.pack(int(rc))
|
||||
payload_buffer = ctypes.create_string_buffer(payload)
|
||||
written = wintypes.DWORD()
|
||||
kernel32.WriteFile(
|
||||
wintypes.HANDLE(result_fd),
|
||||
payload_buffer,
|
||||
len(payload),
|
||||
ctypes.byref(written),
|
||||
None,
|
||||
)
|
||||
kernel32.CloseHandle(wintypes.HANDLE(result_fd))
|
||||
with job_lock:
|
||||
job_open = False
|
||||
kernel32.CloseHandle(job)
|
||||
return rc
|
||||
|
||||
|
||||
def supervisor_main(args: list[str]) -> int:
|
||||
if len(args) < 5 or args[0] != "--supervise" or args[3] != "--":
|
||||
return 2
|
||||
control_fd = int(args[1])
|
||||
result_fd = int(args[2])
|
||||
argv = args[4:]
|
||||
secure_backend_drain_fd()
|
||||
if os.name == "posix":
|
||||
return _supervise_posix(control_fd, result_fd, argv)
|
||||
return _supervise_windows(control_fd, result_fd, argv)
|
||||
|
||||
|
||||
def _main() -> int:
|
||||
return supervisor_main(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main())
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Native-crash diagnostics for the backend process (#2135).
|
||||
|
||||
A crash inside torch/CUDA — graph capture, a driver fault, an allocator abort —
|
||||
kills the interpreter below the level any ``except`` can reach. #2135's reporter
|
||||
saw exactly that: the backend "simply exited" mid-``/generate`` with no Python
|
||||
traceback, no HTTP response, and ``ConnectionRefused`` on the next ``/health``.
|
||||
There was nothing in the logs to diagnose because nothing in Python ever ran
|
||||
again.
|
||||
|
||||
``faulthandler`` installs handlers for the fatal signals (SIGSEGV, SIGABRT,
|
||||
SIGBUS, SIGFPE, SIGILL) that print every thread's Python stack to stderr on the
|
||||
way down. That is the difference between "the process vanished" and a named
|
||||
frame pointing at the engine call that killed it.
|
||||
|
||||
This is strictly a diagnostic: it does not prevent the crash, and it must never
|
||||
be the reason startup fails.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
_DISABLE_ENV = "OMNIVOICE_DISABLE_FAULTHANDLER"
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def _disabled() -> bool:
|
||||
return os.environ.get(_DISABLE_ENV, "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def enable_fault_handler(stderr=None) -> bool:
|
||||
"""Arm fatal-signal tracebacks. Returns True when armed.
|
||||
|
||||
Call as early as possible — before torch is imported — so a crash during
|
||||
model load is covered too. Honours ``OMNIVOICE_DISABLE_FAULTHANDLER=1`` for
|
||||
hosts whose outer supervisor installs its own handlers.
|
||||
|
||||
Args:
|
||||
stderr: optional file object to write dumps to. Defaults to the real
|
||||
``sys.stderr`` (→ ``backend_err.log``). faulthandler keeps the
|
||||
underlying fd, so the object must stay open for the process
|
||||
lifetime.
|
||||
|
||||
Never raises: a frozen build with a detached stderr, or a platform without
|
||||
the signals, degrades to "no crash dump" rather than a failed boot.
|
||||
"""
|
||||
if _disabled():
|
||||
return False
|
||||
try:
|
||||
import faulthandler
|
||||
|
||||
# all_threads=True: the fatal frame is routinely on a GPU-pool or
|
||||
# compile worker, not whichever thread happens to take the signal.
|
||||
if stderr is not None:
|
||||
faulthandler.enable(file=stderr, all_threads=True)
|
||||
else:
|
||||
faulthandler.enable(all_threads=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Exact-origin CSRF checks for ambient browser authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import SplitResult, urlsplit
|
||||
|
||||
|
||||
CSRF_HEADER = "x-voicestudio-csrf"
|
||||
CSRF_VALUE = "1"
|
||||
SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
_FORWARDED_PROTO_HEADER = "x-forwarded-proto"
|
||||
|
||||
|
||||
def effective_scheme(connection) -> str:
|
||||
"""Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence.
|
||||
|
||||
Behind a TLS-terminating proxy (Tailscale Serve — the flagship remote-GPU
|
||||
deployment in docs/remote-gpu.md — nginx, Caddy, ...) the browser talks
|
||||
``https`` while the backend hop is plain ``http``. uvicorn's
|
||||
ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run``
|
||||
in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already
|
||||
rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer
|
||||
is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on
|
||||
bare metal, and we prefer that signal — the scope is consulted first — but
|
||||
it misses Docker (the proxy connects from the bridge gateway) and any other
|
||||
non-loopback proxy topology, so the header is honored here as well.
|
||||
|
||||
Spoofing analysis — why honoring it never weakens a check: the upgrade is
|
||||
one-way. ``https``/``wss`` as the first forwarded value promotes ``http``
|
||||
to ``https``; every other value is ignored, so a forged header can never
|
||||
downgrade a genuine TLS hop. For the exact-origin comparison the host:port
|
||||
half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto
|
||||
cross-site without a CORS preflight this API never grants, and a
|
||||
non-browser client able to forge the header can already forge Origin
|
||||
itself — it gains nothing. For cookies the upgrade can only ADD the Secure
|
||||
flag (a Secure cookie set over plain http is simply dropped by the
|
||||
browser — the spoofer only breaks their own session), never strip it.
|
||||
"""
|
||||
url = getattr(connection, "url", None)
|
||||
scheme = getattr(url, "scheme", None)
|
||||
if not scheme:
|
||||
scope = getattr(connection, "scope", None)
|
||||
scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http"
|
||||
scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme)
|
||||
if scheme != "https":
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
forwarded = (
|
||||
headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else ""
|
||||
)
|
||||
if forwarded.split(",")[0].strip().lower() in {"https", "wss"}:
|
||||
scheme = "https"
|
||||
return scheme
|
||||
|
||||
|
||||
def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None:
|
||||
if not value or value == "null":
|
||||
return None
|
||||
try:
|
||||
parsed: SplitResult = urlsplit(value)
|
||||
port = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if (
|
||||
not parsed.scheme
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in ("", "/")
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in {"http", "https", "tauri", "app"}:
|
||||
return None
|
||||
if port is None:
|
||||
if scheme == "http":
|
||||
port = 80
|
||||
elif scheme == "https":
|
||||
port = 443
|
||||
return scheme, parsed.hostname.lower(), port
|
||||
|
||||
|
||||
DEFAULT_DESKTOP_ORIGINS = ("tauri://localhost", "http://tauri.localhost", "app://voicestudio")
|
||||
|
||||
def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]:
|
||||
raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901")
|
||||
try:
|
||||
ui_port = int(raw_port)
|
||||
except (TypeError, ValueError):
|
||||
ui_port = 3901
|
||||
values = os.environ.get(
|
||||
"OMNIVOICE_ALLOWED_ORIGINS",
|
||||
f"http://localhost:{ui_port},http://127.0.0.1:{ui_port},"
|
||||
+ ",".join(DEFAULT_DESKTOP_ORIGINS),
|
||||
).split(",")
|
||||
return frozenset(
|
||||
origin
|
||||
for value in values
|
||||
if (origin := _origin_tuple(value.strip())) is not None
|
||||
)
|
||||
|
||||
|
||||
def _destination_origin(connection) -> tuple[str, str, int | None] | None:
|
||||
scheme = effective_scheme(connection)
|
||||
url = getattr(connection, "url", None)
|
||||
netloc = getattr(url, "netloc", None)
|
||||
if netloc:
|
||||
return _origin_tuple(f"{scheme}://{netloc}")
|
||||
scope = getattr(connection, "scope", None)
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
if not isinstance(scope, dict):
|
||||
return None
|
||||
host = headers.get("host", "") if hasattr(headers, "get") else ""
|
||||
return _origin_tuple(f"{scheme}://{host}")
|
||||
|
||||
|
||||
def origin_allowed(connection) -> bool:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
origin_value = headers.get("origin", "") if hasattr(headers, "get") else ""
|
||||
presented = _origin_tuple(origin_value)
|
||||
if presented is None:
|
||||
return False
|
||||
return presented == _destination_origin(connection) or presented in configured_allowed_origins()
|
||||
|
||||
|
||||
def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else ""
|
||||
if marker != CSRF_VALUE or not origin_allowed(connection):
|
||||
return False
|
||||
method = getattr(connection, "method", None)
|
||||
if method is None:
|
||||
scope = getattr(connection, "scope", None)
|
||||
method = scope.get("method", "GET") if isinstance(scope, dict) else "GET"
|
||||
method = str(method).upper()
|
||||
if side_effectful_get or method in SAFE_HTTP_METHODS:
|
||||
fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else ""
|
||||
return fetch_site == "same-origin"
|
||||
return True
|
||||
@@ -176,109 +176,6 @@ _BASE_SCHEMA = """
|
||||
created_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pron_lang ON pronunciation_entries(language);
|
||||
|
||||
-- Remote GPU workers (docs/remote-workers.md). Opt-in: an install with no
|
||||
-- remote workers never writes a row here and behaves exactly as before.
|
||||
--
|
||||
-- `public_key` is the worker's identity — a server-assigned id is a name,
|
||||
-- not proof, so every reconnect is verified against this key. Revocation
|
||||
-- is a persisted fact (not in-memory state) precisely so a restart of the
|
||||
-- control plane cannot silently readmit a worker the user removed.
|
||||
CREATE TABLE IF NOT EXISTS remote_workers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
key_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at REAL,
|
||||
priority INTEGER NOT NULL DEFAULT 50,
|
||||
endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_json TEXT NOT NULL DEFAULT '{}',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
|
||||
-- Bumped on every successful (re)connect. Messages stamped with an
|
||||
-- older epoch are from a session we have already replaced.
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
consent_granted_at REAL,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_workers_key ON remote_workers(key_id);
|
||||
|
||||
-- Single-use join tokens. Only the hash is stored: the plaintext exists
|
||||
-- once, in the dialog that shows it.
|
||||
CREATE TABLE IF NOT EXISTS remote_worker_enrollments (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
secret_hash TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL DEFAULT '',
|
||||
cert_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL,
|
||||
used_at REAL,
|
||||
used_by_worker TEXT
|
||||
);
|
||||
|
||||
-- Tasks dispatched to remote workers. Unlike the local `jobs` table (whose
|
||||
-- startup sweep marks anything in-flight as failed), these must SURVIVE a
|
||||
-- control-plane restart: the desktop app quits while a remote GPU keeps
|
||||
-- rendering, and the worker is the source of truth for what is still
|
||||
-- running. Reconciliation on reconnect rebuilds live state from here.
|
||||
CREATE TABLE IF NOT EXISTS remote_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
-- Client-supplied; deduplicates client retries before the worker
|
||||
-- protocol is involved at all.
|
||||
idempotency_key TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
engine TEXT NOT NULL DEFAULT '',
|
||||
model_id TEXT NOT NULL DEFAULT '',
|
||||
params_json TEXT NOT NULL DEFAULT '{}',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
excluded_json TEXT NOT NULL DEFAULT '[]',
|
||||
error_json TEXT,
|
||||
-- Written BEFORE RESULT_ACK is sent. If the server dies between
|
||||
-- receiving a result and acknowledging it, the worker redelivers and
|
||||
-- this row is what makes the second delivery a no-op instead of a
|
||||
-- silently lost multi-minute render.
|
||||
result_ref TEXT,
|
||||
result_json TEXT,
|
||||
project_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
deadline_at REAL,
|
||||
-- Deliberate additive-reconcile exception to the alembic rule: remote
|
||||
-- task recovery must work in bundled installs where alembic may be
|
||||
-- unavailable, and this nullable affinity column is additive-only.
|
||||
pinned_worker_id TEXT,
|
||||
finished_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_tasks_state ON remote_tasks(state, priority, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_tasks_idem ON remote_tasks(idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_task_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1,
|
||||
state TEXT NOT NULL DEFAULT 'assigned',
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
stage TEXT NOT NULL DEFAULT '',
|
||||
error_json TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
accepted_at REAL,
|
||||
started_at REAL,
|
||||
finished_at REAL,
|
||||
lease_expires_at REAL,
|
||||
grace_expires_at REAL,
|
||||
deadlines_json TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
|
||||
"""
|
||||
|
||||
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
|
||||
|
||||
@@ -51,23 +51,6 @@ _DIALECTS = set(_VD._INSTRUCT_CATEGORIES[5]) # the 12 Chinese dialect tokens
|
||||
# the archetype ``attrs`` shape, so the response drops straight into vdStates.
|
||||
CATEGORY_ORDER = ("Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect")
|
||||
|
||||
|
||||
def instruct_to_vd_states(instruct: str | None) -> dict[str, str]:
|
||||
"""Project a saved validator-token instruct onto the complete UI recipe."""
|
||||
attrs = {category: "Auto" for category in CATEGORY_ORDER}
|
||||
sanitized = _VD.sanitize_instruct(instruct)
|
||||
if not sanitized:
|
||||
return attrs
|
||||
for token in sanitized.split(", "):
|
||||
category_index = _VD._instruct_category_index(token)
|
||||
if category_index < 0 or category_index >= len(CATEGORY_ORDER):
|
||||
continue
|
||||
# The first four frontend categories use the English canonical token;
|
||||
# dialects and accents already use their engine-native form.
|
||||
canonical = _VD._INSTRUCT_ZH_TO_EN.get(token, token)
|
||||
attrs[CATEGORY_ORDER[category_index]] = canonical
|
||||
return attrs
|
||||
|
||||
# ── Pinyin / romanized names → Chinese-dialect tokens (functional vocabulary) ─
|
||||
DIALECT_PINYIN = {
|
||||
"henan": "河南话",
|
||||
|
||||
+4
-125
@@ -35,8 +35,7 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "npu", "cpu"]
|
||||
ACCELERATOR_PRIORITY = ("cuda", "rocm", "xpu", "npu", "mps")
|
||||
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "cpu"]
|
||||
|
||||
# Stable substring stamped onto notes that represent a real kernel-launch risk
|
||||
# (arch/driver mismatch) — as opposed to advisory notes (multi-GPU, VRAM query
|
||||
@@ -178,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, ...]:
|
||||
@@ -248,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 "
|
||||
@@ -426,33 +374,6 @@ class HostCaps:
|
||||
probe_ok: bool = True
|
||||
"""``False`` only when torch could not be imported (degraded CPU-only)."""
|
||||
|
||||
requested_family: str = "auto"
|
||||
"""The user's compute-device override as requested — ``"auto"`` when none.
|
||||
``family`` reflects what was actually honored: an override that names a
|
||||
family this host doesn't have is noted and ignored, never obeyed blindly."""
|
||||
|
||||
|
||||
#: Every value the compute-device override accepts. "auto" = today's
|
||||
#: priority pick; "cpu" is always honorable (invariant: cpu is always
|
||||
#: available); accelerator names are honored only when detected.
|
||||
DEVICE_OVERRIDE_CHOICES: tuple[str, ...] = ("auto", "cuda", "rocm", "xpu", "mps", "cpu")
|
||||
|
||||
|
||||
def requested_device_override() -> str:
|
||||
"""The user's compute-device pick: ``OMNIVOICE_DEVICE`` env > the Settings
|
||||
choice (``compute_device`` in prefs.json) > ``"auto"``. Env wins so
|
||||
power-users can pin a device without the UI silently undoing it (same
|
||||
resolution order as engine selection, #981). Unknown values normalize to
|
||||
``"auto"`` — the probe must never raise."""
|
||||
try:
|
||||
from core import prefs
|
||||
|
||||
raw = prefs.resolve("compute_device", env="OMNIVOICE_DEVICE", default="auto")
|
||||
except Exception:
|
||||
raw = os.environ.get("OMNIVOICE_DEVICE", "auto")
|
||||
val = str(raw or "auto").strip().lower()
|
||||
return val if val in DEVICE_OVERRIDE_CHOICES else "auto"
|
||||
|
||||
|
||||
def _probe() -> HostCaps:
|
||||
"""Run the probe once. Enumerates every failure branch from the spec's
|
||||
@@ -465,7 +386,6 @@ def _probe() -> HostCaps:
|
||||
available_families=("cpu",),
|
||||
notes=("torch not importable; treating host as CPU-only",),
|
||||
probe_ok=False,
|
||||
requested_family=requested_device_override(),
|
||||
)
|
||||
|
||||
notes: list[str] = []
|
||||
@@ -534,13 +454,9 @@ def _probe() -> HostCaps:
|
||||
# is the whole truth in that case (CodeRabbit, #1425).
|
||||
notes.extend(why_no_gpu(torch))
|
||||
|
||||
# Older builds register XPU through IPEX; modern torch exposes it directly.
|
||||
# ── Intel XPU via IPEX ───────────────────────────────────────────────
|
||||
try:
|
||||
import intel_extension_for_pytorch # noqa: F401
|
||||
except Exception:
|
||||
# Optional IPEX may be absent or incompatible; still probe native torch XPU.
|
||||
pass
|
||||
try:
|
||||
if hasattr(torch, "xpu") and torch.xpu.is_available():
|
||||
detected.append("xpu")
|
||||
if not device_name:
|
||||
@@ -551,23 +467,7 @@ def _probe() -> HostCaps:
|
||||
pass
|
||||
notes.append("XPU VRAM not queried (unreliable across IPEX versions)")
|
||||
except Exception:
|
||||
# XPU probe failed — no usable XPU on this host.
|
||||
pass
|
||||
|
||||
# Vendor extensions may register an NPU with torch. Probe only an already
|
||||
# registered backend; never install or import an optional vendor package.
|
||||
try:
|
||||
if hasattr(torch, "npu") and torch.npu.is_available():
|
||||
detected.append("npu")
|
||||
if not device_name:
|
||||
try:
|
||||
device_name = torch.npu.get_device_name(0)
|
||||
except Exception:
|
||||
# An unavailable display name does not invalidate a usable NPU.
|
||||
pass
|
||||
notes.append("NPU VRAM not queried")
|
||||
except Exception:
|
||||
# Missing or broken vendor backends mean no usable NPU; continue probing.
|
||||
# IPEX absent or XPU probe failed — no XPU on this host.
|
||||
pass
|
||||
|
||||
# ── Apple Silicon MPS ────────────────────────────────────────────────
|
||||
@@ -600,33 +500,13 @@ def _probe() -> HostCaps:
|
||||
|
||||
# Preferred family by priority; cpu when nothing accelerated was detected.
|
||||
family: DeviceFamily = "cpu"
|
||||
for pref in ACCELERATOR_PRIORITY:
|
||||
for pref in ("cuda", "rocm", "xpu", "mps"):
|
||||
if pref in detected:
|
||||
family = pref # type: ignore[assignment]
|
||||
break
|
||||
# available_families: every detected accelerator + cpu, deduped, cpu last.
|
||||
available: tuple[DeviceFamily, ...] = tuple(dict.fromkeys([*detected, "cpu"]))
|
||||
|
||||
# User override (Settings → Performance, or OMNIVOICE_DEVICE): honored
|
||||
# only when the named family actually exists on this host — an override
|
||||
# can steer, it cannot invent hardware. Applied here, at the single
|
||||
# choke point, so routing, model loads (get_best_device delegates its
|
||||
# family decision here), and every badge inherit it for free.
|
||||
requested = requested_device_override()
|
||||
if requested != "auto":
|
||||
if requested in available:
|
||||
if requested != family:
|
||||
notes.append(
|
||||
f"compute device pinned to '{requested}' by user override "
|
||||
f"(auto would pick '{family}')"
|
||||
)
|
||||
family = requested # type: ignore[assignment]
|
||||
else:
|
||||
notes.append(
|
||||
f"requested compute device '{requested}' is not available on "
|
||||
f"this host (have: {', '.join(available)}) — using '{family}'"
|
||||
)
|
||||
|
||||
return HostCaps(
|
||||
family=family,
|
||||
available_families=available,
|
||||
@@ -635,7 +515,6 @@ def _probe() -> HostCaps:
|
||||
driver=driver,
|
||||
notes=tuple(notes),
|
||||
probe_ok=True,
|
||||
requested_family=requested,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,14 +21,12 @@ Check shape:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core.device_caps import KERNEL_RISK_MARKER
|
||||
from core.scrub import scrub_text
|
||||
from core.version import APP_VERSION
|
||||
|
||||
@@ -190,7 +188,7 @@ def _check_ram() -> dict:
|
||||
def _check_engines() -> dict:
|
||||
try:
|
||||
from services.tts_backend import list_backends, active_backend_id
|
||||
backends = list_backends(include_hidden=True)
|
||||
backends = list_backends()
|
||||
active = active_backend_id()
|
||||
except Exception as e:
|
||||
return _check("engines", "TTS engines", WARN, f"could not enumerate: {e}")
|
||||
@@ -202,12 +200,12 @@ def _check_engines() -> dict:
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL,
|
||||
f"{detail} - active engine '{active}' is unavailable: {reason}",
|
||||
active_row.get("install_hint") or "Pick a different engine in Model Catalogue > Engines.",
|
||||
active_row.get("install_hint") or "Pick a different engine in Settings > Engines.",
|
||||
)
|
||||
if not available:
|
||||
return _check(
|
||||
"engines", "TTS engines", FAIL, detail,
|
||||
"No usable TTS engine. Install one from Model Catalogue > Engines.",
|
||||
"No usable TTS engine. Install one from Settings > Engines.",
|
||||
)
|
||||
return _check("engines", "TTS engines", OK, detail)
|
||||
|
||||
@@ -231,16 +229,11 @@ def _check_gpu_routing() -> dict:
|
||||
host = v.get("host_family", "cpu")
|
||||
|
||||
if status == "accelerated":
|
||||
if reason and KERNEL_RISK_MARKER in reason: # driver/arch caveat — at risk
|
||||
if reason: # driver/arch caveat — accelerated but at risk
|
||||
return _check("gpu_routing", "GPU routing", WARN,
|
||||
f"{engine} -> {dev}: {reason}",
|
||||
"The GPU is selected but may fail at kernel launch — "
|
||||
"update drivers / reinstall torch for this GPU arch.")
|
||||
if reason: # low-VRAM caveat — not a driver/arch issue
|
||||
return _check("gpu_routing", "GPU routing", WARN,
|
||||
f"{engine} -> {dev}: {reason}",
|
||||
"Unload other models before generating, keep the text "
|
||||
"short, or pick a lighter engine.")
|
||||
return _check("gpu_routing", "GPU routing", OK, f"{engine} -> {dev} (accelerated)")
|
||||
if status == "cpu_fallback":
|
||||
return _check("gpu_routing", "GPU routing", WARN,
|
||||
@@ -374,49 +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()
|
||||
rows = (
|
||||
module.list_backends(include_hidden=True)
|
||||
if family == "tts"
|
||||
else module.list_backends()
|
||||
)
|
||||
row = next((item for item in rows 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],
|
||||
@@ -441,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(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Stable engine IDs whose first use requires local license acceptance."""
|
||||
|
||||
LICENSE_GATED_ENGINES: frozenset[str] = frozenset({"supertonic3", "pockettts"})
|
||||
@@ -4,7 +4,7 @@ Used by the React ErrorBoundary's "Open docs for this error" button (via the
|
||||
TypeScript mirror at `frontend/src/utils/errorDocsMap.ts`) and by the Phase 5
|
||||
bug-reporter for "this error has a docs page" links.
|
||||
|
||||
The error taxonomy below is the contract — Phase 5 reporter consumes it,
|
||||
The 5-class taxonomy below is the contract — Phase 5 reporter consumes it,
|
||||
the TS map mirrors it, and `test_error_docs_map.test_keys_match_taxonomy`
|
||||
locks the key set. To add a new class:
|
||||
|
||||
@@ -20,8 +20,6 @@ from core import links
|
||||
_BASE = links.PROJECT_REPO_BLOB_MAIN
|
||||
|
||||
ERROR_DOCS: dict[str, str] = {
|
||||
"DIARIZATION_LOAD_FAILED": f"{_BASE}/docs/features/diarization.md#troubleshooting",
|
||||
"DIARIZATION_MODEL_MISSING": f"{_BASE}/docs/features/diarization.md#local-installation-and-repair",
|
||||
"GATEKEEPER_QUARANTINE": f"{_BASE}/docs/install/macos.md#gatekeeper-quarantine",
|
||||
"APPIMAGE_WEBKIT_WHITESCREEN": f"{_BASE}/docs/install/linux.md#appimage-white-screen-on-fedora-44--ubuntu-2404",
|
||||
"PKG_RESOURCES_MISSING": f"{_BASE}/docs/install/troubleshooting.md#pkg_resources-missing",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user