Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24efca6a83 | ||
|
|
28f69d37aa | ||
|
|
ca7fb9c68d | ||
|
|
3dfe9664cf | ||
|
|
cc9c7cfa18 | ||
|
|
51163cf260 | ||
|
|
4ce4f05c06 | ||
|
|
e77feae817 | ||
|
|
fdc02b398e | ||
|
|
6e1bb44e0d | ||
|
|
4dc90a7f4f | ||
|
|
3b64d317ae | ||
|
|
ee7202b1eb | ||
|
|
871d68a6ff | ||
|
|
b37466b2e5 | ||
|
|
2d5f2e800e | ||
|
|
4db02d0c97 | ||
|
|
ee35d2389e | ||
|
|
1fda5bdf96 | ||
|
|
2477dde688 | ||
|
|
48c9a3b1f8 | ||
|
|
030d5ea01f | ||
|
|
b79ba9bd3b | ||
|
|
3d0c9605df | ||
|
|
bb813ff676 | ||
|
|
bc6acec5a3 | ||
|
|
94ba362ef2 | ||
|
|
aabe5783f3 | ||
|
|
854b4852ed | ||
|
|
579f2e0a2e | ||
|
|
420bc73e78 | ||
|
|
fb46fa4788 | ||
|
|
f83b7371c3 | ||
|
|
9184d7d625 | ||
|
|
0ee62b2261 | ||
|
|
77ae194f9c | ||
|
|
d3822c4976 | ||
|
|
19ae20111a | ||
|
|
b982192011 | ||
|
|
81b146f53b | ||
|
|
72cabb3daf | ||
|
|
4a228a00b4 | ||
|
|
f81ace68d1 | ||
|
|
9832fbd693 | ||
|
|
cd54113173 | ||
|
|
6948399e61 |
@@ -0,0 +1,60 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,357 @@
|
||||
---
|
||||
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
|
||||
@@ -271,6 +271,17 @@ 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
|
||||
@@ -365,6 +376,19 @@ 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)
|
||||
|
||||
@@ -731,6 +731,62 @@ jobs:
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
|
||||
# machine AFTER tauri's files-map has placed the real icon bytes — the
|
||||
# exact bug #1518 guarded against, resurfacing on the first real tag
|
||||
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
|
||||
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
|
||||
# signature covered the old bytes), and clobber the draft release's
|
||||
# asset + the linux signature inside latest.json. The smoke below then
|
||||
# validates the repaired artifact, not the broken one.
|
||||
- name: Repair AppImage .DirIcon, re-sign, re-upload
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# Data, not shell source (zizmor template-injection): a crafted ref
|
||||
# must never expand inside a script that holds the signing key.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
WORK="$(mktemp -d)"; cd "$WORK"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$WORK/squashfs-root"
|
||||
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
|
||||
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
|
||||
echo ".DirIcon already resolves inside the bundle — no repair needed"
|
||||
exit 0
|
||||
fi
|
||||
# The real bytes are at the AppDir root (linuxdeploy put them there
|
||||
# before mislinking). Ship a regular file: nothing left to dangle.
|
||||
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
|
||||
rm -f "$ROOT/.DirIcon"
|
||||
cp "$SRC" "$ROOT/.DirIcon"
|
||||
# Pinned immutable release + checksum: this binary runs with the
|
||||
# updater signing key and a release-write token in its environment,
|
||||
# so a mutable 'continuous' asset is not acceptable supply chain.
|
||||
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
|
||||
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
|
||||
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
|
||||
chmod +x "$WORK/appimagetool"
|
||||
# Same FUSE-less trick the build itself uses.
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
|
||||
cd "$GITHUB_WORKSPACE/frontend"
|
||||
bunx tauri signer sign "$APPIMAGE"
|
||||
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
# latest.json is NOT patched here: every tauri-action leg re-uploads
|
||||
# the shared manifest, so an in-leg patch races the other platforms —
|
||||
# the repair-updater-manifest job below is the single final writer.
|
||||
echo "repacked, re-signed, re-uploaded"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
@@ -844,6 +900,50 @@ jobs:
|
||||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||||
# idempotent. This can never create a second release.
|
||||
# The Linux leg may repack + re-sign its AppImage (see the repair step in
|
||||
# the build matrix); every tauri-action leg also re-uploads the SHARED
|
||||
# latest.json, so patching the manifest inside any leg races the others.
|
||||
# This job runs once after the whole matrix as the single final writer:
|
||||
# it makes the manifest's linux signature agree with the .sig asset that
|
||||
# actually shipped, and refuses to leave a mismatch behind.
|
||||
repair-updater-manifest:
|
||||
needs: [build, preview-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Data, not shell source — same zizmor rule as the leg step.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
steps:
|
||||
- name: Align latest.json's linux signature with the shipped .sig asset
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
|
||||
if [ "$HAS_MANIFEST" != "true" ]; then
|
||||
echo "no latest.json on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
|
||||
# Same fail-closed rule as the manifest: absence is checked against
|
||||
# the asset LIST; an actual download failure must fail the job, or
|
||||
# the manifest keeps a signature nobody shipped.
|
||||
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
|
||||
if [ "$HAS_SIG" != "true" ]; then
|
||||
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
|
||||
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
|
||||
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
|
||||
NEW_SIG=$(cat "$SIG_FILE")
|
||||
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
|
||||
if [ "$CHANGED" -ge 1 ]; then
|
||||
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
|
||||
else
|
||||
echo "manifest already agrees with the shipped .sig — no write"
|
||||
fi
|
||||
|
||||
uninstall-scripts:
|
||||
needs: [build]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
|
||||
@@ -25,4 +25,6 @@ regexes = [
|
||||
'''^hf_QWERTYUIOPasdfghjklZXCVBNM0123456789xyzAB$''',
|
||||
# NLLB generation length argument, not the value of a credential.
|
||||
'''^max_length=400$''',
|
||||
# cryptography's Ed25519 private-key type name, not key material.
|
||||
'''^Ed25519PrivateKey$''',
|
||||
]
|
||||
|
||||
@@ -35,6 +35,10 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
|
||||
|
||||
## Agent skills
|
||||
|
||||
Project development skills are pinned in `skills-lock.json` and installed under
|
||||
`.agents/skills/`: Vite and FastAPI.
|
||||
Repository rules and tracker mappings override generic skill guidance.
|
||||
|
||||
### Issue tracker
|
||||
|
||||
GitHub Issues on `debpalash/VoiceStudio`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
+73
-41
@@ -10,52 +10,71 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- A faster, cleaner Dub workspace for multilingual production (#1489)
|
||||
- VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity
|
||||
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
|
||||
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
|
||||
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU
|
||||
- A warning before a slow generation, rather than after a five-minute wait
|
||||
- The watermark can be turned off in Settings, as the docs always said
|
||||
- Your other GPU can take the work now — send individual jobs to a second machine, opt-in
|
||||
- More than one person can share one GPU machine, without shell access to it or taking turns
|
||||
- A Model Catalogue workspace: every engine and model in one place, with the defaults set there
|
||||
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
|
||||
- macOS support now matches what the app actually delivers
|
||||
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing
|
||||
- The backend now answers within a second of launch and narrates its startup step by step
|
||||
- Reporting a bug from an outdated build now offers the latest release first
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves
|
||||
|
||||
### Changed
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
### Docs
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
- 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)
|
||||
### 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)
|
||||
|
||||
### Added
|
||||
## [0.5.0] — 2026-08-13
|
||||
|
||||
- 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)
|
||||
**Highlights**
|
||||
|
||||
### Added
|
||||
- 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)
|
||||
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
<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.
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
|
||||
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
|
||||
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
|
||||
@@ -75,6 +94,13 @@ 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)
|
||||
@@ -88,14 +114,22 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
|
||||
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
|
||||
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
|
||||
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
|
||||
|
||||
### CI
|
||||
### Docs
|
||||
|
||||
- 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)
|
||||
- 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)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Linux app icon is no longer blank: the AppImage shipped `.DirIcon` as a symlink into the machine that built it, so file managers and app menus drew nothing. (#1518)
|
||||
- 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)
|
||||
@@ -209,19 +243,17 @@ 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)
|
||||
|
||||
### 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)
|
||||
- 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)
|
||||
|
||||
### 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,7 +66,10 @@ Architecture not yet mapped. Follow existing patterns found in the codebase.
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
|
||||
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.
|
||||
- `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.
|
||||
<!-- GSD:skills-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
|
||||
@@ -1,656 +1,385 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="120" height="120" />
|
||||
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub><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><sub>Previously OmniVoice-Studio</sub></p>
|
||||
<h3>Local voice cloning, dubbing, dictation, and long-form audio.</h3>
|
||||
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux</p>
|
||||
<p><strong>Local-first.</strong> No account, API key, subscription, or usage meter for the core workflow.</p>
|
||||
|
||||
<p>
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#install">Install</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="https://x.com/idebpalash">X</a> ·
|
||||
<a href="#comparison">Compare</a> ·
|
||||
<a href="#requirements">Requirements</a> ·
|
||||
<a href="#engines">Engines</a> ·
|
||||
<a href="#architecture">Architecture</a> ·
|
||||
<a href="#api">API</a> ·
|
||||
<a href="#documentation">Docs</a> ·
|
||||
<a href="README_CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="GitHub 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>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Latest release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="AGPL-3.0 license" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord community" /></a>
|
||||
</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>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download VoiceStudio" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — Launchpad" width="100%"/>
|
||||
<img src="docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the VoiceStudio status bar" width="100%" />
|
||||
</div>
|
||||
|
||||
> **Your voice is personal. Your studio should feel personal too.** VoiceStudio keeps its core workflow on your hardware: clone, design, dub, dictate, and publish in 646 languages without a subscription or usage meter. Network-backed engines and services are optional, visible choices—not hidden requirements.
|
||||
|
||||
> [!WARNING]
|
||||
> **Active beta.** 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).
|
||||
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
|
||||
|
||||
## At a glance
|
||||
|
||||
| | VoiceStudio |
|
||||
|---|---|
|
||||
| **Workflows** | Voice cloning and design, video dubbing, dictation, stories, audiobooks, batch generation |
|
||||
| **Language catalogue** | 646 TTS languages; actual coverage and quality depend on the selected engine |
|
||||
| **Engines** | 16 TTS · 11 ASR · switch in Model Catalogue or with <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> |
|
||||
| **Platforms** | macOS 13.3+ on Apple Silicon · Windows 10/11 x64 · Linux x86_64 with glibc 2.39+ |
|
||||
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
|
||||
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
|
||||
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
|
||||
| **License** | AGPL-3.0; optional engines keep their own model licenses |
|
||||
|
||||
<a id="install"></a>
|
||||
|
||||
## Install
|
||||
|
||||
| Platform | Package | Guide |
|
||||
|---|---|---|
|
||||
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
|
||||
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
|
||||
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
|
||||
| Docker | CUDA, ROCm, or CPU | [Run with Docker](docs/install/docker.md) |
|
||||
|
||||
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
|
||||
|
||||
> [!NOTE]
|
||||
> On macOS, first launch needs a one-time right-click → **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
|
||||
|
||||
### First voice
|
||||
|
||||
1. Launch VoiceStudio and open **Voice Cloning**.
|
||||
2. Add a clean voice sample. Three seconds works; 5–15 seconds usually gives a better prompt.
|
||||
3. Enter text, choose a language, then select **Generate**.
|
||||
|
||||
### Run from source
|
||||
|
||||
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup), then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
bun install
|
||||
bun run desktop
|
||||
```
|
||||
|
||||
Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md) for services, tests, and platform packages.
|
||||
|
||||
### If setup fails
|
||||
|
||||
- Run **Settings → About → Run self-check** or `uv run python backend/main.py --diagnose --deep`.
|
||||
- Check [install troubleshooting](docs/install/troubleshooting.md).
|
||||
- Save a scrubbed diagnostic bundle from the app when opening an issue.
|
||||
- For slow generation, compare [measured benchmarks](docs/benchmarks.md) and [performance settings](docs/performance.md).
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ Features
|
||||
## Features
|
||||
|
||||
Three flagships, five more headliners, and a dozen under the fold.
|
||||
| Area | Included |
|
||||
|---|---|
|
||||
| **Voice Cloning** | Zero-shot synthesis from a short reference clip |
|
||||
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
|
||||
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
|
||||
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
|
||||
| **Dictation Widget** | System-wide shortcut, live transcription, optional local-LLM cleanup |
|
||||
| **Vocal Isolation** | Demucs speech/background separation |
|
||||
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
|
||||
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
|
||||
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models |
|
||||
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress |
|
||||
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks |
|
||||
| **AI Watermark** | AudioSeal embedding and detection |
|
||||
| **MCP Server** | Synthesis and transcription tools for MCP clients |
|
||||
| **Diagnostics** | Self-checks, error journal, logs, and scrubbed support bundles |
|
||||
| **Local-first** | Core creation stays local; network-backed features are explicit opt-ins |
|
||||
| **Extensible** | Registry-based TTS, ASR, and plugin interfaces |
|
||||
|
||||
<table>
|
||||
<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>
|
||||
<td width="50%"><img src="docs/media/0.5.0/catalogue.png" alt="VoiceStudio Model Catalogue" width="100%" /></td>
|
||||
<td width="50%"><img src="docs/media/0.5.0/gallery-save.png" alt="Saving a gallery voice as a local profile" width="100%" /></td>
|
||||
</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>
|
||||
<td align="center"><sub>Model Catalogue: engine, device, and install state</sub></td>
|
||||
<td align="center"><sub>Gallery: save a shared voice as a local profile</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<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>
|
||||
<a id="comparison"></a>
|
||||
|
||||
<details>
|
||||
<summary><b>…and 12 more</b> — isolation, diarization, batch, watermarking, diagnostics, and friends</summary>
|
||||
## Comparison
|
||||
|
||||
<br/>
|
||||
VoiceStudio trades managed cloud compute for local control. This is the practical difference:
|
||||
|
||||
- 🔊 **Vocal Isolation** — Demucs-powered: splits speech from music and keeps the background bed.
|
||||
- 👥 **Speaker Diarization** — Pyannote + WhisperX auto-identify who said what.
|
||||
- 📦 **Batch Queue** — drop 50 videos, walk away; per-job progress bars.
|
||||
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
|
||||
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
|
||||
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 📚 **Model Catalogue** — one workspace listing every TTS/ASR/LLM engine and model: set the defaults, install or remove weights.
|
||||
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
|
||||
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
|
||||
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
|
||||
- 🌐 **Remote backend** — point the UI at a remote server; Tailscale-friendly, bearer auth.
|
||||
- 🧠 **Dictation + LLM** — local-LLM cleanup of transcripts, optional echo cancellation.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ Quickstart
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
</div>
|
||||
|
||||
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 Troubleshooting · slow generation · HF tokens · restricted networks</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
- **Something broke?** Run the self-check — **Settings → About → "Run self-check"** (or `uv run python backend/main.py --diagnose --deep`) — then the [top 10 install errors](docs/install/troubleshooting.md). **"Save diagnostic bundle"** packages scrubbed logs for a bug report.
|
||||
- **Feels slow?** [docs/performance.md](docs/performance.md) — where the time goes and how to tune it.
|
||||
- **Want breaths, laughter, emotion?** [docs/expressive-speech.md](docs/expressive-speech.md) — what each engine can do today.
|
||||
- **HF tokens · diarization · download speed / mirrors:** [tokens](docs/setup/huggingface-token.md) · [diarization](docs/features/diarization.md) · [downloads](docs/downloading-models.md).
|
||||
- **Coming from [Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)?** [Migration guide](docs/migration/real-time-voice-cloning.md).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## ⚖️ Why VoiceStudio
|
||||
|
||||
Cloud voice tools are convenient, but they put your workflow behind an account, a meter, and somebody else's infrastructure. VoiceStudio gives you a capable studio that runs on your hardware, with optional integrations when you choose them.
|
||||
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
| | **VoiceStudio** | **Typical hosted voice service** |
|
||||
|---|---|---|
|
||||
| **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 |
|
||||
| **Best fit** | Private, offline, self-hosted, or high-volume work | Fast setup without local model management |
|
||||
| **Data path** | Local by default; remote features are opt-in | Audio and text are processed by the provider |
|
||||
| **Cost model** | Free software; you supply the hardware | Subscription, credits, or metered API use |
|
||||
| **Setup** | Install the app and model weights | Create an account and use the web app or API |
|
||||
| **Performance** | Depends on your engine and hardware | Provider manages compute and scaling |
|
||||
| **Offline use** | Yes, after required models are installed | Usually requires a network connection |
|
||||
| **Customization** | Source, engines, models, API, and routing are open | Limited to provider options |
|
||||
| **Maintenance** | You manage updates, disk, and compute | Provider manages infrastructure |
|
||||
|
||||
Professional-grade voice AI, minus the subscription and the cloud.
|
||||
<a id="requirements"></a>
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<b>Convinced? Come build with us.</b><br/>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Join_Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<br/><br/>
|
||||
</div>
|
||||
## Requirements
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ System Requirements
|
||||
Requirements vary by engine. These values cover the default local workflow.
|
||||
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
|
||||
| **OS** | Windows 10 x64 · macOS 13.3 Apple Silicon · Linux x86_64 with glibc 2.39+ | Current supported OS release |
|
||||
| **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) |
|
||||
| **Disk** | 10 GB free | 20 GB+ SSD |
|
||||
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
|
||||
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
|
||||
| **Python from source** | 3.11+ | 3.11–3.12 |
|
||||
|
||||
> [!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)).
|
||||
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
|
||||
|
||||
<a id="engines"></a>
|
||||
|
||||
## Engines
|
||||
|
||||
Engine support is capability-specific. Check cloning, language, platform, memory, and license before choosing one. Full setup guides: [docs/engines](docs/engines/README.md).
|
||||
|
||||
<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.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
|
||||
|
||||
<br/>
|
||||
### Text to speech
|
||||
|
||||
| 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.5** ⚡ | ZH · EN · JA · ES · AR | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | Yes | — | CUDA/CPU | — | CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
|
||||
| **MOSS-TTS-Nano** | 20 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **KittenTTS** | English | — | — | CPU | CPU | CPU | MIT |
|
||||
| **MLX-Audio** | Model-dependent | Varies | Varies | — | MLX | — | Varies |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
|
||||
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | — | CPU | CPU | CPU | CC-BY-4.0, gated² |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | CPU | CPU | CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ | 24 | Yes | — | CUDA/CPU | CPU | — | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | Yes | — | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
|
||||
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million
|
||||
monthly active users or RMB 1 billion in annual revenue. Review its
|
||||
[model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)
|
||||
before enabling the optional sidecar.
|
||||
⚡ Installed or registered on demand.
|
||||
|
||||
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.
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million monthly active users or RMB 1 billion annual revenue. Review the [model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE).
|
||||
|
||||
> **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).
|
||||
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
|
||||
|
||||
</details>
|
||||
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
|
||||
|
||||
<a id="asr-engines"></a>
|
||||
|
||||
### 🎧 ASR Engines
|
||||
### Speech to text
|
||||
|
||||
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Model Catalogue → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
| Engine | ID | Languages | Best fit |
|
||||
|---|---|:---:|---|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing, subtitles, word-level timing |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | General cross-platform transcription |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Crash-isolated batch transcription |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Apple Silicon |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA, MPS, and CPU fallback |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | Fast CPU/CUDA transcription |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | Apple Silicon dictation and word timestamps |
|
||||
| **Moonshine** | `moonshine` | English | Low-power, low-latency ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | VAD and inline diarization |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
|
||||
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
|
||||
|
||||
<br/>
|
||||
<a id="architecture"></a>
|
||||
|
||||
| Engine | `OMNIVOICE_ASR_BACKEND` | Languages | Best for |
|
||||
|--------|-------------------------|:---------:|----------|
|
||||
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing & subtitles — word-level timing via wav2vec2 forced alignment |
|
||||
| **Faster-Whisper** | `faster-whisper` | ~100 | Fast transcription on Linux / macOS / Windows (CTranslate2) |
|
||||
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Same as Faster-Whisper but crash-isolated in a subprocess — an ASR crash won't take down the app |
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Model Catalogue → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
## Architecture
|
||||
|
||||
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
|
||||
|
||||
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
A **Tauri v2** desktop shell (Rust) wraps a **React** UI and a bundled **Python/FastAPI** backend that runs as a local sidecar on `localhost:3900`. Nothing external — every layer is on your machine.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 shell — Rust │
|
||||
│ window state · global dictation hotkey · system tray · │
|
||||
│ signed auto-updater (stable/preview) · single-instance · │
|
||||
│ first-run bootstrap (installs uv + Python venv) · blank guard │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ Frontend — React + Vite │
|
||||
│ Studio · Dub · Stories · Audiobook · Gallery · Dictation · │
|
||||
│ Batch · Diagnostics · MCP client — Zustand store · WS bus │
|
||||
│ ▲ IPC / HTTP + WS │
|
||||
├──────────────────────────┼─────────────────────────────────────────┤
|
||||
│ Backend — FastAPI sidecar @ localhost:3900 │
|
||||
│ 100+ REST endpoints · SSE + WebSocket streaming · │
|
||||
│ SQLite + Alembic (omnivoice_data/) · OpenAI-compatible API │
|
||||
├───────────┬───────────┬───────────┬───────────┬────────────────────┤
|
||||
│ TTS ×14 │ ASR ×11 │ Demucs │ Pyannote │ AudioSeal │
|
||||
│ clone / │ WhisperX │ vocal │ speaker │ watermark │
|
||||
│ design │ +10 more │ isolation│ diariz. │ embed / detect │
|
||||
├───────────┴───────────┴───────────┴───────────┴────────────────────┤
|
||||
│ Engine routing — per-engine GPU preflight, no silent CPU fallback │
|
||||
│ Hardware: CUDA · MPS · ROCm (Linux) · CPU (auto-detected) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```text
|
||||
Tauri v2 desktop shell (Rust)
|
||||
│ IPC
|
||||
React + Vite UI
|
||||
│ HTTP · SSE · WebSocket on localhost:3900
|
||||
FastAPI backend
|
||||
├── TTS / ASR engine registries
|
||||
├── dubbing / audio / long-form pipelines
|
||||
├── OpenAI-compatible API and MCP server
|
||||
└── SQLite + Alembic → omnivoice_data/
|
||||
```
|
||||
|
||||
- **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.
|
||||
| Layer | Path | Responsibility |
|
||||
|---|---|---|
|
||||
| Desktop shell | `frontend/src-tauri/` | Window lifecycle, tray, shortcuts, updater, sidecar bootstrap |
|
||||
| Frontend | `frontend/src/` | React UI, Zustand state, API and event clients, i18n |
|
||||
| API | `backend/api/` | REST routes, schemas, auth boundaries, streaming |
|
||||
| Core services | `backend/services/` | Generation, dubbing, audio processing, persistence |
|
||||
| Engines | `backend/engines/` | Isolated and optional engine adapters |
|
||||
| Worker system | `backend/worker/` | Authenticated remote compute and job transport |
|
||||
| Data | `omnivoice_data/` | Projects, voices, settings, logs, and SQLite state |
|
||||
| Delivery | `scripts/`, `deploy/`, `.github/workflows/` | Development, packaging, containers, releases, CI |
|
||||
|
||||
<a id="openai-api"></a>
|
||||
### Network boundary
|
||||
|
||||
## 🔌 OpenAI-compatible API
|
||||
- The desktop talks to a loopback-only backend on `localhost:3900`.
|
||||
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
|
||||
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
|
||||
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
|
||||
|
||||
<div align="center">
|
||||
<a id="api"></a>
|
||||
|
||||
**Drop-in replacement for OpenAI / ElevenLabs audio.** One line — no key, no code changes:
|
||||
## OpenAI-compatible API
|
||||
|
||||
Point an OpenAI-compatible audio client at the local backend:
|
||||
|
||||
```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 |
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
|
||||
|
||||
```sh
|
||||
# 1 — find a cloned voice's profile ID
|
||||
curl -s http://localhost:3900/v1/audio/voices | jq '.voices[] | select(.type=="profile") | {voice_id, name}'
|
||||
|
||||
# 2 — synthesize with it
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"tts-1","voice":"<profile-id>","input":"Made on my own hardware.","response_format":"wav"}' \
|
||||
--output speech.wav
|
||||
```
|
||||
| `POST /v1/audio/speech` | TTS to `mp3`, `opus`, `aac`, `flac`, `wav`, or `pcm`; select a profile with `voice` and an engine with `model` |
|
||||
| `POST /v1/audio/transcriptions` | STT to `json`, `text`, `verbose_json`, `srt`, or `vtt` |
|
||||
| `GET /v1/audio/voices` | List local voice profiles and engines |
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="none") # any string — nothing checks it
|
||||
|
||||
# TTS with your cloned voice (or "alloy" / "default"; model= can pin a specific engine)
|
||||
client = OpenAI(base_url="http://localhost:3900/v1", api_key="local")
|
||||
|
||||
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")
|
||||
|
||||
# STT
|
||||
print(client.audio.transcriptions.create(model="whisper-1", file=open("clip.wav", "rb")).text)
|
||||
model="tts-1",
|
||||
voice="<profile-id>",
|
||||
input="Made on my own hardware.",
|
||||
response_format="wav",
|
||||
) as response:
|
||||
response.stream_to_file("speech.wav")
|
||||
```
|
||||
|
||||
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.
|
||||
The full API reference is in **Settings → OpenAPI Reference**. For LAN, Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before exposing the backend.
|
||||
|
||||
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.
|
||||
### Agent skills
|
||||
|
||||
### 📓 Run on Google Colab
|
||||
Install the VoiceStudio skills for Claude Code, Codex, Cursor, and other [skills.sh](https://skills.sh)-compatible agents:
|
||||
|
||||
[](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
|
||||
```bash
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
```
|
||||
|
||||
Ships two [skills](https://skills.sh):
|
||||
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
|
||||
- `oss-maintainer`: the repository's open-source maintenance workflow.
|
||||
|
||||
- **`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.
|
||||
### Google Colab
|
||||
|
||||
---
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
|
||||
## 🗺️ Roadmap
|
||||
The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI on a Colab GPU. Colab is remote compute, so uploaded audio and project data do not remain local to your machine.
|
||||
|
||||
### 🔜 Up Next
|
||||
<a id="documentation"></a>
|
||||
|
||||
- 🎬 **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
|
||||
## Documentation
|
||||
|
||||
| Need | Read |
|
||||
|---|---|
|
||||
| Install | [macOS](docs/install/macos.md) · [Windows](docs/install/windows.md) · [Linux](docs/install/linux.md) · [Docker](docs/install/docker.md) |
|
||||
| Fix setup | [Troubleshooting](docs/install/troubleshooting.md) · [model downloads](docs/downloading-models.md) · [Hugging Face token](docs/setup/huggingface-token.md) |
|
||||
| Choose an engine | [Engine guides](docs/engines/README.md) · [benchmarks](docs/benchmarks.md) · [expressive speech](docs/expressive-speech.md) |
|
||||
| Tune hardware | [Performance](docs/performance.md) · [remote workers](docs/remote-workers.md) |
|
||||
| Build integrations | [API auth](docs/api-auth.md) · [MCP](docs/mcp.md) · [examples](examples/README.md) |
|
||||
| Build VoiceStudio | [Contributing](.github/CONTRIBUTING.md) · [engine acceptance](docs/engine-acceptance.md) |
|
||||
| Track changes | [Changelog](CHANGELOG.md) · [roadmap](docs/ROADMAP.md) · [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) |
|
||||
| Remove everything | [Uninstall guide](docs/install/uninstall.md) |
|
||||
|
||||
## FAQ
|
||||
|
||||
<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** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, screen-sized first-run UI scaling, and native WebKitGTK scaling |
|
||||
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
|
||||
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
|
||||
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
|
||||
| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
|
||||
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
|
||||
<summary><strong>Does it work on Apple Silicon and Intel Macs?</strong></summary>
|
||||
|
||||
Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the local backend because current PyTorch wheels are unavailable; they can connect to a remote backend. See [macOS installation](docs/install/macos.md).
|
||||
</details>
|
||||
|
||||
---
|
||||
<details>
|
||||
<summary><strong>How much VRAM do I need?</strong></summary>
|
||||
|
||||
<a id="sponsor--donate"></a>
|
||||
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 12–16 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
|
||||
</details>
|
||||
|
||||
## 💜 Sponsor / Donate
|
||||
<details>
|
||||
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
|
||||
|
||||
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.
|
||||
Cloning is zero-shot: the clip is a prompt, not training data. Use 5–15 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can I use generated audio commercially?</strong></summary>
|
||||
|
||||
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does VoiceStudio collect data?</strong></summary>
|
||||
|
||||
Not unless you opt in. Analytics is off by default and skipping consent keeps it off. When enabled, the app sends allowlisted, content-free usage metadata. Text, audio, file names, voices, and projects are excluded. Change this at **Settings → Privacy**.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>How do I remove VoiceStudio and its data?</strong></summary>
|
||||
|
||||
Use `scripts/uninstall.sh` on macOS/Linux or `scripts\uninstall.ps1` on Windows. Both show a dry run before deletion. See the [uninstall guide](docs/install/uninstall.md) for every path.
|
||||
</details>
|
||||
|
||||
## Community and contributing
|
||||
|
||||
- [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) for reproducible bugs and feature requests.
|
||||
- [Discord](https://discord.gg/bzQavDfVV9) for setup help and project discussion.
|
||||
- [Good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) for a scoped starting point.
|
||||
- [Contributing guide](.github/CONTRIBUTING.md) for setup, tests, and pull requests.
|
||||
|
||||
## Support development
|
||||
|
||||
VoiceStudio is free and has no paid tier. Donations fund development and infrastructure.
|
||||
|
||||
[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
|
||||
|
||||
## License
|
||||
|
||||
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
|
||||
|
||||
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
VoiceStudio builds on [OmniVoice](https://github.com/k2-fsa/OmniVoice), [WhisperX](https://github.com/m-bain/whisperX), [Demucs](https://github.com/facebookresearch/demucs), [Pyannote](https://github.com/pyannote/pyannote-audio), [CTranslate2](https://github.com/OpenNMT/CTranslate2), [AudioSeal](https://github.com/facebookresearch/audioseal), [Tauri](https://tauri.app), [Supertonic](https://huggingface.co/Supertone/supertonic-3), [Sherpa-ONNX](https://github.com/k2-fsa/sherpa-onnx), [GPT-SoVITS](https://github.com/RVC-Boss/GPT-SoVITS), and [PocketTTS](https://kyutai.org).
|
||||
|
||||
<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>
|
||||
<strong><a href="https://github.com/debpalash/VoiceStudio/releases/latest">Download VoiceStudio</a></strong> ·
|
||||
<a href="https://github.com/debpalash/VoiceStudio">Star the project</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Join Discord</a>
|
||||
</div>
|
||||
|
||||
+59
-52
@@ -37,7 +37,7 @@
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — 启动台" width="100%"/>
|
||||
<img src="docs/media/0.5.0/quick-switch.gif" alt="VoiceStudio — 从状态栏快速切换 TTS 引擎" width="100%"/>
|
||||
</div>
|
||||
|
||||
> **声音很私人,创作空间也应该真正属于你。** VoiceStudio 的核心流程运行在你的硬件上:克隆、设计、配音、听写,并以 646 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
|
||||
@@ -45,6 +45,56 @@
|
||||
> [!WARNING]
|
||||
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR:[提交 Issue](https://github.com/debpalash/VoiceStudio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
|
||||
|
||||
<a id="quickstart"></a>
|
||||
|
||||
## ⚡ 快速开始
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub>三个按钮都会打开最新发布页——在资源列表中下载对应你系统的安装包。</sub><br/>
|
||||
<sub><b>macOS:</b>首次启动需要一次性批准——右键点击 → <b>打开</b>(macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac:</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
|
||||
</div>
|
||||
|
||||
选择你的操作系统,按指南从头到尾操作:
|
||||
|
||||
- 🍎 **macOS** — [docs/install/macos.md](docs/install/macos.md)
|
||||
- 🪟 **Windows** — [docs/install/windows.md](docs/install/windows.md)
|
||||
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
|
||||
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
|
||||
**三步克隆出你的第一个声音:**
|
||||
|
||||
1. **安装并启动。** 首次启动会自动搭建 Python 运行环境并下载模型权重——启动画面会逐步显示进度(仅首次,需要几分钟;之后即开即用)。
|
||||
2. 从启动台打开**语音克隆**,拖入任意声音的 **3 秒音频**。
|
||||
3. **输入一句话,点击生成。** 音频完全属于你——在你的设备上生成和保存,支持 646 种语言。
|
||||
|
||||
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。各引擎/设备的实测数据见 [docs/benchmarks.md](docs/benchmarks.md)。
|
||||
|
||||
> 正在从 **[CorentinJ/Real-Time-Voice-Cloning](https://github.com/CorentinJ/Real-Time-Voice-Cloning)**(现已归档)迁移过来?我们有专门的迁移指南:[docs/migration/real-time-voice-cloning.md](docs/migration/real-time-voice-cloning.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>🧰 卡住了?自检、Token 与受限网络</b></summary>
|
||||
|
||||
<br/>
|
||||
|
||||
先运行内置自检——在应用中打开 **设置 → 关于 → “运行自检”**,或在源码检出目录中执行
|
||||
`uv run python backend/main.py --diagnose`(加 `--deep` 还会实际加载当前引擎进行测试)。然后查看
|
||||
[docs/install/troubleshooting.md](docs/install/troubleshooting.md) 中排名前
|
||||
10 的安装错误。运行时出错时,应用内的错误界面会直接深链到对应条目;**设置 → 关于 →
|
||||
“保存诊断包”** 会把脱敏日志与自检报告打包,方便附在 Bug 报告里。
|
||||
|
||||
Hugging Face Token 的配置见
|
||||
[docs/setup/huggingface-token.md](docs/setup/huggingface-token.md)。说话人分离相关的模型访问门槛见
|
||||
[docs/features/diarization.md](docs/features/diarization.md)。下载速度、⚡ 快速下载(Xet)状态,以及受限网络 / 镜像选项见
|
||||
[docs/downloading-models.md](docs/downloading-models.md)。
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
## ✨ 功能
|
||||
@@ -112,49 +162,6 @@
|
||||
|
||||
---
|
||||
|
||||
<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?
|
||||
@@ -173,8 +180,8 @@ Hugging Face Token 的配置见
|
||||
| **API 密钥** | 需要账号 | 本地流程不需要 |
|
||||
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCm(Linux)· CPU |
|
||||
| **桌面应用** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS 引擎** | 1 | **14** — [完整矩阵](#tts-engines) |
|
||||
| **ASR 引擎** | 1 | **10** — [完整阵容](#asr-engines) |
|
||||
| **TTS 引擎** | 1 | **16** — [完整矩阵](#tts-engines) |
|
||||
| **ASR 引擎** | 1 | **11** — [完整阵容](#asr-engines) |
|
||||
| **MCP 服务器** | ❌ | ✅ 可从 Claude、Cursor 及任何 MCP 客户端使用 |
|
||||
| **自检** | ❌ | ✅ 诊断套件、错误日志、脱敏调试包 |
|
||||
| **可定制** | ❌ 闭源 | ✅ 随你 Fork、扩展、发布 |
|
||||
@@ -214,10 +221,10 @@ Hugging Face Token 的配置见
|
||||
|
||||
### 🗣️ TTS 引擎
|
||||
|
||||
**14 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加六个按需延迟安装的重量级引擎(IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
|
||||
**16 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加八个按需延迟安装的引擎(IndexTTS 2.5、OmniVoice GGUF、OmniVoice 子进程版、PocketTTS、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。**每个引擎都有独立指南:[docs/engines](docs/engines/README.md)(英文)。**
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整矩阵</b>——14 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
|
||||
<summary><b>📊 完整矩阵</b>——16 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -254,10 +261,10 @@ Hugging Face Token 的配置见
|
||||
|
||||
### 🎧 ASR 引擎
|
||||
|
||||
**10 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。九个完全在本地设备上运行;第十个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
|
||||
**11 个引擎**——它们驱动听写、视频配音和字幕。**WhisperX** 是跨平台的默认引擎(约 100 种语言,词级时间对齐);其余引擎均为可选装并自动检测。在 **设置 → 引擎** 中切换。十个完全在本地设备上运行;第十一个(OpenAI 兼容)是可选的远程客户端,可用于 Qwen3-ASR 或任何兼容的服务器。
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整阵容</b>——10 个引擎、各自的强项与计算类型说明</summary>
|
||||
<summary><b>📊 完整阵容</b>——11 个引擎、各自的强项与计算类型说明</summary>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -274,7 +281,7 @@ Hugging Face Token 的配置见
|
||||
| **sherpa-onnx**(实时听写) | `sherpa-onnx-asr` | 25 种欧洲语言 + 90+ | 实时、快于实时的听写——小体积流式/离线 ONNX 模型(Parakeet TDT v3/v2、流式 Zipformer 与 Paraformer、Whisper Tiny),CPU 运行,macOS / Windows / Linux 表现完全一致。在 **设置 → 语音** 中按模型选择。 |
|
||||
| **OpenAI 兼容** ⚠️ 远程 | `openai-compat-asr` | 取决于服务器 | 当下通往 **Qwen3-ASR** 的路径(自托管服务器,无需等 transformers 支持)、任何 OpenAI 兼容的转录端点,或 OpenAI 官方 API——无需安装,在 **设置 → 引擎**(ASR 标签页)中配置并测试连接。音频会离开你的设备,发送到你指定的任何服务器;参见 [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md)。 |
|
||||
|
||||
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。每个引擎都在本地设备上运行——无需 API 密钥,无需云端。
|
||||
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。除可选的 OpenAI 兼容远程客户端外,所有引擎都在本地设备上运行——无需 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` 并重启后端。
|
||||
|
||||
@@ -574,7 +581,7 @@ VoiceStudio 站在这些杰出开源工作的肩膀上:
|
||||
|
||||
## 🧰 来自同一作者的更多本地开源项目
|
||||
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。**
|
||||
喜欢这种本地优先的理念?它是一脉相承的——同一位作者,同一条准则:**你的数据只留在你的设备上。** 全部项目见 [palash.dev](https://palash.dev)。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
|
||||
+112
-119
@@ -6,7 +6,10 @@ composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
(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.
|
||||
- `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
|
||||
@@ -14,64 +17,19 @@ Currently exposed:
|
||||
keep their own inline loopback guards.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
|
||||
# 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())
|
||||
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
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
@@ -107,40 +65,45 @@ def _configured_pin(request) -> str | None:
|
||||
|
||||
|
||||
def _admin_credential_configured(request) -> bool:
|
||||
"""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"):
|
||||
"""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():
|
||||
return True
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
|
||||
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).
|
||||
def _request_presents_admin_credential(
|
||||
request,
|
||||
*,
|
||||
side_effectful_get: bool = False,
|
||||
) -> bool:
|
||||
"""Whether the canonical principal carries remote admin capability.
|
||||
|
||||
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:
|
||||
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,
|
||||
}:
|
||||
return False
|
||||
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))
|
||||
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
|
||||
|
||||
|
||||
def require_loopback(request: Request) -> None:
|
||||
@@ -163,9 +126,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) → open, matching the #261
|
||||
Docker flow where the operator reaches ``/system/*`` off the bridge
|
||||
gateway with nothing set.
|
||||
- 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.
|
||||
- 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
|
||||
@@ -173,14 +136,20 @@ 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. 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).
|
||||
loopback-only; remote admin requires the long API key. 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):
|
||||
@@ -188,6 +157,31 @@ 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.
|
||||
|
||||
@@ -206,12 +200,30 @@ def require_admin(request: Request) -> None:
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
read_only = method in {"GET", "HEAD", "OPTIONS"}
|
||||
if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip():
|
||||
read_only = method in SAFE_HTTP_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
_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()
|
||||
|
||||
|
||||
def require_desktop(request: Request) -> None:
|
||||
@@ -232,9 +244,10 @@ 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_loopback`.
|
||||
dictation WebSocket. Admin routes stay on :func:`require_admin`.
|
||||
|
||||
In server mode the gate is a no-op (same as :func:`require_loopback`)."""
|
||||
In server mode this consumption gate is a no-op. Admin dependencies remain
|
||||
method-aware and independent from this exemption."""
|
||||
host = request.client.host if request.client else None
|
||||
if is_local_host(host):
|
||||
return
|
||||
@@ -256,29 +269,9 @@ 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 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)
|
||||
"""Whether the canonical WS principal has a remote admin credential."""
|
||||
return principal_for(websocket).kind in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}
|
||||
|
||||
@@ -26,8 +26,10 @@ Design notes
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
@@ -37,6 +39,7 @@ from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services import gallery
|
||||
|
||||
@@ -69,6 +72,153 @@ 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.
|
||||
@@ -255,7 +405,7 @@ def _preview_source(a: dict) -> tuple[str, str]:
|
||||
"Pre-rendered preview from the voice gallery — a fixed reference "
|
||||
"rendering, not a render from your current engine."
|
||||
)
|
||||
if (_PREVIEW_DIR / f"{key}.wav").exists():
|
||||
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
|
||||
return "cached", ""
|
||||
if _no_voice_model_downloaded():
|
||||
return "no_model", (
|
||||
@@ -388,9 +538,9 @@ async def preview_archetype(
|
||||
)
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{key}.wav"
|
||||
if not cache_path.exists():
|
||||
if not is_playable_wav(cache_path):
|
||||
try:
|
||||
await _render_archetype_wav(a, cache_path)
|
||||
await _render_wav_atomic(a, cache_path, prefix=".preview-")
|
||||
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
|
||||
@@ -442,70 +592,122 @@ 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. 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.
|
||||
# a fresh duplicate every time. Use a namespaced personality identity so an
|
||||
# imported persona cannot collide with and be rewritten by an archetype id.
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, name FROM voice_profiles WHERE personality = ? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
existing = _existing_archetype_profile(conn, a)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _materialized_audio_is_current(existing, a):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(
|
||||
a, profile_id, publish=existing is None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Model Catalogue → Models → Download."
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail) from e
|
||||
|
||||
if existing is not None:
|
||||
return {"profile_id": existing["id"], "name": existing["name"]}
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
# Same actionable/diagnostic split as /preview — minus the gallery
|
||||
# suggestion, which cannot help here.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"Creating a voice needs the voice model — no voice model is "
|
||||
"downloaded yet. Model Catalogue → Models → Download."
|
||||
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:
|
||||
detail = (
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
|
||||
if audio_path is None: # defensive: a new profile always rendered above
|
||||
raise RuntimeError("new archetype profile has no rendered audio")
|
||||
|
||||
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 — 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()
|
||||
# `personality` is not globally UNIQUE, so serialize and re-check.
|
||||
dup = _existing_archetype_profile(conn, a)
|
||||
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):
|
||||
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(),
|
||||
),
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
if audio_path is not None:
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -153,8 +153,8 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
async def ws_transcribe(websocket: WebSocket):
|
||||
"""Stream audio in, get partial + final transcription out."""
|
||||
# Loopback origin guard — refuse anything not from 127.0.0.1, ::1, or
|
||||
# localhost. HTTP routers use Depends(require_loopback) at router level;
|
||||
# WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# localhost. Privileged HTTP routers use Depends(require_admin) 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
|
||||
|
||||
@@ -20,18 +20,26 @@ 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 urlparse
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.config import DATA_DIR
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import DATA_DIR, VOICES_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.community")
|
||||
router = APIRouter()
|
||||
@@ -42,9 +50,32 @@ _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._-]+/[A-Za-z0-9._-]+$") # owner/repo only
|
||||
_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",
|
||||
)
|
||||
|
||||
|
||||
# ── Config: which content repos to load ───────────────────────────────────────
|
||||
@@ -52,14 +83,18 @@ def configured_sources() -> list[str]:
|
||||
"""Gallery sources, in priority order. Env var > config file > default."""
|
||||
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
|
||||
if env:
|
||||
return [s.strip() for s in env.split(",") if s.strip()]
|
||||
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)
|
||||
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:
|
||||
return [str(s) for s in srcs]
|
||||
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
|
||||
if valid:
|
||||
return valid
|
||||
except Exception:
|
||||
logger.warning("gallery_sources.json unreadable; using default")
|
||||
return list(_DEFAULT_SOURCES)
|
||||
@@ -81,9 +116,51 @@ 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:
|
||||
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)
|
||||
return normalize_preset_instruct(instruct) is not None
|
||||
|
||||
|
||||
def validate_item(raw: dict) -> Optional[dict]:
|
||||
@@ -93,62 +170,203 @@ def validate_item(raw: dict) -> Optional[dict]:
|
||||
it = dict(raw)
|
||||
if it.get("type") not in ("preset", "voice"):
|
||||
return None
|
||||
if not it.get("id") or not it.get("name"):
|
||||
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
|
||||
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
|
||||
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", {})
|
||||
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
|
||||
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 m:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
for raw in (m.get("items") or []):
|
||||
raw_items = m.get("items")
|
||||
for raw in raw_items if isinstance(raw_items, list) else []:
|
||||
v = validate_item(raw)
|
||||
if v and v["id"] not in seen:
|
||||
v["_source_repo"] = src
|
||||
seen.add(v["id"])
|
||||
items.append(v)
|
||||
for p in (m.get("packs") or []):
|
||||
raw_packs = m.get("packs")
|
||||
for p in raw_packs if isinstance(raw_packs, list) else []:
|
||||
if isinstance(p, dict):
|
||||
packs.append({**p, "_source_repo": src})
|
||||
return items, packs
|
||||
|
||||
|
||||
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)
|
||||
if not refresh and cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
def _read_manifest_cache(cache: Path) -> Optional[dict]:
|
||||
try:
|
||||
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")
|
||||
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."""
|
||||
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:
|
||||
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
|
||||
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))
|
||||
return data
|
||||
except Exception as e: # offline / 404 / bad json
|
||||
logger.warning("manifest fetch failed for %s: %s", source, e)
|
||||
if cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
return None
|
||||
|
||||
|
||||
@@ -214,6 +432,385 @@ 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.
|
||||
@@ -223,76 +820,108 @@ 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 = 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.")
|
||||
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)
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
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"]
|
||||
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
|
||||
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)
|
||||
|
||||
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(
|
||||
"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),
|
||||
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_name = (name or item["name"]).strip() or item["name"]
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
try:
|
||||
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),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
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)
|
||||
|
||||
@@ -25,7 +25,7 @@ from huggingface_hub import utils as hf_utils
|
||||
from huggingface_hub.errors import HFValidationError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin, require_admin_action, require_desktop
|
||||
from core import prefs
|
||||
from services import tts_backend, asr_backend, llm_backend, translation_engines
|
||||
from services.audio_dsp import list_effect_presets
|
||||
@@ -41,6 +41,15 @@ _FAMILIES = {
|
||||
"llm": (llm_backend, "llm_backend"),
|
||||
}
|
||||
|
||||
|
||||
def _family_payload(family: str, module):
|
||||
"""Public inventory plus whether an environment pin owns this family."""
|
||||
return {
|
||||
"active": module.active_backend_id(),
|
||||
"env_override": bool(os.environ.get(f"OMNIVOICE_{family.upper()}_BACKEND")),
|
||||
"backends": public_backends(module.list_backends()),
|
||||
}
|
||||
|
||||
def _is_hf_repo_id(value: str) -> bool:
|
||||
"""Validate the route's ``owner/repo`` contract in bounded time."""
|
||||
if not isinstance(value, str) or len(value) > 96 or value.count("/") != 1:
|
||||
@@ -55,34 +64,25 @@ def _is_hf_repo_id(value: str) -> bool:
|
||||
@router.get("/engines")
|
||||
def list_all_engines():
|
||||
return {
|
||||
"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()),
|
||||
},
|
||||
"tts": _family_payload("tts", tts_backend),
|
||||
"asr": _family_payload("asr", asr_backend),
|
||||
"llm": _family_payload("llm", llm_backend),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
|
||||
return _family_payload("tts", tts_backend)
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
|
||||
return _family_payload("asr", asr_backend)
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
|
||||
return _family_payload("llm", llm_backend)
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
@@ -113,7 +113,10 @@ def list_translation_engines():
|
||||
}
|
||||
|
||||
|
||||
@router.post("/engines/translation/{engine_id}/install")
|
||||
@router.post(
|
||||
"/engines/translation/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def install_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -149,7 +152,10 @@ async def install_translation_engine(engine_id: str):
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/engines/translation/{engine_id}")
|
||||
@router.delete(
|
||||
"/engines/translation/{engine_id}",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def uninstall_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -188,15 +194,16 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
# POST /engines/sonitranslate/install). Mirrors the
|
||||
# /engines/translation/{engine_id}/install namespace pattern.
|
||||
#
|
||||
# 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.
|
||||
# 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.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin), Depends(require_desktop)],
|
||||
)
|
||||
def install_sidecar_engine(engine_id: str):
|
||||
"""Start (or report) the one-click install for a sidecar engine.
|
||||
@@ -222,7 +229,7 @@ def install_sidecar_engine(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/sidecar/{engine_id}/install/status",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def sidecar_install_status(engine_id: str):
|
||||
"""Step-by-step status of the sidecar install job (poll while running).
|
||||
@@ -243,7 +250,7 @@ def sidecar_install_status(engine_id: str):
|
||||
|
||||
@router.delete(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def uninstall_sidecar_engine(engine_id: str):
|
||||
"""Remove an app-managed sidecar install (checkout + venv + weights) and
|
||||
@@ -274,8 +281,8 @@ 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.
|
||||
#
|
||||
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
|
||||
# a sidecar spawn through this endpoint.
|
||||
# Admin-gated (T-02-13): only the local desktop frontend or an authenticated
|
||||
# server-mode administrator 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
|
||||
@@ -311,7 +318,7 @@ def _resolve_engine_class(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/health",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def engine_health(engine_id: str):
|
||||
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
|
||||
@@ -385,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. Loopback-gated.
|
||||
# * Only ever on user click (POST) — never on Settings load. Admin-gated.
|
||||
|
||||
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
|
||||
# trips the no-hardcoded-CJK guard.
|
||||
@@ -452,7 +459,7 @@ class SelfTestResponse(BaseModel):
|
||||
@router.post(
|
||||
"/engines/{engine_id}/selftest",
|
||||
response_model=SelfTestResponse,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def engine_selftest(engine_id: str):
|
||||
"""Run a bounded, real synthesis on an available in-process TTS engine.
|
||||
@@ -551,7 +558,11 @@ class SelectEngineResponse(BaseModel):
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
@router.post("/engines/select", response_model=SelectEngineResponse)
|
||||
@router.post(
|
||||
"/engines/select",
|
||||
response_model=SelectEngineResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
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
|
||||
|
||||
+229
-86
@@ -1,18 +1,24 @@
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse
|
||||
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
|
||||
|
||||
@@ -360,46 +366,223 @@ 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."""
|
||||
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}
|
||||
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
|
||||
return {"profile_id": result["profile_id"], "name": result["name"]}
|
||||
|
||||
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
@@ -415,22 +598,10 @@ def preview_voice(voice_id: str):
|
||||
|
||||
audio_path = row["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")
|
||||
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)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -503,33 +674,5 @@ 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."""
|
||||
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"]}
|
||||
result = _materialize_gallery_profile(voice_id)
|
||||
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
|
||||
|
||||
@@ -9,13 +9,13 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
from services import mcp_bindings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/mcp",
|
||||
tags=["mcp"],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class CustomPathRequest(BaseModel):
|
||||
|
||||
@@ -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 (loopback-only, like the dictation router):
|
||||
Endpoints (admin-gated; loopback or authenticated server mode):
|
||||
GET /pronunciation → list every entry
|
||||
POST /pronunciation → create one entry
|
||||
PUT /pronunciation/{entry_id} → update an entry (partial)
|
||||
@@ -30,12 +30,12 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
from core.db import db_conn
|
||||
from services.pronunciation import apply_pronunciation, entries_for_language
|
||||
|
||||
logger = logging.getLogger("omnivoice.pronunciation")
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
_VALID_TYPES = ("respelling", "ipa", "cmu")
|
||||
_ALL_LANG = "*"
|
||||
@@ -133,7 +133,7 @@ class PronImportRequest(BaseModel):
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
@router.get("/pronunciation")
|
||||
def list_entries():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -143,7 +143,7 @@ def list_entries():
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation")
|
||||
def create_entry(entry: PronEntry):
|
||||
term = entry.term.strip()
|
||||
if not term:
|
||||
@@ -171,7 +171,7 @@ def create_entry(entry: PronEntry):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
@router.put("/pronunciation/{entry_id}")
|
||||
def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -226,7 +226,7 @@ def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
@router.delete("/pronunciation/{entry_id}")
|
||||
def delete_entry(entry_id: str):
|
||||
with db_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM pronunciation_entries WHERE id = ?", (entry_id,))
|
||||
@@ -236,7 +236,7 @@ def delete_entry(entry_id: str):
|
||||
# ── Dry-run + import/export ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/pronunciation/test", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation/test")
|
||||
def test_substitution(req: PronTestRequest):
|
||||
"""Show the post-substitution text for ``req.text`` — no model call.
|
||||
|
||||
@@ -258,7 +258,7 @@ def test_substitution(req: PronTestRequest):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pronunciation/export", dependencies=[Depends(require_loopback)])
|
||||
@router.get("/pronunciation/export")
|
||||
def export_entries():
|
||||
"""Every entry as a JSON-serializable list (round-trips ``/import``)."""
|
||||
with db_conn() as conn:
|
||||
@@ -273,7 +273,7 @@ def export_entries():
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/pronunciation/import", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation/import")
|
||||
def import_entries(req: PronImportRequest):
|
||||
"""Bulk-add entries. ``replace=true`` clears the table first.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.logging_utils import log_safe
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_admin, require_admin_action
|
||||
|
||||
logger = logging.getLogger("omnivoice.api.settings")
|
||||
|
||||
@@ -92,8 +92,8 @@ def get_hf_token_state(fresh: bool = Query(False)):
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
|
||||
# router-level `require_loopback` dep.
|
||||
# Threat T-02-04: same admin guard as the hf-token endpoints via the
|
||||
# router-level `require_admin` dep.
|
||||
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
@@ -133,6 +133,83 @@ def set_torch_compile_disabled(body: _TorchCompileBody):
|
||||
return _torch_compile_state()
|
||||
|
||||
|
||||
# ── Compute-device override (Settings → Performance) ──────────────────────
|
||||
|
||||
|
||||
class _ComputeDeviceBody(BaseModel):
|
||||
value: str = Field(..., description="auto | cuda | rocm | xpu | mps | cpu")
|
||||
|
||||
|
||||
def _compute_device_state() -> dict:
|
||||
"""Everything the Performance panel needs to render the device control:
|
||||
the resolved pick (env > prefs > auto), what this process actually applied
|
||||
at probe time (differs after a change until restart — caps are immutable
|
||||
per process), what auto would pick, and which families exist here."""
|
||||
from core import device_caps
|
||||
|
||||
caps = device_caps.detect_host_caps()
|
||||
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
|
||||
auto_family = next(
|
||||
(f for f in ("cuda", "rocm", "xpu", "mps") if f in caps.available_families),
|
||||
"cpu",
|
||||
)
|
||||
value = device_caps.requested_device_override()
|
||||
return {
|
||||
"value": value,
|
||||
"applied": caps.requested_family,
|
||||
"restart_required": value != caps.requested_family,
|
||||
# The running process asked for a family it doesn't have (env pin on
|
||||
# the wrong machine, hardware removed): auto is in effect, and a
|
||||
# restart would not change that — the panel says so instead of
|
||||
# pretending the pick took.
|
||||
"override_ignored": (
|
||||
caps.requested_family not in ("auto", caps.family)
|
||||
),
|
||||
"effective_family": caps.family,
|
||||
"auto_family": auto_family,
|
||||
"available_families": list(caps.available_families),
|
||||
"env_pinned": env_pin in device_caps.DEVICE_OVERRIDE_CHOICES and env_pin != "",
|
||||
"choices": list(device_caps.DEVICE_OVERRIDE_CHOICES),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compute-device")
|
||||
def get_compute_device():
|
||||
"""Current compute-device override state (Settings → Performance)."""
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
@router.put("/compute-device")
|
||||
def set_compute_device(body: _ComputeDeviceBody):
|
||||
"""Persist the compute-device pick. Applied by the capability probe at
|
||||
the next backend start (host caps are immutable per process — same
|
||||
restart contract as the rest of the Performance tab). ``OMNIVOICE_DEVICE``
|
||||
always wins over this pick; the UI shows the pin instead of pretending."""
|
||||
from core import device_caps, prefs
|
||||
|
||||
value = (body.value or "").strip().lower()
|
||||
if value not in device_caps.DEVICE_OVERRIDE_CHOICES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown device '{value}'. Valid: {', '.join(device_caps.DEVICE_OVERRIDE_CHOICES)}",
|
||||
)
|
||||
caps = device_caps.detect_host_caps()
|
||||
if value not in ("auto", "cpu") and value not in caps.available_families:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"'{value}' is not available on this host "
|
||||
f"(have: {', '.join(caps.available_families)})"
|
||||
),
|
||||
)
|
||||
try:
|
||||
prefs.set_("compute_device", value)
|
||||
except Exception:
|
||||
logger.exception("set_compute_device failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist setting")
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
# ── Generation-history retention (Studio takes rail) ──────────────────────
|
||||
|
||||
|
||||
@@ -481,7 +558,10 @@ def _local_models(base_url: str, api_key: str):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/llm-providers/{provider_id}/models")
|
||||
@router.get(
|
||||
"/llm-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def list_llm_provider_models(provider_id: str):
|
||||
"""List model ids the provider's key can access (OpenAI-compat /models).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete
|
||||
from services import network_share
|
||||
from services import tailscale as _tailscale
|
||||
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse
|
||||
from api.dependencies import is_loopback, require_admin
|
||||
from api.dependencies import is_loopback, require_admin, require_admin_action
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import torch
|
||||
import shutil
|
||||
@@ -1089,7 +1089,10 @@ async def diagnostic_bundle(network: bool = Query(False, description="Include th
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/diagnose")
|
||||
@router.get(
|
||||
"/system/diagnose",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
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)"),
|
||||
|
||||
@@ -28,7 +28,7 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
from worker import registry, routing, service
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
@@ -39,9 +39,9 @@ logger = logging.getLogger("omnivoice.worker")
|
||||
# the task's own deadline does.
|
||||
_DISCONNECT_POLL_SECONDS = 1.0
|
||||
|
||||
# Management is loopback-only: these endpoints mint join tokens and revoke
|
||||
# machines, so they follow the same rule as the app's other privileged routes.
|
||||
router = APIRouter(prefix="/workers", tags=["workers"], dependencies=[Depends(require_loopback)])
|
||||
# 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):
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,421 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Exact-origin CSRF checks for ambient browser authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import SplitResult, urlsplit
|
||||
|
||||
|
||||
CSRF_HEADER = "x-voicestudio-csrf"
|
||||
CSRF_VALUE = "1"
|
||||
SAFE_HTTP_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
_FORWARDED_PROTO_HEADER = "x-forwarded-proto"
|
||||
|
||||
|
||||
def effective_scheme(connection) -> str:
|
||||
"""Scheme of the client-facing hop: the resolved scope, TLS-upgraded by proxy evidence.
|
||||
|
||||
Behind a TLS-terminating proxy (Tailscale Serve — the flagship remote-GPU
|
||||
deployment in docs/remote-gpu.md — nginx, Caddy, ...) the browser talks
|
||||
``https`` while the backend hop is plain ``http``. uvicorn's
|
||||
ProxyHeadersMiddleware (on by default in both launch paths: ``uvicorn.run``
|
||||
in backend/main.py and the Docker ``python -m uvicorn`` entrypoint) already
|
||||
rewrites the ASGI scope from ``X-Forwarded-Proto``, but only when the peer
|
||||
is in ``--forwarded-allow-ips`` (default: loopback). That covers Serve on
|
||||
bare metal, and we prefer that signal — the scope is consulted first — but
|
||||
it misses Docker (the proxy connects from the bridge gateway) and any other
|
||||
non-loopback proxy topology, so the header is honored here as well.
|
||||
|
||||
Spoofing analysis — why honoring it never weakens a check: the upgrade is
|
||||
one-way. ``https``/``wss`` as the first forwarded value promotes ``http``
|
||||
to ``https``; every other value is ignored, so a forged header can never
|
||||
downgrade a genuine TLS hop. For the exact-origin comparison the host:port
|
||||
half of the tuple is untouched, a browser cannot attach X-Forwarded-Proto
|
||||
cross-site without a CORS preflight this API never grants, and a
|
||||
non-browser client able to forge the header can already forge Origin
|
||||
itself — it gains nothing. For cookies the upgrade can only ADD the Secure
|
||||
flag (a Secure cookie set over plain http is simply dropped by the
|
||||
browser — the spoofer only breaks their own session), never strip it.
|
||||
"""
|
||||
url = getattr(connection, "url", None)
|
||||
scheme = getattr(url, "scheme", None)
|
||||
if not scheme:
|
||||
scope = getattr(connection, "scope", None)
|
||||
scheme = scope.get("scheme", "http") if isinstance(scope, dict) else "http"
|
||||
scheme = {"ws": "http", "wss": "https"}.get(scheme, scheme)
|
||||
if scheme != "https":
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
forwarded = (
|
||||
headers.get(_FORWARDED_PROTO_HEADER, "") if hasattr(headers, "get") else ""
|
||||
)
|
||||
if forwarded.split(",")[0].strip().lower() in {"https", "wss"}:
|
||||
scheme = "https"
|
||||
return scheme
|
||||
|
||||
|
||||
def _origin_tuple(value: str | None) -> tuple[str, str, int | None] | None:
|
||||
if not value or value == "null":
|
||||
return None
|
||||
try:
|
||||
parsed: SplitResult = urlsplit(value)
|
||||
port = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if (
|
||||
not parsed.scheme
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in ("", "/")
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in {"http", "https", "tauri"}:
|
||||
return None
|
||||
if port is None:
|
||||
if scheme == "http":
|
||||
port = 80
|
||||
elif scheme == "https":
|
||||
port = 443
|
||||
return scheme, parsed.hostname.lower(), port
|
||||
|
||||
|
||||
def configured_allowed_origins() -> frozenset[tuple[str, str, int | None]]:
|
||||
raw_port = os.environ.get("OMNIVOICE_UI_PORT", "3901")
|
||||
try:
|
||||
ui_port = int(raw_port)
|
||||
except (TypeError, ValueError):
|
||||
ui_port = 3901
|
||||
values = os.environ.get(
|
||||
"OMNIVOICE_ALLOWED_ORIGINS",
|
||||
f"http://localhost:{ui_port},http://127.0.0.1:{ui_port},"
|
||||
"tauri://localhost,http://tauri.localhost",
|
||||
).split(",")
|
||||
return frozenset(
|
||||
origin
|
||||
for value in values
|
||||
if (origin := _origin_tuple(value.strip())) is not None
|
||||
)
|
||||
|
||||
|
||||
def _destination_origin(connection) -> tuple[str, str, int | None] | None:
|
||||
scheme = effective_scheme(connection)
|
||||
url = getattr(connection, "url", None)
|
||||
netloc = getattr(url, "netloc", None)
|
||||
if netloc:
|
||||
return _origin_tuple(f"{scheme}://{netloc}")
|
||||
scope = getattr(connection, "scope", None)
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
if not isinstance(scope, dict):
|
||||
return None
|
||||
host = headers.get("host", "") if hasattr(headers, "get") else ""
|
||||
return _origin_tuple(f"{scheme}://{host}")
|
||||
|
||||
|
||||
def origin_allowed(connection) -> bool:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
origin_value = headers.get("origin", "") if hasattr(headers, "get") else ""
|
||||
presented = _origin_tuple(origin_value)
|
||||
if presented is None:
|
||||
return False
|
||||
return presented == _destination_origin(connection) or presented in configured_allowed_origins()
|
||||
|
||||
|
||||
def cookie_csrf_allowed(connection, *, side_effectful_get: bool = False) -> bool:
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
marker = headers.get(CSRF_HEADER, "") if hasattr(headers, "get") else ""
|
||||
if marker != CSRF_VALUE or not origin_allowed(connection):
|
||||
return False
|
||||
method = getattr(connection, "method", None)
|
||||
if method is None:
|
||||
scope = getattr(connection, "scope", None)
|
||||
method = scope.get("method", "GET") if isinstance(scope, dict) else "GET"
|
||||
method = str(method).upper()
|
||||
if side_effectful_get or method in SAFE_HTTP_METHODS:
|
||||
fetch_site = headers.get("sec-fetch-site", "") if hasattr(headers, "get") else ""
|
||||
return fetch_site == "same-origin"
|
||||
return True
|
||||
@@ -374,6 +374,33 @@ 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
|
||||
@@ -386,6 +413,7 @@ 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] = []
|
||||
@@ -507,6 +535,26 @@ def _probe() -> HostCaps:
|
||||
# available_families: every detected accelerator + cpu, deduped, cpu last.
|
||||
available: tuple[DeviceFamily, ...] = tuple(dict.fromkeys([*detected, "cpu"]))
|
||||
|
||||
# User override (Settings → Performance, or OMNIVOICE_DEVICE): honored
|
||||
# only when the named family actually exists on this host — an override
|
||||
# can steer, it cannot invent hardware. Applied here, at the single
|
||||
# choke point, so routing, model loads (get_best_device delegates its
|
||||
# family decision here), and every badge inherit it for free.
|
||||
requested = requested_device_override()
|
||||
if requested != "auto":
|
||||
if requested in available:
|
||||
if requested != family:
|
||||
notes.append(
|
||||
f"compute device pinned to '{requested}' by user override "
|
||||
f"(auto would pick '{family}')"
|
||||
)
|
||||
family = requested # type: ignore[assignment]
|
||||
else:
|
||||
notes.append(
|
||||
f"requested compute device '{requested}' is not available on "
|
||||
f"this host (have: {', '.join(available)}) — using '{family}'"
|
||||
)
|
||||
|
||||
return HostCaps(
|
||||
family=family,
|
||||
available_families=available,
|
||||
@@ -515,6 +563,7 @@ def _probe() -> HostCaps:
|
||||
driver=driver,
|
||||
notes=tuple(notes),
|
||||
probe_ok=True,
|
||||
requested_family=requested,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,13 @@ _WINDOWS_RESERVED_NAMES = frozenset({"CON", "PRN", "AUX", "NUL"}) | frozenset(
|
||||
f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
|
||||
)
|
||||
|
||||
# Both separator families, so a stored sub-path splits into the same components
|
||||
# on every host. Windows accepts ``/`` as a real separator, so splitting on
|
||||
# ``os.sep`` alone left ``"job/out.mp4"`` as a single component there while the
|
||||
# identical value split cleanly on POSIX. POSIX input never reaches this with a
|
||||
# backslash — it is rejected as a foreign separator before the split.
|
||||
_PATH_SEPARATORS = re.compile(r"[\\/]")
|
||||
|
||||
|
||||
class UnsafePath(ValueError):
|
||||
"""Raised when a path crosses its allowed filesystem boundary."""
|
||||
@@ -52,11 +59,10 @@ def resolve_within(root: os.PathLike[str] | str, value: os.PathLike[str] | str)
|
||||
raw = os.fspath(value) if value is not None else ""
|
||||
if not isinstance(raw, str) or not raw:
|
||||
raise UnsafePath("path is empty")
|
||||
# Treat both separator families as structural on every host. Otherwise a
|
||||
# Windows traversal string is an innocent-looking filename when validated
|
||||
# on Linux (and can become dangerous after persisted data is moved).
|
||||
if os.sep != "\\" and ("\\" in raw or bool(ntpath.splitdrive(raw)[0])):
|
||||
raise UnsafePath("path uses a foreign separator or drive")
|
||||
# Treat both separator families as structural on every host while still
|
||||
# rejecting Windows drive paths before rebuilding relative components.
|
||||
if os.sep != "\\" and bool(ntpath.splitdrive(raw)[0]):
|
||||
raise UnsafePath("path uses a drive")
|
||||
root_path = Path(root).expanduser().resolve(strict=False)
|
||||
root_text = str(root_path)
|
||||
if os.path.isabs(raw):
|
||||
@@ -69,7 +75,7 @@ def resolve_within(root: os.PathLike[str] | str, value: os.PathLike[str] | str)
|
||||
# containment proof explicit to static analysis, this rejects empty,
|
||||
# dot, parent, drive, and separator-bearing components before Path sees
|
||||
# any persisted/request-derived string.
|
||||
parts = raw.split(os.sep)
|
||||
parts = _PATH_SEPARATORS.split(raw)
|
||||
clean_parts: list[str] = []
|
||||
for part in parts:
|
||||
clean = os.path.basename(part)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Startup progress ledger — what the backend is doing before it can serve.
|
||||
|
||||
Why this exists: the project's #1 lifetime failure class is "can't reach the
|
||||
local backend", and a large slice of it was never a dead backend at all —
|
||||
just one that couldn't say "I'm starting, currently loading PyTorch" because
|
||||
nothing listened until every heavy import and migration finished. main.py now
|
||||
binds the socket early and defers the heavy work; this module is the shared
|
||||
state the early `/health` + `/startup/progress` endpoints report from while
|
||||
that work runs.
|
||||
|
||||
Thread-safety: the deferred init runs Phase A in an executor thread while the
|
||||
event loop serves probes, so every mutation and snapshot takes the lock.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Execution order matters only for display; the ledger records whatever order
|
||||
# steps actually begin in. Keep ids stable — the desktop shell field-sniffs
|
||||
# them and tests pin them.
|
||||
STEPS: "dict[str, str]" = {
|
||||
"env_prefs": "Restoring settings…",
|
||||
"native_preload": "Preparing GPU libraries…",
|
||||
"ml_imports": "Loading ML runtime (PyTorch)…",
|
||||
"api_routes": "Loading API routes…",
|
||||
"db_migrate": "Preparing database…",
|
||||
"services_start": "Starting background services…",
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_t0 = time.monotonic()
|
||||
_current: "str | None" = None
|
||||
_done: "list[tuple[str, float]]" = [] # (step_id, seconds it took)
|
||||
_started_at: float = 0.0
|
||||
_ready = False
|
||||
_error: "dict | None" = None
|
||||
|
||||
|
||||
def begin_step(step_id: str) -> None:
|
||||
global _current, _started_at
|
||||
with _lock:
|
||||
_finish_current_locked()
|
||||
_current = step_id
|
||||
_started_at = time.monotonic()
|
||||
|
||||
|
||||
def _finish_current_locked() -> None:
|
||||
global _current
|
||||
if _current is not None:
|
||||
_done.append((_current, round(time.monotonic() - _started_at, 2)))
|
||||
_current = None
|
||||
|
||||
|
||||
def mark_ready() -> None:
|
||||
global _ready
|
||||
with _lock:
|
||||
_finish_current_locked()
|
||||
_ready = True
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
"""Record a startup failure against the step that was running."""
|
||||
global _error
|
||||
with _lock:
|
||||
_error = {"step": _current, "message": str(message)[:500]}
|
||||
|
||||
|
||||
def is_ready() -> bool:
|
||||
with _lock:
|
||||
return _ready
|
||||
|
||||
|
||||
def current_step() -> "tuple[str | None, str | None]":
|
||||
"""(step_id, human label) of the active step, or (None, None)."""
|
||||
with _lock:
|
||||
if _current is None:
|
||||
return None, None
|
||||
return _current, STEPS.get(_current, _current)
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""The `/startup/progress` body. Always safe to call, never raises."""
|
||||
with _lock:
|
||||
if _error is not None:
|
||||
status = "failed"
|
||||
elif _ready:
|
||||
status = "ready"
|
||||
else:
|
||||
status = "starting"
|
||||
states = {sid: "pending" for sid in STEPS}
|
||||
for sid, _t in _done:
|
||||
states[sid] = "done"
|
||||
if _current is not None:
|
||||
states[_current] = "active"
|
||||
if _error is not None and _error.get("step"):
|
||||
states[_error["step"]] = "failed"
|
||||
durations = dict(_done)
|
||||
return {
|
||||
"status": status,
|
||||
"step": _current,
|
||||
"label": STEPS.get(_current, _current) if _current else None,
|
||||
"steps": [
|
||||
{
|
||||
"id": sid,
|
||||
"label": label,
|
||||
"state": states.get(sid, "pending"),
|
||||
**({"t": durations[sid]} if sid in durations else {}),
|
||||
}
|
||||
for sid, label in STEPS.items()
|
||||
],
|
||||
"elapsed_s": round(time.monotonic() - _t0, 2),
|
||||
"error": _error,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
global _current, _ready, _error, _started_at
|
||||
with _lock:
|
||||
_current = None
|
||||
_done.clear()
|
||||
_ready = False
|
||||
_error = None
|
||||
_started_at = 0.0
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
|
||||
# release.yml's version-bump job, so it stays equal to
|
||||
# pyproject/tauri.conf/Cargo/package.json.
|
||||
_FALLBACK_VERSION = "0.4.2"
|
||||
_FALLBACK_VERSION = "0.5.0"
|
||||
|
||||
|
||||
def _fallback_version() -> str:
|
||||
|
||||
@@ -85,11 +85,27 @@ def _get_model():
|
||||
global _model
|
||||
if _model is None:
|
||||
from faster_whisper import WhisperModel
|
||||
name = os.environ.get("ASR_MODEL_FW", "large-v3")
|
||||
# Same weights as in-process faster-whisper: ASR_MODEL_FASTER selects
|
||||
# for BOTH variants, ASR_MODEL_FW stays as a sidecar-only override.
|
||||
# Before this, the sidecar read only ASR_MODEL_FW while the download
|
||||
# preflight read ASR_MODEL_FASTER — set one and the other variant (or
|
||||
# the preflight) quietly used a different model.
|
||||
name = (
|
||||
os.environ.get("ASR_MODEL_FW")
|
||||
or os.environ.get("ASR_MODEL_FASTER")
|
||||
or "large-v3"
|
||||
)
|
||||
try:
|
||||
import torch
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
# The probe honors the user compute-device override and the
|
||||
# ROCm/CT2 incompatibility (#1529) — the child must agree with
|
||||
# the parent's device decision, not re-derive its own.
|
||||
from core.device_caps import detect_host_caps
|
||||
device = "cuda" if detect_host_caps().family == "cuda" else "cpu"
|
||||
except Exception:
|
||||
# Fail SAFE: guessing "cuda" from torch here would bypass a cpu
|
||||
# override and hand CTranslate2 HIP-flavoured cuda on ROCm
|
||||
# (#1529). CPU always works; say why in the sidecar log.
|
||||
print("asr-sidecar: device probe failed — using cpu", file=sys.stderr, flush=True)
|
||||
device = "cpu"
|
||||
# Degrade fp16 → int8 rather than crash on GPUs without efficient fp16
|
||||
# (older Maxwell/Pascal, GTX 16xx, CTranslate2/cuDNN mismatch) (#551).
|
||||
|
||||
@@ -353,6 +353,9 @@ def _make_backend_class():
|
||||
display_name = "OmniVoice (GGUF, hardware-adaptive)"
|
||||
gpu_compat = ("cuda", "mps", "cpu")
|
||||
supports_voice_design = False
|
||||
# Every generate() spawns the external binary — allocations live in
|
||||
# that process, invisible to parent-side accelerator counters.
|
||||
runs_out_of_process = True
|
||||
|
||||
# 24 kHz mono Higgs Audio v2 — same as the in-process OmniVoice.
|
||||
_SAMPLE_RATE = 24_000
|
||||
|
||||
+704
-447
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,402 @@
|
||||
"""Process-bound credentials for the first-party remote administration UI.
|
||||
|
||||
The durable ``OMNIVOICE_API_KEY`` is an operator secret, not a browser session.
|
||||
This module exchanges it for opaque, bounded-lifetime credentials without
|
||||
depending on FastAPI or persisting a verifier to disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import ModuleType
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
|
||||
SESSION_TTL_SECONDS = 8 * 60 * 60
|
||||
WS_TICKET_TTL_SECONDS = 30
|
||||
MAX_ADMIN_SESSIONS = 256
|
||||
MAX_WS_TICKETS = 512
|
||||
|
||||
ADMIN_SESSION_PREFIX = "ovs_admin_session_"
|
||||
WS_TICKET_PREFIX = "ovs_ws_ticket_"
|
||||
_TOKEN_BYTES = 32
|
||||
_ENCODED_TOKEN_LENGTH = 43
|
||||
_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$")
|
||||
_ALLOWED_WS_PATHS = frozenset({"/ws/events", "/ws/transcribe"})
|
||||
_ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
|
||||
_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1"
|
||||
|
||||
|
||||
def _hash_token(token: str, pepper: bytes) -> str:
|
||||
# These are 256-bit random values, not user-chosen passwords. A keyed,
|
||||
# process-local index is the right primitive: there is no feasible password
|
||||
# dictionary to slow down, and a copied record is unusable without the
|
||||
# store's independently generated pepper.
|
||||
return hmac.digest(pepper, token.encode("utf-8"), "sha256").hex()
|
||||
|
||||
|
||||
def _encode_token(raw: bytes) -> str:
|
||||
return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedSession:
|
||||
token: str = field(repr=False)
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedTicket:
|
||||
token: str = field(repr=False)
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionRecord:
|
||||
credential_id: str
|
||||
capabilities: frozenset[str]
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredSession:
|
||||
credential_id: str
|
||||
issued_monotonic: float
|
||||
expires_monotonic: float
|
||||
issued_at: float
|
||||
expires_at: float
|
||||
|
||||
def public(self) -> SessionRecord:
|
||||
return SessionRecord(
|
||||
credential_id=self.credential_id,
|
||||
capabilities=_ADMIN_CAPABILITIES,
|
||||
issued_at=self.issued_at,
|
||||
expires_at=self.expires_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StoredTicket:
|
||||
session_hash: str
|
||||
path: str
|
||||
issued_monotonic: float
|
||||
expires_monotonic: float
|
||||
|
||||
|
||||
class AdminSessionStore:
|
||||
"""Thread-safe, process-local store for admin sessions and WS tickets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
wall_time: Callable[[], float] = time.time,
|
||||
token_bytes: Callable[[int], bytes] = secrets.token_bytes,
|
||||
pepper: bytes | None = None,
|
||||
session_ttl_seconds: int = SESSION_TTL_SECONDS,
|
||||
ws_ticket_ttl_seconds: int = WS_TICKET_TTL_SECONDS,
|
||||
max_sessions: int = MAX_ADMIN_SESSIONS,
|
||||
max_tickets: int = MAX_WS_TICKETS,
|
||||
) -> None:
|
||||
if session_ttl_seconds <= 0 or ws_ticket_ttl_seconds <= 0:
|
||||
raise ValueError("credential TTLs must be positive")
|
||||
if max_sessions <= 0 or max_tickets <= 0:
|
||||
raise ValueError("credential store capacities must be positive")
|
||||
self._monotonic = monotonic
|
||||
self._wall_time = wall_time
|
||||
self._token_bytes = token_bytes
|
||||
self._pepper = pepper if pepper is not None else secrets.token_bytes(32)
|
||||
if len(self._pepper) < 32:
|
||||
raise ValueError("session-store pepper must contain at least 256 bits")
|
||||
self._session_ttl = session_ttl_seconds
|
||||
self._ticket_ttl = ws_ticket_ttl_seconds
|
||||
self._max_sessions = max_sessions
|
||||
self._max_tickets = max_tickets
|
||||
self._sessions: OrderedDict[str, _StoredSession] = OrderedDict()
|
||||
self._tickets: OrderedDict[str, _StoredTicket] = OrderedDict()
|
||||
self._ticket_hashes_by_session: dict[str, set[str]] = {}
|
||||
self._key_generation: bytes | None = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
snapshot = self.debug_snapshot()
|
||||
return (
|
||||
"AdminSessionStore("
|
||||
f"sessions={snapshot['sessions']}, ws_tickets={snapshot['ws_tickets']})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_master(api_key: str | None) -> str:
|
||||
return api_key.strip() if isinstance(api_key, str) else ""
|
||||
|
||||
def _generation(self, api_key: str) -> bytes:
|
||||
return HKDF(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=self._pepper,
|
||||
info=_KEY_GENERATION_INFO,
|
||||
).derive(api_key.encode("utf-8", errors="surrogatepass"))
|
||||
|
||||
def _sync_key_locked(self, api_key: str | None) -> bool:
|
||||
normalized = self._normalize_master(api_key)
|
||||
if not normalized:
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = None
|
||||
return False
|
||||
generation = self._generation(normalized)
|
||||
if self._key_generation is None:
|
||||
self._key_generation = generation
|
||||
return True
|
||||
if not hmac.compare_digest(self._key_generation, generation):
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = generation
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _valid_token(token: str | None, prefix: str) -> bool:
|
||||
if not isinstance(token, str) or not token.startswith(prefix):
|
||||
return False
|
||||
return bool(_TOKEN_BODY_RE.fullmatch(token.removeprefix(prefix)))
|
||||
|
||||
def _new_token_locked(self, prefix: str, existing: object) -> tuple[str, str]:
|
||||
for _attempt in range(8):
|
||||
raw = self._token_bytes(_TOKEN_BYTES)
|
||||
if not isinstance(raw, bytes) or len(raw) != _TOKEN_BYTES:
|
||||
raise RuntimeError("token source must return exactly 32 bytes")
|
||||
token = prefix + _encode_token(raw)
|
||||
token_hash = _hash_token(token, self._pepper)
|
||||
if token_hash not in existing:
|
||||
return token, token_hash
|
||||
raise RuntimeError("credential token source produced repeated collisions")
|
||||
|
||||
def _clear_credentials_locked(self) -> None:
|
||||
self._sessions.clear()
|
||||
self._tickets.clear()
|
||||
self._ticket_hashes_by_session.clear()
|
||||
|
||||
def _remove_ticket_locked(self, ticket_hash: str) -> _StoredTicket | None:
|
||||
ticket = self._tickets.pop(ticket_hash, None)
|
||||
if ticket is None:
|
||||
return None
|
||||
session_tickets = self._ticket_hashes_by_session.get(ticket.session_hash)
|
||||
if session_tickets is not None:
|
||||
session_tickets.discard(ticket_hash)
|
||||
if not session_tickets:
|
||||
self._ticket_hashes_by_session.pop(ticket.session_hash, None)
|
||||
return ticket
|
||||
|
||||
def _remove_session_locked(self, session_hash: str) -> _StoredSession | None:
|
||||
record = self._sessions.pop(session_hash, None)
|
||||
for ticket_hash in tuple(self._ticket_hashes_by_session.get(session_hash, ())):
|
||||
self._remove_ticket_locked(ticket_hash)
|
||||
# Defensive cleanup keeps a prior partial mutation from preserving a
|
||||
# dangling reverse-index bucket even when the session was already gone.
|
||||
self._ticket_hashes_by_session.pop(session_hash, None)
|
||||
return record
|
||||
|
||||
def _purge_locked(self, now: float) -> None:
|
||||
# TTLs are fixed per store and monotonic issue times never decrease, so
|
||||
# insertion order is expiry order. Only the expired prefix can require
|
||||
# work; the common request path examines at most one record per type.
|
||||
while self._sessions:
|
||||
session_hash = next(iter(self._sessions))
|
||||
if now < self._sessions[session_hash].expires_monotonic:
|
||||
break
|
||||
self._remove_session_locked(session_hash)
|
||||
|
||||
while self._tickets:
|
||||
ticket_hash = next(iter(self._tickets))
|
||||
if now < self._tickets[ticket_hash].expires_monotonic:
|
||||
break
|
||||
self._remove_ticket_locked(ticket_hash)
|
||||
|
||||
def _evict_sessions_locked(self) -> None:
|
||||
while len(self._sessions) >= self._max_sessions:
|
||||
self._remove_session_locked(next(iter(self._sessions)))
|
||||
|
||||
def _evict_tickets_locked(self) -> None:
|
||||
while len(self._tickets) >= self._max_tickets:
|
||||
self._remove_ticket_locked(next(iter(self._tickets)))
|
||||
|
||||
def issue(self, api_key: str) -> IssuedSession:
|
||||
normalized = self._normalize_master(api_key)
|
||||
if not normalized:
|
||||
raise ValueError("configured API key required")
|
||||
with self._lock:
|
||||
self._sync_key_locked(normalized)
|
||||
now = self._monotonic()
|
||||
wall_now = self._wall_time()
|
||||
self._purge_locked(now)
|
||||
self._evict_sessions_locked()
|
||||
token, token_hash = self._new_token_locked(ADMIN_SESSION_PREFIX, self._sessions)
|
||||
expires_monotonic = now + self._session_ttl
|
||||
expires_at = wall_now + self._session_ttl
|
||||
self._sessions[token_hash] = _StoredSession(
|
||||
credential_id=token_hash,
|
||||
issued_monotonic=now,
|
||||
expires_monotonic=expires_monotonic,
|
||||
issued_at=wall_now,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return IssuedSession(token=token, expires_at=expires_at)
|
||||
|
||||
def resolve(self, token: str | None, api_key: str | None) -> SessionRecord | None:
|
||||
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
|
||||
return None
|
||||
assert isinstance(token, str)
|
||||
with self._lock:
|
||||
if not self._sync_key_locked(api_key):
|
||||
return None
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
record = self._sessions.get(_hash_token(token, self._pepper))
|
||||
if record is None or now >= record.expires_monotonic:
|
||||
return None
|
||||
return record.public()
|
||||
|
||||
def revoke(self, token: str | None) -> bool:
|
||||
if not self._valid_token(token, ADMIN_SESSION_PREFIX):
|
||||
return False
|
||||
assert isinstance(token, str)
|
||||
token_hash = _hash_token(token, self._pepper)
|
||||
with self._lock:
|
||||
return self._remove_session_locked(token_hash) is not None
|
||||
|
||||
def revoke_by_credential(self, credential_id: str | None) -> bool:
|
||||
if not isinstance(credential_id, str) or len(credential_id) != 64:
|
||||
return False
|
||||
with self._lock:
|
||||
return self._remove_session_locked(credential_id) is not None
|
||||
|
||||
def issue_ws_ticket(
|
||||
self,
|
||||
session_token: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> IssuedTicket:
|
||||
if path not in _ALLOWED_WS_PATHS:
|
||||
raise ValueError("WebSocket path is not allowed")
|
||||
if not self._valid_token(session_token, ADMIN_SESSION_PREFIX):
|
||||
raise PermissionError("valid admin session required")
|
||||
assert isinstance(session_token, str)
|
||||
session_hash = _hash_token(session_token, self._pepper)
|
||||
return self.issue_ws_ticket_for_credential(session_hash, path, api_key)
|
||||
|
||||
def issue_ws_ticket_for_credential(
|
||||
self,
|
||||
credential_id: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> IssuedTicket:
|
||||
if path not in _ALLOWED_WS_PATHS:
|
||||
raise ValueError("WebSocket path is not allowed")
|
||||
with self._lock:
|
||||
if not isinstance(credential_id, str) or len(credential_id) != 64:
|
||||
raise PermissionError("valid admin session required")
|
||||
if not self._sync_key_locked(api_key):
|
||||
raise PermissionError("valid admin session required")
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
session = self._sessions.get(credential_id)
|
||||
if session is None or now >= session.expires_monotonic:
|
||||
raise PermissionError("valid admin session required")
|
||||
self._evict_tickets_locked()
|
||||
token, token_hash = self._new_token_locked(WS_TICKET_PREFIX, self._tickets)
|
||||
expires_at = self._wall_time() + self._ticket_ttl
|
||||
self._tickets[token_hash] = _StoredTicket(
|
||||
session_hash=credential_id,
|
||||
path=path,
|
||||
issued_monotonic=now,
|
||||
expires_monotonic=now + self._ticket_ttl,
|
||||
)
|
||||
self._ticket_hashes_by_session.setdefault(credential_id, set()).add(
|
||||
token_hash
|
||||
)
|
||||
return IssuedTicket(token=token, expires_at=expires_at)
|
||||
|
||||
def consume_ws_ticket(
|
||||
self,
|
||||
ticket_token: str | None,
|
||||
path: str,
|
||||
api_key: str | None,
|
||||
) -> SessionRecord | None:
|
||||
if not self._valid_token(ticket_token, WS_TICKET_PREFIX):
|
||||
return None
|
||||
assert isinstance(ticket_token, str)
|
||||
with self._lock:
|
||||
if not self._sync_key_locked(api_key):
|
||||
return None
|
||||
now = self._monotonic()
|
||||
self._purge_locked(now)
|
||||
ticket = self._remove_ticket_locked(
|
||||
_hash_token(ticket_token, self._pepper)
|
||||
)
|
||||
if ticket is None or now >= ticket.expires_monotonic or ticket.path != path:
|
||||
return None
|
||||
session = self._sessions.get(ticket.session_hash)
|
||||
if session is None or now >= session.expires_monotonic:
|
||||
return None
|
||||
return session.public()
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._clear_credentials_locked()
|
||||
self._key_generation = None
|
||||
|
||||
@property
|
||||
def active_session_count(self) -> int:
|
||||
with self._lock:
|
||||
self._purge_locked(self._monotonic())
|
||||
return len(self._sessions)
|
||||
|
||||
def debug_snapshot(self) -> dict[str, int]:
|
||||
with self._lock:
|
||||
self._purge_locked(self._monotonic())
|
||||
return {"sessions": len(self._sessions), "ws_tickets": len(self._tickets)}
|
||||
|
||||
|
||||
#: Synthetic ``sys.modules`` key holding the one per-process store. A module
|
||||
#: object in ``sys.modules`` is the only namespace that survives everything
|
||||
#: test suites do to this package: ``importlib.reload`` re-executes module
|
||||
#: code but never touches unrelated ``sys.modules`` entries, and the purges
|
||||
#: that pop whole ``services.*`` / ``api.*`` trees match package prefixes this
|
||||
#: underscore-prefixed top-level name is outside of.
|
||||
_ANCHOR_MODULE_NAME = "_omnivoice_admin_session_store_anchor"
|
||||
|
||||
|
||||
def _process_store() -> AdminSessionStore:
|
||||
"""Return THE per-process store, however this module was (re)imported.
|
||||
|
||||
Auth is process-global state: the copy of this module that issues a
|
||||
credential and the copy that later resolves it must always be looking at
|
||||
the same store. A bare module-level ``AdminSessionStore()`` breaks that
|
||||
the moment anything reloads or re-imports this module (fresh module dict →
|
||||
fresh store → freshly issued sessions vanish for holders of the old
|
||||
reference, and vice versa). Anchoring the instance outside the module's
|
||||
own namespace makes every copy of this module share one store.
|
||||
"""
|
||||
anchor = sys.modules.get(_ANCHOR_MODULE_NAME)
|
||||
if not isinstance(anchor, ModuleType):
|
||||
anchor = ModuleType(_ANCHOR_MODULE_NAME)
|
||||
anchor.__doc__ = "Process-global anchor for the VoiceStudio admin-session store."
|
||||
sys.modules[_ANCHOR_MODULE_NAME] = anchor
|
||||
store = getattr(anchor, "admin_session_store", None)
|
||||
if store is None:
|
||||
store = AdminSessionStore()
|
||||
anchor.admin_session_store = store
|
||||
return store
|
||||
|
||||
|
||||
admin_session_store = _process_store()
|
||||
@@ -520,12 +520,10 @@ class WhisperXBackend(ASRBackend):
|
||||
def _pick_device() -> tuple[str, str]:
|
||||
# CUDA fp16 when available; otherwise CPU int8 (fastest CPU path,
|
||||
# negligible WER regression vs fp32 for whisper-large-v3).
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
return "cuda", "float16"
|
||||
except Exception:
|
||||
pass
|
||||
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
|
||||
# answers True there, and CTranslate2 has no HIP backend (#1529).
|
||||
if _ctranslate2_cuda_ok():
|
||||
return "cuda", "float16"
|
||||
return "cpu", "int8"
|
||||
|
||||
# Peak VRAM (GB) to load *and transcribe* whisper large-v3 per CTranslate2
|
||||
@@ -981,12 +979,10 @@ class FasterWhisperBackend(ASRBackend):
|
||||
# - Apple Silicon / CPU → CPU int8 (fastest on CPU, negligible
|
||||
# WER regression vs fp32 for whisper-large-v3)
|
||||
device, compute_type = "cpu", "int8"
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
device, compute_type = "cuda", "float16"
|
||||
except Exception:
|
||||
pass
|
||||
# _ctranslate2_cuda_ok, not torch.cuda.is_available: ROCm torch also
|
||||
# answers True there, and CTranslate2 has no HIP backend (#1529).
|
||||
if _ctranslate2_cuda_ok():
|
||||
device, compute_type = "cuda", "float16"
|
||||
logger.info(
|
||||
"faster-whisper loading %s on %s (%s)",
|
||||
self._model_name, device, compute_type,
|
||||
@@ -2329,7 +2325,7 @@ _INSTALL_HINTS: dict[str, str] = {
|
||||
"mac-ARM source installs since 0.3.22. Parakeet TDT v3 on the GPU via "
|
||||
"MLX: 25 European languages, word timestamps, ~2 GB unified memory.)"
|
||||
),
|
||||
"moonshine": "pip install useful-moonshine (edge/CPU-optimized ASR)",
|
||||
"moonshine": "uv pip install moonshine-onnx (or moonshine-voice; edge/CPU-optimized ASR)",
|
||||
"funasr": "pip install funasr (SenseVoiceSmall + FSMN-VAD; CUDA or CPU)",
|
||||
"sherpa-onnx-asr": "uv add sherpa-onnx (ONNX live dictation; CPU, cross-platform)",
|
||||
"openai-compat-asr": (
|
||||
@@ -2464,6 +2460,61 @@ def _mps_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _cuda_reported_available() -> bool:
|
||||
"""``torch.cuda.is_available()`` verbatim — True on real CUDA *and* HIP."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return bool(torch.cuda.is_available())
|
||||
except Exception: # noqa: BLE001 — no torch
|
||||
return False
|
||||
|
||||
|
||||
def _rocm_torch() -> bool:
|
||||
"""True when torch is the ROCm (HIP) build.
|
||||
|
||||
ROCm torch masquerades as CUDA: ``torch.cuda.is_available()`` answers True
|
||||
and tensors live on ``"cuda"`` devices, but the CUDA *runtime libraries*
|
||||
other packages ship are still NVIDIA-only. ``torch.version.hip`` is the
|
||||
one honest tell.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return getattr(torch.version, "hip", None) is not None
|
||||
except Exception: # noqa: BLE001 — no torch
|
||||
return False
|
||||
|
||||
|
||||
def _ctranslate2_cuda_ok() -> bool:
|
||||
"""Whether CTranslate2 (whisperx / faster-whisper) may use ``"cuda"``.
|
||||
|
||||
CTranslate2 has NO HIP backend. On a ROCm host torch says cuda is
|
||||
available (HIP), the device string is handed to CTranslate2, and its
|
||||
NVIDIA CUDA runtime dies with "CUDA driver version is insufficient for
|
||||
CUDA runtime version" — the #1529 report, an AMD RX 7900 XTX in the
|
||||
:rocm Docker image. Real CUDA only; ROCm hosts take the CPU path here
|
||||
(auto-detect prefers pytorch-whisper there, which does use HIP).
|
||||
|
||||
Also honors the user compute-device override (Settings → Performance /
|
||||
``OMNIVOICE_DEVICE``): a host pinned to cpu (or any non-cuda family)
|
||||
must not hand CTranslate2 a CUDA device — the probe applies the
|
||||
override, so gating on its family covers every CT2 loader at once.
|
||||
"""
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
|
||||
if detect_host_caps().family != "cuda":
|
||||
return False
|
||||
except Exception: # noqa: BLE001 — fail SAFE, not fast
|
||||
# Without a working probe we can't know whether an override or a
|
||||
# ROCm build is in play — guessing "cuda" from torch here is exactly
|
||||
# the #1529 crash. CPU always works.
|
||||
logger.warning("device probe failed — CTranslate2 taking the CPU path", exc_info=True)
|
||||
return False
|
||||
return _cuda_reported_available() and not _rocm_torch()
|
||||
|
||||
|
||||
def _auto_detect() -> str:
|
||||
"""Pick the best available ASR engine **for this hardware**.
|
||||
|
||||
@@ -2495,6 +2546,14 @@ def _auto_detect() -> str:
|
||||
"""
|
||||
if _mps_available() and _probe_available(MLXWhisperBackend):
|
||||
return "mlx-whisper"
|
||||
# Same class as the Apple case, on the ROCm axis (#1529): whisperx and
|
||||
# faster-whisper are CTranslate2, which has no HIP backend — on a ROCm
|
||||
# host they run on the CPU while the GPU sits idle (and before
|
||||
# _ctranslate2_cuda_ok they died outright trying NVIDIA's runtime).
|
||||
# pytorch-whisper is a pure transformers pipeline riding torch itself,
|
||||
# so it genuinely uses the HIP GPU there.
|
||||
if _rocm_torch() and _cuda_reported_available() and _probe_available(PyTorchWhisperBackend):
|
||||
return "pytorch-whisper"
|
||||
if _probe_available(WhisperXBackend):
|
||||
return "whisperx"
|
||||
if _probe_available(FasterWhisperBackend):
|
||||
@@ -3077,10 +3136,18 @@ def _offline_asr_repo(backend_id: str | None = None) -> str | None:
|
||||
bid = backend_id or active_backend_id()
|
||||
if bid == "whisperx":
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_WHISPERX", "large-v3"))
|
||||
if bid in ("faster-whisper", "faster-whisper-isolated"):
|
||||
# The crash-isolated sidecar loads the SAME CT2 weights as in-process
|
||||
# faster-whisper (it reuses the ASR_MODEL_FASTER selection).
|
||||
if bid == "faster-whisper":
|
||||
return _fw_repo(os.environ.get("ASR_MODEL_FASTER", _FASTER_WHISPER_DEFAULT))
|
||||
if bid == "faster-whisper-isolated":
|
||||
# Mirror the sidecar's own resolution (_asr_sidecar/main.py):
|
||||
# ASR_MODEL_FW is a sidecar-only override, otherwise the shared
|
||||
# ASR_MODEL_FASTER selection applies — so the preflight can never
|
||||
# download a different repo than the sidecar will load.
|
||||
return _fw_repo(
|
||||
os.environ.get("ASR_MODEL_FW")
|
||||
or os.environ.get("ASR_MODEL_FASTER")
|
||||
or _FASTER_WHISPER_DEFAULT
|
||||
)
|
||||
if bid == "mlx-whisper":
|
||||
return os.environ.get("ASR_MODEL", _MLX_MODEL_DEFAULT)
|
||||
if bid == "parakeet-mlx":
|
||||
|
||||
@@ -57,6 +57,99 @@ def _force_compile_requested() -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# ── FlashInfer opt-in (upstream k2-fsa port) ────────────────────────────────
|
||||
# Explicit power-user opt-in, CUDA-only: OMNIVOICE_FLASHINFER=1 patches the
|
||||
# OmniVoice model with flashinfer packed attention (~2x per upstream's
|
||||
# benchmarks); =graph additionally captures CUDA graphs (best at batch=1).
|
||||
# Off by default — `flashinfer` is not a shipped dependency, and an
|
||||
# optimization must never be a point of failure. Session-sticky failure
|
||||
# latch mirrors torch.compile's (#278).
|
||||
_FLASHINFER_ENV = "OMNIVOICE_FLASHINFER"
|
||||
_flashinfer_runtime_failure: Optional[str] = None
|
||||
|
||||
|
||||
def flashinfer_mode() -> str:
|
||||
"""The user's ``OMNIVOICE_FLASHINFER`` request: 'off' | 'on' | 'graph'.
|
||||
|
||||
Unknown values normalize to 'off' with a log line naming the env var, so
|
||||
a typo degrades to the default path instead of half-applying.
|
||||
"""
|
||||
value = os.environ.get(_FLASHINFER_ENV, "").strip().lower()
|
||||
if value in {"", "0", "false", "no", "off"}:
|
||||
return "off"
|
||||
if value in {"1", "true", "yes", "on"}:
|
||||
return "on"
|
||||
if value == "graph":
|
||||
return "graph"
|
||||
logger.warning(
|
||||
"%s=%r not recognized (valid: 0, 1, graph) — FlashInfer stays off.",
|
||||
_FLASHINFER_ENV, value,
|
||||
)
|
||||
return "off"
|
||||
|
||||
|
||||
def should_flashinfer(device: str) -> str:
|
||||
"""Resolve the FlashInfer request against this host: 'off' | 'on' | 'graph'.
|
||||
|
||||
Requires all of: the ``OMNIVOICE_FLASHINFER`` opt-in, device == "cuda"
|
||||
(flashinfer is CUDA-only), the ``flashinfer`` package importable, and no
|
||||
earlier runtime failure this session. Every refusal is logged with the
|
||||
reason and the knob's name — the user asked for it, so silence would read
|
||||
as "the setting doesn't work".
|
||||
"""
|
||||
mode = flashinfer_mode()
|
||||
if mode == "off":
|
||||
return "off"
|
||||
if device != "cuda":
|
||||
logger.warning(
|
||||
"%s requested but the compute device is %r — FlashInfer is "
|
||||
"CUDA-only, continuing without it.", _FLASHINFER_ENV, device,
|
||||
)
|
||||
return "off"
|
||||
if importlib.util.find_spec("flashinfer") is None:
|
||||
logger.warning(
|
||||
"%s requested but the `flashinfer` package is not installed — "
|
||||
"continuing without it. Install with: uv pip install "
|
||||
"flashinfer-python flashinfer-jit-cache "
|
||||
"--extra-index-url https://flashinfer.ai/whl/cu128/ "
|
||||
"(pick the index matching your CUDA build).", _FLASHINFER_ENV,
|
||||
)
|
||||
return "off"
|
||||
if _flashinfer_runtime_failure is not None:
|
||||
logger.info(
|
||||
"FlashInfer skipped: failed earlier this session (%s) — using the "
|
||||
"standard path.", _flashinfer_runtime_failure,
|
||||
)
|
||||
return "off"
|
||||
return mode
|
||||
|
||||
|
||||
def mark_flashinfer_runtime_failure(reason: str) -> None:
|
||||
"""Latch a FlashInfer apply/runtime failure for the rest of the process,
|
||||
same contract as ``mark_compile_runtime_failure``."""
|
||||
global _flashinfer_runtime_failure
|
||||
try:
|
||||
# Import/kernel errors embed absolute paths (wheels under the user's
|
||||
# home) — redact before latching, since the reason is logged here and
|
||||
# re-logged on every later skip.
|
||||
from core.failure import sanitize
|
||||
|
||||
reason = sanitize(reason)
|
||||
except Exception:
|
||||
# Fail closed: if the redactor itself breaks, latching the raw text
|
||||
# would defeat the redaction. Keep only the exception class (the part
|
||||
# before ':' in our "Type: message" reasons) and drop the message.
|
||||
reason = (
|
||||
f"{(reason or '').split(':', 1)[0][:80]} "
|
||||
"(details redacted: sanitizer unavailable)"
|
||||
).strip()
|
||||
_flashinfer_runtime_failure = reason or "unknown FlashInfer runtime failure"
|
||||
logger.warning(
|
||||
"FlashInfer disabled for this session after a runtime failure: %s",
|
||||
_flashinfer_runtime_failure,
|
||||
)
|
||||
|
||||
|
||||
def _cuda_arch_supported_for_compile() -> "tuple[bool, str]":
|
||||
"""Check the GPU's architecture against this torch build's arch list.
|
||||
|
||||
|
||||
@@ -251,10 +251,33 @@ def list_backends() -> list[dict]:
|
||||
"effective_device": "network",
|
||||
"routing_status": "n/a",
|
||||
"routing_reason": None,
|
||||
# The openai-compat family entry and the LLM Providers panel are
|
||||
# ONE system (this backend resolves through the active provider),
|
||||
# but the UI presented them as unrelated. Naming the resolved
|
||||
# provider + model here lets the catalogue row say which endpoint
|
||||
# actually answers, instead of a generic family label.
|
||||
"hint": _provider_hint(bid) if ok else None,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _provider_hint(bid: str) -> str | None:
|
||||
"""``Provider · model`` for the openai-compat row, None for everything else."""
|
||||
if bid != "openai-compat":
|
||||
return None
|
||||
try:
|
||||
from services import llm_providers
|
||||
p = llm_providers.active_provider()
|
||||
if p is None:
|
||||
return None
|
||||
model = llm_providers.resolve_model(p)
|
||||
return f"{p.display_name} · {model}" if model else p.display_name
|
||||
except Exception:
|
||||
# The hint is decoration; a provider-registry hiccup must not take
|
||||
# down the whole engines listing.
|
||||
return None
|
||||
|
||||
|
||||
def active_backend_id() -> str:
|
||||
explicit = os.environ.get("OMNIVOICE_LLM_BACKEND")
|
||||
if explicit:
|
||||
|
||||
@@ -1376,6 +1376,122 @@ def _install_compile_fallback(_model) -> None:
|
||||
_model.generate = _generate_with_compile_fallback
|
||||
|
||||
|
||||
# ── FlashInfer runtime fallback (upstream k2-fsa port) ──────────────────────
|
||||
|
||||
|
||||
def _is_flashinfer_runtime_failure(exc: BaseException) -> bool:
|
||||
"""True when an exception originates in the FlashInfer fast path (the
|
||||
flashinfer package, our omnivoice_flashinfer patch module, or CUDA-graph
|
||||
capture/replay) rather than in the model or the request itself. Same
|
||||
chain/traceback walk as ``_is_compile_runtime_failure``."""
|
||||
import traceback as _tb
|
||||
|
||||
tb_markers = ("/flashinfer/", "omnivoice_flashinfer")
|
||||
msg_markers = ("flashinfer", "cuda graph", "cudagraph")
|
||||
seen: set[int] = set()
|
||||
cur: BaseException | None = exc
|
||||
while cur is not None and id(cur) not in seen:
|
||||
seen.add(id(cur))
|
||||
mod = type(cur).__module__ or ""
|
||||
if mod.startswith("flashinfer"):
|
||||
return True
|
||||
msg = str(cur).lower()
|
||||
if any(marker in msg for marker in msg_markers):
|
||||
return True
|
||||
try:
|
||||
for frame in _tb.extract_tb(cur.__traceback__):
|
||||
filename = (frame.filename or "").replace("\\", "/")
|
||||
if any(marker in filename for marker in tb_markers):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
if cur.__cause__ is not None:
|
||||
cur = cur.__cause__
|
||||
elif not cur.__suppress_context__:
|
||||
cur = cur.__context__
|
||||
else:
|
||||
cur = None
|
||||
return False
|
||||
|
||||
|
||||
def _unapply_flashinfer(_model) -> None:
|
||||
"""Restore the standard execution path on a FlashInfer-patched model.
|
||||
|
||||
``apply_flashinfer`` works entirely through *instance-level* state —
|
||||
MethodType-bound ``forward``/``_generate_iterative`` overrides and
|
||||
``_fi_*`` attributes — so deleting those attributes restores the class
|
||||
implementations exactly. The attention implementation is restored to the
|
||||
one captured before apply (``_fi_orig_attn_impl`` — could be
|
||||
flash_attention_2, not just sdpa), and use_cache is re-enabled."""
|
||||
llm = getattr(_model, "llm", None)
|
||||
orig_attn = getattr(_model, "_fi_orig_attn_impl", None) or "sdpa"
|
||||
if llm is not None:
|
||||
for module in llm.modules():
|
||||
if "forward" in vars(module):
|
||||
del module.forward
|
||||
for attr in ("_fi_w_qkv", "_fi_qkv_split", "_fi_rope_theta", "_fi_w_gate_up"):
|
||||
if attr in vars(module):
|
||||
delattr(module, attr)
|
||||
try:
|
||||
llm.set_attn_implementation(orig_attn)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"failed to restore %s attention after FlashInfer", orig_attn
|
||||
)
|
||||
llm.config.use_cache = True
|
||||
for attr in (
|
||||
"_fi_orig_attn_impl",
|
||||
"_generate_iterative",
|
||||
"_fi_runner",
|
||||
"_fi_graph_cache",
|
||||
"_fi_enable_cuda_graph",
|
||||
"_fi_graph_buckets",
|
||||
"_fi_overhead_budget",
|
||||
):
|
||||
if attr in vars(_model):
|
||||
delattr(_model, attr)
|
||||
|
||||
|
||||
def _install_flashinfer_fallback(_model) -> None:
|
||||
"""Wrap ``model.generate`` so a FlashInfer failure at inference time falls
|
||||
back to the standard path instead of failing the generation — the same
|
||||
contract as ``_install_compile_fallback`` (#278): an optimization must
|
||||
never turn a working generation into an error."""
|
||||
orig_generate = _model.generate
|
||||
|
||||
def _generate_with_flashinfer_fallback(*args, **kwargs):
|
||||
try:
|
||||
return orig_generate(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
if not _is_flashinfer_runtime_failure(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"FlashInfer runtime failure during generation (%s: %s) — "
|
||||
"restoring the standard path and disabling FlashInfer for "
|
||||
"this session. Generation is being retried without it.",
|
||||
type(exc).__name__, exc,
|
||||
)
|
||||
from services import engine_env
|
||||
engine_env.mark_flashinfer_runtime_failure(
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
# Unapply BEFORE exposing the eager path: while the teardown
|
||||
# mutates modules, _model.generate still routes through the
|
||||
# thread-affinity wrapper, so a concurrent render queues behind
|
||||
# this call instead of racing the half-restored model (Greptile,
|
||||
# #1565 round 2). Only a fully restored model is published.
|
||||
_unapply_flashinfer(_model)
|
||||
_model.generate = orig_generate
|
||||
try:
|
||||
return orig_generate(*args, **kwargs)
|
||||
except Exception as plain_exc:
|
||||
# `from None`: a genuine standard-path failure must not be
|
||||
# chained to — and misread as — the FlashInfer error.
|
||||
raise plain_exc from None
|
||||
|
||||
_model.generate = _generate_with_flashinfer_fallback
|
||||
|
||||
|
||||
# ── #315: thread affinity for cudagraph-compiled models ─────────────────────
|
||||
# `torch.compile(mode="reduce-overhead")` captures CUDA graphs, and captured
|
||||
# graph state is **thread-local** (torch/_inductor/cudagraph_trees keys its
|
||||
@@ -2117,6 +2233,57 @@ def _load_model_sync():
|
||||
"to stop preloading it alongside TTS."
|
||||
) from asr_exc
|
||||
|
||||
# FlashInfer opt-in (upstream k2-fsa port): packed CFG attention +
|
||||
# fused kernels, ~2x on upstream's benchmarks. Applied INSTEAD of
|
||||
# torch.compile — both rewrite the llm's execution and they do not
|
||||
# compose. Best-effort: any apply failure latches the session off and
|
||||
# the standard path continues untouched.
|
||||
flashinfer_applied = False
|
||||
try:
|
||||
from services.engine_env import (
|
||||
mark_flashinfer_runtime_failure,
|
||||
should_flashinfer,
|
||||
)
|
||||
|
||||
fi_mode = should_flashinfer(device)
|
||||
if fi_mode != "off":
|
||||
_set_loading("compiling", "Applying FlashInfer kernels…")
|
||||
try:
|
||||
from omnivoice.models.omnivoice_flashinfer import apply_flashinfer
|
||||
|
||||
# Captured BEFORE apply so unapply (either the failure
|
||||
# branch below or the generate-time fallback) restores
|
||||
# the true prior implementation.
|
||||
_model._fi_orig_attn_impl = getattr(
|
||||
_model.llm.config, "_attn_implementation", "sdpa"
|
||||
)
|
||||
apply_flashinfer(_model, enable_cuda_graph=(fi_mode == "graph"))
|
||||
except Exception as fi_exc: # noqa: BLE001 — perf opt, never fatal
|
||||
mark_flashinfer_runtime_failure(
|
||||
f"{type(fi_exc).__name__}: {fi_exc}"
|
||||
)
|
||||
# apply_flashinfer mutates the model as it goes — a
|
||||
# failure partway leaves half-patched modules that would
|
||||
# crash the next render (Greptile, #1565). Restore fully.
|
||||
_unapply_flashinfer(_model)
|
||||
else:
|
||||
flashinfer_applied = True
|
||||
_install_flashinfer_fallback(_model)
|
||||
# BOTH modes pin inference to one thread. Graph mode for
|
||||
# the #315 reason (captured CUDA-graph state is
|
||||
# thread-local); eager mode because the FlashInfer
|
||||
# attention wrapper and packed position ids are planned
|
||||
# per generation in module state — two _gpu_pool workers
|
||||
# interleaving plan() and run() would corrupt each
|
||||
# other's layout (CodeRabbit/Greptile, #1565).
|
||||
_install_compile_thread_affinity(_model)
|
||||
logger.info(
|
||||
"FlashInfer applied (mode=%s) — torch.compile skipped "
|
||||
"for this load.", fi_mode,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("FlashInfer opt-in check failed; continuing without")
|
||||
|
||||
try:
|
||||
# plan-02 (#65): gate on Triton availability (+ user setting), not
|
||||
# just device==cuda. Triton has no Windows wheel, so the old
|
||||
@@ -2124,7 +2291,7 @@ def _load_model_sync():
|
||||
# falls back to eager there.
|
||||
from services.engine_env import should_torch_compile
|
||||
|
||||
if should_torch_compile(device):
|
||||
if not flashinfer_applied and should_torch_compile(device):
|
||||
_set_loading("compiling", "Compiling model (torch.compile)…")
|
||||
try:
|
||||
_model.llm = torch.compile(_model.llm, mode=_TORCH_COMPILE_MODE)
|
||||
|
||||
@@ -363,6 +363,10 @@ class SubprocessBackend(TTSBackend):
|
||||
# A duck-typed marker survives that.
|
||||
_is_subprocess_isolated: bool = True
|
||||
|
||||
# Generation happens in the sidecar: parent-side accelerator counters
|
||||
# can't see its allocations (see TTSBackend.runs_out_of_process).
|
||||
runs_out_of_process: bool = True
|
||||
|
||||
# Default sample rate; subclasses override.
|
||||
_DEFAULT_SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
+146
-14
@@ -300,6 +300,23 @@ class TTSBackend(ABC):
|
||||
#: 0 means "no meaningful floor" (CPU-class engines) and never warns.
|
||||
min_vram_gb: float = 0.0
|
||||
|
||||
#: True when generation allocates in ANOTHER process — a dedicated-venv
|
||||
#: sidecar (SubprocessBackend) or a spawned binary (omnivoice-gguf).
|
||||
#: Parent-process accelerator counters cannot see those allocations, so
|
||||
#: profilers/diagnostics must not attribute the parent's VRAM numbers to
|
||||
#: the engine. Duck-typed (attribute, not issubclass) for the same
|
||||
#: module-purge reason as `_is_subprocess_isolated`.
|
||||
runs_out_of_process: bool = False
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
"""Which concrete model this backend would run, for adapter engines
|
||||
that host several very different models behind one backend id
|
||||
(mlx-audio, sherpa-onnx, cosyvoice). None means the engine id
|
||||
already names the model. Profilers and diagnostics use this to
|
||||
label results — without it, Kokoro-under-mlx and Dia-under-mlx
|
||||
rows are indistinguishable."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
@@ -395,6 +412,95 @@ _PROMPT_CACHE_MAX = 8
|
||||
_prompt_cache: "OrderedDict[tuple, object]" = OrderedDict()
|
||||
_prompt_cache_lock = threading.Lock()
|
||||
|
||||
# Disk layer under the in-memory LRU (upstream k2-fsa VoiceClonePrompt.save/
|
||||
# load format). The in-memory cache dies with the process, so the first
|
||||
# generation of every session re-encodes each voice (~0.4 s + an ASR pass when
|
||||
# ref_text is missing). Encoded prompts are tiny (a (8, T) int token tensor +
|
||||
# transcript), so we persist them and reload across restarts. Keyed by the
|
||||
# same tuple as the memory cache — the ref file's mtime is inside the key, so
|
||||
# an edited reference never matches a stale file; stale files age out via the
|
||||
# mtime prune. Best-effort like the memory cache: any failure means "no disk
|
||||
# hit / no disk write", never a failed generation. OMNIVOICE_PROMPT_DISK_CACHE=0
|
||||
# disables the layer entirely.
|
||||
_PROMPT_DISK_CACHE_MAX = 32
|
||||
|
||||
|
||||
def _prompt_disk_dir():
|
||||
"""Return the prompt-cache directory (created on first use), or None when
|
||||
the layer is disabled or the directory can't be created."""
|
||||
if os.environ.get("OMNIVOICE_PROMPT_DISK_CACHE", "1") == "0":
|
||||
return None
|
||||
try:
|
||||
from core.config import DATA_DIR
|
||||
|
||||
path = os.path.join(str(DATA_DIR), "prompt_cache")
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
except Exception as e: # noqa: BLE001 — cache layer must never break synthesis
|
||||
logger.debug("prompt disk cache unavailable: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_disk_path(cache_dir: str, key: tuple) -> str:
|
||||
import hashlib
|
||||
|
||||
digest = hashlib.sha256(repr(key).encode("utf-8")).hexdigest()[:32]
|
||||
return os.path.join(cache_dir, f"{digest}.pt")
|
||||
|
||||
|
||||
def _prompt_disk_load(key: tuple):
|
||||
"""Load a persisted prompt for ``key``, or None. Never raises."""
|
||||
cache_dir = _prompt_disk_dir()
|
||||
if cache_dir is None:
|
||||
return None
|
||||
path = _prompt_disk_path(cache_dir, key)
|
||||
if not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
from omnivoice.models.omnivoice import VoiceClonePrompt
|
||||
|
||||
prompt = VoiceClonePrompt.load(path)
|
||||
# Freshen so the LRU prune (by mtime) keeps actively used voices.
|
||||
os.utime(path, None)
|
||||
return prompt
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("failed to load cached voice prompt %s: %s", path, e)
|
||||
try:
|
||||
os.remove(path) # corrupt/incompatible file — don't retry it forever
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_disk_save(key: tuple, prompt) -> None:
|
||||
"""Persist ``prompt`` under ``key`` and prune old entries. Never raises."""
|
||||
cache_dir = _prompt_disk_dir()
|
||||
if cache_dir is None:
|
||||
return
|
||||
path = _prompt_disk_path(cache_dir, key)
|
||||
try:
|
||||
# Unique per write: two GPU-pool threads missing the same key must not
|
||||
# interleave writes into one tmp file (os.replace stays atomic).
|
||||
import uuid
|
||||
|
||||
tmp = f"{path}.tmp.{os.getpid()}.{uuid.uuid4().hex[:8]}"
|
||||
prompt.save(tmp)
|
||||
os.replace(tmp, path)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("failed to persist voice prompt to %s: %s", path, e)
|
||||
return
|
||||
try:
|
||||
entries = [
|
||||
os.path.join(cache_dir, f)
|
||||
for f in os.listdir(cache_dir)
|
||||
if f.endswith(".pt")
|
||||
]
|
||||
entries.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||
for old in entries[_PROMPT_DISK_CACHE_MAX:]:
|
||||
os.remove(old)
|
||||
except OSError as e:
|
||||
logger.debug("prompt disk cache prune skipped: %s", e)
|
||||
|
||||
|
||||
def _clone_prompt_key(ref_audio: str, ref_text, preprocess_prompt: bool = True):
|
||||
try:
|
||||
@@ -433,15 +539,24 @@ def _get_clone_prompt(
|
||||
if hit is not None:
|
||||
_prompt_cache.move_to_end(key)
|
||||
return hit
|
||||
try:
|
||||
# Encode outside the lock (slow). Mirrors exactly what generate() would
|
||||
# do inline for this ref (omnivoice.py:964-978), so output is identical.
|
||||
prompt = model.create_voice_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
|
||||
logger.warning("voice-clone prompt precompute failed; using inline ref: %s", e)
|
||||
return None
|
||||
# Memory miss → disk (survives restarts). A disk hit skips the encode AND
|
||||
# the ASR transcription pass a ref_text-less reference would trigger.
|
||||
prompt = _prompt_disk_load(key)
|
||||
if prompt is None:
|
||||
try:
|
||||
# Encode outside the lock (slow). Mirrors exactly what generate()
|
||||
# would do inline for this ref (omnivoice.py:964-978), so output is
|
||||
# identical.
|
||||
prompt = model.create_voice_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute failed; using inline ref: %s", e
|
||||
)
|
||||
return None
|
||||
if store:
|
||||
_prompt_disk_save(key, prompt)
|
||||
if not store:
|
||||
return prompt
|
||||
with _prompt_cache_lock:
|
||||
@@ -1075,7 +1190,8 @@ class KittenTTSBackend(TTSBackend):
|
||||
- English only
|
||||
- Much faster + much smaller install
|
||||
|
||||
Preset voice is chosen via `extras["voice"]` (defaults to "Jasper"). Any
|
||||
Preset voice is chosen via `extras["voice"]` (defaults to DEFAULT_VOICE,
|
||||
"expr-voice-2-f"). Any
|
||||
`ref_audio` / `instruct` / `language` arg is ignored with a log line so
|
||||
the common call-site doesn't need to know which engine it's talking to.
|
||||
"""
|
||||
@@ -1384,6 +1500,9 @@ class MLXAudioBackend(TTSBackend):
|
||||
def sample_rate(self) -> int:
|
||||
return self._sr
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
return self._model_id
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
# Per-model; Kokoro supports 8, Qwen3 ~4, Kugel 24. Return "multi"
|
||||
@@ -1571,6 +1690,18 @@ class CosyVoiceBackend(TTSBackend):
|
||||
def supported_languages(self) -> list[str]:
|
||||
return ["zh", "en", "ja", "ko", "yue", "de", "es", "fr", "it", "ru"]
|
||||
|
||||
@staticmethod
|
||||
def _resolved_model_dir() -> str:
|
||||
return os.environ.get(
|
||||
"OMNIVOICE_COSYVOICE_MODEL",
|
||||
"pretrained_models/Fun-CosyVoice3-0.5B",
|
||||
)
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
# v1/v2/v3 all live behind the one "cosyvoice" id — the directory
|
||||
# basename is the only thing that tells the models apart.
|
||||
return os.path.basename(os.path.normpath(self._resolved_model_dir()))
|
||||
|
||||
def _ensure_loaded(self):
|
||||
if self._model is not None:
|
||||
return
|
||||
@@ -1578,10 +1709,7 @@ class CosyVoiceBackend(TTSBackend):
|
||||
if not ok:
|
||||
raise RuntimeError(f"CosyVoice unavailable: {msg}")
|
||||
from cosyvoice.cli.cosyvoice import AutoModel # type: ignore[import-not-found]
|
||||
model_dir = os.environ.get(
|
||||
"OMNIVOICE_COSYVOICE_MODEL",
|
||||
"pretrained_models/Fun-CosyVoice3-0.5B",
|
||||
)
|
||||
model_dir = self._resolved_model_dir()
|
||||
logger.info("Loading CosyVoice from %s", model_dir)
|
||||
self._model = AutoModel(model_dir=model_dir)
|
||||
|
||||
@@ -1814,6 +1942,10 @@ class SherpaOnnxBackend(TTSBackend):
|
||||
self._tts = None
|
||||
self._model_dir = os.environ.get("OMNIVOICE_SHERPA_MODEL", "")
|
||||
|
||||
def model_identity(self) -> Optional[str]:
|
||||
model_dir = (self._model_dir or "").strip()
|
||||
return os.path.basename(os.path.normpath(model_dir)) if model_dir else None
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
try:
|
||||
|
||||
@@ -49,9 +49,38 @@ if not os.environ.get("OMNIVOICE_ENV_FILE"):
|
||||
os.environ["OMNIVOICE_MODEL"] = "test"
|
||||
|
||||
|
||||
import functools
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def supports_symlinks() -> bool:
|
||||
"""True when this process may create symlinks. On Windows,
|
||||
``os.symlink`` raises OSError without Developer Mode or admin rights, so
|
||||
symlink-dependent assertions must be skipped there rather than fail."""
|
||||
probe_dir = tempfile.mkdtemp(prefix="omnivoice-symlink-probe-")
|
||||
try:
|
||||
target = os.path.join(probe_dir, "target")
|
||||
with open(target, "w", encoding="utf-8"):
|
||||
pass
|
||||
try:
|
||||
os.symlink(target, os.path.join(probe_dir, "link"))
|
||||
except (OSError, NotImplementedError):
|
||||
return False
|
||||
return True
|
||||
finally:
|
||||
shutil.rmtree(probe_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def symlinks_supported() -> bool:
|
||||
"""Bool fixture over :func:`supports_symlinks` for guarding the
|
||||
symlink-only assertions of a test while its other assertions still run."""
|
||||
return supports_symlinks()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def asr_model_installed(monkeypatch, request):
|
||||
"""Neutralize the no-ASR-installed preflight (asr_model_missing_error →
|
||||
|
||||
@@ -9,7 +9,10 @@ generation.py's proven ``_run_inference`` rather than re-implementing it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,6 +26,23 @@ from core import archetypes # noqa: E402
|
||||
from api.routers import archetypes as arch_router # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24_000)
|
||||
wav.writeframes(b"\x00\x01" * 64)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_wav(path: Path) -> bytes:
|
||||
data = _wav_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
app = FastAPI()
|
||||
@@ -133,8 +153,7 @@ def test_preview_serves_cached_wav_without_model(client):
|
||||
key = arch_router._preview_key(sample)
|
||||
cache_dir = Path(arch_router._PREVIEW_DIR)
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
dummy = b"RIFF\x24\x00\x00\x00WAVEfmt cached-archetype-preview"
|
||||
(cache_dir / f"{key}.wav").write_bytes(dummy)
|
||||
dummy = _write_wav(cache_dir / f"{key}.wav")
|
||||
|
||||
r = client.get(f"/archetypes/{sample['id']}/preview")
|
||||
assert r.status_code == 200
|
||||
@@ -143,7 +162,7 @@ def test_preview_serves_cached_wav_without_model(client):
|
||||
|
||||
|
||||
# ── Materialize-on-use idempotency (dedup, no re-render) ───────────────────────
|
||||
def test_use_is_idempotent_dedup(client, monkeypatch):
|
||||
def test_use_is_idempotent_dedup(client, tmp_path, monkeypatch, symlinks_supported):
|
||||
"""The 2nd `/use` of the same archetype reuses its one materialized profile
|
||||
and does NOT render again — the guarantee that materialize-on-select in any
|
||||
voice picker can't spawn duplicate rows on repeated picks.
|
||||
@@ -151,6 +170,7 @@ def test_use_is_idempotent_dedup(client, monkeypatch):
|
||||
The render boundary (``_render_archetype_wav``) is mocked so no model/GPU is
|
||||
needed: it just drops a stub WAV where the row expects one.
|
||||
"""
|
||||
from core import event_bus
|
||||
from core.db import init_db
|
||||
|
||||
init_db() # ensure the voice_profiles table exists in the hermetic tmp DB
|
||||
@@ -159,10 +179,13 @@ def test_use_is_idempotent_dedup(client, monkeypatch):
|
||||
|
||||
async def _fake_render(a, out_path):
|
||||
render_calls["n"] += 1
|
||||
Path(out_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(out_path).write_bytes(b"RIFF\x24\x00\x00\x00WAVEfmt stub")
|
||||
_write_wav(Path(out_path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", _fake_render)
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
|
||||
sample = archetypes.list_archetypes(featured=True)[0]
|
||||
|
||||
@@ -181,6 +204,248 @@ def test_use_is_idempotent_dedup(client, monkeypatch):
|
||||
from core.db import db_conn
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM voice_profiles WHERE personality = ?", (sample["id"],)
|
||||
"SELECT * FROM voice_profiles WHERE personality = ?",
|
||||
(arch_router._archetype_personality(sample),),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["kind"] == "design"
|
||||
assert json.loads(rows[0]["vd_states"]) == sample["attrs"]
|
||||
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
assert row["kind"] == "design"
|
||||
assert row["instruct"] == sample["instruct"]
|
||||
assert json.loads(row["vd_states"]) == sample["attrs"]
|
||||
|
||||
# A missing sample or synthesis-input drift must be repaired before the
|
||||
# existing profile is returned; Preview and Use must describe one voice.
|
||||
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
|
||||
assert audio_path is not None
|
||||
audio_path.unlink()
|
||||
repaired = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired.status_code == 200 and repaired.json()["profile_id"] == pid
|
||||
assert render_calls["n"] == 2
|
||||
assert audio_path.read_bytes().startswith(b"RIFF")
|
||||
|
||||
with db_conn() as conn:
|
||||
conn.execute("UPDATE voice_profiles SET instruct='male' WHERE id=?", (pid,))
|
||||
refreshed = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["profile_id"] != pid
|
||||
assert render_calls["n"] == 3
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute("SELECT instruct FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
assert edited["instruct"] == "male"
|
||||
|
||||
# Continue corruption checks against the new canonical materialization.
|
||||
pid = refreshed.json()["profile_id"]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (pid,)).fetchone()
|
||||
audio_path = arch_router._profile_audio_path(row["ref_audio_path"])
|
||||
assert audio_path is not None
|
||||
|
||||
audio_path.write_bytes(b"not a WAV")
|
||||
repaired_corrupt = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_corrupt.status_code == 200
|
||||
assert render_calls["n"] == 4
|
||||
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside_bytes = _write_wav(outside)
|
||||
audio_path.unlink()
|
||||
audio_path.symlink_to(outside)
|
||||
repaired_symlink = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_symlink.status_code == 200
|
||||
assert render_calls["n"] == 5
|
||||
assert not audio_path.is_symlink()
|
||||
assert outside.read_bytes() == outside_bytes
|
||||
|
||||
# A valid header with a missing payload is not playable and must self-heal.
|
||||
renders_before = render_calls["n"]
|
||||
truncated = _wav_bytes()[:44]
|
||||
audio_path.write_bytes(truncated)
|
||||
repaired_truncated = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired_truncated.status_code == 200
|
||||
assert render_calls["n"] == renders_before + 1
|
||||
assert audio_path.read_bytes() != truncated
|
||||
|
||||
|
||||
def test_archetype_staged_repair_preserves_concurrently_edited_profile(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""A repair may publish only if the row still belongs to the archetype."""
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[3]
|
||||
personality = arch_router._archetype_personality(sample)
|
||||
edited_personality = f"user-edited:{sample['id']}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
|
||||
(sample["id"], personality, edited_personality),
|
||||
)
|
||||
|
||||
original_id = {"value": None}
|
||||
mutation_seen = {"value": False}
|
||||
|
||||
async def racing_render(_item, path):
|
||||
destination = Path(path)
|
||||
if destination.name.endswith(".staged.wav"):
|
||||
assert original_id["value"] is not None
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
|
||||
(edited_personality, original_id["value"]),
|
||||
)
|
||||
mutation_seen["value"] = True
|
||||
_write_wav(destination)
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", racing_render)
|
||||
first = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert first.status_code == 200
|
||||
original_id["value"] = first.json()["profile_id"]
|
||||
|
||||
with db_conn() as conn:
|
||||
original = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(original_id["value"],),
|
||||
).fetchone()
|
||||
original_audio = arch_router._profile_audio_path(original["ref_audio_path"])
|
||||
assert original_audio is not None
|
||||
corrupt_bytes = b"corrupt user-owned sample"
|
||||
original_audio.write_bytes(corrupt_bytes)
|
||||
|
||||
repaired = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert repaired.status_code == 200
|
||||
repaired_id = repaired.json()["profile_id"]
|
||||
assert mutation_seen["value"]
|
||||
assert repaired_id != original_id["value"]
|
||||
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
|
||||
).fetchone()
|
||||
canonical = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
|
||||
).fetchone()
|
||||
canonical_count = conn.execute(
|
||||
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
|
||||
).fetchone()[0]
|
||||
assert edited["name"] == "User edit"
|
||||
assert edited["personality"] == edited_personality
|
||||
assert edited["instruct"] == sample["instruct"]
|
||||
assert original_audio.read_bytes() == corrupt_bytes
|
||||
assert canonical["personality"] == personality
|
||||
assert canonical["ref_audio_path"] == arch_router._profile_audio_filename(repaired_id)
|
||||
assert canonical_count == 1
|
||||
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
|
||||
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
|
||||
|
||||
|
||||
def test_archetype_use_adopts_only_a_compatible_legacy_row(client, monkeypatch):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[1]
|
||||
legacy_id = "legacyarch"
|
||||
legacy_audio = Path(VOICES_DIR) / f"{legacy_id}.wav"
|
||||
_write_wav(legacy_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(sample["id"], arch_router._archetype_personality(sample)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, created_at) VALUES (?, 'Legacy archetype', ?, ?, ?, ?, 42, ?, "
|
||||
"'clone', NULL, 1)",
|
||||
(
|
||||
legacy_id, legacy_audio.name, sample["sample_script"], sample["instruct"],
|
||||
sample["language"], sample["id"],
|
||||
),
|
||||
)
|
||||
|
||||
async def unexpected_render(*_args):
|
||||
raise AssertionError("a valid legacy archetype sample must be reused")
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", unexpected_render)
|
||||
response = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] == legacy_id
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (legacy_id,)).fetchone()
|
||||
assert row["personality"] == arch_router._archetype_personality(sample)
|
||||
assert row["kind"] == "design"
|
||||
assert json.loads(row["vd_states"]) == sample["attrs"]
|
||||
|
||||
|
||||
def test_archetype_use_does_not_rewrite_an_imported_personality_collision(
|
||||
client, monkeypatch,
|
||||
):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
sample = archetypes.list_archetypes(featured=True)[2]
|
||||
imported_id = "importedarch"
|
||||
imported_ns_id = "importedarchns"
|
||||
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
|
||||
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
|
||||
original_audio = _write_wav(imported_audio)
|
||||
original_ns_audio = _write_wav(imported_ns_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(sample["id"], arch_router._archetype_personality(sample)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
|
||||
"'clone', 1, 1, 1)",
|
||||
(imported_id, imported_audio.name, sample["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
|
||||
"'design', NULL, 0, 0, 2)",
|
||||
(
|
||||
imported_ns_id, imported_ns_audio.name, sample["sample_script"],
|
||||
sample["instruct"], sample["language"],
|
||||
arch_router._archetype_personality(sample),
|
||||
),
|
||||
)
|
||||
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
response = client.post(f"/archetypes/{sample['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != imported_id
|
||||
with db_conn() as conn:
|
||||
imported = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
|
||||
).fetchone()
|
||||
imported_ns = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert imported["personality"] == sample["id"]
|
||||
assert imported["instruct"] == "male"
|
||||
assert imported["ref_text"] == "user transcript"
|
||||
assert imported_audio.read_bytes() == original_audio
|
||||
assert imported_ns["instruct"] == sample["instruct"]
|
||||
assert imported_ns["ref_text"] == sample["sample_script"]
|
||||
assert imported_ns["vd_states"] is None
|
||||
assert imported_ns_audio.read_bytes() == original_ns_audio
|
||||
assert created["personality"] == arch_router._archetype_personality(sample)
|
||||
|
||||
@@ -125,6 +125,16 @@ def test_faster_whisper_float16_unsupported_falls_back_to_int8(monkeypatch):
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch", fake_torch)
|
||||
|
||||
# The compute-device override gate consults the capability probe before
|
||||
# the torch mock above — pin it to a CUDA family so the fallback chain
|
||||
# under test is reachable on a cpu-only CI host.
|
||||
from core.device_caps import HostCaps
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.device_caps.detect_host_caps",
|
||||
lambda: HostCaps(family="cuda", available_families=("cuda", "cpu")),
|
||||
)
|
||||
|
||||
be = FasterWhisperBackend()
|
||||
be._ensure_model()
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Regression tests for the lightweight persisted-WAV trust boundary."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
|
||||
|
||||
def test_oversized_declared_wav_payload_is_not_treated_as_playable(tmp_path):
|
||||
"""A hostile frame count must be bounded and backed by real payload bytes."""
|
||||
path = tmp_path / "oversized.wav"
|
||||
declared_size = 0xFFFF_FFF0
|
||||
header = struct.pack(
|
||||
"<4sI4s4sIHHIIHH4sI",
|
||||
b"RIFF",
|
||||
0xFFFF_FFFF,
|
||||
b"WAVE",
|
||||
b"fmt ",
|
||||
16,
|
||||
1,
|
||||
1,
|
||||
24_000,
|
||||
48_000,
|
||||
2,
|
||||
16,
|
||||
b"data",
|
||||
declared_size,
|
||||
)
|
||||
path.write_bytes(header + b"\x00\x01")
|
||||
|
||||
assert not is_playable_wav(path)
|
||||
|
||||
|
||||
def test_profile_wav_resolution_rejects_escape_and_symlink(tmp_path, symlinks_supported):
|
||||
root = tmp_path / "voices"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside.write_bytes(b"outside")
|
||||
|
||||
assert resolve_regular_file(root, "../outside.wav") is None
|
||||
assert resolve_regular_file(root, str(outside)) is None
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
(root / "linked.wav").symlink_to(outside)
|
||||
assert resolve_regular_file(root, "linked.wav") is None
|
||||
@@ -1,25 +1,43 @@
|
||||
"""Tests for the community gallery (marketplace) loader.
|
||||
|
||||
Covers the no-network surface: strict item validation (invalid presets and
|
||||
unsafe audio URLs are dropped so they can never crash synthesis or fetch from
|
||||
an arbitrary host), manifest merge/dedup, offline cache reads, filtering, and
|
||||
the prefilled submit URL. The render/download paths need the model/network and
|
||||
are exercised at runtime.
|
||||
Covers strict item validation, manifest/cache boundaries, same-origin preview,
|
||||
and idempotent profile materialization without a model or network dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import wave
|
||||
|
||||
import pytest
|
||||
|
||||
# conftest.py puts `backend/` on sys.path and points OMNIVOICE_DATA_DIR at a
|
||||
# throwaway tmpdir before this module imports the REAL core.config (the old
|
||||
# sys.modules stub leaked at collection time and broke mixed runs).
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi import FastAPI, HTTPException, Response # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from api.routers import community # noqa: E402
|
||||
|
||||
|
||||
def _wav_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(24_000)
|
||||
wav.writeframes(b"\x00\x01" * 64)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _write_wav(path: Path) -> bytes:
|
||||
data = _wav_bytes()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
return data
|
||||
|
||||
_FIXTURE = {
|
||||
"schema_version": 1,
|
||||
"items": [
|
||||
@@ -75,12 +93,51 @@ def test_unknown_use_case_dropped():
|
||||
assert community.validate_item(_FIXTURE["items"][4]) is None
|
||||
|
||||
|
||||
def test_malformed_manifest_entries_do_not_break_other_sources():
|
||||
valid = _FIXTURE["items"][0]
|
||||
items, packs = community._merge([
|
||||
("bad/repo", {"items": 42, "packs": "not-a-list"}),
|
||||
("good/repo", {"items": [None, "not-an-item", valid], "packs": [None]}),
|
||||
])
|
||||
|
||||
assert [item["id"] for item in items] == [valid["id"]]
|
||||
assert packs == []
|
||||
|
||||
|
||||
def test_is_valid_instruct():
|
||||
assert community.is_valid_instruct("male, elderly, very low pitch")
|
||||
assert not community.is_valid_instruct("male, sultry")
|
||||
assert not community.is_valid_instruct("male, female")
|
||||
assert not community.is_valid_instruct("british accent, 四川话")
|
||||
assert not community.is_valid_instruct("")
|
||||
|
||||
|
||||
def test_preset_attrs_are_normalized_and_complete():
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
assert item["instruct"] == "female, middle-aged, low pitch"
|
||||
assert item["attrs"] == {
|
||||
"Gender": "female", "Age": "middle-aged", "Pitch": "low pitch",
|
||||
"Style": "Auto", "EnglishAccent": "Auto", "ChineseDialect": "Auto",
|
||||
}
|
||||
assert item["preview_url"] == "/community/items/p1/preview"
|
||||
|
||||
|
||||
def test_remote_transcript_fields_are_bounded():
|
||||
preset = community.validate_item({
|
||||
**_FIXTURE["items"][0],
|
||||
"sample_script": " x " * (community._MAX_SAMPLE_SCRIPT_CHARS + 10),
|
||||
})
|
||||
voice = community.validate_item({
|
||||
**_FIXTURE["items"][3],
|
||||
"audio": {
|
||||
**_FIXTURE["items"][3]["audio"],
|
||||
"ref_text": " y " * (community._MAX_REF_TEXT_CHARS + 10),
|
||||
},
|
||||
})
|
||||
assert len(preset["sample_script"]) == community._MAX_SAMPLE_SCRIPT_CHARS
|
||||
assert len(voice["audio"]["ref_text"]) == community._MAX_REF_TEXT_CHARS
|
||||
|
||||
|
||||
# ── merge keeps only valid items ──────────────────────────────────────────────
|
||||
def test_merge_drops_invalid_and_dedups():
|
||||
items, packs = community._merge([("debpalash/omnivoice-gallery", _FIXTURE)])
|
||||
@@ -116,3 +173,620 @@ def test_submit_url(client):
|
||||
voice = client.get("/community/submit-url", params={"type": "voice"}).json()["url"]
|
||||
assert "preset-submission.yml" in preset and "omnivoice-gallery" in preset
|
||||
assert "voice-submission.yml" in voice
|
||||
|
||||
|
||||
# ── bounded cache freshness + stale offline fallback ─────────────────────────
|
||||
def test_stale_manifest_refreshes_then_stays_fresh(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
source = "debpalash/omnivoice-gallery"
|
||||
cache = community._cache_path(source)
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
os.utime(cache, (100.0, 100.0))
|
||||
|
||||
fresh = {**_FIXTURE, "updated_at": "new"}
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
community, "_fetch_remote_manifest",
|
||||
lambda src: calls.append(src) or fresh,
|
||||
)
|
||||
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
|
||||
assert community._fetch_manifest(source, False, now=now)["updated_at"] == "new"
|
||||
assert community._fetch_manifest(source, False, now=now + 1)["updated_at"] == "new"
|
||||
assert calls == [source]
|
||||
|
||||
|
||||
def test_stale_manifest_falls_back_and_throttles_offline_retry(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
source = "debpalash/omnivoice-gallery"
|
||||
cache = community._cache_path(source)
|
||||
cache.parent.mkdir(parents=True)
|
||||
cache.write_text(json.dumps(_FIXTURE), encoding="utf-8")
|
||||
os.utime(cache, (100.0, 100.0))
|
||||
|
||||
calls = []
|
||||
def offline(src):
|
||||
calls.append(src)
|
||||
raise OSError("offline")
|
||||
monkeypatch.setattr(community, "_fetch_remote_manifest", offline)
|
||||
now = 100.0 + community._MANIFEST_MAX_AGE_S + 1
|
||||
assert community._fetch_manifest(source, False, now=now) == _FIXTURE
|
||||
assert community._fetch_manifest(source, False, now=now + 1) == _FIXTURE
|
||||
assert calls == [source]
|
||||
|
||||
|
||||
def test_manifest_fetch_is_bounded(monkeypatch):
|
||||
monkeypatch.setattr(community, "_MAX_MANIFEST_BYTES", 8)
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b'{"items":[]}'
|
||||
class Client:
|
||||
def stream(self, method, url, **kwargs):
|
||||
assert method == "GET"
|
||||
assert url.startswith("https://cdn.jsdelivr.net/")
|
||||
assert kwargs == {"follow_redirects": False}
|
||||
return Response()
|
||||
|
||||
with pytest.raises(ValueError, match="size limit"):
|
||||
community._fetch_remote_manifest("test/source", client=Client())
|
||||
|
||||
|
||||
def test_manifest_fetch_rejects_redirect_before_external_request():
|
||||
requested = []
|
||||
|
||||
class Response:
|
||||
status_code = 302
|
||||
headers = {"location": "https://evil.example/manifest.json"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
return Response()
|
||||
|
||||
with pytest.raises(ValueError, match="disallowed host"):
|
||||
community._fetch_remote_manifest("test/source", client=Client())
|
||||
assert requested == [community._manifest_url("test/source")]
|
||||
|
||||
|
||||
# ── Preview proxy ─────────────────────────────────────────────────────────────
|
||||
def test_canonical_preset_preview_delegates_same_origin(client, monkeypatch):
|
||||
from core import archetypes
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
canonical = archetypes.list_archetypes(featured=True)[0]
|
||||
item = community.validate_item({
|
||||
**canonical, "type": "preset", "source": "starter",
|
||||
})
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
delegated = []
|
||||
|
||||
async def preview(archetype_id, local=False):
|
||||
delegated.append((archetype_id, local))
|
||||
return Response(_wav_bytes(), media_type="audio/wav")
|
||||
|
||||
monkeypatch.setattr(arch_router, "preview_archetype", preview)
|
||||
response = client.get(f"/community/items/{item['id']}/preview")
|
||||
local = client.get(f"/community/items/{item['id']}/preview?local=true")
|
||||
|
||||
assert response.status_code == local.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert delegated == [(item["id"], False), (item["id"], True)]
|
||||
|
||||
|
||||
def test_noncanonical_preset_preview_renders_once(client, tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
monkeypatch.setattr(community, "_CACHE_DIR", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
from api.routers import archetypes as arch_router
|
||||
calls = []
|
||||
async def render(_item, path):
|
||||
calls.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
first = client.get("/community/items/p1/preview")
|
||||
second = client.get("/community/items/p1/preview")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert first.content == _wav_bytes()
|
||||
assert first.headers["x-omnivoice-preview-source"] == "community"
|
||||
assert len(calls) == 1
|
||||
|
||||
community._preset_preview_path(item).write_bytes(b"not audio")
|
||||
repaired = client.get("/community/items/p1/preview")
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.content == _wav_bytes()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_recorded_preview_is_served_from_same_origin(client, tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
clip = tmp_path / "voice.wav"
|
||||
expected = _write_wav(clip)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
monkeypatch.setattr(community, "_cached_voice_audio", lambda _item: clip)
|
||||
response = client.get("/community/items/v1/preview")
|
||||
assert response.status_code == 200
|
||||
assert response.content == expected
|
||||
|
||||
|
||||
def test_recorded_download_cap_is_atomic(tmp_path, monkeypatch):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
destination.write_bytes(b"existing-good-audio")
|
||||
monkeypatch.setattr(community, "_MAX_VOICE_AUDIO_BYTES", 8)
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b"123456789"
|
||||
class Client:
|
||||
def stream(self, method, url, **kwargs):
|
||||
assert method == "GET" and url.startswith("https://github.com/")
|
||||
assert kwargs == {"follow_redirects": False}
|
||||
return Response()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert getattr(exc.value, "status_code", None) == 502
|
||||
assert destination.read_bytes() == b"existing-good-audio"
|
||||
assert not list(tmp_path.glob(".*.part"))
|
||||
|
||||
|
||||
def test_recorded_download_rejects_redirect_before_external_request(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
requested = []
|
||||
|
||||
class Response:
|
||||
status_code = 302
|
||||
headers = {"location": "https://evil.example/private.wav"}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
return Response()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
community._download_voice_audio(item, tmp_path / "voice.wav", client=Client())
|
||||
assert getattr(exc.value, "status_code", None) == 502
|
||||
assert requested == [item["audio"]["url"]]
|
||||
|
||||
|
||||
def test_recorded_download_follows_allowlisted_redirect(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
requested = []
|
||||
expected = _wav_bytes()
|
||||
|
||||
class Response:
|
||||
def __init__(self, status, headers, body=b""):
|
||||
self.status_code, self.headers, self.body = status, headers, body
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield self.body
|
||||
class Client:
|
||||
def stream(self, _method, url, **_kwargs):
|
||||
requested.append(url)
|
||||
if len(requested) == 1:
|
||||
return Response(302, {"location": "https://objects.githubusercontent.com/v1.wav"})
|
||||
return Response(200, {}, expected)
|
||||
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert destination.read_bytes() == expected
|
||||
assert requested == [item["audio"]["url"], "https://objects.githubusercontent.com/v1.wav"]
|
||||
|
||||
|
||||
def test_recorded_download_rejects_non_audio_bytes(tmp_path):
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
destination = tmp_path / "voice.wav"
|
||||
|
||||
class Response:
|
||||
status_code = 200
|
||||
headers = {}
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *_args): return False
|
||||
def raise_for_status(self): return None
|
||||
def iter_bytes(self): yield b"this is not audio"
|
||||
class Client:
|
||||
def stream(self, _method, _url, **_kwargs): return Response()
|
||||
|
||||
with pytest.raises(HTTPException, match="valid WAV"):
|
||||
community._download_voice_audio(item, destination, client=Client())
|
||||
assert not destination.exists()
|
||||
assert not list(tmp_path.glob(".*.part"))
|
||||
|
||||
|
||||
# ── Materialization ───────────────────────────────────────────────────────────
|
||||
def test_community_use_is_idempotent_design_profile(
|
||||
client, tmp_path, monkeypatch, symlinks_supported,
|
||||
):
|
||||
from core import event_bus
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
calls = []
|
||||
emitted = []
|
||||
async def render(_item, path):
|
||||
calls.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
monkeypatch.setattr(
|
||||
event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
|
||||
first = client.post("/community/items/p1/use")
|
||||
second = client.post("/community/items/p1/use")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert second.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert len(calls) == 1
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "design"
|
||||
assert row["personality"] == personality
|
||||
assert json.loads(row["vd_states"])["Gender"] == "female"
|
||||
assert row["instruct"] == item["instruct"]
|
||||
assert emitted[-1] == (
|
||||
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
||||
)
|
||||
|
||||
profile_audio = community._stored_profile_audio(row["ref_audio_path"])
|
||||
assert profile_audio is not None
|
||||
profile_audio.unlink()
|
||||
repaired = client.post("/community/items/p1/use")
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert profile_audio.read_bytes() == _wav_bytes()
|
||||
# The current preset preview cache repairs the profile without another
|
||||
# model render.
|
||||
assert len(calls) == 1
|
||||
|
||||
profile_audio.write_bytes(b"not a WAV")
|
||||
repaired_corrupt = client.post("/community/items/p1/use")
|
||||
assert repaired_corrupt.status_code == 200
|
||||
assert profile_audio.read_bytes() == _wav_bytes()
|
||||
|
||||
if symlinks_supported: # Windows needs Developer Mode to create symlinks
|
||||
outside = tmp_path / "outside.wav"
|
||||
outside_bytes = _write_wav(outside)
|
||||
profile_audio.unlink()
|
||||
profile_audio.symlink_to(outside)
|
||||
repaired_symlink = client.post("/community/items/p1/use")
|
||||
assert repaired_symlink.status_code == 200
|
||||
assert not profile_audio.is_symlink()
|
||||
assert outside.read_bytes() == outside_bytes
|
||||
|
||||
|
||||
def test_community_staged_repair_preserves_concurrently_edited_profile(
|
||||
client, monkeypatch,
|
||||
):
|
||||
"""A staged community repair must not reclaim a row edited mid-copy."""
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
edited_personality = f"user-edited:{personality}"
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?, ?)",
|
||||
(item["id"], personality, edited_personality),
|
||||
)
|
||||
_write_wav(community._preset_preview_path(item))
|
||||
|
||||
original_id = {"value": None}
|
||||
mutation_seen = {"value": False}
|
||||
real_copy_atomic = community._copy_atomic
|
||||
|
||||
def racing_copy(source, destination):
|
||||
destination = Path(destination)
|
||||
if destination.name.endswith(".staged.wav"):
|
||||
assert original_id["value"] is not None
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name='User edit', personality=? WHERE id=?",
|
||||
(edited_personality, original_id["value"]),
|
||||
)
|
||||
mutation_seen["value"] = True
|
||||
real_copy_atomic(Path(source), destination)
|
||||
|
||||
monkeypatch.setattr(community, "_copy_atomic", racing_copy)
|
||||
first = client.post(f"/community/items/{item['id']}/use")
|
||||
assert first.status_code == 200
|
||||
original_id["value"] = first.json()["profile_id"]
|
||||
|
||||
with db_conn() as conn:
|
||||
original = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(original_id["value"],),
|
||||
).fetchone()
|
||||
original_audio = community._stored_profile_audio(original["ref_audio_path"])
|
||||
assert original_audio is not None
|
||||
corrupt_bytes = b"corrupt user-owned sample"
|
||||
original_audio.write_bytes(corrupt_bytes)
|
||||
|
||||
repaired = client.post(f"/community/items/{item['id']}/use")
|
||||
assert repaired.status_code == 200
|
||||
repaired_id = repaired.json()["profile_id"]
|
||||
assert mutation_seen["value"]
|
||||
assert repaired_id != original_id["value"]
|
||||
|
||||
with db_conn() as conn:
|
||||
edited = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (original_id["value"],),
|
||||
).fetchone()
|
||||
canonical = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (repaired_id,),
|
||||
).fetchone()
|
||||
canonical_count = conn.execute(
|
||||
"SELECT count(*) FROM voice_profiles WHERE personality=?", (personality,),
|
||||
).fetchone()[0]
|
||||
assert edited["name"] == "User edit"
|
||||
assert edited["personality"] == edited_personality
|
||||
assert edited["instruct"] == item["instruct"]
|
||||
assert original_audio.read_bytes() == corrupt_bytes
|
||||
assert canonical["personality"] == personality
|
||||
assert canonical["ref_audio_path"] == community._community_profile_audio_filename(
|
||||
repaired_id, item,
|
||||
)
|
||||
assert canonical_count == 1
|
||||
assert (Path(VOICES_DIR) / canonical["ref_audio_path"]).read_bytes() == _wav_bytes()
|
||||
assert not list(Path(VOICES_DIR).glob(f".{original_id['value']}-*.staged.wav"))
|
||||
|
||||
|
||||
def test_recorded_community_use_is_idempotent_clone_profile(client, tmp_path, monkeypatch):
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][3])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
clip = tmp_path / "recorded.wav"
|
||||
_write_wav(clip)
|
||||
cache_calls = []
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_cached_voice_audio", lambda _item: cache_calls.append(_item["id"]) or clip,
|
||||
)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
|
||||
first = client.post("/community/items/v1/use")
|
||||
second = client.post("/community/items/v1/use")
|
||||
assert first.status_code == second.status_code == 200
|
||||
assert second.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert cache_calls == ["v1"]
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == personality
|
||||
assert row["vd_states"] is None and row["instruct"] == ""
|
||||
assert row["ref_text"] == ""
|
||||
|
||||
old_audio_filename = row["ref_audio_path"]
|
||||
item["audio"]["url"] = "https://raw.githubusercontent.com/test/source/main/v2.wav"
|
||||
refreshed = client.post("/community/items/v1/use")
|
||||
assert refreshed.status_code == 200
|
||||
assert refreshed.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert cache_calls == ["v1", "v1"]
|
||||
with db_conn() as conn:
|
||||
refreshed_row = conn.execute(
|
||||
"SELECT ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert refreshed_row["ref_audio_path"] != old_audio_filename
|
||||
|
||||
|
||||
def test_noncanonical_builtin_id_cannot_heal_archetype_profile(client, monkeypatch):
|
||||
from core import archetypes
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
canonical = archetypes.list_archetypes(featured=True)[0]
|
||||
changed_instruct = "female" if canonical["instruct"] != "female" else "male"
|
||||
item = community.validate_item({
|
||||
**canonical,
|
||||
"type": "preset",
|
||||
"source": "community",
|
||||
"instruct": changed_instruct,
|
||||
})
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
builtin_profile_id = f"b{os.urandom(4).hex()[:7]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(canonical["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, personality, instruct, kind, created_at) "
|
||||
"VALUES (?, 'Built-in profile', ?, 'sentinel', 'design', 1)",
|
||||
(builtin_profile_id, canonical["id"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
response = client.post(f"/community/items/{canonical['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != builtin_profile_id
|
||||
with db_conn() as conn:
|
||||
builtin = conn.execute(
|
||||
"SELECT instruct FROM voice_profiles WHERE id=?", (builtin_profile_id,),
|
||||
).fetchone()
|
||||
community_row = conn.execute(
|
||||
"SELECT personality FROM voice_profiles WHERE id=?",
|
||||
(response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE id IN (?, ?)",
|
||||
(builtin_profile_id, response.json()["profile_id"]),
|
||||
)
|
||||
assert builtin["instruct"] == "sentinel"
|
||||
assert community_row["personality"] == personality
|
||||
|
||||
|
||||
def test_community_use_does_not_rewrite_an_imported_bare_id_collision(
|
||||
client, monkeypatch,
|
||||
):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
imported_id = "importedcomm"
|
||||
imported_ns_id = "importedcommns"
|
||||
imported_audio = Path(VOICES_DIR) / f"{imported_id}.wav"
|
||||
imported_ns_audio = Path(VOICES_DIR) / f"{imported_ns_id}.wav"
|
||||
original_audio = _write_wav(imported_audio)
|
||||
original_ns_audio = _write_wav(imported_ns_audio)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported collision', ?, 'user transcript', 'male', 'Auto', NULL, ?, "
|
||||
"'clone', 1, 1, 1)",
|
||||
(imported_id, imported_audio.name, item["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, is_locked, verified_own_voice, created_at) VALUES "
|
||||
"(?, 'Imported namespaced collision', ?, ?, ?, ?, 42, ?, "
|
||||
"'design', NULL, 0, 0, 2)",
|
||||
(
|
||||
imported_ns_id, imported_ns_audio.name, item["sample_script"],
|
||||
item["instruct"], item["language"], personality,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
|
||||
async def render(_item, path):
|
||||
_write_wav(Path(path))
|
||||
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
response = client.post(f"/community/items/{item['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != imported_id
|
||||
with db_conn() as conn:
|
||||
imported = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_id,),
|
||||
).fetchone()
|
||||
imported_ns = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (imported_ns_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert imported["personality"] == item["id"]
|
||||
assert imported["instruct"] == "male"
|
||||
assert imported["ref_text"] == "user transcript"
|
||||
assert imported_audio.read_bytes() == original_audio
|
||||
assert imported_ns["instruct"] == item["instruct"]
|
||||
assert imported_ns["ref_text"] == item["sample_script"]
|
||||
assert imported_ns["vd_states"] is None
|
||||
assert imported_ns_audio.read_bytes() == original_ns_audio
|
||||
assert created["personality"] == personality
|
||||
|
||||
|
||||
def test_noncolliding_legacy_community_profile_is_adopted(client, monkeypatch):
|
||||
from core.config import VOICES_DIR
|
||||
from core.db import db_conn, init_db
|
||||
from api.routers import archetypes as arch_router
|
||||
|
||||
init_db()
|
||||
item = community.validate_item(_FIXTURE["items"][0])
|
||||
item["_source_repo"] = "test/source"
|
||||
personality = community._community_personality(item)
|
||||
legacy_id = f"l{os.urandom(4).hex()[:7]}"
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM voice_profiles WHERE personality IN (?, ?)",
|
||||
(item["id"], personality),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"kind, vd_states, created_at) VALUES "
|
||||
"(?, 'Legacy community profile', ?, '', ?, ?, NULL, ?, 'design', NULL, 1)",
|
||||
(legacy_id, f"{legacy_id}.wav", item["instruct"], item["language"], item["id"]),
|
||||
)
|
||||
_write_wav(Path(VOICES_DIR) / f"{legacy_id}.wav")
|
||||
monkeypatch.setattr(
|
||||
community, "_load", lambda _refresh: (["test/source"], [item], [], False),
|
||||
)
|
||||
community._preset_preview_path(item).unlink(missing_ok=True)
|
||||
rendered = []
|
||||
async def render(_item, path):
|
||||
rendered.append(path)
|
||||
_write_wav(Path(path))
|
||||
monkeypatch.setattr(arch_router, "_render_archetype_wav", render)
|
||||
|
||||
response = client.post(f"/community/items/{item['id']}/use")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] == legacy_id
|
||||
assert len(rendered) == 1
|
||||
with db_conn() as conn:
|
||||
adopted = conn.execute(
|
||||
"SELECT personality, kind, ref_audio_path FROM voice_profiles WHERE id=?",
|
||||
(legacy_id,),
|
||||
).fetchone()
|
||||
assert adopted["personality"] == personality
|
||||
assert adopted["kind"] == "design"
|
||||
adopted_audio = community._stored_profile_audio(adopted["ref_audio_path"])
|
||||
assert adopted_audio is not None and adopted_audio.is_file()
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Gallery-import profile materialization contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routers import gallery
|
||||
from core.db import db_conn, init_db
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
init_db()
|
||||
gallery._init_gallery_db()
|
||||
app = FastAPI()
|
||||
app.include_router(gallery.router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _gallery_voice(
|
||||
suffix: str = ".wav", content: bytes = b"RIFF imported voice",
|
||||
) -> tuple[str, Path]:
|
||||
voice_id = f"g{uuid.uuid4().hex[:7]}"
|
||||
path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_gallery
|
||||
(id, name, character, category, source_type, source_url, audio_path,
|
||||
duration, description, tags, created_at)
|
||||
VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""",
|
||||
(
|
||||
voice_id, "Imported narrator", "Video title is not an instruct",
|
||||
"https://example.invalid/source", str(path),
|
||||
"Source URL/notes are not a spoken transcript", time.time(),
|
||||
),
|
||||
)
|
||||
return voice_id, path
|
||||
|
||||
|
||||
def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client):
|
||||
voice_id, _ = _gallery_voice()
|
||||
response = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "Reusable import"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == f"gallery:{voice_id}"
|
||||
assert row["ref_text"] == ""
|
||||
assert row["instruct"] == ""
|
||||
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
||||
|
||||
|
||||
def test_to_profile_uses_live_schema_and_clone_metadata(client):
|
||||
voice_id, _ = _gallery_voice()
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
assert response.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert row["kind"] == "clone"
|
||||
assert row["personality"] == f"gallery:{voice_id}"
|
||||
assert row["ref_text"] == row["instruct"] == ""
|
||||
assert row["description"] == "Source URL/notes are not a spoken transcript"
|
||||
|
||||
|
||||
def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch):
|
||||
emitted = []
|
||||
monkeypatch.setattr(
|
||||
gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
|
||||
)
|
||||
voice_id, _ = _gallery_voice()
|
||||
first = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "One reusable profile"},
|
||||
)
|
||||
repeated = client.post(
|
||||
f"/gallery/voices/{voice_id}/save-as-profile",
|
||||
params={"profile_name": "Ignored duplicate name"},
|
||||
)
|
||||
alternate = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert first.status_code == repeated.status_code == alternate.status_code == 200
|
||||
assert {
|
||||
first.json()["profile_id"],
|
||||
repeated.json()["profile_id"],
|
||||
alternate.json()["profile_id"],
|
||||
} == {first.json()["profile_id"]}
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=?",
|
||||
(f"gallery:{voice_id}",),
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "One reusable profile"
|
||||
assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None
|
||||
assert emitted[-1] == (
|
||||
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
|
||||
)
|
||||
|
||||
|
||||
def test_gallery_profile_repairs_a_missing_copy_without_duplication(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
first = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
assert first.status_code == 200
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
|
||||
).fetchone()
|
||||
copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"]
|
||||
copied.unlink()
|
||||
|
||||
repaired = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert repaired.status_code == 200
|
||||
assert repaired.json()["profile_id"] == first.json()["profile_id"]
|
||||
assert copied.read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
collision_id = f"c{uuid.uuid4().hex[:7]}"
|
||||
personality = f"gallery:{voice_id}"
|
||||
collision_name = gallery._gallery_profile_audio_filename(collision_id, source)
|
||||
collision_audio = Path(gallery.VOICES_DIR) / collision_name
|
||||
collision_audio.parent.mkdir(parents=True, exist_ok=True)
|
||||
collision_audio.write_bytes(b"user-owned audio")
|
||||
with db_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"description, kind, vd_states, is_locked, verified_own_voice, created_at) "
|
||||
"VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, "
|
||||
"'user-owned metadata', 'clone', NULL, 0, 0, ?)",
|
||||
(collision_id, collision_name, personality, time.time()),
|
||||
)
|
||||
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["profile_id"] != collision_id
|
||||
with db_conn() as conn:
|
||||
collision = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (collision_id,),
|
||||
).fetchone()
|
||||
created = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
|
||||
).fetchone()
|
||||
assert collision["description"] == "user-owned metadata"
|
||||
assert collision_audio.read_bytes() == b"user-owned audio"
|
||||
assert created["personality"] == personality
|
||||
|
||||
|
||||
def _part_files() -> set[Path]:
|
||||
return set(Path(gallery.VOICES_DIR).glob("*.part")) | set(
|
||||
Path(gallery.VOICES_DIR).glob(".*.part")
|
||||
)
|
||||
|
||||
|
||||
def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch):
|
||||
"""The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction.
|
||||
|
||||
While the copy runs, another backend writer takes (and releases) SQLite's
|
||||
write lock. If materialization copied inside its own write transaction,
|
||||
this concurrent writer would hit `database is locked` and the test fails.
|
||||
"""
|
||||
from core.config import DB_PATH
|
||||
|
||||
voice_id, _ = _gallery_voice()
|
||||
real_copy2 = shutil.copy2
|
||||
concurrent_writes = []
|
||||
|
||||
def copy_and_probe(src, dst, **kwargs):
|
||||
probe = sqlite3.connect(DB_PATH, timeout=0.5)
|
||||
try:
|
||||
probe.execute("BEGIN IMMEDIATE")
|
||||
probe.execute(
|
||||
"UPDATE voice_gallery SET category = category WHERE id = ?",
|
||||
(voice_id,),
|
||||
)
|
||||
probe.commit()
|
||||
concurrent_writes.append(True)
|
||||
finally:
|
||||
probe.close()
|
||||
return real_copy2(src, dst, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe)
|
||||
|
||||
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert concurrent_writes == [True]
|
||||
assert _part_files() == set()
|
||||
|
||||
|
||||
def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch):
|
||||
"""A copy that dies mid-write must not leave .part files or a DB row."""
|
||||
voice_id, _ = _gallery_voice()
|
||||
|
||||
def exploding_copy(src, dst, **kwargs):
|
||||
Path(dst).write_bytes(b"partial bytes")
|
||||
raise OSError("disk full mid-copy")
|
||||
|
||||
monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy)
|
||||
|
||||
with pytest.raises(OSError, match="disk full mid-copy"):
|
||||
client.post(f"/gallery/voices/{voice_id}/to-profile")
|
||||
|
||||
assert _part_files() == set()
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality = ?",
|
||||
(f"gallery:{voice_id}",),
|
||||
).fetchall()
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client):
|
||||
voice_id, source = _gallery_voice()
|
||||
|
||||
response = client.get(
|
||||
f"/gallery/voices/{voice_id}/preview", follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "location" not in response.headers
|
||||
assert response.content == source.read_bytes()
|
||||
|
||||
|
||||
def test_gallery_preview_preserves_non_wav_content_type(client):
|
||||
voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice")
|
||||
|
||||
response = client.get(f"/gallery/voices/{voice_id}/preview")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "audio/mpeg"
|
||||
+40
-15
@@ -460,30 +460,55 @@ class TaskExecutor:
|
||||
|
||||
@staticmethod
|
||||
def _synthesize(backend, text: str, params: dict):
|
||||
"""Call the engine through the same serial GPU gate local jobs use.
|
||||
"""Render through the same seeded pipeline as local ``/generate``.
|
||||
|
||||
Held against the idle sweep for the duration: a long generation touches
|
||||
the instance cache once, at the start, so on elapsed time alone it is
|
||||
indistinguishable from a model nobody wants any more.
|
||||
|
||||
Do not reduce this to ``backend.generate()``. The control plane sends
|
||||
a complete render contract (pinned gallery seed, synthetic reference,
|
||||
quality controls, chunking, effects); calling the adapter directly
|
||||
silently turns a selected gallery voice into a fresh random take.
|
||||
"""
|
||||
from services import tts_backend # noqa: PLC0415
|
||||
from api.routers.generation import _run_backend_inference, _run_inference # noqa: PLC0415
|
||||
|
||||
kwargs = {
|
||||
key: params[key]
|
||||
for key in (
|
||||
"ref_audio",
|
||||
"ref_text",
|
||||
"instruct",
|
||||
"language",
|
||||
"duration",
|
||||
"description",
|
||||
"speed",
|
||||
)
|
||||
if params.get(key) is not None
|
||||
}
|
||||
language = params.get("language")
|
||||
ref_audio = params.get("ref_audio")
|
||||
ref_text = params.get("ref_text")
|
||||
instruct = params.get("instruct")
|
||||
duration = params.get("duration")
|
||||
num_step = params.get("num_step", 16)
|
||||
guidance_scale = params.get("guidance_scale", 2.0)
|
||||
speed = params.get("speed", 1.0)
|
||||
denoise = params.get("denoise", True)
|
||||
postprocess_output = params.get("postprocess_output", True)
|
||||
used_seed = params.get("seed")
|
||||
effect_preset = params.get("effect_preset", "broadcast")
|
||||
max_chunk_chars = params.get("max_chunk_chars")
|
||||
crossfade_ms = params.get("crossfade_ms")
|
||||
try:
|
||||
with tts_backend.engine_in_use(backend):
|
||||
return backend.generate(text, **kwargs)
|
||||
if isinstance(backend, tts_backend.OmniVoiceBackend):
|
||||
# The OSS default engine has an extended native surface;
|
||||
# preserving it is required for a gallery preview and a
|
||||
# GPU-worker take to share the same voice identity.
|
||||
return _run_inference(
|
||||
backend._model, text, language, ref_audio, ref_text,
|
||||
instruct, duration, num_step, guidance_scale, speed,
|
||||
params.get("t_shift"), denoise, postprocess_output,
|
||||
params.get("layer_penalty_factor"),
|
||||
params.get("position_temperature"),
|
||||
params.get("class_temperature"), used_seed,
|
||||
effect_preset, max_chunk_chars, crossfade_ms,
|
||||
)
|
||||
return _run_backend_inference(
|
||||
backend, text, language, ref_audio, ref_text, instruct,
|
||||
duration, num_step, guidance_scale, speed, denoise,
|
||||
postprocess_output, used_seed, effect_preset,
|
||||
max_chunk_chars, crossfade_ms,
|
||||
)
|
||||
except Exception as exc:
|
||||
from worker import errors as worker_errors # noqa: PLC0415
|
||||
|
||||
|
||||
@@ -657,8 +657,25 @@ class WorkerClient:
|
||||
)
|
||||
)
|
||||
return
|
||||
await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref)))
|
||||
# Reserve the slot BEFORE the accept-send await: awaiting yields to
|
||||
# the event loop, and a concurrently delivered assignment would read
|
||||
# the un-reserved counter and over-accept past capacity (#1536 — a
|
||||
# capacity-1 worker accepted a second task on a slow runner). Message
|
||||
# order on the stream survives the swap: _send enqueues synchronously
|
||||
# (put_nowait before any suspension), so ACCEPTED is in the outbox
|
||||
# before this handler ever yields to the just-created _run task.
|
||||
self._running[key] = asyncio.create_task(self._run(assignment))
|
||||
try:
|
||||
await self._send(pb.WorkerMessage(accepted=pb.TaskAccepted(ref=assignment.ref)))
|
||||
except BaseException:
|
||||
# BaseException, not Exception: a handler CANCELLED mid-send must
|
||||
# release the slot too, or the reserved task keeps running work
|
||||
# the scheduler never saw accepted — and double-executes after
|
||||
# reassignment. The stream-death case lands here as well.
|
||||
task = self._running.pop(key, None)
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
raise
|
||||
|
||||
async def _run(self, assignment: pb.TaskAssignment) -> None:
|
||||
key = self._key(assignment.ref)
|
||||
|
||||
@@ -57,6 +57,10 @@ REQUIRED_FEATURES = frozenset({
|
||||
"task_progress_v1",
|
||||
"task_inputs_v1",
|
||||
"remote_model_download_v1",
|
||||
# A generic backend.generate() call accepts the same wire shape but drops
|
||||
# profile conditioning controls. Require the canonical worker render path
|
||||
# so an older peer cannot successfully return a different voice.
|
||||
"remote_tts_render_v1",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# VoiceStudio
|
||||
|
||||
**The open-source ElevenLabs alternative.** Real-time dictation, zero-shot voice
|
||||
cloning, and cinematic video dubbing — fully local, no API keys, no accounts.
|
||||
cloning, and cinematic video dubbing — fully local, with no cloud API keys or accounts.
|
||||
**646 languages.**
|
||||
|
||||
[](https://hub.docker.com/r/palashdeb/omnivoice-studio)
|
||||
@@ -30,6 +30,14 @@ weights + cache (20 GB+ comfortable), and optionally a GPU — 4 GB VRAM works
|
||||
the entire pipeline runs on CPU, just slower. Pull size: ~5 GB compressed
|
||||
(CUDA/CPU image), ~15 GB for the `:rocm` variant.
|
||||
|
||||
## See it in action
|
||||
|
||||

|
||||
|
||||
| Model catalogue | Save a gallery voice |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## Quick start (CPU)
|
||||
@@ -90,12 +98,12 @@ There's also a Compose file in the repo with `cpu` / `gpu` / `rocm` profiles
|
||||
|-----|--------------|
|
||||
| `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
|
||||
| `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
| `:0.4.1` | Exact release version |
|
||||
| `:0.4` | Latest patch within the `0.4` minor |
|
||||
| `:0.5.0` | Exact release version |
|
||||
| `:0.5` | Latest patch within the `0.5` minor |
|
||||
| `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
| `:sha-xxxxxxx` | A specific commit (produced by manual workflow dispatch) |
|
||||
| `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
|
||||
| `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
|
||||
| `:stable-rocm`, `:0.5.0-rocm`, `:0.5-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
|
||||
|
||||
Preview builds always come from `main` and never version-sort below `:stable`,
|
||||
so upgrades flow naturally. The same images and tags
|
||||
@@ -143,11 +151,17 @@ more), auto-detected and selectable in Settings.
|
||||
- The image ships with `OMNIVOICE_SERVER_MODE=1`, which relaxes the desktop-only
|
||||
loopback-origin gate so the admin UI works through Docker's NAT. Set it to `0`
|
||||
if you front the container with your own loopback auth proxy.
|
||||
- For LAN or internet-facing deployments, set a long random
|
||||
`OMNIVOICE_API_KEY` and pass the same key through the browser's login prompt.
|
||||
A six-digit share PIN is also available for casual LAN access, but it does
|
||||
not authorize administration or dictation; see the
|
||||
[API authentication guide](https://github.com/debpalash/VoiceStudio/blob/main/docs/api-auth.md).
|
||||
|
||||
> **Security:** VoiceStudio ships **no authentication**. Anything that can reach the
|
||||
> URL can use the app. Before exposing it beyond localhost, put it behind a
|
||||
> reverse proxy with auth (Caddy `basic_auth`, nginx + htpasswd) or a private
|
||||
> overlay (Tailscale, ZeroTier).
|
||||
> **Security:** Loopback-only publishing is the safe default. Before exposing
|
||||
> VoiceStudio on a trusted LAN, configure `OMNIVOICE_API_KEY`. On any untrusted
|
||||
> network, plain HTTP is not safe for the API key or session cookie. Keep the
|
||||
> backend on an encrypted private overlay such as Tailscale/ZeroTier; do not
|
||||
> expose it directly to the public internet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+106
-30
@@ -12,7 +12,7 @@ env var that exempts trusted callers:
|
||||
| Gate | Turn on with | Guards | Applies to |
|
||||
|---|---|---|---|
|
||||
| **Share PIN** | the in-app Network share toggle | casual LAN-share guests, one session | non-loopback **HTTP** |
|
||||
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | a durable remote credential | non-loopback **HTTP + WebSocket** |
|
||||
| **API key** | `OMNIVOICE_API_KEY` env var on the backend | direct clients and first-party session bootstrap | non-loopback **HTTP + WebSocket** |
|
||||
| **Trusted networks** | `OMNIVOICE_TRUSTED_NETWORKS` env var | *exempts* the two gates above | non-loopback **consumption** routes only |
|
||||
|
||||
Loopback traffic (`127.0.0.1`, `::1`, `localhost`) is **never** gated — local
|
||||
@@ -25,7 +25,9 @@ tools keep working unchanged whichever gate is set.
|
||||
> desktop-only even with a key (see [Admin routes](#admin-routes-and-server-mode)).
|
||||
|
||||
> Both gates can be active at once. The PIN and the API key are independent; when
|
||||
> both are set, each is checked on the paths it covers.
|
||||
> both are set, each is checked on the paths it covers. Session exchange validates
|
||||
> the master key before the PIN gate so the UI can bootstrap safely; ordinary HTTP
|
||||
> requests still require the PIN afterward, and the UI prompts for it next.
|
||||
|
||||
---
|
||||
|
||||
@@ -42,14 +44,14 @@ present it. Supply it any one of three ways:
|
||||
|
||||
| Where | How |
|
||||
|---|---|
|
||||
| Header | `X-VoiceStudio-Pin: <pin>` |
|
||||
| Header | `X-OmniVoice-Pin: <pin>` |
|
||||
| Query param | `?pin=<pin>` |
|
||||
| Cookie | `ov_pin=<pin>` — the backend sets this automatically after the first valid PIN, so browser sessions only prove it once |
|
||||
|
||||
```bash
|
||||
# From another device on the LAN — with the PIN
|
||||
curl http://<host>:3900/v1/audio/voices \
|
||||
-H "X-VoiceStudio-Pin: 123456"
|
||||
-H "X-OmniVoice-Pin: 123456"
|
||||
```
|
||||
|
||||
A missing or wrong PIN returns:
|
||||
@@ -75,9 +77,11 @@ Notes on the PIN gate (`NetworkAccessMiddleware`, `backend/main.py`):
|
||||
|
||||
## API key
|
||||
|
||||
The API key is the durable credential for running the backend somewhere and
|
||||
driving it remotely — a GPU box on your tailnet, a Docker container, a
|
||||
reverse-proxied host. Set it on the **backend** process:
|
||||
The API key is the backend's durable root credential for a GPU box, Docker
|
||||
container, or reverse-proxied host. Direct API clients may send it on each
|
||||
request. The first-party browser/Tauri UI instead exchanges it once for a
|
||||
short-lived administrator session and never stores the master. Set it on the
|
||||
**backend** process:
|
||||
|
||||
```bash
|
||||
# Generate a strong key and start the backend with it
|
||||
@@ -87,14 +91,16 @@ uv run uvicorn backend.main:app --host 0.0.0.0 --port 3900
|
||||
```
|
||||
|
||||
While `OMNIVOICE_API_KEY` is set, every **non-loopback HTTP and WebSocket**
|
||||
request must present it (the SPA shell paths below are the only HTTP exception).
|
||||
Supply it any one of three ways:
|
||||
request must present an accepted credential. SPA shell paths remain public;
|
||||
`POST /api/auth/session` passes through the middleware only so its route can
|
||||
validate the master and perform the one-time exchange. Direct-client
|
||||
compatibility accepts:
|
||||
|
||||
| Where | How |
|
||||
|---|---|
|
||||
| Header | `Authorization: Bearer <key>` — **preferred**; the one place a key isn't at risk of landing in a log |
|
||||
| Cookie | `ov_key=<key>` — set automatically after the first authenticated HTTP request; the safer fallback for browser WebSockets |
|
||||
| Query param | `?api_key=<key>` — last resort (browser WebSockets can't set headers). **A key in a URL leaks into proxy/access logs and browser history** — prefer the header or cookie |
|
||||
| Header | `Authorization: Bearer <key>` — **preferred** for scripts and SDKs |
|
||||
| Legacy cookie | `ov_key=<key>` — accepted only for compatibility and migrated by the first-party UI; the backend no longer creates it |
|
||||
| Legacy query param | `?api_key=<key>` — compatibility only. **A key in a URL leaks into proxy/access logs and browser history** |
|
||||
|
||||
```bash
|
||||
# Prefer an encrypted transport (Tailscale Serve / TLS) for a real key; plain
|
||||
@@ -133,6 +139,8 @@ code **1008** (policy violation) instead of a JSON body.
|
||||
Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
|
||||
|
||||
- The key is compared in **constant time** and is **never logged**.
|
||||
- The backend never copies the master into a response cookie. Browser clients
|
||||
receive only `ov_session`, an opaque, HttpOnly, SameSite=Strict credential.
|
||||
- The SPA shell paths bypass the gate on **HTTP** so a remote UI can load and
|
||||
show what's wrong; WebSockets have no such exemption.
|
||||
- **Plain HTTP is sniffable** — a Bearer key over `http://` on a hostile
|
||||
@@ -140,6 +148,40 @@ Notes on the API-key gate (`BearerKeyMiddleware`, `backend/main.py`):
|
||||
anything beyond a fully trusted LAN. See
|
||||
[docs/remote-gpu.md](remote-gpu.md) for the full remote-backend setup.
|
||||
|
||||
### First-party administrator sessions
|
||||
|
||||
The bundled UI uses a narrower protocol:
|
||||
|
||||
1. `POST /api/auth/session` receives the master in an `Authorization` header
|
||||
exactly once and selects `{"transport":"cookie"}` for exact same-origin
|
||||
browsers or `{"transport":"bearer"}` for Tauri/cross-origin clients.
|
||||
2. Cookie transport returns `204` and sets `ov_session` as HttpOnly,
|
||||
SameSite=Strict, path `/`, with an eight-hour maximum lifetime. Bearer
|
||||
transport returns an opaque `ovs_admin_session_…` value which the UI keeps
|
||||
in **sessionStorage only**, bound to the exact backend base URL. Bearer JSON
|
||||
responses include both `expires_at` and a bounded `expires_in`; the UI uses
|
||||
the relative lifetime when available so clock skew between a remote GPU host
|
||||
and the browser cannot reject a valid session. `expires_at` remains for
|
||||
backward compatibility with older clients and servers.
|
||||
3. `DELETE /api/auth/session` revokes the session. Removing or rotating
|
||||
`OMNIVOICE_API_KEY`, backend restart, explicit logout, and the eight-hour
|
||||
deadline also invalidate it.
|
||||
|
||||
The master is never written to localStorage/sessionStorage, never returned by
|
||||
the backend, and never placed in a WebSocket URL. Legacy `ov_api_key` browser
|
||||
storage is deleted before migration waits on the network. All auth responses,
|
||||
including errors, carry `Cache-Control: no-store`.
|
||||
|
||||
Failed session exchanges are limited per client to ten attempts in a rolling
|
||||
60-second window and then return `429` with `Retry-After`. A correct master key
|
||||
is always evaluated and clears the failure window, so an attacker cannot lock
|
||||
an operator out by deliberately exhausting the limit.
|
||||
|
||||
Cookie-authenticated mutations require both an exact allowed `Origin` and
|
||||
`X-VoiceStudio-CSRF: 1`. Side-effectful GET actions additionally require the
|
||||
browser's `Sec-Fetch-Site: same-origin`. Bearer/header clients are not subject
|
||||
to the ambient-cookie CSRF check.
|
||||
|
||||
---
|
||||
|
||||
## Dictation WebSocket
|
||||
@@ -149,13 +191,22 @@ own inline guard (`backend/api/routers/capture_ws.py`) *in addition to* the
|
||||
API-key middleware. A non-loopback client reaches it only if it is **either**:
|
||||
|
||||
- on a [trusted network](#trusted-networks) (`is_local_host` passes), **or**
|
||||
- presenting the **API key** — as `Authorization: Bearer <key>`, the `ov_key`
|
||||
cookie, or `?api_key=<key>` (URL keys leak into logs — prefer the cookie).
|
||||
- presenting a direct-client **API key** in `Authorization`, or through a
|
||||
legacy `ov_key`/`?api_key=` transport.
|
||||
|
||||
```
|
||||
ws://gpu-box:3900/ws/transcribe?api_key=<key>
|
||||
```
|
||||
|
||||
That URL form is retained for non-browser compatibility only. The first-party
|
||||
UI never constructs it. A bearer administrator session first calls
|
||||
`POST /api/auth/ws-ticket` and puts only the returned `ws_ticket` in the URL.
|
||||
Tickets are scoped to `/ws/transcribe` or `/ws/events`, expire after 30 seconds,
|
||||
return the same bounded `expires_in`/`expires_at` pair, and are consumed
|
||||
atomically at most once. Same-origin UI WebSockets use the
|
||||
HttpOnly session cookie and must pass exact `Origin` validation; `null`, missing,
|
||||
and lookalike origins are rejected.
|
||||
|
||||
The **share PIN does not authorize dictation** — the PIN gate is HTTP-only, and
|
||||
the dictation guard checks only the API key (or trusted-network membership). A
|
||||
LAN guest who has only entered a PIN can use the HTTP API but **not** live
|
||||
@@ -196,10 +247,11 @@ time, so in production **restart the backend** to apply a change. Default empty
|
||||
## Admin routes and server mode
|
||||
|
||||
Admin routes — `/system/*` (including `set-env`, **RCE-class**),
|
||||
`/api/settings/*`, engine install/uninstall, media tools, MCP bindings — sit on
|
||||
a stricter gate (`require_admin`, `backend/api/dependencies.py`) than
|
||||
consumption. On the desktop build they are **true-loopback-only**: no PIN, key,
|
||||
or trusted network reaches them from another machine.
|
||||
`/api/settings/*`, engine selection/install/uninstall, media tools, MCP
|
||||
bindings, pronunciation settings, and remote-worker management — sit on a
|
||||
stricter gate (`require_admin`, `backend/api/dependencies.py`) than consumption.
|
||||
On the desktop build they are **true-loopback-only**: no PIN, key, or trusted
|
||||
network reaches them from another machine.
|
||||
|
||||
In **server mode** (`OMNIVOICE_SERVER_MODE=1`, the Docker image) the loopback
|
||||
origin is unenforceable — NAT rewrites the source and even a
|
||||
@@ -207,16 +259,23 @@ origin is unenforceable — NAT rewrites the source and even a
|
||||
requirement is dropped (issue #261, else the operator is 403'd out of their own
|
||||
`/system/*`). It is replaced by a **credential rule**, not removed:
|
||||
|
||||
- **No API key configured** → read-only admin discovery remains available for
|
||||
the bare Docker bootstrap flow, but `POST`/`PUT`/`PATCH`/`DELETE` requests are
|
||||
denied. Set `OMNIVOICE_API_KEY` before changing settings remotely.
|
||||
- **A credential is configured** → admin requires the **API key** (`Authorization:
|
||||
Bearer` / `?api_key` / `ov_key` cookie), or genuine loopback. The **6-digit
|
||||
share PIN does not gate admin** (it is brute-forceable), and trusted-network
|
||||
membership never does either. A **PIN-only** server-mode deployment therefore
|
||||
allows remote read-only discovery but blocks remote mutations; remote writes
|
||||
require the long API key. Discovery never returns the share PIN itself; only
|
||||
loopback or a caller already authenticated with the API key can read it.
|
||||
- **No credential configured** (neither API key nor share PIN) → read-only
|
||||
admin discovery remains available for the bare Docker bootstrap flow, but
|
||||
`POST`/`PUT`/`PATCH`/`DELETE` requests are denied. Side-effectful GET actions
|
||||
are denied too: engine health may start a sidecar, deep diagnostics may load
|
||||
a model, and LLM provider discovery makes a request with the saved provider
|
||||
credential. Set `OMNIVOICE_API_KEY` before changing settings or triggering
|
||||
those actions remotely.
|
||||
- **An API key is configured** → admin requires that **API key** (direct-client
|
||||
`Authorization` / legacy query or cookie), a valid short-lived administrator
|
||||
session, or genuine loopback. The **6-digit share PIN does not gate admin**
|
||||
(it is brute-forceable), and trusted-network membership never does either. A
|
||||
**PIN-only** server-mode deployment therefore keeps admin routes loopback-only;
|
||||
remote admin starts from the long API key.
|
||||
|
||||
Managed sidecar installation remains true-loopback-only even with an API key.
|
||||
Its installer fetches mutable source and creates an editable environment, so it
|
||||
must be run directly on that machine until the source supply chain is pinned.
|
||||
|
||||
Host paths are never selected through HTTP. The native Tauri process validates
|
||||
model-cache and export destinations plus custom FFmpeg/FFprobe binaries, writes
|
||||
@@ -261,13 +320,30 @@ the default list, so restate the loopback/Tauri origins alongside your own. (The
|
||||
same origin.) If you only moved the Vite dev server's port, set
|
||||
`OMNIVOICE_UI_PORT` instead and the default list follows it.
|
||||
|
||||
CORS wraps both authentication gates: credentialless browser preflights are
|
||||
answered before PIN/API-key enforcement, and gate-generated `401` responses
|
||||
retain CORS headers so the UI can read the actual failure and prompt for the
|
||||
right credential.
|
||||
|
||||
TLS-terminating proxies must establish the effective scheme at the ASGI server
|
||||
boundary. Uvicorn's proxy-header handling trusts loopback by default, which
|
||||
covers Tailscale Serve; a custom proxy on another address must be listed with
|
||||
`--forwarded-allow-ips=<proxy-ip>` (and proxy headers must remain enabled).
|
||||
VoiceStudio deliberately does not trust a raw `X-Forwarded-Proto` header inside
|
||||
the application: once Uvicorn accepts a trusted proxy, the resolved ASGI scheme
|
||||
drives exact-Origin checks and the session cookie's `Secure` attribute.
|
||||
For a public path prefix such as `/studio`, either strip that prefix before
|
||||
forwarding or configure the ASGI `root_path` to the same value. WebSocket ticket
|
||||
validation removes only that trusted, configured prefix; it never accepts an
|
||||
arbitrary path merely because it ends in `/ws/events` or `/ws/transcribe`.
|
||||
|
||||
## Status codes
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
|---|---|---|
|
||||
| **401** | Consumption auth failed — `{"detail": "PIN required"}` or `{"detail": "API key required"}`. | Supply the PIN / key (header, cookie, or query param above). A WebSocket surfaces this as close code **1008**. |
|
||||
| **403** | Authorization failed: loopback/native access was required, a server-mode mutation lacked the API key, or a native path capability was invalid, expired, or for a different operation. | A PIN cannot grant admin or filesystem access. Run native operations from the desktop app; configure and present the API key for remote server-mode mutations; reopen the native picker if a one-shot capability expired. |
|
||||
| **429** | **Not an auth failure.** The GPU pool is saturated (admission control) or a model download is rate-limited. Ships with `Retry-After` and `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds and retry the identical request. |
|
||||
| **403** | Authorization failed: loopback/native access was required, cookie Origin/CSRF validation failed, a server-mode mutation lacked an admin credential, or a native path capability was invalid/expired. | A PIN cannot grant admin or filesystem access. Re-authenticate the UI; scripts should use the API-key header; run native operations from the desktop app. The admin gate names the key only when one can satisfy it: server mode with `OMNIVOICE_API_KEY` configured answers `{"detail": "loopback origin or admin API key required"}` (the bundled UI routes it to the API-key login form); PIN-only/no-key server mode and the desktop build answer `{"detail": "loopback origin required"}` (only loopback can satisfy the gate). |
|
||||
| **429** | A failed administrator-session exchange exceeded its per-client limit, the GPU pool is saturated, or a model download is rate-limited. Ships with `Retry-After`; workload throttles also carry `X-VoiceStudio-Retryable: true`. | Back off for `Retry-After` seconds. For authentication, verify the master before retrying; a correct master is never locked out. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Benchmarks
|
||||
|
||||
Measured numbers per engine and device — how long a generation actually
|
||||
takes on real hardware. Every number here is produced by the in-repo
|
||||
harness, on named hardware, at a named version; nothing is estimated.
|
||||
|
||||
## How numbers are measured
|
||||
|
||||
```bash
|
||||
# stop the app first — a running backend holds a model and skews numbers
|
||||
uv run python scripts/bench_pipeline.py # everything
|
||||
uv run python scripts/bench_pipeline.py tts # just the TTS stage
|
||||
```
|
||||
|
||||
`scripts/bench_pipeline.py` profiles each pipeline stage one at a time,
|
||||
memory-safely: it refuses to start a stage without enough free RAM and
|
||||
unloads models between stages. See [performance.md](performance.md) for
|
||||
what each stage spends its time on.
|
||||
|
||||
The `tts` stage emits the two values this table collects:
|
||||
|
||||
- **RTF** (real-time factor) — seconds of compute per second of generated
|
||||
audio, printed next to each warm measurement. RTF < 1 means faster than
|
||||
real time. Use the **short line (warm)** RTF for the table.
|
||||
- **Peak VRAM** — printed on CUDA only. MPS is unified memory and CPU has
|
||||
no VRAM; subprocess-isolated engines allocate outside the harness's view
|
||||
(it prints `n/a` for them). Leave the column blank in all those cases.
|
||||
|
||||
## Results
|
||||
|
||||
No verified rows yet — this table fills from maintainer runs and community
|
||||
submissions.
|
||||
|
||||
| Engine | Device | RTF (warm) | Peak VRAM (GB) | App version | Source |
|
||||
|---|---|---|---|---|---|
|
||||
| _none yet — contribute yours below_ | | | | | |
|
||||
|
||||
Column meanings: **Engine** — the TTS engine the harness resolved (printed
|
||||
at stage start). **Device** — one string naming what ran the model, e.g.
|
||||
`RTX 3060 12 GB`, `Apple M2 Pro`, `Ryzen 7 5800X (CPU)`. **RTF (warm)** —
|
||||
the short-line warm RTF from the harness. **Peak VRAM** — the harness's
|
||||
CUDA peak, blank on MPS/CPU. **App version** — from `Settings → About`.
|
||||
**Source** — a link to the PR that added the row.
|
||||
|
||||
## Contributing a row
|
||||
|
||||
1. Run the harness on an otherwise-idle machine (app stopped) and copy its
|
||||
summary table.
|
||||
2. Open a PR adding one row using the column meanings above, and paste the
|
||||
raw harness output into the PR description — that PR link becomes the
|
||||
row's **Source**.
|
||||
3. One row per engine+device pair; a newer app version replaces the old row.
|
||||
|
||||
Numbers from different machines aren't directly comparable — that's fine.
|
||||
The point is honest expectations ("this engine on this class of GPU ≈ this
|
||||
fast"), not a leaderboard.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Engine guides
|
||||
|
||||
One page per engine: what it's for, what it needs, how to enable it, and its
|
||||
quirks. Select engines in **Model Catalogue → Engines** (or quick-switch with
|
||||
<kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd>), or pin one with
|
||||
`OMNIVOICE_TTS_BACKEND` / `OMNIVOICE_ASR_BACKEND`.
|
||||
|
||||
The compute device (CUDA/ROCm/MPS/CPU) is auto-detected; pin it under
|
||||
**Settings → Performance & Device** (or `OMNIVOICE_DEVICE`) if auto-detect
|
||||
picks wrong — see [performance](../performance.md).
|
||||
|
||||
Measured speed/VRAM numbers live in [benchmarks](../benchmarks.md); what each
|
||||
engine can do expressively in [expressive-speech](../expressive-speech.md);
|
||||
sidecar disk footprints in [disk-usage](disk-usage.md); the bar a new engine
|
||||
must clear in [engine-acceptance](../engine-acceptance.md).
|
||||
|
||||
New to VoiceStudio? Install the app first — [macOS](../install/macos.md)
|
||||
(first launch needs the one-time right-click → **Open** Gatekeeper
|
||||
approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
|
||||
[Docker](../install/docker.md).
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
| Engine | Guide | Runs on | Cloning | Enabled by |
|
||||
|---|---|---|---|---|
|
||||
| VoiceStudio (OmniVoice) — **default** | [omnivoice](omnivoice.md) | CUDA · MPS · CPU | ✅ | installed by default |
|
||||
| VoxCPM2 | [voxcpm2](voxcpm2.md) | CUDA · MPS · CPU | ✅ + voice design | `pip install "voxcpm>=2.0.3"` |
|
||||
| MOSS-TTS-Nano | [moss-tts-nano](moss-tts-nano.md) | CUDA · CPU | ✅ (ref only) | clone + `uv pip install -e .` |
|
||||
| KittenTTS | [kittentts](kittentts.md) | CPU | — (8 preset voices) | `pip install kittentts` |
|
||||
| MLX-Audio (Kokoro, CSM, Dia, …) | [mlx-audio](mlx-audio.md) | Apple Silicon | model-dependent | `pip install mlx-audio` |
|
||||
| CosyVoice 3 | [cosyvoice](cosyvoice.md) | CUDA · CPU | ✅ | clone + requirements |
|
||||
| GPT-SoVITS | [gpt-sovits](gpt-sovits.md) | external server | ✅ | its own API server |
|
||||
| Sherpa-ONNX | [sherpa-onnx](sherpa-onnx.md) | CUDA · CPU | — | `pip install sherpa-onnx` + model dir |
|
||||
| IndexTTS 2.5 | [indextts](indextts.md) | CUDA · CPU | ✅ + emotion | one-click sidecar install |
|
||||
| OmniVoice GGUF | [omnivoice-gguf](omnivoice-gguf.md) | CUDA · MPS · CPU | ✅ | bundled binary |
|
||||
| Supertonic-3 | [supertonic3](supertonic3.md) | CPU | — (7 preset voices) | `uv sync --extra supertonic` + license |
|
||||
| MOSS-TTS-v1.5 (8B) | [moss-tts-v15](moss-tts-v15.md) | CUDA · CPU | ✅ | clone + env var |
|
||||
| dots.tts (2B) | [dots-tts](dots-tts.md) | CUDA · CPU (not Windows) | ✅ | clone + env var |
|
||||
| OmniVoice (subprocess) | [omnivoice-subprocess](omnivoice-subprocess.md) | CUDA · MPS · CPU | ✅ | opt-in pick, no install |
|
||||
| PocketTTS (Kyutai) | [pockettts](pockettts.md) | CPU (not Intel Mac) | ✅ | `uv sync --extra pockettts` + license |
|
||||
| Confucius4-TTS | [confucius4-tts](confucius4-tts.md) | CUDA · CPU | ✅ | clone + env var |
|
||||
|
||||
## Speech-to-text
|
||||
|
||||
| Engine | Guide | Runs on | Best at | Enabled by |
|
||||
|---|---|---|---|---|
|
||||
| WhisperX | [whisperx](whisperx.md) | CUDA · CPU | dubbing (word timestamps + diarization) | installed by default |
|
||||
| Faster-Whisper | [faster-whisper](faster-whisper.md) | CUDA · CPU | general transcription | installed by default |
|
||||
| Faster-Whisper (isolated) | [faster-whisper-isolated](faster-whisper-isolated.md) | CUDA · CPU | unattended batches | opt-in pick |
|
||||
| MLX Whisper | [mlx-whisper](mlx-whisper.md) | Apple Silicon | Mac default | `pip install mlx-whisper` |
|
||||
| PyTorch Whisper | [pytorch-whisper](pytorch-whisper.md) | CUDA · MPS · CPU | ROCm hosts | installed by default |
|
||||
| Parakeet TDT (NeMo) | [nemo-parakeet](nemo-parakeet.md) | CUDA · CPU | 25 languages, fast CPU | separate venv (never the app's) |
|
||||
| Parakeet TDT (MLX) | [parakeet-mlx](parakeet-mlx.md) | Apple Silicon | dictation, 25 EU languages | default on mac-ARM source installs |
|
||||
| Moonshine | [moonshine](moonshine.md) | CPU | edge/low-power, no timestamps | `pip install` (see guide) |
|
||||
| FunASR (SenseVoice) | [funasr](funasr.md) | CUDA · CPU | 50+ languages, inline diarization | `pip install funasr` |
|
||||
| Sherpa-ONNX dictation | [sherpa-onnx-asr](sherpa-onnx-asr.md) | CPU | live streaming dictation | curated model download |
|
||||
| OpenAI-compatible (remote) | [openai-compatible-asr](openai-compatible-asr.md) | network | offloading to a server (audio leaves the machine) | Model Catalogue |
|
||||
|
||||
Speaker diarization is not an engine registry of its own — the dub pipeline
|
||||
uses pyannote (HF-gated; see [diarization](../features/diarization.md)) and
|
||||
FunASR can diarize inline with its `cam++` speaker model.
|
||||
@@ -0,0 +1,61 @@
|
||||
# VoiceStudio — Faster-Whisper (Crash-Isolated) Engine
|
||||
|
||||
The same CTranslate2 Whisper engine as [faster-whisper](faster-whisper.md),
|
||||
run in a **separate child process** ("sidecar"). CTranslate2's GPU teardown
|
||||
can segfault — the endemic faster-whisper crash — and a hung or crashed
|
||||
transcribe in-process takes the whole backend down with it. Isolated, the
|
||||
child can crash or be force-killed to reclaim a hung transcribe and its VRAM
|
||||
while the backend stays up
|
||||
([#730](https://github.com/debpalash/VoiceStudio/issues/730)).
|
||||
|
||||
There is nothing extra to install: the sidecar reuses the app's own venv —
|
||||
only the process boundary is new.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the crash-isolated row, or
|
||||
- pin it with `OMNIVOICE_ASR_BACKEND=faster-whisper-isolated`.
|
||||
|
||||
It is never picked by auto-detect — it's an explicit opt-in escape hatch.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Long batch runs** where one bad file must not kill the backend.
|
||||
- Machines where in-process faster-whisper has crashed or hung before:
|
||||
a sidecar crash fails only that job, and the next transcribe respawns a
|
||||
fresh sidecar automatically.
|
||||
|
||||
## Platform support
|
||||
|
||||
Same as faster-whisper: CUDA float16 or CPU int8 on macOS, Windows, and
|
||||
Linux. The sidecar picks cuda/cpu itself and walks the same
|
||||
float16 → int8_float16 → int8 degrade chain on GPUs without efficient fp16
|
||||
([#551](https://github.com/debpalash/VoiceStudio/issues/551)).
|
||||
|
||||
## Model selection
|
||||
|
||||
- `ASR_MODEL_FASTER` — the shared model selection, same as the in-process
|
||||
engine: set it once and both variants load the same weights.
|
||||
- `ASR_MODEL_FW` — optional sidecar-only override; when set it wins over
|
||||
`ASR_MODEL_FASTER` for this engine. Default `large-v3`.
|
||||
- `ASR_COMPUTE_TYPE` — optional: pin the sidecar to one CTranslate2 compute
|
||||
type instead of the automatic degrade chain.
|
||||
|
||||
Weights download on first load — see
|
||||
[downloading-models](../downloading-models.md).
|
||||
|
||||
## Trade-offs and quirks
|
||||
|
||||
- **Slightly slower per call** than in-process faster-whisper (IPC overhead);
|
||||
the model stays warm inside the sidecar between calls, so the cost is per
|
||||
request, not per chunk of audio.
|
||||
- Word timestamps are Whisper-native (±100–300 ms) — no forced alignment.
|
||||
For dubbing lip-sync, use [whisperx](whisperx.md) or
|
||||
[mlx-whisper](mlx-whisper.md).
|
||||
- If the sidecar dies mid-transcription the job fails with a clear
|
||||
"sidecar crashed" error and the backend stays up — retry to respawn.
|
||||
- **cuDNN 8 is still required on CUDA** — same CTranslate2 requirement as the
|
||||
in-process engine. It's checked up front so a missing cuDNN 8 shows as
|
||||
"unavailable" in Model Catalogue → Engines instead of a sidecar that
|
||||
silently fails every transcribe
|
||||
([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
|
||||
@@ -0,0 +1,70 @@
|
||||
# VoiceStudio — Faster-Whisper Engine
|
||||
|
||||
Faster-Whisper runs Whisper on CTranslate2 — the same transcription core
|
||||
WhisperX uses, **without** the wav2vec2 forced-alignment pass. It's the safe
|
||||
cross-platform fallback when whisperx isn't installed, and the capture/dictation
|
||||
fallback on non-Apple machines.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the Faster-Whisper row, or
|
||||
- pin it with `OMNIVOICE_ASR_BACKEND=faster-whisper`.
|
||||
|
||||
Auto-detect only picks it when [whisperx](whisperx.md) is unavailable.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Subtitles, dictation buffers, and batch transcription** where Whisper's
|
||||
native word timing (±100–300 ms) is good enough.
|
||||
- For dubbing lip-sync, prefer [whisperx](whisperx.md) (or
|
||||
[mlx-whisper](mlx-whisper.md) on Apple Silicon) — their forced alignment is
|
||||
an order of magnitude tighter on word boundaries.
|
||||
|
||||
## Platform support
|
||||
|
||||
- **CUDA** — float16, with automatic degradation (below).
|
||||
- **CPU** — int8 on macOS, Windows, and Linux.
|
||||
- **Apple Silicon GPU / ROCm** — not supported: CTranslate2 has no Metal or
|
||||
HIP build, so those hosts run on CPU
|
||||
([#1529](https://github.com/debpalash/VoiceStudio/issues/1529)); auto-detect
|
||||
routes them to mlx-whisper / pytorch-whisper instead.
|
||||
|
||||
## Model selection
|
||||
|
||||
`ASR_MODEL_FASTER` — default `Systran/faster-whisper-large-v3`. Accepts the
|
||||
size aliases (`tiny` … `large-v3`, `distil-large-v3`) or any CTranslate2
|
||||
Whisper repo on HF. Weights download on first load — see
|
||||
[downloading-models](../downloading-models.md).
|
||||
|
||||
Segments are cleaned up by faster-whisper's built-in Silero VAD before
|
||||
transcription.
|
||||
|
||||
## Degradation chains
|
||||
|
||||
- GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx, or a
|
||||
CTranslate2/cuDNN mismatch) fail at model construction with a compute-type
|
||||
error; the engine walks float16 → int8_float16 → int8 instead of failing
|
||||
every chunk ([#551](https://github.com/debpalash/VoiceStudio/issues/551)).
|
||||
- A CUDA out-of-memory falls back to CPU (slower, same model and accuracy) —
|
||||
flushing the resident TTS model frees VRAM for GPU-speed ASR
|
||||
([#255](https://github.com/debpalash/VoiceStudio/issues/255)).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **cuDNN 8 required on CUDA** — a missing cuDNN 8 would fast-fail the whole
|
||||
process, so the engine checks up front and reports itself unavailable
|
||||
instead ([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
|
||||
pytorch-whisper covers that case on torch's bundled cuDNN 9.
|
||||
- On some hardened Linux kernels the CTranslate2 native library is rejected
|
||||
with "cannot enable executable stack" (an OSError, not an ImportError) —
|
||||
reported as unavailable rather than crashing engine selection
|
||||
([#692](https://github.com/debpalash/VoiceStudio/issues/692)).
|
||||
- CTranslate2's GPU teardown can rarely segfault the process at unload. If
|
||||
you hit that, switch to the crash-isolated variant —
|
||||
[faster-whisper-isolated](faster-whisper-isolated.md)
|
||||
([#730](https://github.com/debpalash/VoiceStudio/issues/730)).
|
||||
- Transcribes are time-bounded: `OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S`
|
||||
(default 120 s per dub chunk) and `OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S`
|
||||
(default 300 s whole-file).
|
||||
|
||||
Speed comparisons across engines live in [performance](../performance.md).
|
||||
@@ -0,0 +1,62 @@
|
||||
# VoiceStudio — FunASR (SenseVoice) Engine
|
||||
|
||||
FunASR drives Alibaba's SenseVoiceSmall with FSMN-VAD: an all-in-one
|
||||
multilingual pipeline — transcription with punctuation and inverse text
|
||||
normalization across **50+ languages**, plus optional **inline speaker
|
||||
diarization** via the cam++ speaker model. It's the opt-in alternative to
|
||||
WhisperX ([#182](https://github.com/debpalash/VoiceStudio/issues/182));
|
||||
WhisperX remains the cross-platform default.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- Install it into the app venv: `uv pip install funasr`.
|
||||
- Then **Model Catalogue → Engines**, ASR tab → **Use** on the FunASR row, or
|
||||
`OMNIVOICE_ASR_BACKEND=funasr`.
|
||||
|
||||
Auto-detect never picks it; it's an explicit opt-in.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Multi-speaker transcription without any HuggingFace token.** This is the
|
||||
only ASR engine with diarization built in: cam++ labels each sentence
|
||||
(`Speaker 1`, `Speaker 2`, ...) in the same pass — no gated pyannote
|
||||
model, no license click-through. Compare
|
||||
[diarization](../features/diarization.md) for the pyannote/WhisperX route
|
||||
and what each buys you.
|
||||
- **Broad language coverage** beyond Whisper's strongest languages, with
|
||||
punctuation included.
|
||||
|
||||
## Not suited for
|
||||
|
||||
- **Lip-sync dubbing** — FunASR returns sentence-level timestamps, not
|
||||
word-level ones. Use [whisperx](whisperx.md) /
|
||||
[mlx-whisper](mlx-whisper.md) when word timing matters.
|
||||
|
||||
## Platform support
|
||||
|
||||
CUDA or CPU, on macOS, Windows, and Linux.
|
||||
|
||||
## Model selection
|
||||
|
||||
| Variable | Default | Role |
|
||||
| --- | --- | --- |
|
||||
| `ASR_MODEL_FUNASR` | `iic/SenseVoiceSmall` | main ASR model |
|
||||
| `ASR_FUNASR_VAD` | `fsmn-vad` | VAD segmentation model |
|
||||
| `ASR_FUNASR_SPK` | `cam++` | speaker model; set to empty (`ASR_FUNASR_SPK=`) to disable diarization and use the dub pipeline's pyannote/heuristic path instead |
|
||||
|
||||
Weights download on first load (through FunASR's own model hub) — see
|
||||
[downloading-models](../downloading-models.md).
|
||||
|
||||
## Quirks
|
||||
|
||||
- With the speaker model enabled, long recordings are transcribed in **one
|
||||
call** and split by FunASR's internal VAD — cam++ assigns speaker cluster
|
||||
IDs per call, so this is what keeps "Speaker 1" meaning the same person
|
||||
across the whole file.
|
||||
- The engine runs with `spk_mode="vad_segment"`: FunASR 1.3.1's default
|
||||
(`punc_segment`) requires a separate punctuation model and crashes when
|
||||
SenseVoice is loaded without one.
|
||||
- SenseVoice's rich-token markup (language/emotion/event tags around the
|
||||
text) is stripped from the output automatically.
|
||||
- Language detection is automatic (`language: auto`); the detected language
|
||||
is reported per file.
|
||||
@@ -0,0 +1,78 @@
|
||||
# VoiceStudio — GPT-SoVITS Engine
|
||||
|
||||
GPT-SoVITS (RVC-Boss) is one of the most popular open-source voice-cloning
|
||||
systems (57k+ GitHub stars, MIT-licensed). It does zero-shot and few-shot
|
||||
cloning with excellent naturalness in Chinese, English, Japanese, Cantonese,
|
||||
and Korean, and it is very fast (RTF ~0.014 on suitable hardware).
|
||||
|
||||
Unlike VoiceStudio's other engines, GPT-SoVITS does not run inside the app.
|
||||
It ships as a standalone API server, and VoiceStudio connects to it over
|
||||
HTTP.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- You already run (or want to run) a GPT-SoVITS server, e.g. with few-shot
|
||||
fine-tuned voices.
|
||||
- You need fast, natural cloning in zh/en/ja/yue/ko.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install and start the GPT-SoVITS API server (upstream project):
|
||||
|
||||
```bash
|
||||
cd GPT-SoVITS
|
||||
python api_v2.py -a 127.0.0.1 -p 9880 -c GPT_SoVITS/configs/tts_infer.yaml
|
||||
```
|
||||
|
||||
2. Select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=gpt-sovits`.
|
||||
|
||||
VoiceStudio marks the engine available only when the server responds
|
||||
(2-second reachability probe).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_GPTSOVITS_URL` | `http://127.0.0.1:9880` | API server URL |
|
||||
| `OMNIVOICE_TRUSTED_NETWORKS` | (unset) | Required to allow a non-loopback server |
|
||||
|
||||
**Remote servers:** by default VoiceStudio only talks to loopback addresses
|
||||
— part of the local-first guarantee. To point at a server on another
|
||||
machine (e.g. a GPU box on your LAN), add its network to
|
||||
`OMNIVOICE_TRUSTED_NETWORKS`; otherwise the connection is refused as an
|
||||
untrusted endpoint.
|
||||
|
||||
Prefer `https://` (or a private tunnel such as Tailscale/WireGuard) for any
|
||||
non-loopback server: with plain `http://` the text you synthesize and the
|
||||
audio that comes back cross the network unencrypted. VoiceStudio does not
|
||||
disable certificate verification, so a TLS endpoint needs a certificate the
|
||||
system trusts.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 32 kHz mono (server output is resampled if needed).
|
||||
- Cloning passes your reference clip path and optional transcript to the
|
||||
server; the reference path must be readable **by the server process**, so
|
||||
remote servers need the clip on their own filesystem.
|
||||
- Speed control is forwarded as the server's `speed_factor`.
|
||||
- The GPU is whatever the GPT-SoVITS server itself uses (CUDA preferred);
|
||||
VoiceStudio's side is just an HTTP client.
|
||||
|
||||
## Known limits
|
||||
|
||||
- Five languages only; for broader coverage use
|
||||
[OmniVoice](omnivoice.md) ([languages.md](../languages.md)).
|
||||
- No voice design; server availability is your responsibility — if the
|
||||
server stops, generations fail with a "server not reachable" error.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "GPT-SoVITS server not reachable": start the server with the command
|
||||
above, or fix `OMNIVOICE_GPTSOVITS_URL`.
|
||||
- "endpoint is outside loopback or OMNIVOICE_TRUSTED_NETWORKS": see
|
||||
Configuration above.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[expressive-speech.md](../expressive-speech.md).
|
||||
@@ -0,0 +1,75 @@
|
||||
# VoiceStudio — KittenTTS Engine
|
||||
|
||||
KittenTTS (KittenML) is the lightweight English "flash" tier: a 25–80 MB
|
||||
ONNX model with 8 preset voices that runs realtime on any CPU — no torch, no
|
||||
CUDA, no GPU of any kind. Use it when you just need quick English narration
|
||||
(voiceovers, demo reads, short phrases) with no reference sample.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- English-only content where speed and a tiny install matter more than
|
||||
cloning.
|
||||
- Machines with no usable GPU.
|
||||
|
||||
The trade-off against [OmniVoice](omnivoice.md): no voice cloning, English
|
||||
only — but a much faster and much smaller install.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install kittentts
|
||||
```
|
||||
|
||||
Then select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=kittentts`.
|
||||
|
||||
## Voices
|
||||
|
||||
Eight preset voices, four male/female pairs:
|
||||
|
||||
```text
|
||||
expr-voice-2-m expr-voice-2-f (default: expr-voice-2-f)
|
||||
expr-voice-3-m expr-voice-3-f
|
||||
expr-voice-4-m expr-voice-4-f
|
||||
expr-voice-5-m expr-voice-5-f
|
||||
```
|
||||
|
||||
An unknown voice id logs an info message and falls back to the default.
|
||||
|
||||
## Model selection
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_KITTENTTS_MODEL` | `KittenML/kitten-tts-mini-0.8` | HuggingFace checkpoint to load |
|
||||
|
||||
The ~80 MB model downloads from HuggingFace on first use (retried once on a
|
||||
flaky connection). See [downloading-models.md](../downloading-models.md).
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 24 kHz mono.
|
||||
- CPU-only by design — the ONNX graph has no CUDA/MPS path.
|
||||
- Non-English `language` values are ignored with a log line pointing at
|
||||
OmniVoice; reference audio is likewise ignored (no cloning).
|
||||
- **Long-input hardening
|
||||
([#1173](https://github.com/debpalash/VoiceStudio/issues/1173)):** the
|
||||
shipped ONNX graph has a hard 512-token cap, and phonemization can expand
|
||||
text massively (digits especially). VoiceStudio pre-measures every chunk
|
||||
with the model's own tokenizer and splits oversized chunks at word
|
||||
boundaries, so long or digit-heavy inputs no longer abort inside
|
||||
onnxruntime with an opaque "invalid expand shape" error.
|
||||
|
||||
## Known limits
|
||||
|
||||
- English only; no cloning, no voice design, no emotion controls
|
||||
(see [expressive-speech.md](../expressive-speech.md)).
|
||||
- Preset voices only — speed is the one knob.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Engine unavailable: `pip install kittentts` into VoiceStudio's Python
|
||||
environment and restart.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceStudio — MLX-Audio Engine (Apple Silicon)
|
||||
|
||||
MLX-Audio (Blaizzy/mlx-audio) wraps 14+ TTS engines — Kokoro, CSM, Dia,
|
||||
Qwen3-TTS, Chatterbox, MeloTTS, OuteTTS, and more — behind a single adapter
|
||||
that runs on Apple's MLX framework. It is **Apple Silicon only**: the engine
|
||||
is not shipped on Linux, Windows, or Intel Macs, and a stray wheel on those
|
||||
platforms never reports as available
|
||||
([#390](https://github.com/debpalash/VoiceStudio/issues/390)).
|
||||
|
||||
## When to pick it
|
||||
|
||||
- You're on an M-series Mac and want small, fast models tuned for it.
|
||||
- You want one of the specific hosted models (Kokoro for small multilingual,
|
||||
CSM for cloning, Qwen3-TTS for voice design, Dia for dialogue, …).
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
pip install mlx-audio
|
||||
```
|
||||
|
||||
Then select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=mlx-audio`.
|
||||
|
||||
## Model selection
|
||||
|
||||
One backend hosts many models. The curated set:
|
||||
|
||||
| Key | Model | Niche |
|
||||
| --- | --- | --- |
|
||||
| `kokoro` (default) | `mlx-community/Kokoro-82M-bf16` | small multilingual |
|
||||
| `csm` | `mlx-community/csm-1b-8bit` | voice cloning |
|
||||
| `qwen3-tts` | `mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-4bit` | voice design |
|
||||
| `dia` | `mlx-community/Dia-1.6B` | dialogue |
|
||||
| `chatterbox` | `mlx-community/Chatterbox-TTS-4bit` | expressive |
|
||||
| `melotts` | `mlx-community/MeloTTS-English-v3-MLX` | lightweight VITS |
|
||||
| `outetts` | `mlx-community/Llama-OuteTTS-1.0-1B-4bit` | LM-based |
|
||||
|
||||
Pick a model in the **Model Catalogue → Engines** curated picker
|
||||
([#981](https://github.com/debpalash/VoiceStudio/issues/981)) or set
|
||||
`OMNIVOICE_MLX_AUDIO_MODEL` to either a curated key (`kokoro`) or any full
|
||||
HF repo id. The env var overrides the persisted UI choice.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 24 kHz mono for most hosted models.
|
||||
- **Cloning works only with the `csm` model** — it is the only curated model
|
||||
confirmed to accept a reference clip. Other models silently ignore
|
||||
reference audio, so the engine reports cloning support only when CSM is
|
||||
selected (dub/batch jobs gate on this).
|
||||
- Voice design (text description → voice) is available through the
|
||||
Qwen3-TTS VoiceDesign model.
|
||||
- Language support is per-model (Kokoro ~8 languages, others vary). An
|
||||
unsupported language for Kokoro produces a clear error naming what it
|
||||
does support ([#977](https://github.com/debpalash/VoiceStudio/issues/977))
|
||||
— leave language on Auto or switch to a multilingual engine.
|
||||
|
||||
## Platform notes
|
||||
|
||||
This engine is exempt from cross-platform parity as a platform-only
|
||||
capability behind explicit opt-in: it exists only where Apple's MLX runtime
|
||||
exists. On any other platform the engine picker shows it unavailable with
|
||||
the reason.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Unavailable on an M-series Mac: `pip install mlx-audio` into
|
||||
VoiceStudio's Python environment; in a packaged app build, MLX's native
|
||||
libraries may fail to load — the engine reports unavailable rather than
|
||||
crashing.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[languages.md](../languages.md),
|
||||
[downloading-models.md](../downloading-models.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,57 @@
|
||||
# VoiceStudio — MLX Whisper Engine
|
||||
|
||||
MLX Whisper runs Whisper on the Apple Silicon GPU via MLX. It exists because
|
||||
CTranslate2 (whisperx / faster-whisper) has **no Metal build** — on a Mac
|
||||
those engines transcribe on the CPU no matter what GPU is present. Measured
|
||||
on an M2 with whisper-large-v3, one 30 s dub chunk: **90.4 s on WhisperX
|
||||
(CPU) vs 20.5 s on MLX (GPU)** — which is why auto-detect picks MLX Whisper
|
||||
on every Apple Silicon machine
|
||||
([#1127](https://github.com/debpalash/VoiceStudio/issues/1127)).
|
||||
|
||||
## Selecting it
|
||||
|
||||
- Nothing to do on Apple Silicon — auto-detect prefers it there.
|
||||
- Or explicitly: **Model Catalogue → Engines**, ASR tab → **Use**, or
|
||||
`OMNIVOICE_ASR_BACKEND=mlx-whisper`.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Dubbing on a Mac** — it layers the same wav2vec2 forced alignment
|
||||
WhisperX uses on top of the GPU transcription, so word timing (±10–30 ms)
|
||||
and therefore lip-sync accuracy are unchanged. Same model, same alignment,
|
||||
~4x the speed.
|
||||
- **Dictation/capture** — the capture path automatically swaps in
|
||||
`mlx-community/whisper-large-v3-turbo` (~5x faster than large-v3) unless a
|
||||
sherpa dictation model or [parakeet-mlx](parakeet-mlx.md) is preferred.
|
||||
|
||||
## Platform support
|
||||
|
||||
**Apple Silicon only.** A shared platform gate refuses Linux, Windows, and
|
||||
Intel Macs before any package import, so a stray `mlx-whisper` wheel on the
|
||||
wrong platform never reports itself available
|
||||
([#390](https://github.com/debpalash/VoiceStudio/issues/390)). All other
|
||||
platforms use the CUDA/CPU engines instead.
|
||||
|
||||
## Model selection
|
||||
|
||||
- `ASR_MODEL` — default `mlx-community/whisper-large-v3-mlx`. Any MLX-format
|
||||
Whisper repo works. Weights download on first load — see
|
||||
[downloading-models](../downloading-models.md).
|
||||
- `OMNIVOICE_ALIGN_DEVICE` — force the wav2vec2 aligner's device. The aligner
|
||||
runs on MPS when it can and falls back to CPU; languages without a bundled
|
||||
aligner (~20 major languages have one) keep Whisper's native word
|
||||
timestamps.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Audio is decoded through VoiceStudio's validated ffmpeg rather than the
|
||||
bare `ffmpeg` PATH lookup mlx-whisper would do on its own — a clean
|
||||
from-source install with no system ffmpeg works fine
|
||||
([#479](https://github.com/debpalash/VoiceStudio/issues/479)).
|
||||
- The model is warmed into unified memory in the background, so the first
|
||||
transcribe after startup doesn't pay the load cost.
|
||||
- In a packaged app, a native MLX library that fails to load is reported as
|
||||
"unavailable" (with fallback to another engine) rather than crashing the
|
||||
engine list.
|
||||
|
||||
Speed comparisons across engines live in [performance](../performance.md).
|
||||
@@ -0,0 +1,48 @@
|
||||
# VoiceStudio — Moonshine Engine
|
||||
|
||||
Moonshine is an edge-optimized ASR family built for CPU-only machines.
|
||||
Unlike Whisper it processes variable-length audio (no padding everything to
|
||||
30 s), which keeps latency low on short clips — sub-200 ms class on capture
|
||||
buffers. It's the lightest local option for quick transcription on hardware
|
||||
where even int8 whisper-large is too slow.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- Install one of the runtimes into the app venv:
|
||||
`uv pip install moonshine-onnx` (lighter, tried first) or
|
||||
`moonshine-voice`.
|
||||
- Then **Model Catalogue → Engines**, ASR tab → **Use** on the Moonshine row,
|
||||
or `OMNIVOICE_ASR_BACKEND=moonshine`.
|
||||
|
||||
Auto-detect never picks it; it's an explicit opt-in.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Quick notes and short-clip transcription on low-power CPU machines.**
|
||||
- Environments where a sub-1 GB footprint matters more than word timing or
|
||||
language coverage.
|
||||
|
||||
## Not suited for
|
||||
|
||||
- **Dubbing.** Output is plain text as a **single segment spanning the whole
|
||||
file — no word or segment timestamps** — so there's nothing for lip-sync
|
||||
or subtitle timing to work with. Use a Whisper-family engine or
|
||||
[sherpa-onnx-asr](sherpa-onnx-asr.md) for those jobs.
|
||||
- Multilingual work: results report English; for broad language coverage use
|
||||
[whisperx](whisperx.md) or [funasr](funasr.md).
|
||||
|
||||
## Platform support
|
||||
|
||||
CPU only, by design — macOS, Windows, and Linux. It claims no GPU.
|
||||
|
||||
## Model selection
|
||||
|
||||
`ASR_MODEL_MOONSHINE` — default `moonshine/base`. Weights download on first
|
||||
load — see [downloading-models](../downloading-models.md).
|
||||
|
||||
## Quirks
|
||||
|
||||
- The engine tries `moonshine_onnx` first and falls back to
|
||||
`moonshine_voice` — installing either one is enough.
|
||||
- Segment bounds are synthesized from the audio duration (start 0, end =
|
||||
file length), since the model reports none.
|
||||
@@ -0,0 +1,78 @@
|
||||
# VoiceStudio — MOSS-TTS-Nano Engine
|
||||
|
||||
MOSS-TTS-Nano (OpenMOSS) is the low-resource, broad-language pick: a
|
||||
100M-parameter autoregressive codec LM that runs realtime on a 4-core CPU —
|
||||
no GPU required — with native 48 kHz output and 20 languages under an
|
||||
Apache-2.0 license. It fills the "runs on a fanless laptop" tier while still
|
||||
covering languages like Arabic, Hebrew, Persian, Korean, and Turkish.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- CPU-only or low-power hardware, but you still need cloning and non-English
|
||||
coverage.
|
||||
- Your language is among: Chinese, English, German, Spanish, French,
|
||||
Japanese, Italian, Hebrew, Korean, Russian, Persian, Arabic, Polish,
|
||||
Portuguese, Czech, Danish, Swedish, Hungarian, Greek, Turkish.
|
||||
|
||||
## Setup
|
||||
|
||||
The package is **not on PyPI** — install it from the upstream repo into
|
||||
VoiceStudio's Python environment:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/OpenMOSS/MOSS-TTS-Nano.git
|
||||
cd MOSS-TTS-Nano
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
Then select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=moss-tts-nano`.
|
||||
|
||||
## Model selection
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_MOSS_TTS_MODEL` | `OpenMOSS-Team/MOSS-TTS-Nano` | HuggingFace checkpoint to load |
|
||||
|
||||
The first use downloads the weights (retried once on a truncated download).
|
||||
See [downloading-models.md](../downloading-models.md).
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- **Cloning is reference-only**: pass a reference clip. Style instructions,
|
||||
preset speakers, and speed control are not supported and are silently
|
||||
ignored, so mixed-engine call sites keep working.
|
||||
- The model emits 48 kHz stereo; VoiceStudio downmixes to mono, matching the
|
||||
rest of the pipeline (the dub mixer treats TTS output as mono per
|
||||
segment).
|
||||
- Runs on CPU or CUDA.
|
||||
|
||||
## Upstream is unpinned
|
||||
|
||||
The upstream repo is installed straight from git with no pinned release, and
|
||||
the model class it exports has changed before
|
||||
([#1287](https://github.com/debpalash/VoiceStudio/issues/1287)). VoiceStudio
|
||||
therefore verifies that a usable model class actually exists — not just that
|
||||
the package imports — before reporting the engine as ready. If the engine
|
||||
shows unavailable with a "does not expose a usable model class" message,
|
||||
pull the latest upstream and re-run `uv pip install -e .`, or open an issue
|
||||
with the version you have.
|
||||
|
||||
## Known limits
|
||||
|
||||
- No voice design, no instruct, no speed control — cloning from a reference
|
||||
clip only.
|
||||
- Quality sits below the large engines; see
|
||||
[benchmarks.md](../benchmarks.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "moss_tts_nano package not installed": run the clone + `uv pip install -e .`
|
||||
steps above.
|
||||
- Entry-point errors after an upstream update: see "Upstream is unpinned"
|
||||
above.
|
||||
- General issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [languages.md](../languages.md),
|
||||
[expressive-speech.md](../expressive-speech.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,58 @@
|
||||
# VoiceStudio — Parakeet TDT (NVIDIA NeMo) Engine
|
||||
|
||||
NVIDIA's Parakeet TDT via the NeMo toolkit: a FastConformer encoder with a
|
||||
Token-and-Duration Transducer decoder. It beats Whisper large-v3 on English
|
||||
benchmarks (~6% WER) and supports **25 (mostly European) languages** with
|
||||
automatic language detection. The 0.6B model is fast even on CPU — measured
|
||||
RTF 0.08–0.23 on an Apple Silicon M2 CPU (2026-07-02), ~20x faster than
|
||||
faster-whisper large-v3 int8 on the same host.
|
||||
|
||||
## Do not install NeMo into the app venv
|
||||
|
||||
`nemo_toolkit`'s ASR extras pin `transformers>=4.57,<4.58`, which conflicts
|
||||
with VoiceStudio's own `transformers>=5.3` requirement and **will break the
|
||||
backend** (ImportError on startup) if installed into the shared venv. There
|
||||
is currently no safe in-app install path for this engine; in-app isolation
|
||||
is tracked separately.
|
||||
|
||||
If you want the Parakeet models without a separate environment, use these
|
||||
instead — same model family, no NeMo dependency:
|
||||
|
||||
- **Apple Silicon:** [parakeet-mlx](parakeet-mlx.md) (installed by default on
|
||||
mac-ARM source installs).
|
||||
- **Any platform, CPU:** [sherpa-onnx-asr](sherpa-onnx-asr.md) — its default
|
||||
dictation model is an int8 ONNX export of Parakeet TDT v3.
|
||||
|
||||
## Selecting it
|
||||
|
||||
Only meaningful if you've set up `nemo_toolkit[asr]` in a **separate,
|
||||
dedicated Python environment** that runs the backend:
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the Parakeet TDT row, or
|
||||
- `OMNIVOICE_ASR_BACKEND=nemo-parakeet`.
|
||||
|
||||
Auto-detect never picks it; it's an explicit opt-in.
|
||||
|
||||
## Best at
|
||||
|
||||
- **English and European-language transcription** where WER matters more
|
||||
than word-level subtitle timing.
|
||||
- **CPU-only hosts** — faster than realtime without any GPU.
|
||||
|
||||
## Platform support
|
||||
|
||||
CUDA or CPU (the old hard CUDA gate was removed — see the RTF numbers
|
||||
above). Availability is a pure dependency check on `nemo.collections.asr`.
|
||||
|
||||
## Model selection
|
||||
|
||||
`ASR_MODEL_NEMO` — default `nvidia/parakeet-tdt-0.6b-v3`. Weights download
|
||||
on first load — see [downloading-models](../downloading-models.md).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Output is a **single segment** for the whole file (NeMo doesn't VAD-split
|
||||
like Whisper), with word timestamps when the model exposes them — fine for
|
||||
dictation and plain transcripts, not ideal for long-form subtitles.
|
||||
- The detected language isn't exposed cleanly by NeMo, so results report
|
||||
`en` regardless of the actual (auto-detected) language.
|
||||
@@ -0,0 +1,83 @@
|
||||
# VoiceStudio — OmniVoice GGUF Engine
|
||||
|
||||
OmniVoice GGUF runs the same OmniVoice model as the [default
|
||||
engine](omnivoice.md), but through a bundled native binary
|
||||
(`bin/omnivoice-tts-<platform>`) loading quantized GGUF weights. It is
|
||||
hardware-adaptive: a probe picks the quantization that fits your machine, so
|
||||
small GPUs and CPU-only hosts get a working OmniVoice instead of a paging,
|
||||
timing-out one.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- Your GPU is below the default engine's 6 GB VRAM floor.
|
||||
- CPU-only machines that still want OmniVoice's voice and language coverage.
|
||||
- You want generation isolated in a separate process (a crash or leak never
|
||||
takes the app down — each generation spawns the binary fresh).
|
||||
|
||||
## Quantization selection
|
||||
|
||||
Weights come from the `Serveurperso/OmniVoice-GGUF` HuggingFace repo, pinned
|
||||
to an exact revision. The hardware probe selects:
|
||||
|
||||
| Hardware | Quant | Approx. VRAM use |
|
||||
| --- | --- | --- |
|
||||
| 12 GB+ VRAM | BF16 | ~1.6 GB (quality-first) |
|
||||
| 4–12 GB VRAM | Q8_0 | ~945 MB (recommended balance) |
|
||||
| 1–4 GB VRAM | Q4_K_M | ~659 MB (minimal footprint) |
|
||||
| CPU-only | Q4_K_M | RAM-bound, latency-tolerable |
|
||||
|
||||
You can override the selection from Settings; overrides are allow-listed
|
||||
against the same table (an F32 reference quant, ~3.2 GB, is override-only).
|
||||
|
||||
## Setup
|
||||
|
||||
Nothing to install: installer and CI builds bundle the binary for your
|
||||
platform. Select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=omnivoice-gguf`. The quant weights download on first
|
||||
use (see [downloading-models.md](../downloading-models.md)) — install them
|
||||
ahead of time from **Model Catalogue → Models** if you want the first
|
||||
generation to be quick; a long first render is the download, not a hang.
|
||||
|
||||
**Source checkouts:** the repo ships zero-byte placeholders in `bin/` — real
|
||||
binaries come from CI or the installer. The engine detects a placeholder and
|
||||
reports unavailable with instructions
|
||||
([#1172](https://github.com/debpalash/VoiceStudio/issues/1172)) instead of
|
||||
failing at spawn time; build one with
|
||||
`scripts/build-omnivoice-tts.sh --platform <slug>` or use the default
|
||||
in-process engine.
|
||||
|
||||
## Integrity and self-healing
|
||||
|
||||
Before reporting ready, the engine:
|
||||
|
||||
- verifies the binary against the SHA-256 manifest (`bin/checksums.sha256`);
|
||||
- detects macOS Gatekeeper quarantine and prints the exact
|
||||
`xattr -cr '/Applications/VoiceStudio.app'` fix;
|
||||
- restores a missing execute bit (a git clone or zip extract on POSIX can
|
||||
drop `+x`, which used to surface as a permission error mislabeled as
|
||||
out-of-memory — [#437](https://github.com/debpalash/VoiceStudio/issues/437)).
|
||||
The chmod runs only after the SHA check confirms it's the right file.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 24 kHz mono — same model, same rate as in-process OmniVoice.
|
||||
- Cloning from a reference clip (with optional transcript) and style
|
||||
instructions are supported; no voice design.
|
||||
- Same multilingual surface as OmniVoice ([languages.md](../languages.md)).
|
||||
- Because generation runs in another process, the app's own GPU counters
|
||||
don't see its allocations — diagnostics label it accordingly.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_GGUF_GENERATE_TIMEOUT_S` | (generous built-in) | Per-generation timeout for the spawned binary |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "GGUF binary missing": this build doesn't bundle the runtime for your
|
||||
platform — use the default engine.
|
||||
- Checksum mismatch or quarantine messages: follow the printed fix, or
|
||||
reinstall.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[performance.md](../performance.md), [disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,100 @@
|
||||
# VoiceStudio — OmniVoice Engine (default)
|
||||
|
||||
OmniVoice (k2-fsa/OmniVoice) is VoiceStudio's default TTS engine — the one a
|
||||
fresh install uses without any configuration. It does zero-shot voice cloning
|
||||
across 600+ languages and outputs 24 kHz mono audio. Voice cloning, dubbing,
|
||||
and dictation all run on it out of the box.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- You want cloning plus the broadest language coverage (see
|
||||
[languages.md](../languages.md)).
|
||||
- You have a GPU (CUDA or Apple Silicon MPS) with ~6 GB VRAM or more.
|
||||
- You just installed VoiceStudio — it's already selected.
|
||||
|
||||
For low-VRAM or CPU-only machines, the
|
||||
[OmniVoice GGUF](omnivoice-gguf.md) variant runs the same model through a
|
||||
quantized native binary with a much smaller memory footprint.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Runs on CUDA, MPS (Apple Silicon), or CPU — auto-detected.
|
||||
- Recommended VRAM floor: **6 GB** on a dedicated GPU. This is the only
|
||||
engine with a measured floor: on 4 GB cards (GTX 1650 Ti, Quadro P2000 —
|
||||
issues [#1226](https://github.com/debpalash/VoiceStudio/issues/1226) /
|
||||
[#1222](https://github.com/debpalash/VoiceStudio/issues/1222)) the driver
|
||||
pages to system RAM and a render that should take seconds runs for minutes
|
||||
until the compute budget kills it. The UI warns before you wait; nothing
|
||||
hard-blocks, since short inputs can still fit.
|
||||
- No extra install — the model ships with the app and downloads its weights
|
||||
on first use (see [downloading-models.md](../downloading-models.md)).
|
||||
|
||||
## Selecting the engine
|
||||
|
||||
OmniVoice is the default, so normally there is nothing to do. If you switched
|
||||
away and want it back:
|
||||
|
||||
- **Model Catalogue → Engines**, or
|
||||
- set `OMNIVOICE_TTS_BACKEND=omnivoice`.
|
||||
|
||||
The env var overrides the persisted UI choice.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Weights load lazily on first use and are shared with the rest of the app
|
||||
(dubbing, dictation) — the model is never double-loaded.
|
||||
- On CUDA the model runs fp16 with `torch.compile`; a speech recognizer is
|
||||
co-loaded for the cloning path.
|
||||
- Output is 24 kHz mono; the shared mastering chain (highpass + compressor)
|
||||
is tuned for this rate and applied automatically.
|
||||
- Cloning takes a short reference clip (`ref_audio`); 3–10 seconds is the
|
||||
sweet spot. A transcript of the clip improves conditioning — if the profile
|
||||
has none, VoiceStudio transcribes the clip automatically on first use and
|
||||
saves the result to the profile.
|
||||
- Encoded voice references persist on disk (`prompt_cache/` in the app data
|
||||
dir), so the first generation with a known voice after a restart skips the
|
||||
re-encode and any transcription pass. Set `OMNIVOICE_PROMPT_DISK_CACHE=0`
|
||||
to keep the cache in memory only.
|
||||
- Style attributes (`instruct`) and a reference clip can be **combined**:
|
||||
when they agree, the instruct stabilizes cloning for the attributes it
|
||||
names (upstream documents dialect cloning as the canonical case — dialect
|
||||
reference + matching dialect instruct). When they conflict, the reference
|
||||
audio wins.
|
||||
- Inline pronunciation control: Chinese via pinyin with tone numbers
|
||||
(`打ZHE2出售`), English via bracketed CMU phonemes (`[B EY1 S]`). Non-verbal
|
||||
tags like `[laughter]` are covered in
|
||||
[expressive-speech.md](../expressive-speech.md).
|
||||
- Voice design works from attributes (gender, age, pitch, whisper, English
|
||||
accents, Chinese dialects) via the Design tab — no reference audio needed.
|
||||
- Optional FlashInfer acceleration on CUDA: set `OMNIVOICE_FLASHINFER=1`
|
||||
(or `=graph` for CUDA-graph capture, best for one render at a time) after
|
||||
installing the `flashinfer-python` package — see
|
||||
[performance.md](../performance.md). Off by default; if the package is
|
||||
missing or a kernel fails, the app logs why and continues on the standard
|
||||
path.
|
||||
|
||||
## Known limits
|
||||
|
||||
- Voice design understands only the fixed attribute vocabulary — free-form
|
||||
design *prose* is mapped onto those attributes, and wording outside them
|
||||
is ignored. Design is trained on English and Chinese and can be unstable
|
||||
in low-resource languages; for description-driven design in other cases
|
||||
try [VoxCPM2](voxcpm2.md).
|
||||
- Below the 6 GB VRAM floor, expect very slow renders or budget timeouts;
|
||||
prefer [OmniVoice GGUF](omnivoice-gguf.md) or a CPU engine such as
|
||||
[PocketTTS](pockettts.md).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "Too heavy for the available compute" on a small GPU: see the VRAM floor
|
||||
above — switch to OmniVoice GGUF or close other GPU apps.
|
||||
- First generation is slow: the first call downloads multi-GB weights. To
|
||||
keep the first render quick, install the model ahead of time from
|
||||
**Model Catalogue → Models** — a long first generate is almost always the
|
||||
download, not a hang.
|
||||
- General install issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[performance.md](../performance.md),
|
||||
[expressive-speech.md](../expressive-speech.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,58 @@
|
||||
# VoiceStudio — Parakeet TDT v3 (MLX) Engine
|
||||
|
||||
NVIDIA's Parakeet TDT v3 on the Apple Silicon GPU, via the small pure-Python
|
||||
`parakeet-mlx` package. It gives Macs the Parakeet tier CUDA/CPU users get
|
||||
through NeMo or sherpa-onnx: **25 European languages**, word timestamps from
|
||||
the TDT decoder itself (no wav2vec2 alignment pass needed), ~1.2 GB download,
|
||||
~2 GB unified memory, dictation-grade speed on the GPU.
|
||||
|
||||
Unlike [nemo-parakeet](nemo-parakeet.md) it needs no `nemo_toolkit` (whose
|
||||
transformers pin conflicts with the app's) — it is **installed by default on
|
||||
Apple Silicon source installs since 0.3.22**.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the Parakeet TDT v3
|
||||
(MLX) row, or `OMNIVOICE_ASR_BACKEND=parakeet-mlx`.
|
||||
- **Dictation prefers it automatically**: once the model weights are
|
||||
installed (Model Catalogue → Models — the auto-pick never triggers a
|
||||
download), live dictation/capture uses it whenever your system language is
|
||||
one of the 25 covered European languages. Other languages keep the
|
||||
multilingual Whisper engine, so dictation coverage never regresses.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Live dictation on a Mac** — TDT decoding is fast enough for the capture
|
||||
path, at Parakeet's better-than-Whisper English WER.
|
||||
- **European-language transcription** with word timestamps at a fraction of
|
||||
whisper-large-v3's memory and compute.
|
||||
|
||||
For languages outside the 25 (CJK, Arabic, ...), use
|
||||
[mlx-whisper](mlx-whisper.md) instead.
|
||||
|
||||
## Platform support
|
||||
|
||||
**Apple Silicon only** — the same shared MLX platform gate as mlx-whisper
|
||||
refuses Linux, Windows, and Intel Macs before any import
|
||||
([#390](https://github.com/debpalash/VoiceStudio/issues/390)). It runs on the
|
||||
unified-memory GPU; there is no CPU tier.
|
||||
|
||||
## Model selection
|
||||
|
||||
`ASR_MODEL_PARAKEET_MLX` — default `mlx-community/parakeet-tdt-0.6b-v3`.
|
||||
Weights download on first load — see
|
||||
[downloading-models](../downloading-models.md).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Long files are processed in 120 s chunks internally to bound unified-memory
|
||||
use; short dictation buffers and dub chunks are unaffected.
|
||||
- Parakeet v3 auto-detects among its 25 languages but doesn't expose the
|
||||
pick, so the reported language is the one you requested (or none) — it is
|
||||
never hardcoded to English.
|
||||
- Word timestamps are merged from the decoder's subword tokens — good for
|
||||
subtitles and dictation; for lip-sync-critical dubbing the wav2vec2-aligned
|
||||
engines ([mlx-whisper](mlx-whisper.md), [whisperx](whisperx.md)) remain the
|
||||
accuracy tier.
|
||||
|
||||
Speed comparisons across engines live in [performance](../performance.md).
|
||||
@@ -0,0 +1,82 @@
|
||||
# VoiceStudio — PocketTTS Engine
|
||||
|
||||
PocketTTS (kyutai-labs/pocket-tts, 100M parameters) is the fastest-CPU-render
|
||||
pick: small, low-latency, CPU-only, with zero-shot voice cloning from a
|
||||
reference clip. It covers six languages — English, French, German,
|
||||
Portuguese, Italian, Spanish — with one model per language, and measures
|
||||
roughly 8–9x real-time on an Apple M3 Pro.
|
||||
|
||||
It complements the quality engines: where they fall back to CPU, PocketTTS
|
||||
is built for it. CPU-only is deliberate — upstream observes no GPU speedup
|
||||
for this model.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- CPU-only machines that need fast rendering *and* voice cloning.
|
||||
- Latency-sensitive use (dictation-style, short utterances) in one of the
|
||||
six languages.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the optional dependency:
|
||||
|
||||
```bash
|
||||
uv sync --extra pockettts
|
||||
```
|
||||
|
||||
(Or enable it from **Model Catalogue → Engines**.)
|
||||
|
||||
2. **Accept the license in-app**
|
||||
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
|
||||
is MIT and the weights are CC-BY-4.0, but the weights are **gated on
|
||||
HuggingFace** behind an access agreement with an acceptable-use clause.
|
||||
VoiceStudio surfaces this before first use: the engine stays unavailable
|
||||
until you review and accept in **Model Catalogue → Engines → PocketTTS**.
|
||||
You also need HuggingFace access to the gated repo (see
|
||||
[downloading-models.md](../downloading-models.md) for token setup).
|
||||
|
||||
3. Select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=pockettts`.
|
||||
|
||||
## Platform notes
|
||||
|
||||
- Works on Linux, Windows, macOS Apple Silicon — CPU only everywhere.
|
||||
- **Not available on Intel Macs**: the required PyTorch version has no
|
||||
macOS x86_64 wheel. The engine reports this plainly instead of failing
|
||||
mid-install.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 24 kHz mono.
|
||||
- Six languages, one model per language, chosen by the `language` you
|
||||
request; cloning takes a short reference clip.
|
||||
- Runs in a crash-isolated sidecar process (parent Python environment): a
|
||||
wedged generation is hard-killed by a watchdog and its memory reclaimed —
|
||||
something an in-process engine cannot do.
|
||||
- The first use downloads the gated weights; the sidecar heartbeats
|
||||
progress during the download so the watchdog doesn't fire.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S` | `600` | Sidecar response deadline in seconds (min 30; cold loads download weights) |
|
||||
|
||||
## Known limits
|
||||
|
||||
- No voice design, no emotion controls
|
||||
(see [expressive-speech.md](../expressive-speech.md)).
|
||||
- Six languages only — for broader coverage use
|
||||
[OmniVoice](omnivoice.md) ([languages.md](../languages.md)).
|
||||
- Revoking the license acceptance takes effect immediately, without a
|
||||
restart — subsequent generations refuse.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "pocket_tts package not installed": run the `uv sync` above.
|
||||
- "license not accepted": open **Model Catalogue → Engines → PocketTTS**
|
||||
and review/accept.
|
||||
- Timeouts on a slow connection: raise
|
||||
`OMNIVOICE_POCKETTTS_RECV_TIMEOUT_S` for the first (download-heavy) run.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[performance.md](../performance.md), [disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,61 @@
|
||||
# VoiceStudio — PyTorch Whisper Engine
|
||||
|
||||
Whisper through the plain `transformers` pipeline, riding torch itself. No
|
||||
extra install — transformers ships with the app — and because it runs on
|
||||
torch's own stack (including torch's bundled cuDNN 9), it works on machines
|
||||
where the CTranslate2 engines can't load. It is also the engine that
|
||||
genuinely uses **AMD ROCm** GPUs, so auto-detect picks it on ROCm hosts
|
||||
([#1529](https://github.com/debpalash/VoiceStudio/issues/1529)).
|
||||
|
||||
## Selecting it
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the PyTorch Whisper
|
||||
row, or `OMNIVOICE_ASR_BACKEND=pytorch-whisper`.
|
||||
- Auto-detect picks it on ROCm, and as the last resort everywhere else.
|
||||
|
||||
## Best at
|
||||
|
||||
- **ROCm dubbing/transcription** — the only Whisper engine that uses the HIP
|
||||
GPU (CTranslate2 has no HIP build, MLX is Apple-only).
|
||||
- **Rescue engine** when whisperx/faster-whisper can't load — e.g. the
|
||||
missing-cuDNN-8 case
|
||||
([#255](https://github.com/debpalash/VoiceStudio/issues/255)) — since it
|
||||
needs neither CTranslate2 nor cuDNN 8.
|
||||
|
||||
For lip-sync-grade word timing prefer [whisperx](whisperx.md) or
|
||||
[mlx-whisper](mlx-whisper.md); this engine returns the pipeline's own word
|
||||
timestamps.
|
||||
|
||||
## Platform support
|
||||
|
||||
CUDA, Apple Silicon (MPS), ROCm (HIP), and CPU — wherever torch runs, on
|
||||
macOS, Windows, and Linux.
|
||||
|
||||
## Model selection
|
||||
|
||||
`OMNIVOICE_PYTORCH_ASR_MODEL` — default `openai/whisper-large-v3-turbo`. Any
|
||||
transformers-format Whisper repo works. Weights download on first load — see
|
||||
[downloading-models](../downloading-models.md).
|
||||
|
||||
## VRAM preflight
|
||||
|
||||
whisper-large-v3-turbo needs roughly 3.2 GiB before generation adds its
|
||||
workspace; loading it onto a nearly-full card "succeeds" and then the first
|
||||
transcribe OOMs with zero segments. So on CUDA the engine checks free VRAM
|
||||
against a 5 GB budget before loading and uses the CPU instead when the card
|
||||
is too full (flush the TTS model to restore GPU-speed ASR). Disable with
|
||||
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- If the pipeline fails to import (`AutoFeatureExtractor` errors), the cause
|
||||
is either an incomplete transformers install or a torch/torchvision
|
||||
version mismatch — the error message names the exact reinstall command;
|
||||
the trio has to move together at the pinned versions
|
||||
([#549](https://github.com/debpalash/VoiceStudio/issues/549),
|
||||
[#1376](https://github.com/debpalash/VoiceStudio/issues/1376)).
|
||||
- Transcribes are time-bounded like every local engine:
|
||||
`OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S` (default 120 s per dub chunk),
|
||||
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (default 300 s whole-file).
|
||||
|
||||
Speed comparisons across engines live in [performance](../performance.md).
|
||||
@@ -0,0 +1,71 @@
|
||||
# VoiceStudio — Sherpa-ONNX Dictation Engine
|
||||
|
||||
The k2-fsa/sherpa-onnx ONNX runtime as a **live dictation** engine: small
|
||||
int8 models that transcribe faster than realtime on CPU, with identical
|
||||
behavior on macOS (arm64 + x86_64), Windows, and Linux — no CUDA dependency.
|
||||
Streaming models emit partial text frame-by-frame as you speak; offline
|
||||
models re-transcribe a growing buffer on a short cadence, so you see live
|
||||
partials either way.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- Ensure `sherpa-onnx` is installed (`uv add sherpa-onnx` on source installs).
|
||||
- Pick a dictation model in the app (Model Catalogue → Models lists the
|
||||
curated set below), or **Model Catalogue → Engines**, ASR tab → **Use**, or
|
||||
pin `OMNIVOICE_ASR_BACKEND=sherpa-onnx-asr`.
|
||||
- `OMNIVOICE_SHERPA_ASR_MODEL` selects the model — default
|
||||
`sherpa-parakeet-tdt-v3`.
|
||||
|
||||
## Best at
|
||||
|
||||
- **Live dictation on CPU** — the whole point of this engine. Fast partials,
|
||||
automatic endpointing on silence, no GPU required.
|
||||
- It also honors the regular offline `transcribe` contract, so any of its
|
||||
models can transcribe a file — plain text, single segment, no word
|
||||
timestamps, which makes it a dictation/notes tool rather than a dubbing
|
||||
engine.
|
||||
|
||||
## The 7 curated models
|
||||
|
||||
| Id | Type | Languages | Download |
|
||||
| --- | --- | --- | --- |
|
||||
| `sherpa-parakeet-tdt-v3` (default) | offline | 25 European languages | 0.67 GB |
|
||||
| `sherpa-parakeet-tdt-v2` | offline | English | 0.66 GB |
|
||||
| `sherpa-zipformer-bilingual-zh-en` | streaming | Chinese + English | 0.20 GB |
|
||||
| `sherpa-paraformer-bilingual-zh-en` | streaming | Chinese + English | 0.24 GB |
|
||||
| `sherpa-zipformer-en-20m` | streaming | English | 0.044 GB |
|
||||
| `sherpa-zipformer-zh-14m` | streaming | Chinese | 0.025 GB |
|
||||
| `sherpa-whisper-tiny` | offline | 90+ languages (auto-detect) | 0.104 GB |
|
||||
|
||||
Sizes are measured on-disk download sizes. Weights are int8 ONNX checkpoints
|
||||
that download on first use through the same HF cache as everything else —
|
||||
see [downloading-models](../downloading-models.md). Peak RAM for the 0.6B
|
||||
Parakeets is noticeably higher than their download size (onnxruntime's arena
|
||||
allocator holds onto freed blocks).
|
||||
|
||||
## Platform support
|
||||
|
||||
CPU on every platform, by the strict cross-platform default-parity rule.
|
||||
`OMNIVOICE_SHERPA_ASR_PROVIDER` can override the ONNX provider on a verified
|
||||
GPU build, but the default never diverges.
|
||||
|
||||
## Tuning
|
||||
|
||||
- `OMNIVOICE_SHERPA_ASR_THREADS` — decode threads (default 2; the 0.6B
|
||||
Parakeets automatically use up to 4 when the host has the cores, so decode
|
||||
keeps ahead of the speaker).
|
||||
- `OMNIVOICE_DICTATION_ENDPOINT_R1` / `OMNIVOICE_DICTATION_ENDPOINT_R2` —
|
||||
streaming endpoint rules in seconds (defaults 1.0 / 0.6: text commits
|
||||
~0.6 s after you stop speaking). Applied without a restart.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The recognizer is **pre-warmed in the background** so the first dictation
|
||||
session doesn't pay the 1.3–2.5 s ONNX session load
|
||||
([#888](https://github.com/debpalash/VoiceStudio/issues/888)); it's then
|
||||
shared warm across sessions.
|
||||
- On Apple Silicon, installing the [parakeet-mlx](parakeet-mlx.md) model
|
||||
makes dictation prefer the GPU Parakeet automatically for the 25 covered
|
||||
languages; an explicitly selected sherpa model still wins.
|
||||
- The offline `transcribe` path reports `language: auto` — per-file language
|
||||
detection is only meaningful for the Whisper Tiny model.
|
||||
@@ -0,0 +1,75 @@
|
||||
# VoiceStudio — Sherpa-ONNX Engine
|
||||
|
||||
Sherpa-ONNX (k2-fsa/sherpa-onnx) is a unified C++ ONNX runtime that wraps
|
||||
20+ TTS model families (VITS, MeloTTS, Piper, Kokoro, Matcha, and more)
|
||||
behind one API, with pre-built wheels for Linux, Windows, and macOS (x86 and
|
||||
ARM). You bring the model: point VoiceStudio at any downloaded sherpa-onnx
|
||||
TTS model directory.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- You want a specific community model (e.g. a Piper or VITS voice for your
|
||||
language) that no other engine hosts.
|
||||
- You need a dependable CPU engine with optional CUDA acceleration.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the runtime:
|
||||
|
||||
```bash
|
||||
pip install sherpa-onnx
|
||||
```
|
||||
|
||||
2. Download a TTS model from the
|
||||
[sherpa-onnx releases](https://github.com/k2-fsa/sherpa-onnx/releases)
|
||||
and unpack it somewhere permanent.
|
||||
|
||||
3. Point VoiceStudio at the model directory and restart:
|
||||
|
||||
```bash
|
||||
export OMNIVOICE_SHERPA_MODEL=/path/to/model-dir
|
||||
```
|
||||
|
||||
4. Select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=sherpa-onnx`.
|
||||
|
||||
The directory must contain `model.onnx` and `tokens.txt`. Sherpa-ONNX ships
|
||||
no bundled default model, so the engine reports unavailable — with the
|
||||
reason — until `OMNIVOICE_SHERPA_MODEL` points at a valid directory. (Before
|
||||
this gate, selecting the engine unconfigured produced a failure mislabeled
|
||||
as out-of-memory —
|
||||
[#919](https://github.com/debpalash/VoiceStudio/issues/919).)
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_SHERPA_MODEL` | (unset) | Directory containing `model.onnx` + `tokens.txt` |
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output defaults to 22.05 kHz (the VITS default); once a model is loaded,
|
||||
its own sample rate is used.
|
||||
- CPU is the universal baseline; the CUDA onnxruntime provider is available
|
||||
on Linux/Windows installs.
|
||||
- **No cloning**: voices come from the model itself. Multi-speaker VITS
|
||||
models select a voice by numeric speaker id; speed is supported.
|
||||
- Languages depend entirely on the model you download.
|
||||
|
||||
## Known limits
|
||||
|
||||
- One model at a time — switching models means changing
|
||||
`OMNIVOICE_SHERPA_MODEL` and restarting.
|
||||
- No voice design, no reference-audio cloning, no emotion controls
|
||||
(see [expressive-speech.md](../expressive-speech.md)).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "OMNIVOICE_SHERPA_MODEL not set" / "No model.onnx in …": follow Setup
|
||||
above — the variable must point at the *unpacked* model directory, not
|
||||
the archive.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[languages.md](../languages.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceStudio — Supertonic-3 Engine
|
||||
|
||||
Supertonic-3 (Supertone Inc.) is a ~99M-parameter ONNX TTS engine covering
|
||||
31 languages with 7 preset voices at native 44.1 kHz. It is CPU-only by
|
||||
design — pure ONNX Runtime on the CPU execution provider, with no CUDA or
|
||||
MPS path in the upstream SDK — and runs in its own sidecar process so
|
||||
crashes and cold init never block the rest of VoiceStudio.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- Broad language coverage on machines with no usable GPU.
|
||||
- Preset-voice narration at a higher sample rate than the default engine.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the optional dependency into VoiceStudio's environment:
|
||||
|
||||
```bash
|
||||
uv sync --extra supertonic
|
||||
```
|
||||
|
||||
(Or enable it from **Model Catalogue → Engines**, which installs the
|
||||
pinned `supertonic` wheel for you.)
|
||||
|
||||
2. **Accept the license in-app.** First use is gated behind an explicit
|
||||
acceptance dialog: the inference SDK is MIT, but the model weights are
|
||||
**OpenRAIL-M**, which carries use restrictions. The engine stays
|
||||
unavailable until you review and accept in **Model Catalogue → Engines →
|
||||
Supertonic-3**.
|
||||
|
||||
3. Select the engine via **Model Catalogue → Engines** or
|
||||
`OMNIVOICE_TTS_BACKEND=supertonic3`.
|
||||
|
||||
The first synthesis cold-downloads ~400 MB of model weights, pinned to an
|
||||
exact HuggingFace revision SHA so the bytes match what the SDK was validated
|
||||
against. See [downloading-models.md](../downloading-models.md).
|
||||
|
||||
## Voices
|
||||
|
||||
Seven preset voices are surfaced: `M1` (default), `M3`, `M4`, `M5`, `F3`,
|
||||
`F4`, `F5`. The SDK itself accepts the full `M1`–`M5` / `F1`–`F5` set if a
|
||||
caller passes one explicitly; unknown ids fall back to the default with a
|
||||
log line.
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 44.1 kHz mono.
|
||||
- Runs as a long-lived sidecar in the parent Python environment (its
|
||||
dependencies — onnxruntime, numpy, soundfile — already match
|
||||
VoiceStudio's pins); subsequent calls reuse the warm ONNX session.
|
||||
- `speed` is clamped to 0.7–2.0; quality steps clamp to 5–12.
|
||||
- Language is an ISO 639-1 code; Auto engages the SDK's multilingual
|
||||
fallback.
|
||||
|
||||
## Known limits
|
||||
|
||||
- **No cloning and no voice design** — preset voices only. Dub/batch jobs
|
||||
that need cloning won't select it.
|
||||
- CPU-only: hardware acceleration is a property of the upstream SDK, not a
|
||||
VoiceStudio limitation.
|
||||
- OpenRAIL-M weights are not covered by VoiceStudio's blanket
|
||||
commercial-use statement — review the model license terms in the
|
||||
acceptance dialog.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- "supertonic package not installed": run the `uv sync` above or enable
|
||||
from the Model Catalogue.
|
||||
- "license not accepted": open **Model Catalogue → Engines → Supertonic-3**
|
||||
and accept.
|
||||
- Other issues: [install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [benchmarks.md](../benchmarks.md),
|
||||
[languages.md](../languages.md),
|
||||
[expressive-speech.md](../expressive-speech.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceStudio — VoxCPM2 Engine
|
||||
|
||||
VoxCPM2 (OpenBMB) is the studio-quality option: native 48 kHz output,
|
||||
zero-shot voice cloning, and — uniquely among VoiceStudio's engines —
|
||||
**voice design**: creating a synthetic voice from a text description
|
||||
("young female, warm tone, British accent") with no reference audio at all.
|
||||
|
||||
## When to pick it
|
||||
|
||||
- You want voice design without a reference clip.
|
||||
- You want the highest output sample rate (48 kHz vs OmniVoice's 24 kHz).
|
||||
- Your language is among its 30 supported languages: Arabic, Burmese,
|
||||
Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew,
|
||||
Hindi, Indonesian, Italian, Japanese, Khmer, Korean, Lao, Malay,
|
||||
Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish,
|
||||
Tagalog, Thai, Turkish, Vietnamese.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python ≥ 3.10, PyTorch ≥ 2.5.
|
||||
- CUDA ≥ 12 recommended for full speed; MPS (Apple Silicon) and CPU also
|
||||
work.
|
||||
|
||||
## Setup
|
||||
|
||||
Install the package into VoiceStudio's Python environment:
|
||||
|
||||
```bash
|
||||
pip install "voxcpm>=2.0.3"
|
||||
```
|
||||
|
||||
That is a version **floor**, not a pin — an older install still works, but
|
||||
the engine logs an upgrade hint at load time. Then select the engine via
|
||||
**Model Catalogue → Engines** or `OMNIVOICE_TTS_BACKEND=voxcpm2`.
|
||||
|
||||
## Model selection
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `OMNIVOICE_VOXCPM_MODEL` | `openbmb/VoxCPM2` | HuggingFace checkpoint to load |
|
||||
|
||||
The first use downloads a multi-GB checkpoint from HuggingFace. A download
|
||||
interrupted near the end used to abort the load outright
|
||||
([#1224](https://github.com/debpalash/VoiceStudio/issues/1224)); the load is
|
||||
now retried once with a fresh client. See
|
||||
[downloading-models.md](../downloading-models.md).
|
||||
|
||||
## Behaviour notes
|
||||
|
||||
- **Voice design:** provide a description and no reference audio.
|
||||
- **Cloning:** the reference clip is prepared before use (edge-silence trim
|
||||
and length cap) so dead air in a raw clip doesn't condition the output; on
|
||||
any prep problem the raw clip is used as-is.
|
||||
- **Style instructions** are passed as an inline prefix to the text.
|
||||
- VoxCPM2 emits mastered, studio-grade audio, so VoiceStudio **skips its
|
||||
shared mastering chain** (which is tuned for 24 kHz engines) — only benign
|
||||
loudness normalization applies.
|
||||
- A trailing-silence guard trims long near-silent tails from generations,
|
||||
keeping a short natural tail.
|
||||
|
||||
## Known limits
|
||||
|
||||
- Slower than the lightweight CPU engines — see
|
||||
[benchmarks.md](../benchmarks.md) and [performance.md](../performance.md).
|
||||
- Language coverage is 30 languages; for anything else use the default
|
||||
[OmniVoice](omnivoice.md) engine ([languages.md](../languages.md)).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Engine shows unavailable: the `voxcpm` package isn't installed — run the
|
||||
`pip install` above and restart VoiceStudio.
|
||||
- Repeated first-download failures: check connectivity/HF access, then see
|
||||
[install/troubleshooting.md](../install/troubleshooting.md).
|
||||
|
||||
See also: [expressive-speech.md](../expressive-speech.md),
|
||||
[disk usage](disk-usage.md).
|
||||
@@ -0,0 +1,80 @@
|
||||
# VoiceStudio — WhisperX Engine
|
||||
|
||||
WhisperX is the default ASR engine on CUDA and plain-CPU hosts: faster-whisper
|
||||
(CTranslate2) transcription plus a **wav2vec2 forced-alignment** pass that
|
||||
snaps word boundaries to ±10–30 ms (Whisper's own timestamps are ±100–300 ms).
|
||||
That word timing is what dubbing lip-sync depends on, which is why auto-detect
|
||||
prefers it wherever CTranslate2 can use the GPU.
|
||||
|
||||
## Selecting it
|
||||
|
||||
- **Model Catalogue → Engines**, ASR tab → **Use** on the WhisperX row, or
|
||||
- pin it with `OMNIVOICE_ASR_BACKEND=whisperx` (the env var always wins over
|
||||
the Settings pick; with neither set, auto-detect chooses per-hardware).
|
||||
|
||||
## Best at
|
||||
|
||||
- **Dubbing** — the forced alignment is the accuracy tier lip-sync needs.
|
||||
- **Batch transcription** with word-level subtitles.
|
||||
- Multi-speaker work: it pairs with pyannote speaker diarization — see
|
||||
[diarization](../features/diarization.md).
|
||||
|
||||
## Platform support
|
||||
|
||||
| Host | What happens |
|
||||
| --- | --- |
|
||||
| NVIDIA CUDA | GPU, float16 (degrades automatically, see below) |
|
||||
| CPU (any OS) | int8 — works, but slow for large-v3 |
|
||||
| Apple Silicon | CPU only — CTranslate2 has no Metal build, so auto-detect prefers [mlx-whisper](mlx-whisper.md) there ([#1127](https://github.com/debpalash/VoiceStudio/issues/1127)) |
|
||||
| AMD ROCm | CPU only — CTranslate2 has no HIP build, so auto-detect prefers [pytorch-whisper](pytorch-whisper.md) there ([#1529](https://github.com/debpalash/VoiceStudio/issues/1529)) |
|
||||
|
||||
## Model selection
|
||||
|
||||
- `ASR_MODEL_WHISPERX` — default `large-v3`. Accepts the usual size aliases
|
||||
(`tiny` … `large-v3`, `distil-large-v3`) or a full HF repo id. Weights
|
||||
download on first load — see [downloading-models](../downloading-models.md).
|
||||
- `OMNIVOICE_ALIGN_DEVICE` — force the wav2vec2 aligner's device. Aligners
|
||||
exist for ~20 major languages; other languages keep Whisper's native word
|
||||
timestamps instead of failing.
|
||||
|
||||
## VRAM preflight and degradation
|
||||
|
||||
Loading fp16 large-v3 onto a nearly-full 8 GB card dies as a *native* CUDA
|
||||
abort — no Python exception, the whole backend goes down
|
||||
([#723](https://github.com/debpalash/VoiceStudio/issues/723)). So before every
|
||||
load the engine checks free VRAM against per-compute-type budgets
|
||||
(float16 5.0 GB, int8_float16 3.5 GB, int8 3.0 GB, scaled down for smaller
|
||||
models) and degrades the compute type — or falls to CPU int8 — instead of
|
||||
starting a load that would kill the process. Disable with
|
||||
`OMNIVOICE_ASR_VRAM_PREFLIGHT=0`.
|
||||
|
||||
Two more fallback chains run at load time:
|
||||
|
||||
- GPUs without efficient fp16 (older Maxwell/Pascal, GTX 16xx) raise a
|
||||
compute-type error — the engine retries int8_float16, then int8
|
||||
([#551](https://github.com/debpalash/VoiceStudio/issues/551)).
|
||||
- A genuine CUDA OOM retries on CPU int8, so dubbing still completes
|
||||
(slower, same model and accuracy).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **cuDNN 8 required on CUDA.** CTranslate2 links cuDNN 8; if it's missing the
|
||||
process fast-fails with no traceback, so the engine is reported unavailable
|
||||
up front and selection falls through to pytorch-whisper, which uses torch's
|
||||
own cuDNN 9 ([#1371](https://github.com/debpalash/VoiceStudio/issues/1371)).
|
||||
- On some hardened Linux kernels CTranslate2's native library is rejected with
|
||||
"cannot enable executable stack" — reported as unavailable, not a crash
|
||||
([#692](https://github.com/debpalash/VoiceStudio/issues/692)).
|
||||
- A partially-installed environment (interrupted sync, antivirus quarantine)
|
||||
can break WhisperX's deep import chain (whisperx → pyannote →
|
||||
lightning_fabric). The engine is then reported unavailable with a repair
|
||||
hint — reinstall, or `uv sync --reinstall` on a source checkout
|
||||
([#1185](https://github.com/debpalash/VoiceStudio/issues/1185)).
|
||||
- Audio is decoded through VoiceStudio's validated ffmpeg, not a bare `ffmpeg`
|
||||
PATH lookup ([#479](https://github.com/debpalash/VoiceStudio/issues/479)).
|
||||
- Transcribes are time-bounded: each dub chunk by
|
||||
`OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S` (default 120 s), whole files by
|
||||
`OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S` (default 300 s). Raise them for very
|
||||
long files on slow hardware.
|
||||
|
||||
Speed comparisons across engines live in [performance](../performance.md).
|
||||
+19
-8
@@ -13,12 +13,12 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
|
||||
> |-----|--------------|
|
||||
> | `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
|
||||
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
|
||||
> | `:0.4.1` | Exact release version |
|
||||
> | `:0.4` | Latest patch within the 0.4 minor |
|
||||
> | `:0.5.0` | Exact release version |
|
||||
> | `:0.5` | Latest patch within the 0.5 minor |
|
||||
> | `:main` | Alias of the same rolling `main` build as `:latest` |
|
||||
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
|
||||
> | `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
|
||||
> | `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
|
||||
> | `:stable-rocm`, `:0.5.0-rocm`, `:0.5-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
|
||||
>
|
||||
> Versioning rule: preview builds always come from `main` and never
|
||||
> version-sort below `:stable` — upgrades flow naturally.
|
||||
@@ -89,7 +89,7 @@ PublishPort=127.0.0.1:3900:3900
|
||||
Volume=omnivoice-data:/app/omnivoice_data
|
||||
```
|
||||
|
||||
Release pins exist too: `:stable-rocm`, `:0.4.1-rocm`, `:0.4-rocm` mirror
|
||||
Release pins exist too: `:stable-rocm`, `:0.5.0-rocm`, `:0.5-rocm` mirror
|
||||
the CUDA tags exactly.
|
||||
|
||||
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
|
||||
@@ -192,10 +192,14 @@ docker run -e OMNIVOICE_PUBLIC_API_BASE=https://api.your-host.example \
|
||||
> may instead bake `VITE_OMNIVOICE_API` at build time, but the runtime var above
|
||||
> is simpler and image-agnostic.
|
||||
|
||||
> **Security:** VoiceStudio ships no authentication. Anything on your LAN with
|
||||
> the URL can use the app. Put it behind a reverse proxy with `basic_auth`
|
||||
> (Caddy / nginx + htpasswd) or a private network overlay (Tailscale, ZeroTier)
|
||||
> before exposing publicly.
|
||||
> **Security:** Loopback-only publishing is the safe default. On a trusted LAN,
|
||||
> set a long random `OMNIVOICE_API_KEY` with `docker run -e` or Compose; the
|
||||
> browser will prompt for it. The optional six-digit share PIN permits casual
|
||||
> consumption access but does not authorize administration or dictation. On any
|
||||
> untrusted network, plain HTTP is not safe for the API key or session cookie.
|
||||
> Keep the backend on an encrypted private overlay such as Tailscale/ZeroTier;
|
||||
> do not expose it directly to the public internet. See [API
|
||||
> authentication](../api-auth.md) for the complete access model.
|
||||
|
||||
## Volume mounts
|
||||
|
||||
@@ -214,6 +218,13 @@ Two paths are worth persisting across container restarts:
|
||||
The running version is now shown in **Settings → About → Version** (read live
|
||||
from the backend), so the web UI no longer displays a dash in Docker.
|
||||
- **Checking which version is running:** `docker exec <container> python3 -c "import importlib.metadata; print(importlib.metadata.version('omnivoice'))"`, or hit the `/health` endpoint — it returns `{"status": "ok", "device": ..., "version": "0.3.x"}`. Use the container name listed by `docker compose ps` (or `omnivoice` for the `docker run` examples).
|
||||
- **Watching startup:** the port answers within about a second of container
|
||||
start, but heavy initialization (PyTorch, API routes, database migration)
|
||||
continues in the background. During that window `/health` returns **503**
|
||||
with the current step, and `GET /startup/progress` returns the full
|
||||
step-by-step ledger (`status`, current `step`/`label`, per-step states) —
|
||||
useful when a start seems slow and you want to see where it actually is.
|
||||
The Docker `HEALTHCHECK` flips healthy only once `/health` is 200.
|
||||
- **"Loopback origin required" errors (and a blank version):** the desktop
|
||||
build restricts the `/system/*` and `/api/settings/*` routes to a loopback
|
||||
origin, but Docker's NAT makes every request look non-loopback, so the gate
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
@@ -80,6 +80,9 @@ None of them are required — the defaults are chosen for the common case.
|
||||
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `OMNIVOICE_DEVICE` | `auto` | Pin the compute device (`cuda` / `rocm` / `xpu` / `mps` / `cpu`) instead of auto-detect. Same control lives in **Settings → Performance & Device** (the env var wins over the UI pick). Honored only for devices the host actually has — a family that isn't detected is noted and ignored, never obeyed blindly. Applies at the next backend start. |
|
||||
| `OMNIVOICE_FLASHINFER` | `0` | CUDA-only accelerated decoding for the default engine via [FlashInfer](https://github.com/flashinfer-ai/flashinfer) kernels (packed CFG attention, fused RMSNorm/RoPE/GEMM) — ~2x on upstream's benchmarks. `1` enables it; `graph` also captures CUDA graphs (best when you render one thing at a time). Requires installing the optional `flashinfer-python` package into the backend environment first (`uv pip install flashinfer-python flashinfer-jit-cache --extra-index-url https://flashinfer.ai/whl/cu128/`, matching your CUDA build). Replaces `torch.compile` for that session, pins inference to a single GPU thread (the FlashInfer attention plan is per-generation state), and keeps fused copies of the attention/MLP weights resident (~roughly half the LLM's weight size extra VRAM) — leave it off on tight-VRAM cards. If the package is missing or a FlashInfer/CUDA-graph kernel fails at runtime, the app logs the reason and falls back to the standard path; failures outside those kernels (e.g. a genuine out-of-memory) surface normally. |
|
||||
| `OMNIVOICE_PROMPT_DISK_CACHE` | `1` | Persist encoded voice-clone references (`prompt_cache/` in the app data dir, ~10 KB per voice, 32 newest kept) so the first generation with a known voice after a restart skips the reference re-encode and any auto-transcription. Set `0` to keep the cache in memory only. |
|
||||
| `OMNIVOICE_IDLE_TIMEOUT_S` | `900` | Seconds of idle before the TTS model unloads to free memory. Raise it (e.g. `3600`) if you generate in bursts and dislike the ~8 s reload; lower it on tight-memory machines. |
|
||||
| `OMNIVOICE_SIDECAR_IDLE_TIMEOUT_S` | `300` | Same idea for sidecar engines (IndexTTS 2.5 etc.). |
|
||||
| `OMNIVOICE_LLM_CONCURRENCY` | `6` | Parallel LLM translation calls during a dub. Raise for a fast API endpoint, lower if your provider rate-limits. |
|
||||
@@ -229,6 +232,9 @@ uv run python scripts/bench_pipeline.py tts clone # just these stages
|
||||
If you report a performance issue, pasting its table (plus your platform and
|
||||
RAM/VRAM) turns a guessing game into a bisect.
|
||||
|
||||
Measured results per engine/device — and how to contribute yours — live in
|
||||
[benchmarks.md](benchmarks.md).
|
||||
|
||||
## Things that look like knobs but aren't
|
||||
|
||||
- **Deleting and re-adding a voice** doesn't speed anything up; the reference
|
||||
|
||||
+28
-17
@@ -24,13 +24,14 @@ loopback-only exactly as before.
|
||||
┌──────────────┐ tailnet (WireGuard) ┌─────────────────────┐
|
||||
│ laptop │ ws/https to MagicDNS URL │ gpu-box │
|
||||
│ VoiceStudio UI │ ──────────────────────────▶ │ VoiceStudio backend │
|
||||
│ (thin client) │ Authorization: Bearer … │ OMNIVOICE_API_KEY set │
|
||||
│ (thin client) │ short-lived session/ticket │ OMNIVOICE_API_KEY set │
|
||||
└──────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
The desktop app *is* the thin client — there is no separate binary. You set a
|
||||
**Backend URL** and an **API key** in Settings, and every request (including
|
||||
the dictation and TTS WebSockets) is sent to the remote with the key attached.
|
||||
The desktop app *is* the thin client — there is no separate binary. You enter a
|
||||
**Backend URL** and an **API key** in Settings. The key is exchanged once for a
|
||||
short-lived session; ordinary HTTP requests use that session and WebSockets use
|
||||
path-bound, single-use tickets. The master is never stored or put in a URL.
|
||||
|
||||
## 1. On the GPU box: run the backend with a key
|
||||
|
||||
@@ -50,11 +51,11 @@ the backend's CORS allow-list to include that origin — see
|
||||
[Browsers from another origin (CORS)](api-auth.md#browsers-from-another-origin-cors);
|
||||
neither server mode nor trusted networks covers CORS.
|
||||
|
||||
When `OMNIVOICE_API_KEY` is set, **every non-loopback HTTP and WebSocket
|
||||
request must present it**, as `Authorization: Bearer <key>`, `?api_key=<key>`
|
||||
(browser WebSockets can't set headers), or the `ov_key` cookie the backend
|
||||
sets after the first authenticated request. Loopback traffic on the box
|
||||
itself is never gated, so local tools keep working.
|
||||
When `OMNIVOICE_API_KEY` is set, every non-loopback request needs an accepted
|
||||
credential. Scripts should use `Authorization: Bearer <key>`. Legacy
|
||||
`?api_key=` and `ov_key` transports remain accepted for compatibility, but the
|
||||
backend no longer creates a master-key cookie and the bundled UI uses only
|
||||
short-lived sessions. Loopback traffic on the box itself remains ungated.
|
||||
|
||||
## 2. Reach it over Tailscale
|
||||
|
||||
@@ -79,6 +80,11 @@ Serve terminates on the node and forwards from `127.0.0.1`, so to the backend
|
||||
the request looks like loopback — which is why the **API key is still
|
||||
required** in that path (the bearer gate doesn't rely on the source address
|
||||
for non-local exposure; set the key and it always applies to keyed clients).
|
||||
Uvicorn trusts proxy headers from loopback by default, so Serve's forwarded
|
||||
HTTPS scheme becomes the authoritative ASGI scheme and browser session cookies
|
||||
receive `Secure`. For a non-loopback reverse proxy, explicitly configure
|
||||
Uvicorn's `--forwarded-allow-ips=<proxy-ip>`; the application never trusts an
|
||||
arbitrary `X-Forwarded-Proto` header itself.
|
||||
|
||||
> **Do not use `tailscale funnel`** (public-internet exposure) for this. Even
|
||||
> with a key, a voice-cloning backend should not be on the open internet.
|
||||
@@ -90,10 +96,11 @@ Settings → Sharing → **Remote backend**:
|
||||
- **Backend URL**: the MagicDNS URL from step 2 (with `:3900` if you didn't
|
||||
use Serve, or no port if you did).
|
||||
- **API key**: the value of `OMNIVOICE_API_KEY` from step 1.
|
||||
- **Test connection** hits `{url}/health` and shows the remote's version and
|
||||
device.
|
||||
- **Save & reload** stores both in this browser/app and restarts the UI
|
||||
against the remote. The URL must be a full `http://` or `https://` URL
|
||||
- **Test connection** hits the auth-exempt `{url}/health` with no credential,
|
||||
then exchanges the entered key for a session if health succeeds.
|
||||
- **Save & reload** stores only the URL and restarts the UI against the remote.
|
||||
The key input is cleared after its single exchange. The URL must be a full
|
||||
`http://` or `https://` URL
|
||||
(`gpu-box:3900` alone is rejected), and saving a URL that hasn't passed
|
||||
**Test connection** asks for confirmation first — a wrong base would leave
|
||||
the app unable to reach any backend until you change it back here.
|
||||
@@ -111,12 +118,13 @@ https://gpu-box.your-tailnet.ts.net/#api_key=<key>
|
||||
|
||||
Use the fragment (`#`, not `?`) deliberately: fragments are never sent to the
|
||||
server, so the key stays out of the GPU box's and any reverse proxy's request
|
||||
logs. The key is stored for that browser and the fragment is scrubbed from the
|
||||
address bar (so it doesn't linger in history or get re-applied on a reload). If
|
||||
logs. The fragment is scrubbed synchronously, then the key is exchanged once
|
||||
for an eight-hour maximum session; the master is not stored. If
|
||||
your key contains `+`, `&`, `#`, or `=`, URL-encode it (e.g. `#api_key=a%2Bb`);
|
||||
keys from `secrets.token_urlsafe` (above) need no encoding.
|
||||
Thereafter the UI loads normally with the key attached to every request. If a
|
||||
request ever 401s again (wrong/rotated key), you're prompted to re-enter it. The
|
||||
Thereafter the UI loads normally with the short-lived session. Cross-origin
|
||||
bearer sessions are tab-scoped; closing the tab requires re-entry. If a request
|
||||
401s again (expired/wrong/rotated key), you're prompted to re-enter it. The
|
||||
same gate shows a LAN-share **PIN** prompt instead when network sharing — not a
|
||||
remote key — is what's gating access.
|
||||
|
||||
@@ -125,6 +133,9 @@ remote key — is what's gating access.
|
||||
- **Plain HTTP is sniffable.** A bearer key over `http://` on a hostile
|
||||
network can be read off the wire. Use Tailscale (WireGuard-encrypted) or
|
||||
Tailscale Serve (TLS) for anything beyond a fully trusted LAN.
|
||||
- The first-party UI never persists `OMNIVOICE_API_KEY`, never creates a URL
|
||||
containing it, and never puts its administrator session in a WebSocket URL.
|
||||
WebSocket tickets expire after 30 seconds and work once for one path.
|
||||
- The API key and the LAN-share **PIN** are independent: the PIN guards a
|
||||
casual share session, the key is the durable remote credential. Either can
|
||||
be active; both are checked when set.
|
||||
|
||||
+26
-8
@@ -1,12 +1,18 @@
|
||||
# Remote GPU workers
|
||||
|
||||
Run OmniVoice on this machine, but hand individual jobs to GPUs on your other
|
||||
Run VoiceStudio on this machine, but hand individual jobs to GPUs on your other
|
||||
machines. Results come back here.
|
||||
|
||||
This is **opt-in and off by default**. Until you turn it on and approve a
|
||||
worker, nothing leaves your computer, no port is opened, and the app behaves
|
||||
exactly as it did before.
|
||||
|
||||
Worker management is an admin surface. In Docker/server mode, viewing status
|
||||
works during bare bootstrap, but joining, enabling, approving, issuing keys,
|
||||
disconnecting, or removing machines remotely requires `OMNIVOICE_API_KEY`.
|
||||
The share PIN and trusted-network exemptions authorize playback, not worker
|
||||
administration.
|
||||
|
||||
> **Not the same as [Remote backend](remote-gpu.md).** That points this app at
|
||||
> a backend running somewhere else, so the whole app — your projects, your
|
||||
> voices, your history — lives on that machine. This keeps everything here and
|
||||
@@ -17,7 +23,7 @@ exactly as it did before.
|
||||
|
||||
## What you need
|
||||
|
||||
* OmniVoice on both machines, on versions no more than two releases apart.
|
||||
* VoiceStudio on both machines, on versions no more than two releases apart.
|
||||
* The worker machine must be able to **reach** this one over the network. Same
|
||||
LAN is enough at home; across networks, a VPN such as
|
||||
[Tailscale](https://tailscale.com/) is the reliable answer. The worker dials
|
||||
@@ -147,6 +153,15 @@ fallback is reported once. ASR, diarization and translation also remain local. D
|
||||
runs here, deliberately and permanently, because there latency *is* the
|
||||
feature. The remaining operations are being ported one at a time.
|
||||
|
||||
### Voice identity parity
|
||||
|
||||
For TTS, the worker receives the complete local rendering contract: the voice
|
||||
profile's reference audio and transcript, its pinned seed, model quality
|
||||
controls, text chunking/crossfade settings, and output effect preset. The
|
||||
worker runs the same native or generic rendering pipeline as local
|
||||
`/generate`; selecting a gallery voice therefore does not turn it into a new
|
||||
random voice merely because it was rendered on another GPU.
|
||||
|
||||
The picker knows this. It resolves against the surface you are on, so a chosen
|
||||
worker reads **Local** on a tab whose work has no remote path yet and names the
|
||||
reason, instead of showing a green dot next to a GPU that receives nothing. The
|
||||
@@ -157,10 +172,11 @@ The Dictation surface states that it always uses this machine without showing
|
||||
the generic "not ported yet" notice.
|
||||
|
||||
For protocol development, a task can also be placed by hand with
|
||||
`POST /workers/tasks` — a **development-only** endpoint. It is loopback-only,
|
||||
`POST /workers/tasks` — a **development-only** endpoint. It is admin-gated,
|
||||
sits behind the same opt-in as everything else here, takes a mandatory
|
||||
deadline, submits one task and waits for it. It is not a stable API and goes
|
||||
away once generation routes itself.
|
||||
deadline, submits one task and waits for it. On desktop that means loopback;
|
||||
in server mode a remote caller needs `OMNIVOICE_API_KEY`. It is not a stable
|
||||
API and goes away once generation routes itself.
|
||||
|
||||
## How work is placed
|
||||
|
||||
@@ -197,16 +213,18 @@ The row tells you what happened in words — "Paused after 3 failures … retryi
|
||||
in 45s" — and **Resume** clears it immediately when you've fixed the machine.
|
||||
|
||||
**You quit the app mid-task.** Remote work keeps running on the worker. On next
|
||||
launch OmniVoice recovers those tasks and reconciles with each worker about
|
||||
launch VoiceStudio recovers those tasks and reconciles with each worker about
|
||||
what is genuinely still in flight.
|
||||
|
||||
**Version or feature mismatch.** The protocol keeps a two-release compatibility
|
||||
window, but release numbers alone do not prove that a worker understands every
|
||||
additive command. Registration therefore also declares named features for task
|
||||
inputs, progress leases, and remote model downloads. A worker outside the
|
||||
inputs, progress leases, remote model downloads, and the voice-identity render
|
||||
pipeline. A worker outside the
|
||||
version window, or one missing a required feature, is refused with
|
||||
`UPGRADE_REQUIRED` and an update instruction before any task runs. It can never
|
||||
silently render without reference audio or leave a download stuck at 0%.
|
||||
silently render without reference audio, substitute a different voice, or leave
|
||||
a download stuck at 0%.
|
||||
|
||||
Every remote failure includes a concrete next step. Capacity, missing models,
|
||||
expired leases or sessions, authentication, rejected inputs, and result upload
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 185 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,7 @@
|
||||
# Spec — TASK #26: Gallery "Use in Stories" / "Set as Audiobook default" + create-voice handoff
|
||||
|
||||
> **Implemented (2026-08-13).** Gallery and Community persona cards now materialize once and hand the returned profile directly to Studio, the current Stories cast, or the current Audiobook default. The implementation uses the unified `longformSlice` that superseded the store additions proposed below; the remainder of this document preserves the original design record.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Today the Gallery's "Use voice" action materializes an archetype/community voice into a profile and hard-codes a handoff into the **Studio** synthesis view (`frontend/src/pages/VoiceGallery.jsx:199-210` for archetypes; `frontend/src/App.jsx:254-268` for the studio-side pickup). There is no path from the Gallery into the **Stories** cast or the **Audiobook** default narrator. This task adds two quick-actions to gallery + community cards — "Use in Stories" and "Set as Audiobook default" — that (a) materialize the voice into a real profile (same backend call as today) and (b) land it in the right destination: appended to the Stories cast as a new character, or set as the persisted Audiobook default voice. The Audiobook default currently has no store binding at all, so this task also promotes it from local `useState` to a persisted store field.
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
# Frontend Responsiveness: Persistence Write-Amplification Remediation Plan
|
||||
|
||||
| Field | Decision |
|
||||
| --- | --- |
|
||||
| Status | Implemented in draft PR #1541; CI and review pending |
|
||||
| Target | One focused frontend PR |
|
||||
| Priority | P1 responsiveness and data-safety hardening |
|
||||
| Risk | Medium: persistence timing changes, persisted formats do not |
|
||||
| Dependencies | None |
|
||||
| Rollback | Revert the PR; the existing keys and schemas remain readable |
|
||||
|
||||
## Executive decision
|
||||
|
||||
The first optimization PR should remove synchronous JSON serialization and `localStorage` writes from high-frequency interaction paths. It should preserve the existing `omnivoice.app` and `omni_ui` contracts, coalesce each burst to the latest value, flush within a bounded window, and prevent deferred writes from undoing Factory Reset.
|
||||
|
||||
This is the best first change because it addresses a measured, cross-workspace bottleneck without combining it with a storage migration, backend change, or `App.jsx` rewrite. Incremental-dub scheduling, transactional undo, and workspace decomposition remain separate follow-ups with their own evidence and rollback boundaries.
|
||||
|
||||
## Evidence and diagnosis
|
||||
|
||||
### Static path
|
||||
|
||||
Two independent persistence paths run on the browser main thread:
|
||||
|
||||
1. Every Zustand `set` invokes the persist middleware. The middleware runs `partialize`, serializes the complete persisted projection, and calls synchronous `localStorage.setItem('omnivoice.app', ...)`, even when the mutation only changes transient state.
|
||||
2. `useAppData` has a broad effect that serializes and writes `omni_ui` whenever text, dub segments, transcript, tracks, history, or a related preference changes.
|
||||
|
||||
The resulting hot path is:
|
||||
|
||||
`input -> store update -> render/effects -> full projection -> JSON.stringify -> localStorage.setItem`
|
||||
|
||||
The cost scales with document size rather than with the small field the user changed. `localStorage` is synchronous, so both serialization and the physical write compete with the next frame.
|
||||
|
||||
### Local runtime baseline
|
||||
|
||||
The following measurements are diagnostic baselines from commit `3e3189d04d2d6dba69b4dd07fefc8725b9c94af6`, not portable CI thresholds. Each scenario performs 20 UI-scale interactions; raw storage timing excludes `JSON.stringify`, so it is a lower bound. Two unrelated contact-key writes were excluded from the target-key counts but included in the aggregate raw timing.
|
||||
|
||||
| Fixture | Writes to target keys | Input-to-next-frame | Raw `setItem` time |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Small local state | 40 `omnivoice.app` + 20 `omni_ui` | 13.9 ms average, 18.9 ms max | 1.9 ms |
|
||||
| 1,800 dub segments + 400 story tracks | 40 + 20 | 23.6 ms average, 39.0 ms max | 50.7 ms |
|
||||
| 3,000 dub segments + 3,000 story tracks | 40 + 20 | Repeated 56-114 ms long tasks | 809 ms |
|
||||
|
||||
Representative serialized sizes were approximately 156 KB for `omnivoice.app` and 1.5 MB for `omni_ui`. A direct text-edit probe also produced one write to each key for each change.
|
||||
|
||||
### Baseline verification
|
||||
|
||||
- Baseline commit: `3e3189d04d2d6dba69b4dd07fefc8725b9c94af6`.
|
||||
- `bun run test -- src/utils/prefKeys.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts src/test/dubPerLangTranslations.test.jsx src/test/dubVoiceMatchRequest.test.jsx` passes: 6 files, 35 tests.
|
||||
- The production build passes. The main application chunk is approximately 381.82 KB minified / 116.27 KB gzip.
|
||||
- `backend/api/routers/mcp_bindings.py` is not implicated: its list handler is a thin delegation, and the bindings panel already loads bindings and profiles concurrently.
|
||||
- The large Settings/OpenAPI chunk is lazy and is not the interaction-time bottleneck targeted here.
|
||||
|
||||
## Goal
|
||||
|
||||
For a rapid sequence of edits, perform no JSON serialization or physical storage write in the originating interaction task and persist only the newest value after the burst, while retaining synchronous hydration and the current recovery formats.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- One shared, typed, coalescing JSON writer for browser `localStorage`.
|
||||
- A Zustand-compatible structured storage adapter that defers serialization itself.
|
||||
- Deferred `omni_ui` persistence with its exact current field set.
|
||||
- Trailing flush, maximum-wait flush, and page-lifecycle flush.
|
||||
- Single-writer protection for the standalone Tauri capture widget.
|
||||
- Factory Reset cancellation so pending values cannot recreate deleted keys.
|
||||
- Deterministic unit/integration tests, a before/after browser trace, and an Unreleased changelog entry.
|
||||
|
||||
### Explicitly out of scope
|
||||
|
||||
- IndexedDB, workers, new storage keys, schema changes, or a Zustand version bump.
|
||||
- Removing duplicated fields from `omni_ui` or changing restore precedence.
|
||||
- Backend/API/database changes, including MCP bindings.
|
||||
- Debouncing `/tools/incremental` in this PR.
|
||||
- Changing undo/redo semantics or snapshot representation.
|
||||
- Splitting stores, decomposing `App.jsx`, or moving workspace imports.
|
||||
- New dependencies, user-visible strings, locale files, or an app version bump.
|
||||
- Hardware-sensitive timing assertions in CI.
|
||||
|
||||
## Compatibility and safety invariants
|
||||
|
||||
The implementation must preserve all of the following:
|
||||
|
||||
| Contract | Required invariant |
|
||||
| --- | --- |
|
||||
| Zustand key | `omnivoice.app` |
|
||||
| Zustand envelope | `{ state, version: 7 }`, serialized with normal `JSON.stringify` semantics |
|
||||
| Zustand projection | Existing `partialize` fields and transient-field stripping remain semantically unchanged |
|
||||
| Zustand migration | Existing v1-v7 migration behavior remains unchanged |
|
||||
| Legacy recovery key | `omni_ui` |
|
||||
| Legacy recovery shape | Exact current field names, omission behavior, and `sanitizeOmniUi` restore path |
|
||||
| Hydration | Synchronous; no loading gate or async race is introduced |
|
||||
| Durability | When serialization/storage succeeds and the browser runs timers, a dirty key is attempted within 1,000 ms of its first unflushed change |
|
||||
| Lifecycle | `pagehide` and hidden-document events attempt pending values; both events together cause at most one physical write per unchanged generation |
|
||||
| Reset | A removed preference key cannot be recreated by old or newly queued work before the reset reload |
|
||||
| Desktop windows | Persistence starts in an unknown/read-only role; the resolved main webview is activated as the only writer and the standalone widget stays read-only |
|
||||
| Privacy | Logs may contain a key and error name, never persisted user content |
|
||||
| Platform parity | Same default behavior on macOS, Windows, Linux, browser, and Docker |
|
||||
|
||||
Direct consumers such as `utils/donationMoments.js`, E2E state seeding, long-form recovery, and the preference-key registry must continue to parse the existing envelope without changes. The donation opt-out's primary `omnivoice.donate.optOut` flag remains an immediate, separate write; add a compatibility assertion that its immediate behavior and the flushed legacy-envelope fallback both remain valid.
|
||||
|
||||
Concurrent browser/Docker tabs are explicitly not promoted to a coordinated multi-writer system in this PR. They retain unsupported last-physical-writer-wins behavior. The PR description must state that boundary; adding cross-tab revisions or `BroadcastChannel` arbitration would be a separate data-consistency design.
|
||||
|
||||
## Proposed design
|
||||
|
||||
### 1. Shared coalescing writer
|
||||
|
||||
Create `frontend/src/utils/coalescedJsonStorage.ts` with an injectable core and one application singleton. The public contract should be small:
|
||||
|
||||
| API | Contract |
|
||||
| --- | --- |
|
||||
| `queueJsonWrite(key, readLatestValue)` | Mark `key` dirty and replace its lazy provider; return a generation-bound disposer that can cancel only this registration |
|
||||
| `createZustandJsonStorage()` | Return a `PersistStorage` adapter whose `getItem` is synchronous and whose `setItem` queues the structured `StorageValue` |
|
||||
| `flushPendingWrites()` | Synchronously serialize and attempt every pending write; return a summary for tests/diagnostics |
|
||||
| `discardPendingWrites(predicate?)` | Cancel timers and pending values matching a key predicate |
|
||||
| `suspendJsonWrites(predicate)` | Discard matching work and reject later matching queues until the returned resume callback is used |
|
||||
| `configurePersistenceRole(role)` | Resolve the singleton from initial `unknown` to `main` or `readonly`; activate staged main work or discard all staged widget work |
|
||||
| Adapter `removeItem(key)` | Cancel/stage-remove that key before raw removal; propagate main-window removal errors; remain inert in a read-only widget |
|
||||
| `installPersistenceLifecycleFlush()` | Install the singleton listener pair once for the main bootstrap owner; cleanup is idempotent and reserved for tests/HMR teardown |
|
||||
|
||||
Required scheduling semantics:
|
||||
|
||||
- Quiet delay: 250 ms after the latest value for a key.
|
||||
- Hard maximum: 1,000 ms from the first unflushed value for that key; continuous input must not starve persistence.
|
||||
- Last scheduled value wins.
|
||||
- The queued provider is evaluated on the JavaScript thread only at flush, so the value serialized is the latest application value at flush time rather than a deep-cloned event-time object.
|
||||
- The quiet timer resets on replacement; the maximum timer does not.
|
||||
- A successful maximum flush starts a new window for later updates.
|
||||
- Use standard timers. Do not make `requestIdleCallback` part of the correctness path; availability differs across the supported webviews.
|
||||
- Do not wrap `createJSONStorage`. It stringifies before calling the adapter and would leave the main cost inside the interaction path.
|
||||
|
||||
Flush behavior:
|
||||
|
||||
1. Read the latest provider and serialize only at flush time.
|
||||
2. Compare the serialized value with the currently durable raw value and skip an identical physical write.
|
||||
3. Call `setItem` once at most for each dirty key in that flush.
|
||||
4. Mark the entry clean only after a successful write or confirmed identical value.
|
||||
5. Ensure an old timer cannot commit after a newer value, cancellation, or removal.
|
||||
|
||||
`getItem` must evaluate and return the latest pending structured value when one exists; otherwise it must synchronously parse the durable raw value. This keeps explicit Zustand `rehydrate()` calls internally consistent without changing cold-start hydration.
|
||||
|
||||
The lazy-provider contract avoids copying a 1.5 MB document on every input. Task 0 must audit every persisted nested container for in-place mutation. React/Zustand setters are expected to publish replacements; any isolated violation must be fixed or explicitly converted to a safe value provider before wiring this scheduler. If the audit reveals a broad mutable-data convention, stop and redesign this PR rather than hiding a state-model refactor inside it. A deterministic test must pin current-at-flush semantics: mutate/replace the provider's source without serializing, then flush and verify the current value is written.
|
||||
|
||||
### 2. Failure semantics
|
||||
|
||||
- `JSON.stringify` or storage failures must not escape through a Zustand setter, React effect, or lifecycle event.
|
||||
- A serialization failure discards that invalid value after a warning; a later valid update can proceed.
|
||||
- Every flush attempt clears both timers first.
|
||||
- A quota/security/write failure leaves the previous durable blob untouched and keeps the newest value dirty, but disarms automatic retry. A later queue starts a fresh 250/1,000 ms window; an explicit/lifecycle flush attempts it once. Advancing timers alone must not create a retry loop.
|
||||
- A multi-key flush is isolated per key: successful keys become clean; a failed key remains dirty; retrying the failed key must not rewrite successful siblings.
|
||||
- Warn once per key/operation/error class to avoid console floods.
|
||||
- Never log the value, text, segment data, or serialized payload.
|
||||
- Adapter `removeItem` and Factory Reset remain truthful: cancel pending work first, then allow a main-window raw removal failure to reach the caller.
|
||||
- The 1,000 ms durability statement applies only when the browser schedules the timer and storage succeeds. Timer throttling, quota denial, a crashed process, or a failed lifecycle write cannot be promised durable; these cases are observable and non-crashing.
|
||||
|
||||
### 3. Main-window ownership
|
||||
|
||||
The Tauri widget imports the same Zustand store in a separate webview and calls setters for runtime dictation state. Today those transient setters can persist an older projection over the main window's current preferences.
|
||||
|
||||
Do not duplicate widget detection inside the storage utility. `detectIsWidget()` already resolves the initialization marker, Tauri `getCurrentWindow().label`, and legacy development URL. `bootstrapApp()` must pass that exact resolved result to `configurePersistenceRole()` before React renders.
|
||||
|
||||
The singleton begins in `unknown`: hydration reads work, but writes/removals can only be staged and no timer, serialization, or raw mutation may run. Resolving `main` replays only the latest staged operation per key and starts its 250/1,000 ms clocks at activation; time spent awaiting role detection does not count against a window in which writing was forbidden. Resolving `readonly` discards staged work and makes both `setItem` and `removeItem` inert. This is necessary because the store is statically imported before asynchronous window detection completes. The in-page browser capture pill shares the main document and remains writable.
|
||||
|
||||
Tests that import the store without `bootstrapApp()` must use an isolated writer or explicitly configure `main` in setup and reset role, staged work, suspensions, timers, and listeners in teardown. Existing migration tests must clear scheduler state before seeding raw fixtures; otherwise a staged pending value can mask the fixture during `persist.rehydrate()`.
|
||||
|
||||
### 4. Lifecycle ownership
|
||||
|
||||
After `detectIsWidget()` resolves, `bootstrapApp()` should configure the role and install lifecycle flushing before rendering only for the main window. Bootstrap is the sole production owner; an isolated writer instance or explicit teardown resets listeners in tests.
|
||||
|
||||
- Flush on `pagehide`.
|
||||
- Flush on `visibilitychange` only when `document.visibilityState === 'hidden'`.
|
||||
- Do not add `beforeunload`; it is unnecessary and can interfere with back/forward caching.
|
||||
- Lifecycle flush uses the same generation/cancellation checks as timer flushes. If hidden visibility and `pagehide` both fire, the second invocation observes a clean generation and performs no second serialization/write.
|
||||
|
||||
### 5. Zustand integration
|
||||
|
||||
In `frontend/src/store/index.ts`:
|
||||
|
||||
- Replace `createJSONStorage(() => localStorage)` with the structured coalescing adapter.
|
||||
- Preserve `name`, `partialize`, `version: 7`, and `migrate` semantically unchanged.
|
||||
- Keep the long-form projection and removal of `generating`/`audioUrl` intact.
|
||||
- Do not add `text`, dub segments, or other legacy recovery fields to this key.
|
||||
|
||||
This PR deliberately leaves `partialize` synchronous. If post-change profiling shows its `storyTracks.map(...)` is still material, optimize projection scheduling in a separate change rather than replacing hydration and migration machinery here.
|
||||
|
||||
### 6. `omni_ui` integration
|
||||
|
||||
In `frontend/src/hooks/useAppData.js`:
|
||||
|
||||
- Build the same recovery object with the same property order and values.
|
||||
- Replace direct `JSON.stringify` + `localStorage.setItem` with a lazy `queueJsonWrite('omni_ui', readLatestOmniUi)` provider.
|
||||
- Keep synchronous parsing, `sanitizeOmniUi`, legacy `clone`/`design` handling, and dub-step clamping unchanged.
|
||||
- Add an explicit `omniUiRestoreComplete` readiness state. The initial persistence effect must queue nothing; the restore effect sets all recovered values and flips readiness in the same batch, and the subsequent render supplies the first writable value.
|
||||
- Prove an immediate lifecycle event between the initial effects and the restored render cannot persist defaults.
|
||||
- Feed a lazy latest-value provider to the writer and invoke its generation-bound disposer in effect cleanup. An obsolete StrictMode/unmounted effect may cancel only its own registration, never a newer mount's provider. Do not deep-clone at queue time; the immutability audit and current-at-flush contract above define ownership.
|
||||
|
||||
### 7. Factory Reset integration
|
||||
|
||||
In `clearLocalPreferences`:
|
||||
|
||||
1. Suspend and discard every pending key for which `isPrefKey(key)` is true.
|
||||
2. Enumerate and remove durable preference keys exactly as today.
|
||||
3. Preserve connection credentials and user-data keys exactly as today.
|
||||
|
||||
The suspension lasts for the remainder of the successful reset session, because background store activity can occur during the 400 ms before reload. Wrap the entire enumerate-and-remove transaction, including `length`, `key()`, and key filtering/access, so any failure resumes writes before rethrowing. This prevents the existing reset error path from leaving persistence silently disabled. This ordering is mandatory: a stale timer, a new post-reset store update, or the later `pagehide` could otherwise resurrect `omnivoice.app` or `omni_ui` after deletion. Tests that simulate a successful reset without a real reload must explicitly reset the isolated writer afterward.
|
||||
|
||||
## File-level change budget
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `frontend/src/utils/coalescedJsonStorage.ts` | New lazy scheduler, Zustand adapter, role configuration, suspension, and lifecycle ownership |
|
||||
| `frontend/src/utils/coalescedJsonStorage.test.ts` | New deterministic scheduler/failure/lifecycle/widget tests |
|
||||
| `frontend/src/store/index.ts` | Swap storage adapter only; preserve projection and migrations |
|
||||
| `frontend/src/store/persistenceScheduling.test.ts` | New Zustand envelope, coalescing, hydration, and long-form projection tests |
|
||||
| `frontend/src/hooks/useAppData.js` | Gate restore readiness and queue the existing `omni_ui` value provider |
|
||||
| `frontend/src/hooks/useAppData.persistence.test.jsx` | New restore and burst-write integration tests |
|
||||
| `frontend/src/main-app.jsx` | Configure the resolved window role, then install main-only lifecycle flushing |
|
||||
| `frontend/src/main-app.test.jsx` | Extend label/marker/URL role-order coverage |
|
||||
| `frontend/src/utils/prefKeys.js` | Suspend pending and future preference writes across successful reset |
|
||||
| `frontend/src/utils/prefKeys.test.js` | Add no-resurrection coverage |
|
||||
| `frontend/src/utils/donationMoments.test.js` | Preserve immediate primary opt-out and flushed legacy fallback behavior |
|
||||
| `frontend/e2e-perf/responsiveness.spec.ts` | Add opt-in production-bundle fixture, route mocks, instrumentation, and JSON artifact; no wall-clock CI assertions |
|
||||
| `frontend/playwright.perf.config.ts` | Add cross-platform production-preview benchmark config derived from the existing prod smoke config |
|
||||
| `CHANGELOG.md` | One Unreleased performance/fix line once the PR number exists |
|
||||
|
||||
No backend, locale, package manifest, lockfile, or persisted-schema file should change.
|
||||
|
||||
## Implementation sequence
|
||||
|
||||
### Task 0: Freeze the current contracts
|
||||
|
||||
- [ ] Record the parent commit SHA and rerun the browser baseline with identical fixtures.
|
||||
- [ ] Add characterization assertions for the exact Zustand envelope, version, legacy snapshot keys, direct readers, and reset key registry; these must pass before production changes.
|
||||
- [ ] Add integration assertions for burst write counts and initial-default overwrite behavior; these must fail on the current immediate writer for the expected reason.
|
||||
- [ ] Confirm existing direct readers (`donationMoments`, E2E helpers) against the frozen fixture.
|
||||
- [ ] Audit the persisted Zustand projection and every `omni_ui` nested value for in-place mutation. Record the search paths in the PR; resolve any hit before adopting lazy providers.
|
||||
- [ ] Keep the current 35 targeted tests green while adding fail-before cases.
|
||||
|
||||
Exit condition: characterization tests pass; behavioral integration tests fail only because writes are immediate/repeated or startup persistence is ungated. Scheduler-specific unit tests are introduced with the new utility rather than pretending to fail before their seam exists.
|
||||
|
||||
### Task 1: Implement the storage primitive
|
||||
|
||||
- [ ] Implement per-key quiet and maximum timers with injected clock/storage/serializer dependencies.
|
||||
- [ ] Make value materialization and serialization lazy and deduplicate against the durable raw string.
|
||||
- [ ] Implement synchronous pending/durable reads.
|
||||
- [ ] Implement generation-bound provider disposers plus flush, discard, and removal guards.
|
||||
- [ ] Implement predicate-based suspension for destructive reset windows.
|
||||
- [ ] Define failed attempts as timer-disarmed; a later queue starts a new maximum window.
|
||||
- [ ] Isolate partial failures across multiple dirty keys.
|
||||
- [ ] Recover from throwing providers, durable reads, malformed JSON, and raw writes without poisoning later valid operations.
|
||||
- [ ] Deduplicate warnings and prove no value, serialized payload, or error message containing user content is logged.
|
||||
- [ ] Contain and deduplicate errors without logging payloads.
|
||||
- [ ] Add `unknown -> main|readonly` role configuration; unknown work cannot reach raw storage.
|
||||
- [ ] Make adapter removal obey cancellation, role, and error-propagation contracts.
|
||||
- [ ] Add single-owner lifecycle installation and idempotent teardown.
|
||||
- [ ] Add a full isolated-writer reset hook for tests: role, staged operations, suspensions, timers, listeners, and warning registry.
|
||||
|
||||
Exit condition: all utility tests pass without importing React or the application store.
|
||||
|
||||
### Task 2: Wire Zustand without changing its contract
|
||||
|
||||
- [ ] Replace `createJSONStorage` with the structured adapter.
|
||||
- [ ] Keep `partialize`, `version`, and `migrate` unchanged except for any mechanical key constant extraction needed by tests.
|
||||
- [ ] Prove that 100 rapid transient updates cause zero synchronous serializations/writes and at most one trailing write.
|
||||
- [ ] Prove the final JSON contains the latest persisted update and `{ version: 7 }`.
|
||||
- [ ] Prove `persist.clearStorage()` cannot be undone by timers/lifecycle and is inert in the widget role.
|
||||
- [ ] Update raw-seeded migration tests to reset pending/staged writer state before `rehydrate()`.
|
||||
- [ ] Prove long-form fields round-trip while `generating` and `audioUrl` remain excluded.
|
||||
- [ ] Prove v6-to-v7 and older accepted fixtures still hydrate synchronously.
|
||||
|
||||
Exit condition: existing store migration tests plus the new scheduling suite pass.
|
||||
|
||||
### Task 3: Wire `omni_ui`
|
||||
|
||||
- [ ] Extract snapshot construction only if needed for a precise shape test; do not redesign ownership.
|
||||
- [ ] Add the restore-complete state gate, then queue a latest-value provider rather than serializing in the effect.
|
||||
- [ ] Add a seeded-restore test proving the initial defaults never become the durable winner.
|
||||
- [ ] Dispatch lifecycle flush before the post-restore render and prove it writes no defaults.
|
||||
- [ ] Add a burst test proving the latest text and dub segment data win after one write.
|
||||
- [ ] Cover StrictMode double effects plus unmount/remount before the quiet timer; no obsolete provider may win.
|
||||
- [ ] Re-run schema, legacy-mode, and restored-dub-step tests unchanged.
|
||||
|
||||
Exit condition: a reload after explicit flush restores a deep-equal latest snapshot through `sanitizeOmniUi`.
|
||||
|
||||
### Task 4: Close lifecycle and reset races
|
||||
|
||||
- [ ] Configure the exact `detectIsWidget()` result before render, then install main-window lifecycle flushing.
|
||||
- [ ] Prove marker, Tauri-label-only, and legacy-URL detection; pre-role setters cannot leak from a widget.
|
||||
- [ ] Prove unknown-role set→remove ends removed, remove→set activates the set, and the 1-second clock starts at main-role activation.
|
||||
- [ ] Prove duplicate installation does not duplicate listeners and teardown removes the exact callbacks.
|
||||
- [ ] Prove hidden visibility plus `pagehide` produce at most one serialization/write for an unchanged pending generation.
|
||||
- [ ] Suspend pending and future preference values before Factory Reset removal.
|
||||
- [ ] Queue another store update, advance every fake timer, and dispatch lifecycle events after reset; both target keys must remain absent.
|
||||
- [ ] Prove raw removal and enumeration/access failures resume normal persistence before propagating the error.
|
||||
- [ ] Prove preserved connection/data keys remain untouched.
|
||||
- [ ] Prove standalone-widget setters and `persist.clearStorage()` cannot mutate durable state, while main-window operations still work.
|
||||
|
||||
Exit condition: neither stale timers, lifecycle events, StrictMode, nor the widget can overwrite newer or deliberately removed durable state.
|
||||
|
||||
### Task 5: Verify and document
|
||||
|
||||
- [ ] Run targeted tests during iteration.
|
||||
- [ ] Run frontend typecheck, lint, format check, full Vitest, build, and production-bundle smoke.
|
||||
- [ ] Run the repository's backend suites offline before landing, despite no backend diff, because they are merge gates.
|
||||
- [ ] Check in the opt-in Playwright benchmark with deterministic fixture generation and JSON output.
|
||||
- [ ] Run an alternating parent/implementation/parent (A/B/A) benchmark sequence with five repeats per leg; repeat if same-commit variance exceeds 5%.
|
||||
- [ ] Attach counts, payload sizes, p50/p95/max interaction latency, and long-task evidence to the PR.
|
||||
- [ ] Open the draft PR to obtain its number, then add/amend the Unreleased changelog line before requesting review.
|
||||
|
||||
Exit condition: deterministic acceptance criteria pass; build/merge gates are green; browser timing is attached as reproducible decision evidence rather than a hardware-sensitive CI gate.
|
||||
|
||||
## Required deterministic tests
|
||||
|
||||
| Scenario | Required result |
|
||||
| --- | --- |
|
||||
| 100 replacements in one burst | 0 synchronous provider/serializer/write calls; 1 trailing write with value 100 |
|
||||
| Lazy provider source changes before flush | Current-at-flush value is written; no deep clone or serialization occurred while queueing |
|
||||
| Obsolete provider disposer | Cancels only its generation; it cannot cancel a newer provider for the same key |
|
||||
| Continuous updates beyond 1 second | A maximum-wait flush occurs; later updates start a new window |
|
||||
| Identical durable value | Serialization may occur at flush; physical `setItem` is skipped |
|
||||
| Explicit `getItem` before flush | Latest pending structured value is returned synchronously |
|
||||
| Hidden document followed by `pagehide` | At most one serialization/write for the unchanged pending generation |
|
||||
| Cancel/remove followed by all timers | Deleted key stays absent |
|
||||
| Zustand `persist.clearStorage()` | Pending/staged key is cancelled; timer/lifecycle cannot resurrect it |
|
||||
| Successful reset followed by a new store update | Matching writes remain suspended and deleted keys stay absent until reload |
|
||||
| Failed reset removal | Error propagates and write suspension is released |
|
||||
| Reset enumeration/access failure | Error propagates and write suspension is released |
|
||||
| Serialization error | Caller does not throw; invalid entry does not poison a later valid update |
|
||||
| Provider throws | Caller/lifecycle does not crash; invalid entry is discarded and a later valid provider succeeds |
|
||||
| Durable `getItem` throws | Hydration falls back to defaults without crashing; a later valid queue can persist |
|
||||
| Malformed durable JSON | Hydration follows the current safe fallback/migration behavior and later persistence repairs it |
|
||||
| Quota/security error | Caller does not throw; old durable value remains; timers do not retry; one later queue starts one new window |
|
||||
| Two-key partial failure | Successful key stays clean; failed key alone retries later |
|
||||
| Unknown staged set→remove / remove→set | Only the final operation activates on `main`; its clocks start at activation |
|
||||
| Unknown/standalone-widget update and removal | Reads work; no raw mutation before role resolution or after read-only resolution |
|
||||
| Duplicate lifecycle installation/teardown | One listener set; exact callbacks are removed once |
|
||||
| Zustand transient burst | At most one `omnivoice.app` write and unchanged v7 envelope |
|
||||
| Legacy recovery burst | At most one `omni_ui` write with latest text/segments |
|
||||
| Seeded initial recovery | Defaults never overwrite restored state, including immediate lifecycle and StrictMode/unmount races |
|
||||
| Factory Reset race | Both pending target keys remain absent after timers and lifecycle events |
|
||||
| Donation opt-out compatibility | Primary opt-out remains immediately visible; flushed v7 legacy fallback remains readable |
|
||||
| Repeated warning | One warning per key/operation/error class; no value, serialized payload, or content-bearing error message appears |
|
||||
|
||||
Do not use elapsed milliseconds as Vitest pass/fail assertions. Use fake timers and call counts for CI; use browser traces for performance evidence.
|
||||
|
||||
## Verification commands
|
||||
|
||||
Run targeted tests while iterating:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
bun run test -- src/utils/coalescedJsonStorage.test.ts src/store/persistenceScheduling.test.ts src/hooks/useAppData.persistence.test.jsx src/main-app.test.jsx src/utils/prefKeys.test.js src/utils/donationMoments.test.js src/test/omniUiSchema.test.js src/test/dubStepRestoreClamp.test.js src/store/uiScaleMigration.test.ts
|
||||
```
|
||||
|
||||
Run the frontend landing gate:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
bun run typecheck:ci
|
||||
bun run lint
|
||||
bun run format:check
|
||||
bun run test
|
||||
bun run test:prod-bundle
|
||||
bun run test:legacy
|
||||
```
|
||||
|
||||
`test:prod-bundle` already performs the production build before its smoke test, so a separate `bun run build` would only duplicate work. Run it separately only when build output is needed during iteration.
|
||||
|
||||
Run the backend CI-equivalent suites from the repository root with a genuinely empty Hugging Face cache:
|
||||
|
||||
```powershell
|
||||
$previousOffline = $env:HF_HUB_OFFLINE
|
||||
$previousCache = $env:HF_HUB_CACHE
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$emptyHfCache = [IO.Path]::GetFullPath((Join-Path $tempRoot ("omnivoice-hf-empty-" + [guid]::NewGuid())))
|
||||
if (-not $emptyHfCache.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe cache path' }
|
||||
New-Item -ItemType Directory -Path $emptyHfCache | Out-Null
|
||||
try {
|
||||
if (@(Get-ChildItem -LiteralPath $emptyHfCache -Force).Count -ne 0) { throw 'HF cache is not empty' }
|
||||
$env:HF_HUB_OFFLINE = '1'
|
||||
$env:HF_HUB_CACHE = $emptyHfCache
|
||||
uv run --no-sync pytest tests/ -q --tb=short
|
||||
if ($LASTEXITCODE -ne 0) { throw "tests/ failed with exit code $LASTEXITCODE" }
|
||||
uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
if ($LASTEXITCODE -ne 0) { throw "backend/tests/ failed with exit code $LASTEXITCODE" }
|
||||
} finally {
|
||||
$env:HF_HUB_OFFLINE = $previousOffline
|
||||
$env:HF_HUB_CACHE = $previousCache
|
||||
Remove-Item -LiteralPath $emptyHfCache -Recurse -Force
|
||||
}
|
||||
```
|
||||
|
||||
These are repository landing gates, not evidence that the frontend optimization itself works. The unique cache and restored environment prevent a populated developer cache or leaked shell state from masking failures.
|
||||
|
||||
## Browser validation protocol
|
||||
|
||||
Check in `frontend/e2e-perf/responsiveness.spec.ts` and `frontend/playwright.perf.config.ts` as a non-CI production-bundle benchmark harness. Keeping it outside `e2e-prod/` ensures the existing production-smoke CI command cannot discover this manual benchmark. The config must mirror `playwright.prod.config.ts`: build the real `dist/`, serve it with `vite preview` on a dedicated strict port, honor `PLAYWRIGHT_CHROMIUM`, use `/usr/bin/chromium` only when it exists, and otherwise fall back to Playwright's bundled browser. It must not use the dev-server E2E config.
|
||||
|
||||
The spec must generate fixtures from fixed seeds, install all required API/WebSocket route mocks or a deterministic bootstrap bypass before navigation, and make no assumption that a backend is running on port 3900. It must use `page.addInitScript` before application code to wrap target-key storage writes and `PerformanceObserver`, drive selectors rather than arbitrary sleeps, and emit machine-readable JSON under Playwright's `test-results` directory. It asserts final state and observable deterministic write counts, but it does not assert elapsed milliseconds or claim to observe serializer task identity. The injected Vitest scheduler tests own the stronger “no provider/serializer execution in the originating task” assertion.
|
||||
|
||||
Run it with:
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
node ./node_modules/@playwright/test/cli.js test --config=playwright.perf.config.ts responsiveness.spec.ts --repeat-each=5 --reporter=line
|
||||
```
|
||||
|
||||
Run `bun install --frozen-lockfile` first. The command above is verified from `frontend/` to resolve the installed Playwright 1.61.0 CLI by exact package path; do not replace it with `bun x playwright` or a global `bun run` shim, which can select another Playwright version, fetch a package, or even resolve a stale Windows shim. If `PLAYWRIGHT_CHROMIUM` is unset and no supported system Chromium exists, install the pinned browser once with `node ./node_modules/@playwright/test/cli.js install chromium`. This adds no project dependency, and the dedicated config provides the cross-platform executable fallback. The config owns port 4174 and never reuses an existing listener, so a stale preview fails loudly and every successful run tears down the exact server it started.
|
||||
|
||||
Use the same browser version, build mode, machine power state, and fixture on both commits.
|
||||
|
||||
1. Instrument target-key `setItem` count, serialized byte length, and call duration before the app loads.
|
||||
2. Observe long tasks and event-to-next-`requestAnimationFrame` latency.
|
||||
3. Seed 1,800 dub segments and 400 story tracks using the current v7/legacy formats.
|
||||
4. Run 20 UI-scale updates 25 ms apart, keeping the complete burst below the hard maximum.
|
||||
5. Run 20 Studio text updates under the same cadence.
|
||||
6. End each burst, wait 1,250 ms, and verify the durable latest values by parsing both keys.
|
||||
7. Run A/B/A (parent, implementation, parent), five repeats per leg; compare median p95 and retain every JSON artifact.
|
||||
8. Run the 3,000/3,000 fixture once as a diagnostic stress case, not as a product limit.
|
||||
|
||||
Deterministic merge gates:
|
||||
|
||||
- A sub-1-second 20-event burst produces no more than one physical write per target key after the burst: at least a 96% reduction from the measured 60 target writes.
|
||||
- Injected utility/integration tests prove no target-key provider, serialization, or write executes in the originating input task; the browser harness independently verifies observable physical writes.
|
||||
- Both parsed durable values contain the final interaction's state.
|
||||
|
||||
Manual decision thresholds, not CI merge gates:
|
||||
|
||||
- Target at least 20% lower median p95 input-to-frame latency on the representative fixture.
|
||||
- Target no more than 5% median-p95 regression on the small fixture.
|
||||
- Expect no greater-than-50-ms task during the interaction burst with persistence work in its trace stack.
|
||||
- If either target is missed or same-commit A/A variance exceeds 5%, treat the timing as inconclusive, attach the raw artifacts, and re-profile. Do not widen this PR merely to manufacture a favorable number.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
The PR is ready for review only when all are true:
|
||||
|
||||
- [ ] Existing keys, field sets, JSON envelope, version, migrations, and restore behavior are unchanged.
|
||||
- [ ] One burst yields at most one trailing write per dirty key and the newest value wins.
|
||||
- [ ] Normal continuous input schedules an attempt within 1 second; failure and timer-throttling limits are documented accurately.
|
||||
- [ ] With healthy storage, orderly hide/navigation flushes synchronously and a hard process termination can lose at most the scheduled unflushed window; failure/throttling exceptions are documented.
|
||||
- [ ] Factory Reset cannot be undone by pending work.
|
||||
- [ ] Unknown-role work cannot reach raw storage, and the standalone widget cannot write or remove main-window preferences.
|
||||
- [ ] Storage failures cannot crash input handling and never leak user content to logs.
|
||||
- [ ] Deterministic tests meet merge gates; the checked-in A/B/A benchmark and raw timing artifacts are attached as non-CI decision evidence.
|
||||
- [ ] Frontend and backend merge gates pass.
|
||||
- [ ] No dependency, lockfile, locale, backend, persisted-version, or package-version change is present.
|
||||
- [ ] The PR remains reviewable as one persistence concern; no opportunistic refactor is included.
|
||||
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --- | --- |
|
||||
| Up to the scheduled window of edits lost on a hard process kill | 250 ms quiet flush, 1,000 ms maximum attempt, hidden/pagehide flush; disclose timer/storage limitations |
|
||||
| Pending or newly queued write recreates reset data | Suspend by `isPrefKey` before raw removal; post-reset update + timer + lifecycle regression test |
|
||||
| Widget flushes stale main-window state | Resolve the existing detector before render; unknown cannot write; widget set/remove operations stay read-only |
|
||||
| Older timer overwrites a newer value | Per-key generation token and last-value-wins tests |
|
||||
| Mutable data changes before deferred serialization | Lazy current-value provider plus a documented mutation audit; never claim event-time snapshot semantics |
|
||||
| Quota or disabled storage breaks the UI | Contain write errors, preserve the previous durable blob, disarm timers, retry only on later activity/explicit flush |
|
||||
| Concurrent browser tabs overwrite each other | Keep the unsupported last-physical-writer boundary explicit; do not add an incomplete conflict protocol here |
|
||||
| Trailing flush is still expensive for pathological documents | Measure it; do not hide it. Escalate to document storage/worker design in a separate PR if representative flush exceeds the budget |
|
||||
| Middleware contract accidentally changes | Exact envelope/fixture tests plus existing migration and direct-reader suites |
|
||||
| Lifecycle listeners duplicate in development/tests | One production owner, isolated test instances, idempotent teardown, and duplicate-install test |
|
||||
| Timing benchmark flakes in CI | Keep wall-clock evidence informational/manual; gate deterministic operation counts |
|
||||
|
||||
## Rollback plan
|
||||
|
||||
No data rollback or migration is required. Reverting the adapter wiring restores immediate writes, and both old and new builds read the same `omnivoice.app` v7 envelope and `omni_ui` object. If a release-only issue appears, revert the PR rather than introducing a second persistence mode or format.
|
||||
|
||||
## Follow-up queue
|
||||
|
||||
These are intentionally not part of the first PR:
|
||||
|
||||
1. **Incremental dub scheduling.** Add a 300 ms debounce, pass `AbortController.signal` through `apiPost`, use a monotonic request revision, cancel outside Dub, and prove one request per burst plus stale-response rejection.
|
||||
2. **Transactional dub undo.** Profile `pushUndo`, which currently stringifies the complete segment array per edit and retains up to 50 snapshots. If material, group edits by segment/field and focus or idle boundary while preserving one-step undo behavior.
|
||||
3. **Workspace isolation.** Profile React commits after persistence remediation; then extract one workspace at a time, moving heavy hooks/imports behind lazy boundaries. Source length and selector count alone are not success metrics.
|
||||
4. **Document storage migration.** Consider IndexedDB or a worker only if representative post-PR flushes remain over budget. That work requires an independent migration, downgrade, reset, quota, and async-hydration design.
|
||||
|
||||
Each follow-up must begin from a fresh trace. None should be pulled into this PR merely because it is nearby.
|
||||
@@ -0,0 +1,529 @@
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const APP_STORE_KEY = 'omnivoice.app';
|
||||
const OMNI_UI_KEY = 'omni_ui';
|
||||
const TARGET_KEYS = [APP_STORE_KEY, OMNI_UI_KEY] as const;
|
||||
const UPDATE_COUNT = 20;
|
||||
const UPDATE_INTERVAL_MS = 25;
|
||||
const TRAILING_FLUSH_SETTLE_MS = 1_250;
|
||||
|
||||
type TargetKey = (typeof TARGET_KEYS)[number];
|
||||
|
||||
interface PhysicalWrite {
|
||||
phase: string;
|
||||
key: TargetKey;
|
||||
atMs: number;
|
||||
bytes: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface LongTaskSample {
|
||||
phase: string;
|
||||
atMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface InputFrameSample {
|
||||
phase: string;
|
||||
target: 'ui-scale' | 'studio-text';
|
||||
atMs: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
interface BrowserMetrics {
|
||||
phase: string;
|
||||
writes: PhysicalWrite[];
|
||||
longTasks: LongTaskSample[];
|
||||
inputToNextRaf: InputFrameSample[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OV_WINDOW__?: string;
|
||||
__OMNIVOICE_API_BASE__?: string;
|
||||
__ovResponsivenessMetrics?: BrowserMetrics;
|
||||
__ovSetResponsivenessPhase?: (phase: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
function makeStoryTracks() {
|
||||
return Array.from({ length: 400 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
character: index % 2 === 0 ? 'narrator' : 'guest',
|
||||
text: `Story track ${index.toString().padStart(3, '0')} ${'narration '.repeat(8)}`,
|
||||
profileId: null,
|
||||
emotion: index % 3 === 0 ? 'warm' : null,
|
||||
speed: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function makeDubSegments() {
|
||||
return Array.from({ length: 1_800 }, (_, index) => ({
|
||||
id: `segment-${index.toString().padStart(4, '0')}`,
|
||||
start: index * 2.5,
|
||||
end: index * 2.5 + 2.25,
|
||||
speaker: index % 2 === 0 ? 'SPEAKER_00' : 'SPEAKER_01',
|
||||
text_original: `Original line ${index} ${'source '.repeat(7)}`,
|
||||
text: `Translated line ${index} ${'target '.repeat(7)}`,
|
||||
profile_id: null,
|
||||
direction: '',
|
||||
}));
|
||||
}
|
||||
|
||||
function persistedFixtures() {
|
||||
return {
|
||||
app: {
|
||||
state: {
|
||||
mode: 'settings',
|
||||
defineMethod: 'audio',
|
||||
uiScale: 1,
|
||||
uiScaleConfigured: true,
|
||||
navStyle: 'rail',
|
||||
locale: 'en',
|
||||
localeChosen: true,
|
||||
langPromptSeen: true,
|
||||
storyTracks: makeStoryTracks(),
|
||||
},
|
||||
version: 7,
|
||||
},
|
||||
omniUi: {
|
||||
uiScale: 1,
|
||||
text: 'Seeded studio text',
|
||||
mode: 'settings',
|
||||
defineMethod: 'audio',
|
||||
vdStates: {
|
||||
Gender: 'Auto',
|
||||
Age: 'Auto',
|
||||
Pitch: 'Auto',
|
||||
Style: 'Auto',
|
||||
EnglishAccent: 'Auto',
|
||||
ChineseDialect: 'Auto',
|
||||
},
|
||||
language: 'Auto',
|
||||
isSidebarCollapsed: false,
|
||||
sidebarTab: 'projects',
|
||||
dubJobId: 'responsiveness-fixture',
|
||||
dubFilename: 'responsiveness-fixture.mp4',
|
||||
dubDuration: 4_500,
|
||||
dubSegments: makeDubSegments(),
|
||||
dubLang: 'English',
|
||||
dubLangCode: 'en',
|
||||
dubTracks: [],
|
||||
dubStep: 'editing',
|
||||
dubTranscript: '',
|
||||
exportTracks: {},
|
||||
preserveBg: true,
|
||||
defaultTrack: 'dialogue',
|
||||
exportHistory: [],
|
||||
speed: 1,
|
||||
steps: 16,
|
||||
cfg: 2,
|
||||
denoise: true,
|
||||
showOverrides: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function installDeterministicBrowserState(page: Page): Promise<Set<string>> {
|
||||
const fixtures = persistedFixtures();
|
||||
const unexpectedRequests = new Set<string>();
|
||||
await page.addInitScript(
|
||||
({ appKey, omniUiKey, app, omniUi }) => {
|
||||
// Fix window identity and API routing before any application module runs.
|
||||
window.__OV_WINDOW__ = 'main';
|
||||
window.__OMNIVOICE_API_BASE__ = window.location.origin;
|
||||
|
||||
// Seed through the native method so fixture setup is not counted as an
|
||||
// application write. Both payloads intentionally match production schema.
|
||||
const nativeSetItem = Storage.prototype.setItem;
|
||||
nativeSetItem.call(localStorage, appKey, JSON.stringify(app));
|
||||
nativeSetItem.call(localStorage, omniUiKey, JSON.stringify(omniUi));
|
||||
nativeSetItem.call(localStorage, 'omnivoice.settings.category', 'appearance');
|
||||
|
||||
const targetKeys = new Set([appKey, omniUiKey]);
|
||||
const metrics: BrowserMetrics = {
|
||||
phase: 'startup',
|
||||
writes: [],
|
||||
longTasks: [],
|
||||
inputToNextRaf: [],
|
||||
};
|
||||
window.__ovResponsivenessMetrics = metrics;
|
||||
window.__ovSetResponsivenessPhase = (phase) => {
|
||||
metrics.phase = phase;
|
||||
};
|
||||
|
||||
Storage.prototype.setItem = function setItem(key: string, value: string): void {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
nativeSetItem.call(this, key, value);
|
||||
} finally {
|
||||
if (targetKeys.has(key)) {
|
||||
const durationMs = performance.now() - startedAt;
|
||||
metrics.writes.push({
|
||||
phase: metrics.phase,
|
||||
key: key as TargetKey,
|
||||
atMs: startedAt,
|
||||
// Encode after the native call so byte accounting is excluded
|
||||
// from the measured physical-storage duration.
|
||||
bytes: new TextEncoder().encode(value).byteLength,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener(
|
||||
'input',
|
||||
(event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const sampleTarget = target.matches('.appearance-panel input[type="range"]')
|
||||
? 'ui-scale'
|
||||
: target.matches('textarea.studio-script-input')
|
||||
? 'studio-text'
|
||||
: null;
|
||||
if (!sampleTarget) return;
|
||||
const startedAt = performance.now();
|
||||
requestAnimationFrame(() => {
|
||||
metrics.inputToNextRaf.push({
|
||||
phase: metrics.phase,
|
||||
target: sampleTarget,
|
||||
atMs: startedAt,
|
||||
durationMs: performance.now() - startedAt,
|
||||
});
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (
|
||||
'PerformanceObserver' in window &&
|
||||
PerformanceObserver.supportedEntryTypes?.includes('longtask')
|
||||
) {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
metrics.longTasks.push({
|
||||
phase: metrics.phase,
|
||||
atMs: entry.startTime,
|
||||
durationMs: entry.duration,
|
||||
});
|
||||
}
|
||||
});
|
||||
observer.observe({ type: 'longtask', buffered: true });
|
||||
}
|
||||
|
||||
// Keep the realtime hook deterministic and fully local while preserving
|
||||
// the handler and EventTarget surfaces used by capture/realtime clients.
|
||||
class DeterministicWebSocket extends EventTarget {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readonly url: string;
|
||||
readyState = DeterministicWebSocket.CONNECTING;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super();
|
||||
this.url = String(url);
|
||||
queueMicrotask(() => {
|
||||
if (this.readyState !== DeterministicWebSocket.CONNECTING) return;
|
||||
this.readyState = DeterministicWebSocket.OPEN;
|
||||
const event = new Event('open');
|
||||
this.dispatchEvent(event);
|
||||
this.onopen?.(event);
|
||||
});
|
||||
}
|
||||
|
||||
send(): void {}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === DeterministicWebSocket.CLOSED) return;
|
||||
this.readyState = DeterministicWebSocket.CLOSED;
|
||||
const event = new CloseEvent('close', { code: 1000, wasClean: true });
|
||||
this.dispatchEvent(event);
|
||||
this.onclose?.(event);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'WebSocket', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: DeterministicWebSocket,
|
||||
});
|
||||
},
|
||||
{
|
||||
appKey: APP_STORE_KEY,
|
||||
omniUiKey: OMNI_UI_KEY,
|
||||
app: fixtures.app,
|
||||
omniUi: fixtures.omniUi,
|
||||
},
|
||||
);
|
||||
|
||||
// Production resolves API calls to the preview origin. Fulfil every
|
||||
// fetch/XHR deterministically, while allowing HTML, chunks, fonts and CSS to
|
||||
// come from the real production bundle under test.
|
||||
await page.route('**/*', async (route) => {
|
||||
const request = route.request();
|
||||
if (!['fetch', 'xhr'].includes(request.resourceType())) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
|
||||
const path = new URL(request.url()).pathname;
|
||||
const responseByPath: Record<string, unknown> = {
|
||||
'/health': { status: 'ok' },
|
||||
'/setup/status': {
|
||||
models_ready: true,
|
||||
missing: [],
|
||||
hf_cache_dir: '/deterministic/models',
|
||||
disk_free_gb: 100,
|
||||
min_free_gb: 1,
|
||||
enough_disk: true,
|
||||
},
|
||||
'/model/status': { status: 'idle', sub_stage: null, detail: '', error: null, progress: null },
|
||||
'/profiles': [],
|
||||
'/personalities': [],
|
||||
'/history': [],
|
||||
'/dub/history': [],
|
||||
'/projects': [],
|
||||
'/export/history': [],
|
||||
'/engines': {
|
||||
tts: { active: null, backends: [] },
|
||||
asr: { active: null, backends: [] },
|
||||
llm: { active: null, backends: [] },
|
||||
},
|
||||
'/sysinfo': { cpu: 0, ram: 0, total_ram: 32, vram: 0, gpu_active: false },
|
||||
'/system/info': { platform: 'benchmark', device: 'deterministic' },
|
||||
'/system/notifications': { notifications: [] },
|
||||
'/system/last-run-crash': { record: null, acknowledged: false },
|
||||
'/system/logs': { path: '', exists: false, lines: [] },
|
||||
'/system/logs/tauri': { path: '', exists: false, lines: [] },
|
||||
'/system/network/state': { enabled: false },
|
||||
'/dictation/prefs': {
|
||||
enabled: false,
|
||||
mode: 'toggle',
|
||||
model_id: 'sherpa-parakeet-tdt-v3',
|
||||
},
|
||||
'/workers': { enabled: false, running: false, workers: [] },
|
||||
'/workers/target': {
|
||||
target: 'local',
|
||||
active: { remote: false },
|
||||
targets: [
|
||||
{ id: 'local', label: 'Local', is_local: true, status: 'ready', available: true },
|
||||
],
|
||||
},
|
||||
'/api/settings/analytics': { available: false, prompted: true, opted_in: false },
|
||||
'/donation_progress.json': {
|
||||
raised: 10,
|
||||
goal: 200,
|
||||
currency: 'USD',
|
||||
sponsorCount: 1,
|
||||
updated: '2026-06-17',
|
||||
},
|
||||
};
|
||||
|
||||
const responseBody = responseByPath[path];
|
||||
if (responseBody === undefined) {
|
||||
unexpectedRequests.add(`${request.method()} ${path}`);
|
||||
await route.fulfill({
|
||||
status: 501,
|
||||
contentType: 'application/json',
|
||||
headers: { 'x-omnivoice-backend': '1' },
|
||||
body: JSON.stringify({ detail: 'Unhandled deterministic benchmark route' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'x-omnivoice-backend': '1' },
|
||||
body: JSON.stringify(responseBody),
|
||||
});
|
||||
});
|
||||
return unexpectedRequests;
|
||||
}
|
||||
|
||||
async function setPhase(page: Page, phase: string): Promise<void> {
|
||||
await page.evaluate((nextPhase) => window.__ovSetResponsivenessPhase?.(nextPhase), phase);
|
||||
}
|
||||
|
||||
async function driveNativeInputBurst(
|
||||
page: Page,
|
||||
selector: string,
|
||||
values: string[],
|
||||
): Promise<void> {
|
||||
await page.locator(selector).evaluate(
|
||||
async (node, burst) => {
|
||||
const element = node as HTMLInputElement | HTMLTextAreaElement;
|
||||
const prototype =
|
||||
element instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
const nativeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
|
||||
if (!nativeValueSetter) throw new Error(`No native value setter for ${element.tagName}`);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
// Schedule against one common origin. Measuring UI work must not add
|
||||
// another 25 ms after every handler and accidentally turn a 475 ms
|
||||
// burst into a >1 s stream that rightfully crosses the max-flush gate.
|
||||
burst.values.forEach((value, index) => {
|
||||
setTimeout(() => {
|
||||
nativeValueSetter.call(element, value);
|
||||
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
||||
if (index === burst.values.length - 1) resolve();
|
||||
}, index * burst.intervalMs);
|
||||
});
|
||||
});
|
||||
},
|
||||
{ values, intervalMs: UPDATE_INTERVAL_MS },
|
||||
);
|
||||
}
|
||||
|
||||
async function readDurableValues(page: Page) {
|
||||
return page.evaluate(
|
||||
({ appKey, omniUiKey }) => ({
|
||||
app: JSON.parse(localStorage.getItem(appKey) || 'null'),
|
||||
omniUi: JSON.parse(localStorage.getItem(omniUiKey) || 'null'),
|
||||
}),
|
||||
{ appKey: APP_STORE_KEY, omniUiKey: OMNI_UI_KEY },
|
||||
);
|
||||
}
|
||||
|
||||
function writesFor(metrics: BrowserMetrics, phase: string, key: TargetKey): PhysicalWrite[] {
|
||||
return metrics.writes.filter((write) => write.phase === phase && write.key === key);
|
||||
}
|
||||
|
||||
async function writeReport(testInfo: TestInfo, report: unknown): Promise<void> {
|
||||
const artifactPath = testInfo.outputPath('responsiveness.json');
|
||||
await writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
await testInfo.attach('responsiveness.json', {
|
||||
path: artifactPath,
|
||||
contentType: 'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
test('coalesces large-state persistence during rapid UI input', async ({ page }, testInfo) => {
|
||||
const unexpectedRequests = await installDeterministicBrowserState(page);
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
const listenerProbe = await page.evaluate(async () => {
|
||||
const socket = new WebSocket('ws://benchmark.invalid');
|
||||
let onceCalls = 0;
|
||||
let removedCalls = 0;
|
||||
const removedListener = () => {
|
||||
removedCalls += 1;
|
||||
};
|
||||
socket.addEventListener(
|
||||
'open',
|
||||
() => {
|
||||
onceCalls += 1;
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
socket.addEventListener('open', removedListener);
|
||||
socket.removeEventListener('open', removedListener);
|
||||
await Promise.resolve();
|
||||
socket.dispatchEvent(new Event('open'));
|
||||
socket.close();
|
||||
return { onceCalls, removedCalls };
|
||||
});
|
||||
expect(listenerProbe).toEqual({ onceCalls: 1, removedCalls: 0 });
|
||||
|
||||
const scaleSelector = '.appearance-panel input[type="range"]';
|
||||
await expect(page.locator(scaleSelector)).toBeVisible();
|
||||
|
||||
// Let startup restoration and its trailing persistence window fully settle;
|
||||
// subsequent records are phase-labelled and attributable to one burst.
|
||||
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
|
||||
|
||||
const scaleValues = Array.from({ length: UPDATE_COUNT }, (_, index) =>
|
||||
(0.65 + index * 0.05).toFixed(2),
|
||||
);
|
||||
const finalScale = Number(scaleValues.at(-1));
|
||||
await setPhase(page, 'ui-scale');
|
||||
await driveNativeInputBurst(page, scaleSelector, scaleValues);
|
||||
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
|
||||
|
||||
const afterScale = await readDurableValues(page);
|
||||
expect(afterScale.app?.state?.uiScale).toBe(finalScale);
|
||||
expect(afterScale.omniUi?.uiScale).toBe(finalScale);
|
||||
|
||||
// Navigate through the real production UI. Waiting before phase assignment
|
||||
// prevents the navigation write from being counted as a text-input write.
|
||||
await setPhase(page, 'navigation');
|
||||
await page.locator('.nav-rail button[aria-label="Voice"]').click();
|
||||
const textSelector = 'textarea.studio-script-input';
|
||||
await expect(page.locator(textSelector)).toBeVisible();
|
||||
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
|
||||
|
||||
const textValues = Array.from(
|
||||
{ length: UPDATE_COUNT },
|
||||
(_, index) => `responsiveness-${index.toString().padStart(2, '0')}-${'voice '.repeat(8)}`,
|
||||
);
|
||||
const finalText = textValues.at(-1);
|
||||
await setPhase(page, 'studio-text');
|
||||
await driveNativeInputBurst(page, textSelector, textValues);
|
||||
await page.waitForTimeout(TRAILING_FLUSH_SETTLE_MS);
|
||||
|
||||
const durable = await readDurableValues(page);
|
||||
const metrics = await page.evaluate(() => window.__ovResponsivenessMetrics as BrowserMetrics);
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
fixture: { appStoreVersion: 7, storyTracks: 400, dubSegments: 1_800 },
|
||||
burst: { updates: UPDATE_COUNT, requestedIntervalMs: UPDATE_INTERVAL_MS },
|
||||
durable: {
|
||||
appUiScale: durable.app?.state?.uiScale,
|
||||
omniUiScale: durable.omniUi?.uiScale,
|
||||
omniUiText: durable.omniUi?.text,
|
||||
},
|
||||
phases: {
|
||||
uiScale: {
|
||||
writes: Object.fromEntries(
|
||||
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'ui-scale', key)]),
|
||||
),
|
||||
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale'),
|
||||
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'ui-scale'),
|
||||
},
|
||||
studioText: {
|
||||
writes: Object.fromEntries(
|
||||
TARGET_KEYS.map((key) => [key, writesFor(metrics, 'studio-text', key)]),
|
||||
),
|
||||
inputToNextRaf: metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text'),
|
||||
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'studio-text'),
|
||||
},
|
||||
},
|
||||
startup: {
|
||||
writes: metrics.writes.filter((write) => write.phase === 'startup'),
|
||||
longTasks: metrics.longTasks.filter((sample) => sample.phase === 'startup'),
|
||||
},
|
||||
network: { unexpectedRequests: [...unexpectedRequests].sort() },
|
||||
};
|
||||
await writeReport(testInfo, report);
|
||||
|
||||
expect(durable.omniUi?.text).toBe(finalText);
|
||||
expect([...unexpectedRequests].sort(), 'every fetch/XHR must have an explicit fixture').toEqual(
|
||||
[],
|
||||
);
|
||||
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'ui-scale')).toHaveLength(
|
||||
UPDATE_COUNT,
|
||||
);
|
||||
expect(metrics.inputToNextRaf.filter((sample) => sample.phase === 'studio-text')).toHaveLength(
|
||||
UPDATE_COUNT,
|
||||
);
|
||||
for (const phase of ['ui-scale', 'studio-text']) {
|
||||
for (const key of TARGET_KEYS) {
|
||||
expect(
|
||||
writesFor(metrics, phase, key).length,
|
||||
`${phase} should physically write ${key} no more than once`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { expect, test, type Page, type Request, type Route } from '@playwright/test';
|
||||
|
||||
const MASTER = 'root-master-never-retained';
|
||||
const SESSION = `ovs_admin_session_${'S'.repeat(43)}`;
|
||||
|
||||
async function browserCredentialSnapshot(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
href: location.href,
|
||||
legacyMaster: localStorage.getItem('ov_api_key'),
|
||||
storedSession: sessionStorage.getItem('ov_admin_session'),
|
||||
localValues: Object.values(localStorage),
|
||||
sessionValues: Object.values(sessionStorage),
|
||||
}));
|
||||
}
|
||||
|
||||
test('same-origin production bootstrap exchanges once into an HttpOnly cookie', async ({
|
||||
context,
|
||||
page,
|
||||
}) => {
|
||||
const seen: Request[] = [];
|
||||
page.on('request', (request) => seen.push(request));
|
||||
await page.addInitScript((master) => localStorage.setItem('ov_api_key', master), MASTER);
|
||||
|
||||
let exchange: Request | undefined;
|
||||
await page.route('**/api/auth/session', async (route) => {
|
||||
exchange = route.request();
|
||||
await route.fulfill({
|
||||
status: 204,
|
||||
headers: {
|
||||
'cache-control': 'no-store',
|
||||
'set-cookie': `ov_session=${SESSION}; HttpOnly; SameSite=Strict; Path=/; Max-Age=28800`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/#api_key=${MASTER}&tab=voices`, { waitUntil: 'domcontentloaded' });
|
||||
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
|
||||
|
||||
expect(exchange?.postDataJSON()).toEqual({ transport: 'cookie' });
|
||||
const snapshot = await browserCredentialSnapshot(page);
|
||||
expect(snapshot.href).toMatch(/#tab=voices$/);
|
||||
expect(snapshot.href).not.toContain(MASTER);
|
||||
expect(snapshot.legacyMaster).toBeNull();
|
||||
expect(snapshot.storedSession).toBeNull();
|
||||
expect([...snapshot.localValues, ...snapshot.sessionValues].join('\n')).not.toContain(MASTER);
|
||||
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const cookie = cookies.find(({ name }) => name === 'ov_session');
|
||||
expect(cookie).toMatchObject({ value: SESSION, httpOnly: true, sameSite: 'Strict', path: '/' });
|
||||
expect(cookies.some(({ name }) => name === 'ov_key')).toBe(false);
|
||||
expect(cookies.map(({ value }) => value).join('\n')).not.toContain(MASTER);
|
||||
});
|
||||
|
||||
test('cross-origin production bootstrap stores only a backend-bound tab session', async ({
|
||||
page,
|
||||
}) => {
|
||||
const remote = 'http://gpu.test:3900';
|
||||
const seen: Request[] = [];
|
||||
page.on('request', (request) => seen.push(request));
|
||||
await page.addInitScript(
|
||||
({ backend, master }) => {
|
||||
localStorage.setItem('ov_backend_url', backend);
|
||||
localStorage.setItem('ov_api_key', master);
|
||||
},
|
||||
{ backend: remote, master: MASTER },
|
||||
);
|
||||
|
||||
let exchange: Request | undefined;
|
||||
await page.route(`${remote}/**`, async (route: Route) => {
|
||||
const request = route.request();
|
||||
const corsHeaders = {
|
||||
'access-control-allow-credentials': 'true',
|
||||
'access-control-allow-headers': 'authorization,content-type,x-voicestudio-csrf',
|
||||
'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
|
||||
'access-control-allow-origin': request.headers().origin ?? 'http://localhost:4173',
|
||||
'access-control-expose-headers': 'x-omnivoice-backend',
|
||||
'x-omnivoice-backend': 'e2e',
|
||||
};
|
||||
if (request.method() === 'OPTIONS') {
|
||||
await route.fulfill({ status: 204, headers: corsHeaders });
|
||||
return;
|
||||
}
|
||||
if (new URL(request.url()).pathname === '/api/auth/session') {
|
||||
exchange = request;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'cache-control': 'no-store',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: SESSION, expires_at: 1, expires_in: 3600 }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (new URL(request.url()).pathname === '/health') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'ok', version: 'e2e', device: 'cpu' }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'content-type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/#api_key=${MASTER}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect.poll(() => exchange?.headers().authorization).toBe(`Bearer ${MASTER}`);
|
||||
|
||||
expect(exchange?.postDataJSON()).toEqual({ transport: 'bearer' });
|
||||
const snapshot = await browserCredentialSnapshot(page);
|
||||
expect(snapshot.href).not.toContain(MASTER);
|
||||
expect(snapshot.legacyMaster).toBeNull();
|
||||
expect(snapshot.storedSession).not.toBeNull();
|
||||
expect(JSON.parse(snapshot.storedSession ?? '{}')).toMatchObject({
|
||||
token: SESSION,
|
||||
apiBase: remote,
|
||||
});
|
||||
expect(snapshot.localValues.join('\n')).not.toContain(MASTER);
|
||||
expect(snapshot.sessionValues.join('\n')).not.toContain(MASTER);
|
||||
expect(seen.map((request) => request.url()).join('\n')).not.toContain(MASTER);
|
||||
expect(
|
||||
seen.filter((request) => request.headers().authorization === `Bearer ${MASTER}`),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
@@ -27,7 +27,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => {
|
||||
|
||||
test('gallery: bottom-most voice card stays above the collapsed footer', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const cards = page.locator('.archetype-card');
|
||||
const cards = page.getByTestId('gallery-persona-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const top = await footerTop(page);
|
||||
@@ -45,7 +45,7 @@ test.describe('LogsFooter never covers page content @ 900x600', () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const cards = page.locator('.archetype-card');
|
||||
const cards = page.getByTestId('gallery-persona-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Expand the logs panel (chevron toggle in the collapsed bar).
|
||||
|
||||
@@ -9,7 +9,9 @@ test.describe('VoiceStudio Gallery', () => {
|
||||
|
||||
test('facet dropdowns use the dark theme, not the OS-default light surface', async ({ page }) => {
|
||||
await gotoMode(page, 'gallery');
|
||||
const select = page.locator('select.facet-select').first();
|
||||
// Scope by testid, not the translated 'Archetypes' label — the accessible
|
||||
// name follows the app locale and breaks under non-English navigators.
|
||||
const select = page.getByTestId('archetypes-zone').getByRole('combobox').first();
|
||||
await expect(select).toBeVisible();
|
||||
// Regression guard for the undefined-var fallback: the fixed style resolves
|
||||
// --chrome-hover-bg → rgba(255,255,255,0.04), NOT an opaque UA light surface
|
||||
@@ -25,7 +27,7 @@ test.describe('VoiceStudio Gallery', () => {
|
||||
await gotoMode(page, 'gallery');
|
||||
|
||||
// Cards load from the backend; wait for the first one.
|
||||
const designerBtn = page.locator('.archetype-card .designer-btn').first();
|
||||
const designerBtn = page.getByRole('button', { name: /Open in Designer/i }).first();
|
||||
await expect(designerBtn).toBeVisible({ timeout: 20_000 });
|
||||
await designerBtn.click();
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { gotoMode } from './_helpers';
|
||||
|
||||
const MIN_WINDOW = { width: 900, height: 600 };
|
||||
|
||||
test.describe('Support page stays on one screen @ 900x600', () => {
|
||||
test.use({ viewport: MIN_WINDOW });
|
||||
|
||||
test('support, commercial licence and contact panels do not overflow', async ({ page }) => {
|
||||
await gotoMode(page, 'donate');
|
||||
await expect(page.getByRole('heading', { name: 'Support VoiceStudio' })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
for (const tabName of ['Support', 'Commercial License', 'Contact']) {
|
||||
const tab = page.getByRole('tab', { name: tabName });
|
||||
if ((await tab.getAttribute('aria-selected')) !== 'true') await tab.click({ force: true });
|
||||
|
||||
await expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
const panel = page.getByRole('tabpanel');
|
||||
await expect(panel).toBeVisible();
|
||||
const { clientHeight, scrollHeight } = await panel.evaluate((element) => ({
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
}));
|
||||
expect(
|
||||
scrollHeight,
|
||||
`${tabName} panel should not need vertical scrolling`,
|
||||
).toBeLessThanOrEqual(clientHeight + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omnivoice-studio",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
// Opt-in production-bundle responsiveness benchmark. Keep it separate from
|
||||
// playwright.prod.config.ts: the smoke suite is a CI correctness gate, while
|
||||
// this harness records machine-dependent timing diagnostics for local review.
|
||||
const PORT = Number(process.env.E2E_PERF_PORT || 4174);
|
||||
|
||||
// An explicit browser wins; Linux CI/dev containers commonly provide a system
|
||||
// Chromium; contributors on Windows/macOS fall back to Playwright's bundle.
|
||||
const SYSTEM_CHROMIUM = '/usr/bin/chromium';
|
||||
const browserPath =
|
||||
process.env.PLAYWRIGHT_CHROMIUM || (existsSync(SYSTEM_CHROMIUM) ? SYSTEM_CHROMIUM : undefined);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e-perf',
|
||||
testMatch: 'responsiveness.spec.ts',
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
// `--repeat-each=5` is a variance sample, not five independent load tests.
|
||||
// Keep repeats serial so they do not contend with each other or distort the
|
||||
// input/long-task evidence on high-core development machines.
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
outputDir: 'test-results/responsiveness',
|
||||
use: {
|
||||
baseURL: `http://localhost:${PORT}`,
|
||||
headless: true,
|
||||
trace: 'retain-on-failure',
|
||||
...(browserPath ? { launchOptions: { executablePath: browserPath } } : {}),
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
webServer: {
|
||||
// Playwright launches through the platform shell. Invoke the repo-pinned
|
||||
// Vite binary directly so Windows does not depend on whichever global Bun
|
||||
// shim happens to precede the checked-in toolchain on PATH.
|
||||
command: `node ./node_modules/vite/bin/vite.js build && node ./node_modules/vite/bin/vite.js preview --port ${PORT} --strictPort`,
|
||||
url: `http://localhost:${PORT}`,
|
||||
// Always own the production preview used for a measurement. Reusing an
|
||||
// arbitrary listener can benchmark stale dist bytes and leaves teardown
|
||||
// ownership ambiguous; a stale 4174 listener should fail loudly instead.
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
},
|
||||
});
|
||||
Generated
+1
-1
@@ -2941,7 +2941,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "omnivoice-studio"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"dirs-next",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# launcher's pkill matches `omnivoice-studio` and must never match a user's
|
||||
# installed app. Renaming it would collapse that distinction.
|
||||
name = "omnivoice-studio"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
description = "VoiceStudio – AI voice cloning & dubbing desktop app"
|
||||
authors = ["Debpalash"]
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -100,3 +100,6 @@ zbus = "5.16"
|
||||
# Scoped-reset tests build real directory trees to prove the delete guard only
|
||||
# ever removes paths inside a validated OmniVoice root.
|
||||
tempfile = "3"
|
||||
# MockRuntime app for the backend-lifecycle fault-injection harness
|
||||
# (tests/backend_lifecycle.rs) — feature-unifies onto the main dep.
|
||||
tauri = { version = "2.11.0", features = ["test"] }
|
||||
|
||||
@@ -44,5 +44,21 @@ fn main() {
|
||||
ensure_sidecar_placeholder("uv");
|
||||
ensure_sidecar_placeholder("ffmpeg");
|
||||
ensure_sidecar_placeholder("ffprobe");
|
||||
|
||||
// Windows test binaries need the Common-Controls v6 manifest that
|
||||
// tauri-build embeds into the app binary but cargo gives tests none of:
|
||||
// without it the loader resolves comctl32 v5 (no TaskDialogIndirect —
|
||||
// imported by tauri's dialog/tray stack) and every integration-test
|
||||
// binary dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139).
|
||||
// See tests/windows-test.manifest.
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
|
||||
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into()))
|
||||
.join("tests")
|
||||
.join("windows-test.manifest");
|
||||
println!("cargo:rerun-if-changed={}", manifest.display());
|
||||
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}", manifest.display());
|
||||
}
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
@@ -100,6 +100,52 @@ pub fn backend_deep_healthy(port: u16) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness = identity AND capability. The shallow probe proves the
|
||||
/// responder is OUR backend; the deep probe proves it can actually serve a
|
||||
/// DB-backed route. Declaring Ready on the shallow probe alone announced a
|
||||
/// backend whose install/DB was broken underneath as up — the UI looked
|
||||
/// alive while every real request 500'd or dead-ended on "can't reach the
|
||||
/// backend". Both Ready transitions (startup poll, supervisor respawn wait)
|
||||
/// gate on this; the supervisor's DEATH detection stays process-exit-only
|
||||
/// (`try_wait`), so a busy-but-alive backend is still never killed.
|
||||
pub fn backend_ready(port: u16) -> bool {
|
||||
backend_healthy(port) && backend_deep_healthy(port)
|
||||
}
|
||||
|
||||
/// Startup progress from the backend's early-bind `/startup/progress`
|
||||
/// endpoint: `(status, step, label)`, e.g. `("starting", "ml_imports",
|
||||
/// "Loading ML runtime (PyTorch)…")`. `None` when nothing answers, when the
|
||||
/// responder lacks the `x-omnivoice-backend` marker header (a foreign
|
||||
/// process on our port must not narrate our splash), or on an old backend
|
||||
/// without the endpoint — callers fall back to the legacy probes.
|
||||
pub fn startup_progress(port: u16) -> Option<(String, String, String)> {
|
||||
let url = format!("http://127.0.0.1:{}/startup/progress", port);
|
||||
let resp = raw_http_get(&url, Duration::from_millis(800)).ok()?;
|
||||
if parse_http_status(&resp) != Some(200) {
|
||||
return None;
|
||||
}
|
||||
let head_end = resp.find("\r\n\r\n").unwrap_or(resp.len());
|
||||
if !resp[..head_end].to_ascii_lowercase().contains("x-omnivoice-backend") {
|
||||
return None;
|
||||
}
|
||||
let body = &resp[resp.find("\r\n\r\n").map(|i| i + 4).unwrap_or(0)..];
|
||||
let status = parse_json_string_field(body, "status")?;
|
||||
let step = parse_json_string_field(body, "step").unwrap_or_default();
|
||||
let label = parse_json_string_field(body, "label").unwrap_or_default();
|
||||
Some((status, step, label))
|
||||
}
|
||||
|
||||
/// First `"key": "value"` string field in a JSON body — same dependency-free
|
||||
/// sniffing style as `parse_app_version`. `None` for absent or non-string
|
||||
/// (e.g. `null`) values.
|
||||
fn parse_json_string_field(body: &str, key: &str) -> Option<String> {
|
||||
let needle = format!("\"{key}\"");
|
||||
let rest = &body[body.find(&needle)? + needle.len()..];
|
||||
let rest = rest[rest.find(':')? + 1..].trim_start();
|
||||
let rest = rest.strip_prefix('"')?;
|
||||
Some(rest[..rest.find('"')?].to_string())
|
||||
}
|
||||
|
||||
/// Status code from a raw HTTP response ("HTTP/1.1 200 OK" → 200).
|
||||
fn parse_http_status(response: &str) -> Option<u16> {
|
||||
let line = response.lines().next()?;
|
||||
@@ -243,6 +289,16 @@ pub fn kill_orphan_on_port(port: u16) {
|
||||
// ── Log paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn backend_log_path() -> PathBuf {
|
||||
// Support/test override: point logs (and the crash-marker store, which
|
||||
// derives from this path) somewhere explicit. The fault-injection
|
||||
// harness gives every scenario its own tempdir through this.
|
||||
if let Ok(dir) = std::env::var("OMNIVOICE_LOG_DIR") {
|
||||
if !dir.trim().is_empty() {
|
||||
let log_dir = PathBuf::from(dir);
|
||||
let _ = fs::create_dir_all(&log_dir);
|
||||
return log_dir.join("backend.log");
|
||||
}
|
||||
}
|
||||
let log_dir = if cfg!(target_os = "macos") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join("Library/Logs/OmniVoice")
|
||||
@@ -262,18 +318,117 @@ pub fn backend_log_path() -> PathBuf {
|
||||
}
|
||||
|
||||
/// Read the last N lines from backend_err.log for diagnostic messages.
|
||||
///
|
||||
/// Whole-file view — bootstrap phases (uv sync et al.) that predate any
|
||||
/// backend run use this. Anything reporting on a specific backend process
|
||||
/// (crash markers, death diagnostics) must use [`read_error_log_tail_for_run`]
|
||||
/// instead: the file outlives runs, so an unbounded tail can attribute one
|
||||
/// run's output to another (#1510).
|
||||
pub fn read_error_log_tail(max_lines: usize) -> String {
|
||||
let err_path = backend_log_path().with_file_name("backend_err.log");
|
||||
match fs::read_to_string(&err_path) {
|
||||
Ok(content) => {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start = lines.len().saturating_sub(max_lines);
|
||||
lines[start..].join("\n")
|
||||
read_error_log_tail_at(&err_path, 0, max_lines)
|
||||
}
|
||||
|
||||
// ── Per-run crash evidence (#1510) ────────────────────────────────────────
|
||||
//
|
||||
// backend_err.log is one file shared by every backend run in an app session,
|
||||
// and it used to be TRUNCATED on each spawn. Both properties destroyed crash
|
||||
// evidence: a respawn wiped the dead process's final words, and any tail read
|
||||
// after the replacement started could attach the new run's healthy startup to
|
||||
// the old run's crash marker — exactly the undiagnosable report in #1510.
|
||||
// The file is append-only now, each spawn records where its run begins, and
|
||||
// death paths read only their own run's slice.
|
||||
|
||||
/// Byte offset in backend_err.log where the CURRENT run's output begins.
|
||||
/// Set by `spawn_backend` before the child starts writing.
|
||||
static ERR_LOG_RUN_START: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Rotate once the shared file gets this big (append-only would otherwise
|
||||
/// grow across runs forever). Generous: evidence beats disk here.
|
||||
const ERR_LOG_ROTATE_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
/// Where the current backend run's slice of backend_err.log begins.
|
||||
pub fn err_log_run_start() -> u64 {
|
||||
ERR_LOG_RUN_START.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Last N lines of the CURRENT run's slice of backend_err.log.
|
||||
///
|
||||
/// This is the reader every death path must use: it cannot see another run's
|
||||
/// output, so a crash marker carries the dying process's words or nothing.
|
||||
pub fn read_error_log_tail_for_run(max_lines: usize) -> String {
|
||||
let err_path = backend_log_path().with_file_name("backend_err.log");
|
||||
read_error_log_tail_at(&err_path, err_log_run_start(), max_lines)
|
||||
}
|
||||
|
||||
/// Tail of `path` starting at byte `start` (whole file when `start` is 0 or
|
||||
/// no longer valid — an externally replaced/shrunk file must degrade to the
|
||||
/// old whole-file behaviour, never to a silent empty capture).
|
||||
fn read_error_log_tail_at(path: &Path, start: u64, max_lines: usize) -> String {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
let start = usize::try_from(start).unwrap_or(0);
|
||||
let slice = if start > 0 && start <= content.len() && content.is_char_boundary(start) {
|
||||
&content[start..]
|
||||
} else {
|
||||
&content[..]
|
||||
};
|
||||
let lines: Vec<&str> = slice.lines().collect();
|
||||
let from = lines.len().saturating_sub(max_lines);
|
||||
lines[from..].join("\n")
|
||||
}
|
||||
|
||||
/// The previous run's stderr-drainer thread. Joined (bounded) before a new
|
||||
/// spawn records its offset, so a dying run's still-buffered stderr cannot be
|
||||
/// appended AFTER the new run's start offset and get attributed to the new
|
||||
/// run. (Full per-child offset binding isn't needed: spawns are serialized by
|
||||
/// the #1223 spawn-once flow, so the only race left was this buffered tail.)
|
||||
static ERR_LOG_DRAINER: Mutex<Option<std::thread::JoinHandle<()>>> = Mutex::new(None);
|
||||
|
||||
/// Wait briefly for the previous run's stderr drainer to flush. A wedged
|
||||
/// drainer (pipe held open by an orphaned grandchild) must not block a
|
||||
/// respawn forever — after the bound we proceed; the offset then simply
|
||||
/// includes whatever the old run still manages to write, which degrades to
|
||||
/// attributing too MUCH to the new run, never to destroying evidence.
|
||||
fn join_previous_err_drainer(bound: Duration) {
|
||||
let handle = ERR_LOG_DRAINER.lock().ok().and_then(|mut g| g.take());
|
||||
if let Some(handle) = handle {
|
||||
let deadline = std::time::Instant::now() + bound;
|
||||
while !handle.is_finished() && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
if handle.is_finished() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open backend_err.log for a new run: append-only (a respawn must not
|
||||
/// destroy the previous run's evidence), rotated when oversized, with the
|
||||
/// run's start offset returned for `ERR_LOG_RUN_START`.
|
||||
fn open_err_log_for_run(err_path: &Path) -> (Option<fs::File>, u64) {
|
||||
let len = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0);
|
||||
if len > ERR_LOG_ROTATE_BYTES {
|
||||
let rotated = err_path.with_file_name("backend_err.log.1");
|
||||
// Rename preferred (keeps the old evidence in .1); on failure —
|
||||
// e.g. the file is still held open on Windows — fall back to
|
||||
// truncating, which is exactly the pre-#1510 behaviour.
|
||||
if fs::rename(err_path, &rotated).is_err() {
|
||||
let file = fs::File::create(err_path).ok();
|
||||
return (file, 0);
|
||||
}
|
||||
}
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(err_path)
|
||||
.ok();
|
||||
let start = fs::metadata(err_path).map(|m| m.len()).unwrap_or(0);
|
||||
(file, start)
|
||||
}
|
||||
|
||||
/// Human-readable diagnostic for a failed `Command::spawn()` of the backend.
|
||||
///
|
||||
/// #144 / #127: when the bundled venv Python can't exec (the common Linux/
|
||||
@@ -281,6 +436,21 @@ pub fn read_error_log_tail(max_lines: usize) -> String {
|
||||
/// process "never started" and we previously surfaced "no error output
|
||||
/// captured". Writing this to backend_err.log lets read_error_log_tail show the
|
||||
/// real OS error + an actionable hint instead.
|
||||
/// Replace the user's home-directory prefix with `~`. This diagnostic is
|
||||
/// retained in backend_err.log across runs and lands verbatim in bug
|
||||
/// reports, so the username must not travel with it.
|
||||
fn redact_home(text: &str) -> String {
|
||||
for var in ["HOME", "USERPROFILE"] {
|
||||
if let Ok(home) = std::env::var(var) {
|
||||
let home = home.trim_end_matches(['/', '\\']);
|
||||
if home.len() > 1 && text.starts_with(home) {
|
||||
return format!("~{}", &text[home.len()..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
|
||||
// Platform-specific tail (cfg! resolves to this build's target OS, i.e. the
|
||||
// OS it runs on) — don't show AppImage/loader wording to macOS/Windows users.
|
||||
@@ -305,7 +475,7 @@ fn spawn_failure_diagnostic(python: &Path, err: &std::io::Error) -> String {
|
||||
Interpreter present on disk: {}\n\
|
||||
OS error: {}\n\n\
|
||||
{} Use \"Clean & Retry\" to rebuild the environment.",
|
||||
python.display(),
|
||||
redact_home(&python.display().to_string()),
|
||||
python.exists(),
|
||||
err,
|
||||
os_hint,
|
||||
@@ -357,6 +527,29 @@ fn analytics_env(baked_token: Option<&str>, baked_host: Option<&str>) -> Vec<(St
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the `OMNIVOICE_BACKEND_CMD` override: a JSON array (`["prog","a"]`)
|
||||
/// when it starts with `[` — the form the harness uses, so paths with spaces
|
||||
/// survive — else whitespace-split. `None` for unset/empty/unparseable.
|
||||
pub fn parse_backend_cmd_override(raw: &str) -> Option<Vec<String>> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let argv: Vec<String> = if raw.starts_with('[') {
|
||||
serde_json::from_str(raw).ok()?
|
||||
} else {
|
||||
raw.split_whitespace().map(str::to_string).collect()
|
||||
};
|
||||
if argv.is_empty() || argv[0].trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(argv)
|
||||
}
|
||||
|
||||
fn backend_cmd_override() -> Option<Vec<String>> {
|
||||
parse_backend_cmd_override(&std::env::var("OMNIVOICE_BACKEND_CMD").ok()?)
|
||||
}
|
||||
|
||||
pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Option<&Arc<Mutex<BootstrapStage>>>) -> Option<Child> {
|
||||
let log_path = backend_log_path();
|
||||
let err_path = log_path.with_file_name("backend_err.log");
|
||||
@@ -366,12 +559,22 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
err_path.display(),
|
||||
);
|
||||
|
||||
let (python, backend_dir) = match ensure_venv_ready(app, progress) {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
log::error!("Venv bootstrap failed — backend not started");
|
||||
return None;
|
||||
}
|
||||
// Fault-injection / QA seam: OMNIVOICE_BACKEND_CMD runs the given argv
|
||||
// as "the backend". Venv bootstrap and ffmpeg resolution are skipped
|
||||
// (they can install toolchains or touch the network); everything else —
|
||||
// the err-log run offset, the drainer threads, env pinning, real OS
|
||||
// pipes, the spawn-failure diagnostic — stays exactly real, which is
|
||||
// the point: the lifecycle harness exercises genuine process deaths.
|
||||
let cmd_override = backend_cmd_override();
|
||||
let (python, backend_dir) = match cmd_override {
|
||||
Some(ref argv) => (PathBuf::from(&argv[0]), PathBuf::new()),
|
||||
None => match ensure_venv_ready(app, progress) {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
log::error!("Venv bootstrap failed — backend not started");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(p) = progress {
|
||||
@@ -379,7 +582,24 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
}
|
||||
|
||||
let stdout_file = fs::File::create(&log_path).ok();
|
||||
let err_log_file = fs::File::create(&err_path).ok();
|
||||
// Append + per-run offset, never truncate: the previous run's stderr is
|
||||
// crash evidence until someone reads it (#1510). Flush the previous
|
||||
// drainer first so old buffered lines land BEFORE this run's offset.
|
||||
join_previous_err_drainer(Duration::from_secs(2));
|
||||
let (err_log_file, err_log_start) = open_err_log_for_run(&err_path);
|
||||
ERR_LOG_RUN_START.store(err_log_start, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(ref f) = err_log_file {
|
||||
use std::io::Write;
|
||||
let mut f = f;
|
||||
let _ = writeln!(
|
||||
f,
|
||||
"──── backend run starting (unix {}s) ────",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
);
|
||||
}
|
||||
|
||||
let mut env: Vec<(String, String)> = vec![("PYTHONUNBUFFERED".into(), "1".into())];
|
||||
// Pin the child's OMNIVOICE_PORT to the value Rust resolved so Python's
|
||||
@@ -433,18 +653,20 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
}
|
||||
// Analytics destination (#1123) — see analytics_env() below for why.
|
||||
env.extend(analytics_env(option_env!("VITE_POSTHOG_KEY"), option_env!("VITE_POSTHOG_HOST")));
|
||||
let app_data = app.path().app_local_data_dir().unwrap_or_default();
|
||||
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
|
||||
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
|
||||
}
|
||||
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
|
||||
let ffprobe_str: String = ffprobe_path.to_string_lossy().into();
|
||||
env.push(("FFPROBE_PATH".into(), ffprobe_str.clone()));
|
||||
// Issue #76: OMNIVOICE_FFPROBE_PATH is the canonical name going
|
||||
// forward — explicit, namespaced, and unambiguously the path of a
|
||||
// file (not a PATH-style command name). FFPROBE_PATH stays for
|
||||
// backward compat with prior backend releases.
|
||||
env.push(("OMNIVOICE_FFPROBE_PATH".into(), ffprobe_str));
|
||||
if cmd_override.is_none() {
|
||||
let app_data = app.path().app_local_data_dir().unwrap_or_default();
|
||||
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
|
||||
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
|
||||
}
|
||||
if let Some(ffprobe_path) = resolve_ffprobe(app, &app_data) {
|
||||
let ffprobe_str: String = ffprobe_path.to_string_lossy().into();
|
||||
env.push(("FFPROBE_PATH".into(), ffprobe_str.clone()));
|
||||
// Issue #76: OMNIVOICE_FFPROBE_PATH is the canonical name going
|
||||
// forward — explicit, namespaced, and unambiguously the path of a
|
||||
// file (not a PATH-style command name). FFPROBE_PATH stays for
|
||||
// backward compat with prior backend releases.
|
||||
env.push(("OMNIVOICE_FFPROBE_PATH".into(), ffprobe_str));
|
||||
}
|
||||
}
|
||||
let mut cmd = Command::new(&python);
|
||||
cmd.env_remove("PYTHONHOME").env_remove("PYTHONPATH").env_remove("LD_LIBRARY_PATH");
|
||||
@@ -463,18 +685,25 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
// nvidia-smi probe already uses (setup.rs).
|
||||
cmd.creation_flags(0x0800_0000 | 0x0000_0200);
|
||||
}
|
||||
match cmd_override {
|
||||
Some(ref argv) => {
|
||||
cmd.args(&argv[1..]);
|
||||
}
|
||||
None => {
|
||||
cmd.args([
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"main:app",
|
||||
"--app-dir",
|
||||
backend_dir.to_string_lossy().as_ref(),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
&backend_port().to_string(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
let mut child = match cmd
|
||||
.args([
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"main:app",
|
||||
"--app-dir",
|
||||
backend_dir.to_string_lossy().as_ref(),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
&backend_port().to_string(),
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -493,7 +722,16 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
// real exec error instead of "no error output captured".
|
||||
let diag = spawn_failure_diagnostic(&python, &e);
|
||||
log::error!("{}", diag);
|
||||
let _ = fs::write(&err_path, &diag);
|
||||
// Append (not overwrite): the run header above already marks this
|
||||
// run's slice, and earlier runs' evidence stays intact.
|
||||
if let Ok(mut f) = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&err_path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = writeln!(f, "{}", diag);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -516,7 +754,9 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
|
||||
if let Some(stderr_pipe) = child.stderr.take() {
|
||||
let app_clone = app.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Tracked (not detached): the next spawn joins this handle so this
|
||||
// run's buffered tail flushes before the next run's offset is taken.
|
||||
let drainer = std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let reader = BufReader::new(stderr_pipe);
|
||||
let mut log_file = err_log_file;
|
||||
@@ -528,6 +768,9 @@ pub fn spawn_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, progress: Opt
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Ok(mut guard) = ERR_LOG_DRAINER.lock() {
|
||||
*guard = Some(drainer);
|
||||
}
|
||||
}
|
||||
|
||||
Some(child)
|
||||
@@ -597,6 +840,119 @@ mod tests {
|
||||
std::env::remove_var("OMNIVOICE_INSTALL_CHANNEL");
|
||||
}
|
||||
|
||||
/// Loopback responder for the /startup/progress probe tests.
|
||||
fn spawn_progress_stub(with_marker: bool, body: &'static str) -> u16 {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { break };
|
||||
let mut buf = [0u8; 512];
|
||||
let _ = stream.read(&mut buf);
|
||||
let marker = if with_marker {
|
||||
"x-omnivoice-backend: 0.0.0\r\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let resp = format!(
|
||||
"HTTP/1.1 200 OK\r\n{marker}Content-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
/// Loopback HTTP responder for the probe tests: answers `/system/info`
|
||||
/// with a genuine-looking backend body and `/profiles` with the given
|
||||
/// status — the exact shape of a zombie whose install/DB broke while
|
||||
/// `/system/info` kept answering from memory.
|
||||
fn spawn_probe_stub(profiles_status: u16) -> u16 {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { break };
|
||||
let mut buf = [0u8; 512];
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let resp = if req.starts_with("GET /system/info") {
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 19\r\n\r\n{\"data_dir\": \"/x\"}\n".to_string()
|
||||
} else {
|
||||
format!("HTTP/1.1 {profiles_status} X\r\nContent-Length: 2\r\n\r\n[]")
|
||||
};
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_cmd_override_parses_json_and_whitespace_forms() {
|
||||
// JSON form (the harness's): paths with spaces survive.
|
||||
assert_eq!(
|
||||
parse_backend_cmd_override(r#"["/tmp/my dir/prog", "arg1"]"#),
|
||||
Some(vec!["/tmp/my dir/prog".into(), "arg1".into()])
|
||||
);
|
||||
// Whitespace form (manual QA): OMNIVOICE_BACKEND_CMD="/bin/false x".
|
||||
assert_eq!(
|
||||
parse_backend_cmd_override("/bin/false x"),
|
||||
Some(vec!["/bin/false".into(), "x".into()])
|
||||
);
|
||||
// Unset/empty/garbage never activates the seam — production behavior
|
||||
// is byte-identical without the env var.
|
||||
assert_eq!(parse_backend_cmd_override(""), None);
|
||||
assert_eq!(parse_backend_cmd_override(" "), None);
|
||||
assert_eq!(parse_backend_cmd_override("[not json"), None);
|
||||
assert_eq!(parse_backend_cmd_override("[]"), None);
|
||||
assert_eq!(parse_backend_cmd_override(r#"[""]"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_progress_parses_fields_and_requires_the_marker() {
|
||||
const BODY: &str =
|
||||
r#"{"status": "starting", "step": "ml_imports", "label": "Loading ML runtime (PyTorch)…", "error": null}"#;
|
||||
// Marker present → the tuple the poll loops narrate from.
|
||||
let port = spawn_progress_stub(true, BODY);
|
||||
assert_eq!(
|
||||
startup_progress(port),
|
||||
Some((
|
||||
"starting".into(),
|
||||
"ml_imports".into(),
|
||||
"Loading ML runtime (PyTorch)…".into()
|
||||
))
|
||||
);
|
||||
// No marker header → a foreign responder must not narrate our splash.
|
||||
let foreign = spawn_progress_stub(false, BODY);
|
||||
assert_eq!(startup_progress(foreign), None);
|
||||
// Ready body with null step/label → status still parses, step empty.
|
||||
let ready = spawn_progress_stub(true, r#"{"status": "ready", "step": null, "label": null}"#);
|
||||
assert_eq!(startup_progress(ready), Some(("ready".into(), String::new(), String::new())));
|
||||
// Nothing listening → None (old backend / dead port fall back).
|
||||
assert_eq!(startup_progress(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_requires_the_deep_probe_not_just_identity() {
|
||||
// Regression for the shallow-Ready class: a backend that identifies
|
||||
// itself on /system/info but 500s a DB-backed route must NOT be
|
||||
// announced Ready — that zombie looked alive while every real
|
||||
// request dead-ended on "can't reach the backend".
|
||||
let broken = spawn_probe_stub(500);
|
||||
assert!(backend_healthy(broken), "identity probe should pass");
|
||||
assert!(!backend_deep_healthy(broken), "deep probe must fail on 500");
|
||||
assert!(!backend_ready(broken), "Ready must gate on the deep probe");
|
||||
|
||||
let ok = spawn_probe_stub(200);
|
||||
assert!(backend_ready(ok), "identity + working DB route is Ready");
|
||||
|
||||
// Nothing listening at all: no probe passes.
|
||||
assert!(!backend_ready(1)); // port 1 — never bindable by us
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_failure_diagnostic_surfaces_path_error_and_hint() {
|
||||
let err = io::Error::new(io::ErrorKind::NotFound, "No such file or directory");
|
||||
@@ -647,4 +1003,131 @@ mod tests {
|
||||
// unversioned (pre-app_version backend) is stale by definition
|
||||
assert!(!same_app_version(""));
|
||||
}
|
||||
|
||||
// ── Per-run crash evidence (#1510) ───────────────────────────────────
|
||||
// The reported failure shape: a crash marker whose stderr tail was the
|
||||
// REPLACEMENT process's healthy startup, because the shared err log was
|
||||
// truncated on respawn and read unbounded afterwards.
|
||||
|
||||
#[test]
|
||||
fn a_respawn_preserves_the_previous_runs_evidence() {
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
|
||||
let (file, start) = open_err_log_for_run(&path);
|
||||
assert_eq!(start, 0);
|
||||
writeln!(file.unwrap(), "run1: fatal abort, last words").unwrap();
|
||||
|
||||
// Respawn: pre-#1510 this truncated the file (File::create), turning
|
||||
// the dead run's final output into nothing.
|
||||
let (file2, start2) = open_err_log_for_run(&path);
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
content.contains("run1: fatal abort"),
|
||||
"respawn destroyed the previous run's evidence: {content:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
start2 as usize,
|
||||
content.len(),
|
||||
"run2 must begin at the old EOF"
|
||||
);
|
||||
drop(file2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_bounded_tail_cannot_show_another_runs_output() {
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
|
||||
let (file, _) = open_err_log_for_run(&path);
|
||||
writeln!(file.unwrap(), "run1: Traceback — the actual crash").unwrap();
|
||||
let (file2, start2) = open_err_log_for_run(&path);
|
||||
writeln!(file2.unwrap(), "run2: OmniVoice model loaded successfully.").unwrap();
|
||||
|
||||
// The dead run's slice: only its own words.
|
||||
let run1 = read_error_log_tail_at(&path, 0, 10);
|
||||
assert!(run1.contains("the actual crash"));
|
||||
// The replacement's slice: its startup, and NEVER run1's crash —
|
||||
// and, symmetrically, a marker bounded to run1's slice could never
|
||||
// have contained run2's healthy startup (the #1510 report).
|
||||
let run2 = read_error_log_tail_at(&path, start2, 10);
|
||||
assert!(run2.contains("model loaded successfully"));
|
||||
assert!(
|
||||
!run2.contains("the actual crash"),
|
||||
"run-bounded tail leaked another run's output: {run2:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_offset_degrades_to_the_whole_file_not_to_silence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
fs::write(&path, "only line\n").unwrap();
|
||||
// Offset beyond EOF (file replaced/shrunk externally): evidence
|
||||
// beats precision — degrade to the whole file, never to "".
|
||||
assert_eq!(read_error_log_tail_at(&path, 10_000, 10), "only line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dying_runs_buffered_stderr_flushes_before_the_next_offset() {
|
||||
use std::io::Write;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
fs::write(&path, "run1: early line\n").unwrap();
|
||||
|
||||
// A drainer still flushing the dead run's buffered tail…
|
||||
let p = path.clone();
|
||||
let late = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_millis(120));
|
||||
let mut f = fs::OpenOptions::new().append(true).open(&p).unwrap();
|
||||
writeln!(f, "run1: buffered last words").unwrap();
|
||||
});
|
||||
*ERR_LOG_DRAINER.lock().unwrap() = Some(late);
|
||||
|
||||
// …must land BEFORE the next run records where its output begins.
|
||||
join_previous_err_drainer(Duration::from_secs(2));
|
||||
let (_file, start) = open_err_log_for_run(&path);
|
||||
let run2 = read_error_log_tail_at(&path, start, 10);
|
||||
assert!(
|
||||
!run2.contains("buffered last words"),
|
||||
"old run's buffered stderr was attributed to the new run: {run2:?}"
|
||||
);
|
||||
assert!(fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.contains("buffered last words"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spawn_diagnostic_never_carries_the_users_home_path() {
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let saved = std::env::var("HOME").ok();
|
||||
std::env::set_var("HOME", "/home/realname");
|
||||
let diag = spawn_failure_diagnostic(
|
||||
Path::new("/home/realname/.local/share/app/venv/bin/python"),
|
||||
&io::Error::new(io::ErrorKind::NotFound, "nope"),
|
||||
);
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("HOME", v),
|
||||
None => std::env::remove_var("HOME"),
|
||||
}
|
||||
assert!(!diag.contains("/home/realname"), "home path leaked: {diag}");
|
||||
assert!(diag.contains("~/.local/share/app/venv/bin/python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_log_rotates_instead_of_growing_forever() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("backend_err.log");
|
||||
fs::write(&path, "x".repeat((ERR_LOG_ROTATE_BYTES + 1) as usize)).unwrap();
|
||||
|
||||
let (_file, start) = open_err_log_for_run(&path);
|
||||
assert_eq!(start, 0, "a rotated log starts the new run at offset 0");
|
||||
let rotated = path.with_file_name("backend_err.log.1");
|
||||
assert!(
|
||||
rotated.exists(),
|
||||
"old evidence must survive rotation in the sibling file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,14 +293,20 @@ pub fn respawn_backend(
|
||||
/// the venv — is removed and the bootstrap re-runs once, recreating it through
|
||||
/// the normal `CreatingVenv` / `InstallingDeps` setup path instead of
|
||||
/// surfacing the same dead-end failure on every retry.
|
||||
pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapStage>>) {
|
||||
pub fn spawn_backend_and_wait<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle: &Arc<Mutex<BootstrapStage>>) {
|
||||
let mut venv_heal_attempted = false;
|
||||
'bootstrap: loop {
|
||||
let child = crate::backend::spawn_backend(app, Some(stage_handle));
|
||||
track_backend_child(app, child);
|
||||
let start = std::time::Instant::now();
|
||||
while start.elapsed() < Duration::from_secs(300) {
|
||||
if crate::backend::backend_healthy(backend_port()) {
|
||||
// Early-bind narration: the backend answers /startup/progress within
|
||||
// ~1s of spawn, long before it is Ready — surface each step change
|
||||
// as a log line so the splash shows "Loading ML runtime (PyTorch)…"
|
||||
// instead of a silent 300s wait. An old backend (no endpoint) yields
|
||||
// None and the wait looks exactly as it did before.
|
||||
let mut last_step = String::new();
|
||||
while start.elapsed() < startup_budget() {
|
||||
if crate::backend::backend_ready(backend_port()) {
|
||||
set_stage(stage_handle, BootstrapStage::Ready);
|
||||
// #567/#570/#571: once Ready, keep watching the backend child
|
||||
// and respawn it if it dies mid-session, so a crash self-heals
|
||||
@@ -338,7 +344,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
None
|
||||
};
|
||||
if let Some((exit_info, real_exit)) = process_dead {
|
||||
let err_tail = crate::backend::read_error_log_tail(30);
|
||||
let err_tail = crate::backend::read_error_log_tail_for_run(30);
|
||||
// #941: persist the forensics for every true process death —
|
||||
// startup crashes included — unless the app is shutting down
|
||||
// or a retry flow deliberately killed the child.
|
||||
@@ -347,7 +353,7 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
crate::crash::record_crash(crate::crash::marker_now(
|
||||
exit,
|
||||
backend_uptime_s(app),
|
||||
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
|
||||
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -441,13 +447,25 @@ pub fn spawn_backend_and_wait(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<B
|
||||
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
|
||||
return;
|
||||
}
|
||||
if let Some((status, step, label)) =
|
||||
crate::backend::startup_progress(backend_port())
|
||||
{
|
||||
if status == "starting" && !step.is_empty() && step != last_step {
|
||||
last_step = step;
|
||||
emit_log(app, "starting_backend", &format!("Startup: {label}"));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
let err_tail = crate::backend::read_error_log_tail(20);
|
||||
let err_tail = crate::backend::read_error_log_tail_for_run(20);
|
||||
let msg = if err_tail.is_empty() {
|
||||
"Backend did not respond within 300 s".to_string()
|
||||
format!("Backend did not respond within {} s", startup_budget().as_secs())
|
||||
} else {
|
||||
format!("Backend did not respond within 300 s. Last stderr output:\n{}", err_tail)
|
||||
format!(
|
||||
"Backend did not respond within {} s. Last stderr output:\n{}",
|
||||
startup_budget().as_secs(),
|
||||
err_tail
|
||||
)
|
||||
};
|
||||
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
|
||||
return;
|
||||
@@ -476,6 +494,15 @@ static SUPERVISOR_ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||
/// moment a fresh child is spawned and tracked (`track_backend_child`).
|
||||
static BACKEND_KILL_INTENDED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Bumped every time `track_backend_child` installs a new child. The
|
||||
/// supervisor snapshots it when it observes a death; a change during its
|
||||
/// backoff pause means ANOTHER flow (Retry / Clean & Retry) spawned and
|
||||
/// tracked a replacement — ownership has transferred, whether or not that
|
||||
/// replacement is still alive when sampled (the flag and a liveness check
|
||||
/// can both be missed inside one 500ms window; the generation cannot).
|
||||
static BACKEND_SPAWN_GENERATION: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
pub fn set_backend_kill_intended(value: bool) {
|
||||
BACKEND_KILL_INTENDED.store(value, Ordering::SeqCst);
|
||||
}
|
||||
@@ -499,7 +526,7 @@ const CRASH_STDERR_TAIL_LINES: usize = 40;
|
||||
const MAX_RESTARTS: usize = 3;
|
||||
const RESTART_WINDOW: Duration = Duration::from_secs(600);
|
||||
|
||||
fn app_is_quitting(app: &tauri::AppHandle) -> bool {
|
||||
fn app_is_quitting<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
|
||||
app.try_state::<AppFlags>()
|
||||
.map(|f| f.quitting.load(Ordering::SeqCst))
|
||||
.unwrap_or(false)
|
||||
@@ -508,7 +535,7 @@ fn app_is_quitting(app: &tauri::AppHandle) -> bool {
|
||||
/// Store the freshly spawned backend child (and its spawn time, for the crash
|
||||
/// marker's `uptime_s`), and re-arm the death watchers: any deliberate-kill
|
||||
/// window ends the moment a new child is tracked.
|
||||
fn track_backend_child(app: &tauri::AppHandle, child: Option<std::process::Child>) {
|
||||
fn track_backend_child<R: tauri::Runtime>(app: &tauri::AppHandle<R>, child: Option<std::process::Child>) {
|
||||
let state = app.state::<BackendState>();
|
||||
if let Ok(mut guard) = state.process.lock() {
|
||||
*guard = child;
|
||||
@@ -516,11 +543,12 @@ fn track_backend_child(app: &tauri::AppHandle, child: Option<std::process::Child
|
||||
if let Ok(mut spawned) = state.spawned_at.lock() {
|
||||
*spawned = Some(Instant::now());
|
||||
}
|
||||
BACKEND_SPAWN_GENERATION.fetch_add(1, Ordering::SeqCst);
|
||||
set_backend_kill_intended(false);
|
||||
}
|
||||
|
||||
/// Seconds since the tracked backend child was spawned (0 when unknown).
|
||||
fn backend_uptime_s(app: &tauri::AppHandle) -> u64 {
|
||||
fn backend_uptime_s<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> u64 {
|
||||
app.try_state::<BackendState>()
|
||||
.and_then(|s| s.spawned_at.lock().ok().and_then(|g| *g))
|
||||
.map(|t| t.elapsed().as_secs())
|
||||
@@ -530,7 +558,7 @@ fn backend_uptime_s(app: &tauri::AppHandle) -> u64 {
|
||||
/// Returns `Some(BackendExit)` if the tracked backend child has exited,
|
||||
/// `None` if it is still running (or none is tracked — which we never treat as
|
||||
/// a death to respawn, to avoid fighting a deliberate teardown).
|
||||
fn backend_child_exit(app: &tauri::AppHandle) -> Option<BackendExit> {
|
||||
fn backend_child_exit<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Option<BackendExit> {
|
||||
let state = app.try_state::<BackendState>()?;
|
||||
let mut guard = state.process.lock().ok()?;
|
||||
match guard.as_mut() {
|
||||
@@ -543,6 +571,30 @@ fn backend_child_exit(app: &tauri::AppHandle) -> Option<BackendExit> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the launch poll waits for the backend to become Ready before
|
||||
/// declaring Failed. 300s in production; `OMNIVOICE_STARTUP_BUDGET_S`
|
||||
/// exists for the fault-injection harness (a slow-start scenario must not
|
||||
/// sleep five minutes in CI) and for support triage on pathological disks.
|
||||
fn startup_budget() -> Duration {
|
||||
std::env::var("OMNIVOICE_STARTUP_BUDGET_S")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|&s| s > 0)
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(Duration::from_secs(300))
|
||||
}
|
||||
|
||||
/// The supervisor's death-detection poll interval. 2s in production;
|
||||
/// `OMNIVOICE_SUPERVISOR_POLL_MS` shrinks it for the harness only.
|
||||
fn supervisor_poll() -> Duration {
|
||||
std::env::var("OMNIVOICE_SUPERVISOR_POLL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())
|
||||
.filter(|&ms| ms > 0)
|
||||
.map(Duration::from_millis)
|
||||
.unwrap_or(Duration::from_secs(2))
|
||||
}
|
||||
|
||||
/// Drop restart timestamps older than `RESTART_WINDOW` and report whether the
|
||||
/// remaining count has hit the cap. Pure so the backoff policy is unit-tested
|
||||
/// without spawning real processes.
|
||||
@@ -551,19 +603,40 @@ fn restart_budget_exhausted(times: &mut Vec<Instant>, now: Instant) -> bool {
|
||||
times.len() >= MAX_RESTARTS
|
||||
}
|
||||
|
||||
/// Escalating pause before a respawn, keyed on how many restarts already
|
||||
/// happened inside `RESTART_WINDOW`. The FIRST respawn stays immediate (a
|
||||
/// one-off crash should self-heal fast); repeat deaths get breathing room so
|
||||
/// a tight crash loop doesn't burn the whole 3-in-600s budget in seconds —
|
||||
/// back-to-back torch-import storms are exactly what pushes a
|
||||
/// memory-pressured machine over the edge again. Pure for unit testing.
|
||||
fn restart_backoff_delay(recent_restarts: usize) -> Duration {
|
||||
match recent_restarts {
|
||||
0 => Duration::ZERO,
|
||||
1 => Duration::from_secs(5),
|
||||
_ => Duration::from_secs(15),
|
||||
}
|
||||
}
|
||||
|
||||
/// After the backend is Ready, watch its process and respawn it on an
|
||||
/// unexpected exit. Runs on the (otherwise-returning) bootstrap thread and
|
||||
/// stops the instant the app is quitting so it never resurrects the backend
|
||||
/// during shutdown. Death is detected only via a *confirmed process exit*
|
||||
/// (`try_wait`), never a slow health probe, so a busy-but-alive backend is
|
||||
/// never killed.
|
||||
fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapStage>>) {
|
||||
fn supervise_backend<R: tauri::Runtime>(app: &tauri::AppHandle<R>, stage_handle: &Arc<Mutex<BootstrapStage>>) {
|
||||
let mut restart_times: Vec<Instant> = Vec::new();
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
std::thread::sleep(supervisor_poll());
|
||||
if app_is_quitting(app) {
|
||||
return;
|
||||
}
|
||||
// Snapshot the spawn generation BEFORE observing the exit: sampled
|
||||
// after, a replacement tracked in the gap between `try_wait` and the
|
||||
// load would be baked into the snapshot and the transfer missed
|
||||
// (third-pass review find). Sampled before, any tracking that
|
||||
// happens from here on — even one whose child we are about to see
|
||||
// exit — reads as a generation change and yields.
|
||||
let observed_generation = BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst);
|
||||
let exit = match backend_child_exit(app) {
|
||||
Some(exit) => exit,
|
||||
None => continue, // still running
|
||||
@@ -587,10 +660,10 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
crate::crash::record_crash(crate::crash::marker_now(
|
||||
&exit,
|
||||
uptime_s,
|
||||
crate::backend::read_error_log_tail(CRASH_STDERR_TAIL_LINES),
|
||||
crate::backend::read_error_log_tail_for_run(CRASH_STDERR_TAIL_LINES),
|
||||
));
|
||||
if restart_budget_exhausted(&mut restart_times, Instant::now()) {
|
||||
let tail = crate::backend::read_error_log_tail(30);
|
||||
let tail = crate::backend::read_error_log_tail_for_run(30);
|
||||
let msg = format!(
|
||||
"The backend kept crashing ({} times in {} min; last death: {}) and couldn't \
|
||||
be kept running. Use Clean & Retry, or check Settings → Logs → Backend.{}",
|
||||
@@ -604,6 +677,10 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
set_stage(stage_handle, BootstrapStage::Failed { message: msg });
|
||||
return;
|
||||
}
|
||||
// Backoff BEFORE this restart is recorded: `restart_times` was just
|
||||
// pruned to the window, so its length is the number of recent
|
||||
// respawns already attempted.
|
||||
let backoff = restart_backoff_delay(restart_times.len());
|
||||
restart_times.push(Instant::now());
|
||||
log::warn!("Backend process exited unexpectedly ({exit_info}) — restarting it (#567)");
|
||||
emit_log(app, "starting_backend", "Backend stopped unexpectedly — restarting it automatically");
|
||||
@@ -611,6 +688,51 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
// poll has already stopped post-Ready, so the stage alone won't show).
|
||||
let _ = app.emit("backend-restarting", exit_info.clone());
|
||||
set_stage(stage_handle, BootstrapStage::StartingBackend);
|
||||
// The banner is already up, so the pause reads as "reconnecting", not
|
||||
// as a hang. Chunked so quitting (or a deliberate retry-flow kill,
|
||||
// which owns the respawn) is honored within 500 ms.
|
||||
if !backoff.is_zero() {
|
||||
log::info!(
|
||||
"Backend died {} time(s) in the last {} min — waiting {}s before respawning",
|
||||
restart_times.len(),
|
||||
RESTART_WINDOW.as_secs() / 60,
|
||||
backoff.as_secs()
|
||||
);
|
||||
let waited = Instant::now();
|
||||
while waited.elapsed() < backoff {
|
||||
if app_is_quitting(app) {
|
||||
return;
|
||||
}
|
||||
if backend_kill_intended() {
|
||||
log::info!("Deliberate replace during restart backoff — supervisor yielding");
|
||||
return;
|
||||
}
|
||||
// A completed Retry/Clean&Retry sets the deliberate-kill flag
|
||||
// and then `track_backend_child` CLEARS it — possibly both
|
||||
// between two of these samples, so the flag alone can be
|
||||
// missed. The durable tell is the spawn GENERATION: it bumps
|
||||
// when a replacement is tracked and never un-bumps, so it is
|
||||
// observed even if the replacement has itself already exited
|
||||
// by the time we sample. Yield promptly (not at backoff end)
|
||||
// so the retry's own spawn_backend_and_wait can claim the
|
||||
// supervisor slot at Ready — and so we never free_port() a
|
||||
// replacement out from under the flow that owns it.
|
||||
if BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst) != observed_generation {
|
||||
log::info!(
|
||||
"A replacement backend was tracked during restart backoff — supervisor yielding"
|
||||
);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
// Last look before touching the port — covers the zero-backoff first
|
||||
// respawn (which never enters the pause loop) and the tail of the
|
||||
// pause itself. After this point we own the respawn.
|
||||
if BACKEND_SPAWN_GENERATION.load(Ordering::SeqCst) != observed_generation {
|
||||
log::info!("A replacement backend was tracked — supervisor yielding to its flow");
|
||||
return;
|
||||
}
|
||||
// Clear any orphan still holding the port before the respawn. #1223:
|
||||
// if it can't be cleared, respawning just reproduces the bind failure
|
||||
// — stop and say so rather than burning a restart attempt.
|
||||
@@ -642,11 +764,12 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
// Wait (bounded) for the respawn to become healthy. If it dies again
|
||||
// immediately, bail early so the next loop counts it toward the cap.
|
||||
let start = Instant::now();
|
||||
let mut last_step = String::new();
|
||||
while start.elapsed() < Duration::from_secs(120) {
|
||||
if app_is_quitting(app) {
|
||||
return;
|
||||
}
|
||||
if crate::backend::backend_healthy(backend_port()) {
|
||||
if crate::backend::backend_ready(backend_port()) {
|
||||
set_stage(stage_handle, BootstrapStage::Ready);
|
||||
let _ = app.emit("backend-restored", ());
|
||||
log::info!("Backend restarted and healthy again");
|
||||
@@ -655,6 +778,16 @@ fn supervise_backend(app: &tauri::AppHandle, stage_handle: &Arc<Mutex<BootstrapS
|
||||
if backend_child_exit(app).is_some() {
|
||||
break;
|
||||
}
|
||||
// Same early-bind narration as the launch poll: name the startup
|
||||
// step in the reconnecting window instead of a silent wait.
|
||||
if let Some((status, step, label)) =
|
||||
crate::backend::startup_progress(backend_port())
|
||||
{
|
||||
if status == "starting" && !step.is_empty() && step != last_step {
|
||||
last_step = step;
|
||||
emit_log(app, "starting_backend", &format!("Startup: {label}"));
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
@@ -2024,6 +2157,46 @@ mod tests {
|
||||
assert!(aged.is_empty(), "stale timestamps should have been dropped");
|
||||
}
|
||||
|
||||
/// Env-mutating tests in THIS module serialize on their own lock (cargo
|
||||
/// runs tests in threads; the harness binary has its own).
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn timing_overrides_default_to_production_values() {
|
||||
// The env overrides exist for the fault-injection harness only —
|
||||
// production timing must not drift when they are unset.
|
||||
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::env::remove_var("OMNIVOICE_STARTUP_BUDGET_S");
|
||||
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
|
||||
assert_eq!(startup_budget(), Duration::from_secs(300));
|
||||
assert_eq!(supervisor_poll(), Duration::from_secs(2));
|
||||
// Zero/garbage never yields a degenerate loop.
|
||||
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "0");
|
||||
std::env::set_var("OMNIVOICE_SUPERVISOR_POLL_MS", "abc");
|
||||
assert_eq!(startup_budget(), Duration::from_secs(300));
|
||||
assert_eq!(supervisor_poll(), Duration::from_secs(2));
|
||||
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "6");
|
||||
assert_eq!(startup_budget(), Duration::from_secs(6));
|
||||
std::env::remove_var("OMNIVOICE_STARTUP_BUDGET_S");
|
||||
std::env::remove_var("OMNIVOICE_SUPERVISOR_POLL_MS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_backoff_escalates_but_first_respawn_is_immediate() {
|
||||
// A one-off crash self-heals with zero added latency; repeat deaths
|
||||
// inside the window get an escalating pause so a tight crash loop
|
||||
// can't burn the whole 3-in-600s budget in seconds.
|
||||
assert_eq!(restart_backoff_delay(0), Duration::ZERO);
|
||||
assert_eq!(restart_backoff_delay(1), Duration::from_secs(5));
|
||||
assert_eq!(restart_backoff_delay(2), Duration::from_secs(15));
|
||||
// Monotonic, and capped rather than unbounded — the budget check is
|
||||
// what ends a hopeless loop, not an ever-growing sleep.
|
||||
assert_eq!(restart_backoff_delay(50), Duration::from_secs(15));
|
||||
for n in 0..10 {
|
||||
assert!(restart_backoff_delay(n) <= restart_backoff_delay(n + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torch_download_failure_is_detected_for_targeted_help() {
|
||||
// #569: the cu128 torch wheel host (and a torch-named download/fetch
|
||||
|
||||
@@ -98,14 +98,16 @@ impl PortalShortcutState {
|
||||
|
||||
const DESKTOP_ID: &str = "com.debpalash.omnivoice-studio";
|
||||
|
||||
fn desktop_entry_exists() -> bool {
|
||||
fn user_entry_path() -> Option<std::path::PathBuf> {
|
||||
dirs_next::data_dir().map(|dir| {
|
||||
dir.join("applications")
|
||||
.join(format!("{DESKTOP_ID}.desktop"))
|
||||
})
|
||||
}
|
||||
|
||||
/// A packaged (system-dir) entry — deb installs manage their own; never touch.
|
||||
fn system_entry_exists() -> bool {
|
||||
let filename = format!("{DESKTOP_ID}.desktop");
|
||||
let user_entry = dirs_next::data_dir()
|
||||
.map(|dir| dir.join("applications").join(&filename))
|
||||
.is_some_and(|path| path.is_file());
|
||||
if user_entry {
|
||||
return true;
|
||||
}
|
||||
std::env::var_os("XDG_DATA_DIRS")
|
||||
.map(|dirs| {
|
||||
std::env::split_paths(&dirs)
|
||||
@@ -121,6 +123,54 @@ fn desktop_entry_exists() -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `[Desktop Entry]` group's Exec target, unquoted. `None` when the main
|
||||
/// group has no usable Exec line — which GLib treats the same as a missing
|
||||
/// program. Scoped to the main group deliberately: a `[Desktop Action …]`
|
||||
/// group carries its own `Exec=`, and accepting it would retain an entry GLib
|
||||
/// still cannot resolve (CodeRabbit, #1526).
|
||||
fn entry_exec_target(content: &str) -> Option<std::path::PathBuf> {
|
||||
let mut in_main_group = false;
|
||||
let mut exec = None;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with('[') {
|
||||
in_main_group = line == "[Desktop Entry]";
|
||||
continue;
|
||||
}
|
||||
if in_main_group {
|
||||
if let Some(value) = line.strip_prefix("Exec=") {
|
||||
exec = Some(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = exec?.trim();
|
||||
let unquoted = raw
|
||||
.strip_prefix('"')
|
||||
.and_then(|rest| rest.split('"').next())
|
||||
.unwrap_or_else(|| raw.split_whitespace().next().unwrap_or(raw));
|
||||
if unquoted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(unquoted))
|
||||
}
|
||||
|
||||
/// Whether a user-local identity entry must be rewritten before the portal
|
||||
/// will accept it.
|
||||
///
|
||||
/// GLib refuses to resolve a desktop entry whose Exec program does not exist
|
||||
/// (`GDesktopAppInfo` returns NULL), and the portal then rejects the bind with
|
||||
/// "App info not found" — the shortcut silently dies for the whole session.
|
||||
/// A dev entry pointing at a `target/debug` binary goes stale exactly this
|
||||
/// way: a `cargo clean`, a moved checkout, or anything that relocates the
|
||||
/// binary breaks system-wide dictation with only a log line to show for it.
|
||||
fn entry_needs_rewrite(content: &str, exec_exists: impl Fn(&std::path::Path) -> bool) -> bool {
|
||||
match entry_exec_target(content) {
|
||||
Some(target) => !exec_exists(&target),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn desktop_exec_path() -> Result<std::path::PathBuf, String> {
|
||||
// AppImage's current_exe() points inside its transient mount. APPIMAGE is
|
||||
// the stable launcher path the desktop entry must retain.
|
||||
@@ -144,19 +194,31 @@ fn desktop_exec_value(path: &std::path::Path) -> String {
|
||||
/// Deb packages already install one; dev builds and standalone AppImages may
|
||||
/// not. Add an invisible identity entry only when none exists.
|
||||
fn ensure_desktop_identity() -> Result<(), String> {
|
||||
if desktop_entry_exists() {
|
||||
if system_entry_exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let applications = dirs_next::data_dir()
|
||||
.ok_or("could not locate the user data directory")?
|
||||
.join("applications");
|
||||
std::fs::create_dir_all(&applications)
|
||||
.map_err(|error| format!("could not create applications directory: {error}"))?;
|
||||
let path = user_entry_path().ok_or("could not locate the user data directory")?;
|
||||
if let Ok(existing) = std::fs::read_to_string(&path) {
|
||||
if !entry_needs_rewrite(&existing, |target| target.exists()) {
|
||||
return Ok(());
|
||||
}
|
||||
// Stale: GLib returns NULL for an entry whose Exec is gone, and the
|
||||
// portal then refuses the bind ("App info not found"). Rewrite with
|
||||
// where the app actually is NOW. The user dir with our app id is ours
|
||||
// to manage — packaged entries live in the system dirs handled above.
|
||||
log::info!(
|
||||
"Wayland portal identity at {} points at a missing program — rewriting",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|error| format!("could not create applications directory: {error}"))?;
|
||||
}
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\nType=Application\nName=VoiceStudio\nExec={}\nTerminal=false\nNoDisplay=true\nStartupWMClass=VoiceStudio\nX-VoiceStudio-Generated=true\n",
|
||||
desktop_exec_value(&desktop_exec_path()?)
|
||||
);
|
||||
let path = applications.join(format!("{DESKTOP_ID}.desktop"));
|
||||
std::fs::write(&path, entry)
|
||||
.map_err(|error| format!("could not create {}: {error}", path.display()))?;
|
||||
log::info!("Installed Wayland portal identity at {}", path.display());
|
||||
@@ -636,6 +698,46 @@ mod tests {
|
||||
assert_eq!(portal_trigger("Ctrl+K+L"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_identity_entry_is_rewritten() {
|
||||
// The class from 2026-08-13: the entry's Exec pointed at a binary that
|
||||
// had been moved. GLib then resolves the entry to NULL and the portal
|
||||
// refuses the bind with "App info not found" — system-wide dictation
|
||||
// silently dead for the whole session.
|
||||
let stale = "[Desktop Entry]\nType=Application\nExec=/gone/omnivoice-studio\n";
|
||||
assert!(super::entry_needs_rewrite(stale, |_| false));
|
||||
|
||||
let healthy = "[Desktop Entry]\nType=Application\nExec=\"/opt/VoiceStudio.AppImage\"\n";
|
||||
assert!(!super::entry_needs_rewrite(healthy, |path| {
|
||||
path == std::path::Path::new("/opt/VoiceStudio.AppImage")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_targets_parse_quoted_legacy_and_missing_lines() {
|
||||
use super::entry_exec_target;
|
||||
// Current writer: quoted.
|
||||
assert_eq!(
|
||||
entry_exec_target("[Desktop Entry]\nExec=\"/tmp/Voice Studio/app\"\n").as_deref(),
|
||||
Some(std::path::Path::new("/tmp/Voice Studio/app"))
|
||||
);
|
||||
// Pre-quoting entries from older builds still parse.
|
||||
assert_eq!(
|
||||
entry_exec_target("[Desktop Entry]\nExec=/home/u/target/debug/omnivoice-studio\n")
|
||||
.as_deref(),
|
||||
Some(std::path::Path::new("/home/u/target/debug/omnivoice-studio"))
|
||||
);
|
||||
// No Exec at all resolves to NULL in GLib — treat as needing rewrite.
|
||||
assert_eq!(entry_exec_target("[Desktop Entry]\nType=Application\n"), None);
|
||||
assert!(super::entry_needs_rewrite("[Desktop Entry]\n", |_| true));
|
||||
// An action group's Exec is NOT the entry's Exec: GLib still resolves
|
||||
// the entry to NULL without a main-group Exec, so accepting this would
|
||||
// keep exactly the stale entry the rewrite exists to replace.
|
||||
let action_only = "[Desktop Entry]\nType=Application\n[Desktop Action new]\nExec=/bin/true\n";
|
||||
assert_eq!(entry_exec_target(action_only), None);
|
||||
assert!(super::entry_needs_rewrite(action_only, |_| true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_exec_paths_are_quoted_and_escaped() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
//! Backend-lifecycle fault-injection harness.
|
||||
//!
|
||||
//! Runs `spawn_backend_and_wait` / `supervise_backend` against REAL dying
|
||||
//! child processes (via the `OMNIVOICE_BACKEND_CMD` seam) and asserts the
|
||||
//! user receives the CORRECT NAMED DIAGNOSIS — not merely that recovery
|
||||
//! happened. Diagnosis quality is the bar: 61% of the historical "can't
|
||||
//! reach the backend" class was closed undiagnosed.
|
||||
//!
|
||||
//! The scenario "backend" is this test binary re-invoking itself
|
||||
//! (`scenario_child`), so exit codes, Unix signals, and pipe-close ordering
|
||||
//! are the genuine OS articles on all three platforms — no system python,
|
||||
//! no mocks of the behaviors under test.
|
||||
//!
|
||||
//! Every test mutates process-global state (env vars, the crash store, the
|
||||
//! kill-intended flag), so they hold one mutex AND CI runs this binary with
|
||||
//! `--test-threads=1`.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::Listener;
|
||||
use tauri::Manager;
|
||||
|
||||
use app_lib::bootstrap::{
|
||||
spawn_backend_and_wait, BootstrapStage, BootstrapState, LogPayload,
|
||||
set_backend_kill_intended,
|
||||
};
|
||||
use app_lib::{AppFlags, BackendState, CaptureDispatchState};
|
||||
|
||||
static HARNESS: Mutex<()> = Mutex::new(());
|
||||
|
||||
// ── Scenario child ────────────────────────────────────────────────────────
|
||||
|
||||
/// Not a real test: when `OMNIVOICE_SCENARIO` is set, this plays the backend
|
||||
/// — optionally serving minimal HTTP on `OMNIVOICE_PORT`, printing a stderr
|
||||
/// script, then dying the scripted death. A no-op in a normal test pass.
|
||||
#[test]
|
||||
fn scenario_child() {
|
||||
// The gate value is the PID of the process that ARMED the scenario (the
|
||||
// parent harness). The parent's own libtest also runs this test — in a
|
||||
// parallel local `cargo test` it could observe the armed env and start
|
||||
// fault-injecting itself (binding the port, idling 600s). Only a
|
||||
// DIFFERENT process — the spawned child — may play the backend.
|
||||
match std::env::var("OMNIVOICE_SCENARIO") {
|
||||
Ok(v) if v.parse::<u32>() == Ok(std::process::id()) => return, // the parent itself
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
let get = |k: &str| std::env::var(k).unwrap_or_default();
|
||||
let get_ms = |k: &str| get(k).parse::<u64>().ok();
|
||||
|
||||
if let Some(delay) = get_ms("OMNIVOICE_SCENARIO_START_DELAY_MS") {
|
||||
std::thread::sleep(Duration::from_millis(delay));
|
||||
}
|
||||
|
||||
// Serve /system/info + /profiles (the two probes behind backend_ready)
|
||||
// and /startup/progress (marker-stamped) for the given window; 0 = serve
|
||||
// forever.
|
||||
if let Some(serve_ms) = get_ms("OMNIVOICE_SCENARIO_SERVE_MS") {
|
||||
let port: u16 = get("OMNIVOICE_PORT").parse().expect("OMNIVOICE_PORT");
|
||||
let progress_only = get("OMNIVOICE_SCENARIO_PROGRESS_ONLY") == "1";
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("bind scenario port");
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let deadline = if serve_ms == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Instant::now() + Duration::from_millis(serve_ms))
|
||||
};
|
||||
loop {
|
||||
if let Some(d) = deadline {
|
||||
if Instant::now() >= d {
|
||||
break;
|
||||
}
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let mut buf = [0u8; 512];
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
|
||||
let n = stream.read(&mut buf).unwrap_or(0);
|
||||
let req = String::from_utf8_lossy(&buf[..n]);
|
||||
let resp = if req.starts_with("GET /startup/progress") {
|
||||
let body = r#"{"status": "starting", "step": "ml_imports", "label": "Loading ML runtime (PyTorch)_"}"#;
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nx-omnivoice-backend: 0.0.0\r\nContent-Length: {}\r\n\r\n{}",
|
||||
body.len(), body
|
||||
)
|
||||
} else if progress_only {
|
||||
"HTTP/1.1 503 X\r\nContent-Length: 0\r\n\r\n".to_string()
|
||||
} else if req.starts_with("GET /system/info") {
|
||||
let body = r#"{"data_dir": "/x", "app_version": "0.0.0"}"#;
|
||||
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body)
|
||||
} else {
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]".to_string()
|
||||
};
|
||||
let _ = stream.write_all(resp.as_bytes());
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stderr_script = get("OMNIVOICE_SCENARIO_STDERR");
|
||||
if !stderr_script.is_empty() {
|
||||
// \n-encoded so a multi-line traceback fits in one env var.
|
||||
eprintln!("{}", stderr_script.replace("\\n", "\n"));
|
||||
let _ = std::io::stderr().flush();
|
||||
// Let the shell's drainer thread pull the pipe before death.
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if get("OMNIVOICE_SCENARIO_SIGNAL") == "9" {
|
||||
unsafe { libc::raise(libc::SIGKILL) };
|
||||
}
|
||||
if let Some(code) = get_ms("OMNIVOICE_SCENARIO_EXIT") {
|
||||
std::process::exit(code as i32);
|
||||
}
|
||||
// Scripted to serve forever / be killed externally: idle out.
|
||||
std::thread::sleep(Duration::from_secs(600));
|
||||
}
|
||||
|
||||
// ── Harness plumbing ──────────────────────────────────────────────────────
|
||||
|
||||
struct Scenario<'a> {
|
||||
stderr: &'a str,
|
||||
exit: Option<i32>,
|
||||
signal9: bool,
|
||||
serve_ms: Option<u64>,
|
||||
progress_only: bool,
|
||||
}
|
||||
|
||||
impl Default for Scenario<'_> {
|
||||
fn default() -> Self {
|
||||
Scenario { stderr: "", exit: None, signal9: false, serve_ms: None, progress_only: false }
|
||||
}
|
||||
}
|
||||
|
||||
const SCENARIO_ENV: &[&str] = &[
|
||||
"OMNIVOICE_SCENARIO",
|
||||
"OMNIVOICE_SCENARIO_STDERR",
|
||||
"OMNIVOICE_SCENARIO_EXIT",
|
||||
"OMNIVOICE_SCENARIO_SIGNAL",
|
||||
"OMNIVOICE_SCENARIO_SERVE_MS",
|
||||
"OMNIVOICE_SCENARIO_PROGRESS_ONLY",
|
||||
"OMNIVOICE_SCENARIO_START_DELAY_MS",
|
||||
"OMNIVOICE_BACKEND_CMD",
|
||||
"OMNIVOICE_LOG_DIR",
|
||||
"OMNIVOICE_PORT",
|
||||
"OMNIVOICE_STARTUP_BUDGET_S",
|
||||
"OMNIVOICE_SUPERVISOR_POLL_MS",
|
||||
];
|
||||
|
||||
struct TestApp {
|
||||
app: tauri::App<tauri::test::MockRuntime>,
|
||||
stage: Arc<Mutex<BootstrapStage>>,
|
||||
logs: Arc<Mutex<Vec<LogPayload>>>,
|
||||
_logdir: tempfile::TempDir,
|
||||
_guard: MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
fn new(scenario: &Scenario) -> Self {
|
||||
let guard = HARNESS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for k in SCENARIO_ENV {
|
||||
std::env::remove_var(k);
|
||||
}
|
||||
// Reset the retry-flow flag a previous scenario may have left set.
|
||||
set_backend_kill_intended(false);
|
||||
|
||||
let logdir = tempfile::tempdir().expect("logdir");
|
||||
std::env::set_var("OMNIVOICE_LOG_DIR", logdir.path());
|
||||
// Fresh ephemeral port per scenario.
|
||||
let port = {
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
l.local_addr().unwrap().port()
|
||||
};
|
||||
std::env::set_var("OMNIVOICE_PORT", port.to_string());
|
||||
std::env::set_var("OMNIVOICE_STARTUP_BUDGET_S", "6");
|
||||
std::env::set_var("OMNIVOICE_SUPERVISOR_POLL_MS", "100");
|
||||
|
||||
let exe = std::env::current_exe().expect("current_exe");
|
||||
std::env::set_var(
|
||||
"OMNIVOICE_BACKEND_CMD",
|
||||
serde_json::to_string(&[
|
||||
exe.to_string_lossy().as_ref(),
|
||||
"scenario_child",
|
||||
"--exact",
|
||||
"--nocapture",
|
||||
])
|
||||
.unwrap(),
|
||||
);
|
||||
// Armed with OUR pid: the in-process scenario_child test sees its own
|
||||
// pid and stays inert; only the spawned child (a different pid) runs.
|
||||
std::env::set_var("OMNIVOICE_SCENARIO", std::process::id().to_string());
|
||||
if !scenario.stderr.is_empty() {
|
||||
std::env::set_var("OMNIVOICE_SCENARIO_STDERR", scenario.stderr);
|
||||
}
|
||||
if let Some(code) = scenario.exit {
|
||||
std::env::set_var("OMNIVOICE_SCENARIO_EXIT", code.to_string());
|
||||
}
|
||||
if scenario.signal9 {
|
||||
std::env::set_var("OMNIVOICE_SCENARIO_SIGNAL", "9");
|
||||
}
|
||||
if let Some(ms) = scenario.serve_ms {
|
||||
std::env::set_var("OMNIVOICE_SCENARIO_SERVE_MS", ms.to_string());
|
||||
}
|
||||
if scenario.progress_only {
|
||||
std::env::set_var("OMNIVOICE_SCENARIO_PROGRESS_ONLY", "1");
|
||||
}
|
||||
|
||||
let app = tauri::test::mock_builder()
|
||||
.build(tauri::test::mock_context(tauri::test::noop_assets()))
|
||||
.expect("mock app");
|
||||
app.manage(BackendState { process: Mutex::new(None), spawned_at: Mutex::new(None) });
|
||||
app.manage(AppFlags {
|
||||
quitting: AtomicBool::new(false),
|
||||
dictating: AtomicBool::new(false),
|
||||
capture: Mutex::new(CaptureDispatchState { ready: false, pending: None }),
|
||||
});
|
||||
let stage = Arc::new(Mutex::new(BootstrapStage::Checking));
|
||||
let logs: Arc<Mutex<Vec<LogPayload>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
app.manage(BootstrapState { stage: stage.clone(), logs: logs.clone() });
|
||||
TestApp { app, stage, logs, _logdir: logdir, _guard: guard }
|
||||
}
|
||||
|
||||
fn handle(&self) -> tauri::AppHandle<tauri::test::MockRuntime> {
|
||||
self.app.handle().clone()
|
||||
}
|
||||
|
||||
/// Run the bootstrap on a thread; the returned closure joins it with a
|
||||
/// hard timeout so a wiring regression fails red instead of hanging CI.
|
||||
fn run_bootstrap(&self) -> std::thread::JoinHandle<()> {
|
||||
let handle = self.handle();
|
||||
let stage = self.stage.clone();
|
||||
std::thread::spawn(move || spawn_backend_and_wait(&handle, &stage))
|
||||
}
|
||||
|
||||
fn stage_snapshot(&self) -> BootstrapStage {
|
||||
self.stage.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
fn failed_message(&self) -> Option<String> {
|
||||
match self.stage_snapshot() {
|
||||
BootstrapStage::Failed { message } => Some(message),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn markers(&self) -> app_lib::crash::CrashStore {
|
||||
app_lib::crash::load_store_from(&app_lib::crash::markers_path())
|
||||
}
|
||||
|
||||
fn record_events(&self, name: &'static str) -> Arc<Mutex<Vec<String>>> {
|
||||
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen2 = seen.clone();
|
||||
self.app.handle().listen(name, move |_ev| {
|
||||
seen2.lock().unwrap_or_else(|e| e.into_inner()).push(name.to_string());
|
||||
});
|
||||
seen
|
||||
}
|
||||
|
||||
fn kill_tracked_child(&self) {
|
||||
let state = self.app.state::<BackendState>();
|
||||
let guard = state.process.lock();
|
||||
if let Ok(mut guard) = guard {
|
||||
if let Some(child) = guard.as_mut() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quit(&self) {
|
||||
self.app
|
||||
.state::<AppFlags>()
|
||||
.quitting
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestApp {
|
||||
fn drop(&mut self) {
|
||||
self.quit(); // stop any still-running supervisor loop promptly
|
||||
self.kill_tracked_child();
|
||||
for k in SCENARIO_ENV {
|
||||
std::env::remove_var(k);
|
||||
}
|
||||
set_backend_kill_intended(false);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_until(timeout: Duration, mut pred: impl FnMut() -> bool) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if pred() {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn join_with_timeout(h: std::thread::JoinHandle<()>, timeout: Duration, what: &str) {
|
||||
let start = Instant::now();
|
||||
while !h.is_finished() {
|
||||
assert!(
|
||||
start.elapsed() < timeout,
|
||||
"{what}: bootstrap thread still running after {timeout:?} — a lifecycle \
|
||||
regression is hanging instead of diagnosing"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let _ = h.join();
|
||||
}
|
||||
|
||||
// ── Scenarios ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// S1 — the backend exits EXIT_PORT_IN_USE: the user must read a port
|
||||
/// conflict (in the exact phrasing BootstrapSplash.detectHints localizes),
|
||||
/// not a traceback whose one meaningful line is an OS-translated errno.
|
||||
#[test]
|
||||
fn port_conflict_is_named_as_a_port_conflict() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
stderr: "FATAL: port is already in use",
|
||||
exit: Some(app_lib::backend::EXIT_PORT_IN_USE),
|
||||
..Default::default()
|
||||
});
|
||||
let h = t.run_bootstrap();
|
||||
join_with_timeout(h, Duration::from_secs(30), "port conflict");
|
||||
|
||||
let msg = t.failed_message().expect("stage must be Failed");
|
||||
assert!(
|
||||
msg.contains("is already in use, so the backend could not"),
|
||||
"diagnosis must carry the detectHints-matchable port phrasing, got: {msg}"
|
||||
);
|
||||
let store = t.markers();
|
||||
assert_eq!(store.markers.len(), 1, "one real death → one marker");
|
||||
assert_eq!(store.markers.last().unwrap().exit_code, Some(app_lib::backend::EXIT_PORT_IN_USE));
|
||||
}
|
||||
|
||||
/// S3 — generic startup traceback: the Failed message must carry the stderr
|
||||
/// tail INCLUDING the chained-traceback root cause, and the marker must
|
||||
/// record the death's shape.
|
||||
#[test]
|
||||
fn generic_traceback_surfaces_the_root_cause() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
stderr: "Traceback (most recent call last):\\n File \"main.py\", line 1\\nImportError: libcublas.so.12: cannot open shared object file\\n\\nThe above exception was the direct cause of the following exception:\\n\\nTraceback (most recent call last):\\n File \"wrapper.py\", line 9\\nRuntimeError: failed to initialize CUDA backend",
|
||||
exit: Some(1),
|
||||
..Default::default()
|
||||
});
|
||||
let h = t.run_bootstrap();
|
||||
join_with_timeout(h, Duration::from_secs(30), "generic traceback");
|
||||
|
||||
let msg = t.failed_message().expect("stage must be Failed");
|
||||
assert!(msg.contains("Backend process exited"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("libcublas.so.12"),
|
||||
"the root-cause line must survive into the diagnosis, got: {msg}"
|
||||
);
|
||||
let store = t.markers();
|
||||
assert_eq!(store.markers.len(), 1);
|
||||
let m = store.markers.last().unwrap();
|
||||
assert_eq!(m.exit_code, Some(1));
|
||||
assert!(m.last_stderr.contains("Traceback"), "marker carries the evidence");
|
||||
assert!(m.last_stderr.contains("libcublas.so.12"));
|
||||
}
|
||||
|
||||
/// S4 — spawn failure (the program does not exist): the spawn diagnostic
|
||||
/// must reach the user, and NO crash marker is written — nothing ever ran.
|
||||
#[test]
|
||||
fn spawn_failure_diagnoses_and_writes_no_bogus_marker() {
|
||||
let t = TestApp::new(&Scenario::default());
|
||||
// Point the seam at a program that cannot exist.
|
||||
let missing = t._logdir.path().join("no-such-backend");
|
||||
std::env::set_var(
|
||||
"OMNIVOICE_BACKEND_CMD",
|
||||
serde_json::to_string(&[missing.to_string_lossy().as_ref()]).unwrap(),
|
||||
);
|
||||
let h = t.run_bootstrap();
|
||||
join_with_timeout(h, Duration::from_secs(30), "spawn failure");
|
||||
|
||||
let msg = t.failed_message().expect("stage must be Failed");
|
||||
assert!(
|
||||
msg.contains("Failed to launch the backend process"),
|
||||
"spawn_failure_diagnostic must reach the user, got: {msg}"
|
||||
);
|
||||
assert_eq!(
|
||||
t.markers().markers.len(),
|
||||
0,
|
||||
"never-started is not a crash — no marker may be written"
|
||||
);
|
||||
}
|
||||
|
||||
/// S5 — slow start past the budget: the timeout diagnosis must name the
|
||||
/// budget and carry the last stderr, and no death marker exists (the
|
||||
/// process is alive, just slow).
|
||||
#[test]
|
||||
fn slow_start_times_out_with_the_last_stderr() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
stderr: "Loading checkpoint shards_ 10%",
|
||||
serve_ms: None,
|
||||
..Default::default()
|
||||
});
|
||||
// The child prints, then idles far past the 6s budget without serving.
|
||||
let h = t.run_bootstrap();
|
||||
join_with_timeout(h, Duration::from_secs(60), "slow start");
|
||||
|
||||
let msg = t.failed_message().expect("stage must be Failed");
|
||||
assert!(msg.contains("did not respond within 6 s"), "got: {msg}");
|
||||
assert!(
|
||||
msg.contains("Loading checkpoint shards"),
|
||||
"the last stderr must ride along so triage sees WHERE it was, got: {msg}"
|
||||
);
|
||||
assert_eq!(t.markers().markers.len(), 0, "no death → no marker");
|
||||
}
|
||||
|
||||
/// S6 — post-Ready crash loop: markers are recorded BEFORE each restart,
|
||||
/// restarts are announced, and budget exhaustion lands on a Failed message
|
||||
/// naming the pattern and the last exit.
|
||||
#[test]
|
||||
fn crash_loop_exhausts_the_budget_with_a_named_diagnosis() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
stderr: "RuntimeError: CUDA error: out of memory",
|
||||
exit: Some(1),
|
||||
serve_ms: Some(1500),
|
||||
..Default::default()
|
||||
});
|
||||
let restarts = t.record_events("backend-restarting");
|
||||
let gave_up = t.record_events("backend-restart-failed");
|
||||
let h = t.run_bootstrap();
|
||||
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(20), || matches!(
|
||||
t.stage_snapshot(),
|
||||
BootstrapStage::Ready | BootstrapStage::StartingBackend | BootstrapStage::Failed { .. }
|
||||
)),
|
||||
"backend never reached Ready"
|
||||
);
|
||||
join_with_timeout(h, Duration::from_secs(120), "crash loop");
|
||||
|
||||
let msg = t.failed_message().expect("budget exhaustion must land on Failed");
|
||||
assert!(msg.contains("kept crashing"), "got: {msg}");
|
||||
assert!(msg.contains("exit code 1"), "the last death must be named, got: {msg}");
|
||||
assert_eq!(restarts.lock().unwrap().len(), 3, "3 respawns before giving up");
|
||||
assert_eq!(gave_up.lock().unwrap().len(), 1);
|
||||
let store = t.markers();
|
||||
assert!(
|
||||
!store.markers.is_empty(),
|
||||
"every real death records forensics BEFORE the restart decision"
|
||||
);
|
||||
assert!(
|
||||
store.markers.iter().all(|m| m.exit_code == Some(1)),
|
||||
"markers carry the actual exit"
|
||||
);
|
||||
assert!(
|
||||
store.markers.last().unwrap().last_stderr.contains("out of memory"),
|
||||
"the OOM evidence must be in the marker"
|
||||
);
|
||||
}
|
||||
|
||||
/// S7 (unix) — SIGKILL (the OS OOM killer's signature): the death must be
|
||||
/// named as signal 9, not exit-code noise.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sigkill_is_named_as_signal_nine() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
signal9: true,
|
||||
serve_ms: Some(1500),
|
||||
..Default::default()
|
||||
});
|
||||
let h = t.run_bootstrap();
|
||||
join_with_timeout(h, Duration::from_secs(120), "sigkill loop");
|
||||
|
||||
let msg = t.failed_message().expect("stage must be Failed");
|
||||
assert!(msg.contains("signal 9"), "signal deaths must be named, got: {msg}");
|
||||
let store = t.markers();
|
||||
let m = store.markers.last().expect("marker written");
|
||||
assert_eq!(m.exit_code, None);
|
||||
assert_eq!(m.signal, Some(9));
|
||||
}
|
||||
|
||||
/// S8 — a deliberate kill (Retry/Clean&Retry owns the respawn): the
|
||||
/// supervisor must yield silently — no crash marker, no restart, the stage
|
||||
/// never Failed.
|
||||
#[test]
|
||||
fn deliberate_kill_yields_without_a_crash_marker() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
serve_ms: Some(0), // serve forever
|
||||
..Default::default()
|
||||
});
|
||||
let restarts = t.record_events("backend-restarting");
|
||||
let h = t.run_bootstrap();
|
||||
|
||||
assert!(
|
||||
wait_until(Duration::from_secs(20), || matches!(
|
||||
t.stage_snapshot(),
|
||||
BootstrapStage::Ready
|
||||
)),
|
||||
"backend never reached Ready"
|
||||
);
|
||||
let before = t.markers().markers.len();
|
||||
set_backend_kill_intended(true);
|
||||
t.kill_tracked_child();
|
||||
join_with_timeout(h, Duration::from_secs(30), "deliberate kill");
|
||||
|
||||
assert_eq!(t.markers().markers.len(), before, "no marker for an intentional kill");
|
||||
assert_eq!(restarts.lock().unwrap().len(), 0, "no respawn — the retry flow owns it");
|
||||
assert!(
|
||||
matches!(t.stage_snapshot(), BootstrapStage::Ready),
|
||||
"the stage must never flip to Failed for a deliberate replace"
|
||||
);
|
||||
}
|
||||
|
||||
/// S9 — early-bind narration + a deferred-startup FATAL: the splash log
|
||||
/// narrates the step the backend reported, and when it dies the named step
|
||||
/// reaches both the user-facing diagnosis and the crash forensics.
|
||||
#[test]
|
||||
fn deferred_startup_failure_names_the_step() {
|
||||
let t = TestApp::new(&Scenario {
|
||||
stderr: "Traceback (most recent call last):\\n File \"main.py\"\\nImportError: torch\\nFATAL: backend startup failed during 'ml_imports': ImportError: torch",
|
||||
exit: Some(1),
|
||||
serve_ms: Some(1500),
|
||||
progress_only: true, // /startup/progress answers; health probes do not
|
||||
..Default::default()
|
||||
});
|
||||
let h = t.run_bootstrap();
|
||||
join_with_timeout(h, Duration::from_secs(60), "deferred FATAL");
|
||||
|
||||
let msg = t.failed_message().expect("stage must be Failed");
|
||||
assert!(
|
||||
msg.contains("FATAL: backend startup failed during 'ml_imports'"),
|
||||
"the named step must reach the user, got: {msg}"
|
||||
);
|
||||
let store = t.markers();
|
||||
assert!(store
|
||||
.markers
|
||||
.last()
|
||||
.expect("marker written")
|
||||
.last_stderr
|
||||
.contains("failed during 'ml_imports'"));
|
||||
let logs = t.logs.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert!(
|
||||
logs.iter().any(|l| l.line.contains("Startup: Loading ML runtime")),
|
||||
"the launch poll must narrate the step the backend reported; logs: {:?}",
|
||||
logs.iter().map(|l| &l.line).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!-- Manifest embedded into TEST binaries on Windows (build.rs,
|
||||
rustc-link-arg-tests). tauri-build embeds the app's manifest into the
|
||||
application binary, but cargo test binaries get none — so the loader
|
||||
resolves comctl32 v5, which lacks the TaskDialogIndirect entry point
|
||||
tauri's dialog/tray stack imports, and every integration-test binary
|
||||
dies at load with STATUS_ENTRYPOINT_NOT_FOUND (0xc0000139) before a
|
||||
single test runs. Declaring the Common-Controls v6 dependency here is
|
||||
the documented remedy. -->
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user