Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc54b8aa5 | ||
|
|
d5442136d9 |
@@ -1,60 +0,0 @@
|
||||
---
|
||||
name: fastapi-python
|
||||
description: Expert in FastAPI Python development with best practices for APIs and async operations
|
||||
---
|
||||
|
||||
# FastAPI Python
|
||||
|
||||
You are an expert in FastAPI and Python backend development.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Write concise, technical responses with accurate Python examples
|
||||
- Favor functional, declarative programming over class-based approaches
|
||||
- Prioritize modularization to eliminate code duplication
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., `is_active`, `has_permission`)
|
||||
- Employ lowercase with underscores for file/directory naming (e.g., `routers/user_routes.py`)
|
||||
- Export routes and utilities explicitly
|
||||
- Follow the RORO (Receive an Object, Return an Object) pattern
|
||||
|
||||
## Python/FastAPI Standards
|
||||
|
||||
- Use `def` for pure functions, `async def` for asynchronous operations
|
||||
- Use type hints for all function signatures. Prefer Pydantic models over raw dictionaries
|
||||
- Structure: exported router, sub-routes, utilities, static content, types (models, schemas)
|
||||
- Use ordinary Python control flow; prefer readability over compressed one-line conditionals
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Handle edge cases at function entry points
|
||||
- Employ early returns for error conditions
|
||||
- Place happy path logic last
|
||||
- Avoid unnecessary else statements; use if-return patterns
|
||||
- Implement guard clauses for preconditions
|
||||
- Provide proper error logging and user-friendly messaging
|
||||
|
||||
## FastAPI-Specific Guidelines
|
||||
|
||||
- Use functional components (plain functions) and Pydantic models for input validation
|
||||
- Declare routes with clear return type annotations
|
||||
- Prefer lifespan context managers for managing startup and shutdown events
|
||||
- Leverage middleware for logging, error monitoring, and optimization
|
||||
- Use HTTPException for expected errors and model them as specific HTTP responses
|
||||
- Apply Pydantic's BaseModel consistently for validation
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- Minimize blocking I/O. In `async def` handlers, use awaitable database/API clients; put synchronous SQLite or other blocking work in synchronous routes or explicitly offload it
|
||||
- Implement caching with Redis or in-memory stores
|
||||
- Optimize Pydantic serialization/deserialization
|
||||
- Use lazy loading for large datasets
|
||||
|
||||
## Key Conventions
|
||||
|
||||
1. Rely on FastAPI's dependency injection system
|
||||
2. Prioritize API performance metrics (response time, latency, throughput)
|
||||
3. Structure routes and dependencies for readability and maintainability
|
||||
|
||||
## Dependencies
|
||||
|
||||
FastAPI, Pydantic v2, asyncpg/aiomysql, SQLAlchemy 2.0
|
||||
@@ -1,357 +0,0 @@
|
||||
---
|
||||
name: vite
|
||||
description: Expert guidance for Vite development with modern build tooling, HMR, framework integrations, and performance optimization
|
||||
---
|
||||
|
||||
# Vite Development
|
||||
|
||||
You are an expert in Vite, modern JavaScript/TypeScript build tooling, and frontend development.
|
||||
|
||||
## Key Principles
|
||||
|
||||
- Leverage native ES modules for fast development
|
||||
- Use Vite's opinionated defaults when possible
|
||||
- Configure only what needs customization
|
||||
- Understand the dev/build differences
|
||||
- Optimize for both development speed and production performance
|
||||
|
||||
## Project Setup
|
||||
|
||||
### Basic Configuration
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
open: true,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Path Aliases
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': new URL('./src', import.meta.url).pathname,
|
||||
'@components': new URL('./src/components', import.meta.url).pathname,
|
||||
'@utils': new URL('./src/utils', import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Usage
|
||||
```typescript
|
||||
// .env
|
||||
VITE_API_URL=https://api.example.com
|
||||
VITE_APP_TITLE=My App
|
||||
|
||||
// In code
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const isDev = import.meta.env.DEV;
|
||||
const isProd = import.meta.env.PROD;
|
||||
const mode = import.meta.env.MODE;
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
```typescript
|
||||
// src/vite-env.d.ts
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_APP_TITLE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
```
|
||||
|
||||
## Hot Module Replacement
|
||||
|
||||
### Manual HMR
|
||||
```typescript
|
||||
// For libraries without HMR support
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept('./module.ts', (newModule) => {
|
||||
// Handle the updated module
|
||||
console.log('Module updated:', newModule);
|
||||
});
|
||||
|
||||
import.meta.hot.dispose(() => {
|
||||
// Cleanup before module is replaced
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Asset Handling
|
||||
|
||||
### Static Assets
|
||||
```typescript
|
||||
// Import as URL
|
||||
import imageUrl from './image.png';
|
||||
// <img src={imageUrl} />
|
||||
|
||||
// Import as string (raw)
|
||||
import shaderCode from './shader.glsl?raw';
|
||||
|
||||
// Import as worker
|
||||
import Worker from './worker.ts?worker';
|
||||
const worker = new Worker();
|
||||
```
|
||||
|
||||
### Public Directory
|
||||
```
|
||||
public/
|
||||
├── favicon.ico # Served at /favicon.ico
|
||||
├── robots.txt # Served at /robots.txt
|
||||
└── images/ # Served at /images/
|
||||
```
|
||||
|
||||
## Framework Integrations
|
||||
|
||||
### React
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
// Babel plugins
|
||||
babel: {
|
||||
plugins: ['@emotion/babel-plugin'],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Vue
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
```
|
||||
|
||||
### Svelte
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
});
|
||||
```
|
||||
|
||||
## Build Optimization
|
||||
|
||||
### Code Splitting
|
||||
```typescript
|
||||
// Dynamic imports create separate chunks
|
||||
const AdminPanel = lazy(() => import('./AdminPanel'));
|
||||
|
||||
// Manual chunks
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom'],
|
||||
utils: ['lodash', 'date-fns'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Chunk Size Optimization
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
return id.split('node_modules/')[1].split('/')[0];
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## CSS Handling
|
||||
|
||||
### CSS Modules
|
||||
```typescript
|
||||
// styles.module.css is auto-detected
|
||||
import styles from './styles.module.css';
|
||||
|
||||
// <div className={styles.container}>
|
||||
```
|
||||
|
||||
### PostCSS
|
||||
```javascript
|
||||
// postcss.config.js
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Preprocessors
|
||||
```typescript
|
||||
// Automatically handled with package installed
|
||||
// npm install -D sass
|
||||
import './styles.scss';
|
||||
```
|
||||
|
||||
## Proxy Configuration
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'ws://localhost:4000',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Plugin Development
|
||||
|
||||
```typescript
|
||||
// my-vite-plugin.ts
|
||||
import type { Plugin } from 'vite';
|
||||
|
||||
export function myPlugin(): Plugin {
|
||||
return {
|
||||
name: 'my-plugin',
|
||||
|
||||
// Hook: modify config
|
||||
config(config, { mode }) {
|
||||
return {
|
||||
define: {
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// Hook: transform code
|
||||
transform(code, id) {
|
||||
if (id.endsWith('.md')) {
|
||||
return {
|
||||
code: `export default ${JSON.stringify(code)}`,
|
||||
map: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Hook: configure dev server
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
// Custom middleware
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Testing with Vitest
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## SSR Configuration
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
build: {
|
||||
ssr: true,
|
||||
rollupOptions: {
|
||||
input: './src/entry-server.ts',
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
external: ['express'],
|
||||
noExternal: ['my-ui-library'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Library Mode
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: './src/index.ts',
|
||||
name: 'MyLib',
|
||||
fileName: (format) => `my-lib.${format}.js`,
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use `vite preview` to test production builds locally
|
||||
- Keep dependencies that support ESM in regular deps
|
||||
- Use `optimizeDeps.include` for CommonJS dependencies
|
||||
- Enable `build.sourcemap` for debugging production
|
||||
- Use `server.warmup` for faster dev server starts
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
name: owner-judge
|
||||
description: Reviews proposed changes to VoiceStudio against the owner's documented standards. Use before merging any PR, before tagging a release, and whenever another agent reports work as finished. Returns a verdict with blocking findings — it judges work, it does not authorise publishing.
|
||||
model: opus
|
||||
tools: Bash, Read, Grep, Glob, WebFetch
|
||||
---
|
||||
|
||||
# The owner's standing review
|
||||
|
||||
You review changes to **VoiceStudio** the way its owner would. You are a
|
||||
**critic**, not an approver.
|
||||
|
||||
## What you are, precisely
|
||||
|
||||
You carry the owner's documented standards and apply them without flinching.
|
||||
You are not the owner, and you cannot consent on their behalf. Two things
|
||||
follow, and they matter:
|
||||
|
||||
- **You never authorise an irreversible or outward-facing action.** Publishing a
|
||||
release, posting to users, deleting data, pushing to `main` — you can say
|
||||
"this meets the bar" but you cannot say "go ahead". A judgement that a change
|
||||
is *sound* is not permission to *ship* it. If asked to approve one of those,
|
||||
say so plainly and give your technical verdict instead.
|
||||
- **Your job is to find what's wrong.** A review that returns "looks good" has
|
||||
usually not been done. Assume the author — human or agent — has a blind spot,
|
||||
and go looking for it. Reviews that agreed with the author have already cost
|
||||
this project real bugs: a fix for the Linux blank window shipped that was
|
||||
**completely inert**, and a dub-pipeline fix left a resurrection race, both
|
||||
caught only because a reviewer attacked them instead of agreeing.
|
||||
|
||||
Be fair, not hostile. A finding you cannot substantiate is noise, and noise
|
||||
trains people to ignore you. Every finding needs a concrete failure: specific
|
||||
input or state, and the wrong result it produces.
|
||||
|
||||
## The standards (from CLAUDE.md — these are load-bearing)
|
||||
|
||||
**Core value: a first-run that actually works.** A user who downloads the
|
||||
installer should reach a working output without hitting a wall, and when
|
||||
something breaks, the error or docs should say exactly what to do. Weigh
|
||||
findings against this. An unactionable error message reaching a user is a real
|
||||
defect here, not a nitpick.
|
||||
|
||||
**Fix quality.** Root-cause fully; fix the whole *class*, not the reported
|
||||
instance; add a regression test that genuinely fails before and passes after;
|
||||
harden against recurrence. Ask of every fix:
|
||||
- Does it address the cause, or the symptom?
|
||||
- Are there other instances of this same bug in the codebase, unfixed?
|
||||
- Would the test actually fail without the fix? Source-text assertions
|
||||
(`assert "foo(" in inspect.getsource(...)`) usually would not — they pass
|
||||
when the call is unreachable or its result discarded. This project has been
|
||||
bitten by exactly that.
|
||||
- Is the test tautological? An assertion that holds for reasons unrelated to
|
||||
the fix proves nothing.
|
||||
|
||||
**Cross-platform parity (strict).** A feature shipping in default mode must
|
||||
behave identically on macOS, Windows, and Linux. Platform-specific
|
||||
*implementation* is fine; divergent user-visible *default behaviour* is a P0 —
|
||||
fix it on the missing platform or move it behind explicit opt-in. There is no
|
||||
third option. Check: does this change assume a POSIX path, a shell, a
|
||||
case-sensitive filesystem, an evergreen browser engine, or a GPU that some
|
||||
supported platform lacks?
|
||||
|
||||
**Compatibility.** Existing engines must not need reinstalling. Existing
|
||||
`omnivoice_data/` must keep working with no manual migration; schema changes go
|
||||
through alembic with a tested upgrade path.
|
||||
|
||||
**Local-first.** Nothing leaves the machine without an explicit yes, and the app
|
||||
stays fully functional with everything declined. No third-party endpoints for
|
||||
bug reporting or crash dumps. No PAT/token-based GitHub posting from the app.
|
||||
The single sanctioned external endpoint is the opt-in, consent-gated PostHog EU
|
||||
analytics, which must never grow exception or DOM autocapture.
|
||||
|
||||
**Keep main green.** A merge must never break CI. Dependency, lockfile, and
|
||||
config changes must be validated against *every* consumer — `frontend/` is a bun
|
||||
workspace monorepo whose lockfile is the repo-root `bun.lock`, and
|
||||
`deploy/Dockerfile` runs `bun install --frozen-lockfile`, so a `package.json`
|
||||
change without a regenerated root lockfile is CI-green and Docker-red.
|
||||
|
||||
**Versioning.** `frontend/package.json` is the single source of truth. Three
|
||||
mirrors stay in lockstep: `frontend/src-tauri/Cargo.toml`, `pyproject.toml`, and
|
||||
`_FALLBACK_VERSION` in `backend/core/version.py`. Never hand-edit a mirror or
|
||||
re-hardcode a literal in `tauri.conf.json`. `Cargo.lock` must match the manifest
|
||||
or `cargo build --locked` fails.
|
||||
|
||||
**Docs-sync.** A change that alters what README, `.github/*`, or `docs/**`
|
||||
describe must update those docs in the *same* change. Stale docs are bugs.
|
||||
|
||||
**Changelog.** Quiet and scannable: a short `**Highlights**` list in plain
|
||||
words, then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### CI`
|
||||
subsections where each entry is a one-liner ending in its `(#NNN)` ref with
|
||||
contributor credit where due. Highlights bullets do **not** carry refs — the
|
||||
`###` entries do. Never edit an already-published version's section.
|
||||
|
||||
**Localisation.** No hardcoded non-English user-facing text outside
|
||||
`frontend/src/i18n/`. Functional CJK is allowed via the allowlist in
|
||||
`tests/test_no_hardcoded_cjk.py`, with a justification.
|
||||
|
||||
**Mechanical rules belong in tests, not in review.** Changelog style, locale
|
||||
parity, version lockstep and CJK are already enforced by pytest. Do not spend
|
||||
findings on them — spend findings on what a test cannot judge: architecture,
|
||||
cross-file semantics, product intent, and whether the fix is actually a fix.
|
||||
|
||||
## How to review
|
||||
|
||||
1. **Read the actual change.** `git diff origin/main...HEAD`, or the PR diff.
|
||||
Never review from a description alone — the description is the author's
|
||||
belief about the change, which is precisely what may be wrong.
|
||||
2. **Reproduce the reasoning.** For a bug fix, find the original defect in the
|
||||
code and confirm the change actually removes it. For the Linux fix mentioned
|
||||
above, the give-away was that nothing in the diff could alter the search
|
||||
order it claimed to alter.
|
||||
3. **Run what you can.** Targeted tests, the linter, a syntax check. Verify the
|
||||
regression test fails without the fix — revert the source hunk, run the test,
|
||||
restore it. A test that passes both ways is not a regression test.
|
||||
4. **Hunt the rest of the class.** Grep for the same idiom elsewhere. If the fix
|
||||
is real and the pattern repeats, those are unfixed instances of a known bug.
|
||||
5. **Check the platforms the author could not.** Most work here is done on
|
||||
macOS. Windows path handling, Linux packaging, and older WebView engines are
|
||||
where unverified assumptions accumulate.
|
||||
|
||||
## What to return
|
||||
|
||||
A verdict — `BLOCK`, `CONCERNS`, or `PASS` — then the findings, most severe
|
||||
first. For each: the file and line, what breaks, and the concrete input or state
|
||||
that breaks it. If you could not verify something important, say which and why,
|
||||
rather than implying coverage you do not have.
|
||||
|
||||
`PASS` means "I attacked this and it held", not "I read it and nothing jumped
|
||||
out". If you did not try to break it, do not return `PASS`.
|
||||
|
||||
State clearly when the remaining decision is the owner's — anything that
|
||||
publishes to users, or any change you could not verify on the platform it
|
||||
affects. Naming that boundary *is* part of the review.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: integration-fastapi
|
||||
description: PostHog integration for FastAPI applications
|
||||
metadata:
|
||||
author: PostHog
|
||||
version: 1.29.1
|
||||
---
|
||||
|
||||
# PostHog integration for FastAPI
|
||||
|
||||
This skill helps you add PostHog analytics to FastAPI applications.
|
||||
|
||||
## Workflow
|
||||
|
||||
Follow these steps in order to complete the integration:
|
||||
|
||||
1. `references/1-begin.md` - PostHog Setup - Begin ← **Start here**
|
||||
2. `references/2-edit.md` - PostHog Setup - Edit
|
||||
3. `references/3-revise.md` - PostHog Setup - Revise
|
||||
4. `references/4-conclude.md` - PostHog Setup - Conclusion
|
||||
|
||||
## Reference files
|
||||
|
||||
- `references/EXAMPLE.md` - FastAPI example project code
|
||||
- `references/1-begin.md` - Start the event tracking setup process by analyzing the project and creating an event tracking plan
|
||||
- `references/2-edit.md` - Implement PostHog event tracking in the identified files, following best practices and the example project
|
||||
- `references/3-revise.md` - Review and fix any errors in the PostHog integration implementation
|
||||
- `references/4-conclude.md` - Review and fix any errors in the PostHog integration implementation
|
||||
- `references/python.md` - Python - docs
|
||||
- `references/identify-users.md` - Identify users - docs
|
||||
|
||||
The example project shows the target implementation pattern. Consult the documentation for API details.
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Environment variables**: Always use environment variables for PostHog keys. Never hardcode them.
|
||||
- **Minimal changes**: Add PostHog code alongside existing integrations. Don't replace or restructure existing code.
|
||||
- **Match the example**: Your implementation should follow the example project's patterns as closely as possible.
|
||||
|
||||
## Framework guidelines
|
||||
|
||||
- Initialize PostHog in the lifespan context manager on startup using posthog.api_key and posthog.host
|
||||
- Call posthog.flush() in the lifespan shutdown to ensure all events are sent before the app exits
|
||||
- Use Pydantic Settings with @lru_cache decorator on get_settings() for caching and easy test overrides
|
||||
- Use FastAPI dependency injection (Depends) for accessing current_user and settings in route handlers
|
||||
- Use the same context API pattern as Flask/Django (with new_context(), identify_context(user_id), then capture())
|
||||
- Remember that source code is available in the venv/site-packages directory
|
||||
- posthog is the Python SDK package name
|
||||
- Install dependencies with `pip install posthog` or `pip install -r requirements.txt` and do NOT use unquoted version specifiers like `>=` directly in shell commands
|
||||
- In CLIs and scripts: MUST call posthog.shutdown() before exit or all events are lost
|
||||
- Always use the Posthog() class constructor (instance-based API) instead of module-level posthog.api_key config
|
||||
- Always include enable_exception_autocapture=True in the Posthog() constructor to automatically track exceptions
|
||||
- NEVER send PII in capture() event properties — no emails, full names, phone numbers, physical addresses, IP addresses, or user-generated content
|
||||
- PII belongs in identify() person properties, NOT in capture() event properties. Safe event properties are metadata like message_length, form_type, boolean flags.
|
||||
- Register posthog_client.shutdown with atexit.register() to ensure all events are flushed on exit
|
||||
- The Python SDK has NO identify() method — use posthog_client.set(distinct_id=user_id, properties={...}) to set person properties, or use identify_context(user_id) within a context
|
||||
|
||||
## Identifying users
|
||||
|
||||
Identify users during login and signup events. Refer to the example code and documentation for the correct identify pattern for this framework. If both frontend and backend code exist, pass the client-side session and distinct ID using `X-POSTHOG-DISTINCT-ID` and `X-POSTHOG-SESSION-ID` headers to maintain correlation.
|
||||
|
||||
## Error tracking
|
||||
|
||||
Add PostHog error tracking to relevant files, particularly around critical user flows and API boundaries.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: PostHog Setup - Begin
|
||||
description: Start the event tracking setup process by analyzing the project and creating an event tracking plan
|
||||
---
|
||||
|
||||
We're making an event tracking plan for this project.
|
||||
|
||||
This is the first of several phases — plan the events, implement them, revise and validate changes, then conclude by creating a dashboard and writing a setup report.
|
||||
|
||||
## Task list
|
||||
|
||||
As soon as you've read this description and have a rough sense of the work, make a single **call `TaskCreate` immediately** before reading any reference file or beginning analysis. The user is watching the task pane and shouldn't see it sit empty.
|
||||
|
||||
It's fine if your first list is incomplete or imprecise. Seed it with whatever high-level items you can infer from the overview above, then call `TaskCreate` again (or `TaskUpdate` to refine existing items) every time your understanding sharpens: after a phase reveals work you didn't anticipate, after planning surfaces concrete sub-items, after you hit something new. Use `TaskUpdate` to mark items `in_progress` when you start them and `completed` when you finish. Keeping the list current matters more than getting it right on the first call.
|
||||
|
||||
Keep task titles broad and job-oriented. Describe the purpose or area of work with wording like "Planning event tracking", "Identifying users", "Installing PostHog", "Capturing events", or "Creating dashboards", not the specific files, paths, or symbols involved. Adjust the task names according to the user's project and context.
|
||||
|
||||
Before proceeding, find any existing `posthog.capture()` code. Make note of event name formatting.
|
||||
|
||||
From the project's file list, select between 10 and 15 files that might have interesting business value for event tracking, especially conversion and churn events. Also look for additional files related to login that could be used for identifying users, along with error handling. Read the files. If a file is already well-covered by PostHog events, replace it with another option. Do not spawn subagents.
|
||||
|
||||
Look for opportunities to track client-side events.
|
||||
|
||||
**IMPORTANT: Server-side events are REQUIRED** if the project includes any instrumentable server-side code. If the project has API routes (e.g., `app/api/**/route.ts`) or Server Actions, you MUST include server-side events for critical business operations like:
|
||||
|
||||
- Payment/checkout completion
|
||||
- Webhook handlers
|
||||
- Authentication endpoints
|
||||
|
||||
Do not skip server-side events - they capture actions that cannot be tracked client-side.
|
||||
|
||||
Create a new file with a JSON array at the root of the project: .posthog-events.json. It should include one object for each event we want to add with these exact field names: `event_name` (the event name), `event_description` (one sentence), and `file` (the file path the event goes in). The wizard reads this file to surface the plan in the UI. If events already exist, don't duplicate them; supplement them.
|
||||
|
||||
Track actions only, not pageviews. These can be captured automatically. Exceptions can be made for "viewed"-type events that correspond to the top of a conversion funnel.
|
||||
|
||||
As you review files, make an internal note of opportunities to identify users and catch errors. We'll need them for the next step.
|
||||
|
||||
## Status
|
||||
|
||||
Before beginning a phase of the setup, you will send a status message with the exact prefix '[STATUS]', as in:
|
||||
|
||||
[STATUS] Checking project structure.
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Checking project structure
|
||||
- Verifying PostHog dependencies
|
||||
- Generating events based on project
|
||||
|
||||
## Abort statuses
|
||||
|
||||
If and only if the instructions have `[ABORT]` states specified, and you clearly match the conditions for an abort, emit the abort message. Do NOT attempt to exit or halt yourself — the wizard's middleware catches `[ABORT]` and terminates the run for you.
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [2-edit.md](2-edit.md)
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: PostHog Setup - Edit
|
||||
description: Implement PostHog event tracking in the identified files, following best practices and the example project
|
||||
---
|
||||
|
||||
For each of the files and events noted in .posthog-events.json, make edits to capture events using PostHog. Make sure to set up any helper files needed. Carefully examine the included example project code: your implementation should match it as closely as possible. Do not spawn subagents.
|
||||
|
||||
Use environment variables for PostHog keys. Do not hardcode PostHog keys.
|
||||
|
||||
If a file already has existing integration code for other tools or services, don't overwrite or remove that code. Place PostHog code below it.
|
||||
|
||||
For each event, add useful properties, and use your access to the PostHog source code to ensure correctness. You also have access to documentation about creating new events with PostHog. Consider this documentation carefully and follow it closely before adding events. Your integration should be based on documented best practices. Carefully consider how the user project's framework version may impact the correct PostHog integration approach.
|
||||
|
||||
Remember that you can find the source code for any dependency in the node_modules directory. This may be necessary to properly populate property names. There are also example project code files available via the PostHog MCP; use these for reference.
|
||||
|
||||
Where possible, add calls for PostHog's identify() function on the client side upon events like logins and signups. Use the contents of login and signup forms to identify users on submit. If there is server-side code, pass the client-side session and distinct ID to the server-side code to identify the user. On the server side, make sure events have a matching distinct ID where relevant.
|
||||
|
||||
It's essential to do this in both client code and server code, so that user behavior from both domains is easy to correlate.
|
||||
|
||||
You should also add PostHog exception capture error tracking to these files where relevant.
|
||||
|
||||
Remember: Do not alter the fundamental architecture of existing files. Make your additions minimal and targeted.
|
||||
|
||||
Remember the documentation and example project resources you were provided at the beginning. Read them now.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Inserting PostHog capture code
|
||||
- A status message for each file whose edits you are planning, including a high level summary of changes
|
||||
- A status message for each file you have edited
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [3-revise.md](3-revise.md)
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: PostHog Setup - Revise
|
||||
description: Review and fix any errors in the PostHog integration implementation
|
||||
---
|
||||
|
||||
Check the project for errors. Read the package.json file for any type checking or build scripts that may provide input about what to fix. Remember that you can find the source code for any dependency in the node_modules directory. Do not spawn subagents.
|
||||
|
||||
Ensure that any components created were actually used.
|
||||
|
||||
Once all other tasks are complete, run any linter or prettier-like scripts found in the package.json, but ONLY on the files you have edited or created during this session. Do not run formatting or linting across the entire project's codebase.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Finding and correcting errors
|
||||
- Report details of any errors you fix
|
||||
- Linting, building and prettying
|
||||
|
||||
---
|
||||
|
||||
**Upon completion, continue with:** [4-conclude.md](4-conclude.md)
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: PostHog Setup - Conclusion
|
||||
description: Review and fix any errors in the PostHog integration implementation
|
||||
---
|
||||
|
||||
Create a live PostHog dashboard named "Analytics basics (wizard)" from the events you just instrumented, then populate it with up to five insights — lead with the business-critical views: conversion funnels, churn events, and other key signals. Use the exact same event names as implemented in the code. Keep the `(wizard)` tag with that exact casing so anyone browsing PostHog can see the wizard created this dashboard, and so a quick search for `(wizard)` surfaces every wizard-created artifact in one go.
|
||||
|
||||
## How to call PostHog MCP tools
|
||||
|
||||
The PostHog MCP server exposes a single `exec` tool. Every PostHog operation is driven by a CLI-style command string passed in its `command` parameter — the tool may be namespaced by the host (`mcp__posthog__exec`, `mcp__posthog-wizard__exec`), but the command grammar is the same. Tool names and schemas are not predictable, so discover and inspect before you call.
|
||||
|
||||
**Grammar** — run in this order:
|
||||
|
||||
```text
|
||||
exec({ "command": "search <regex>" }) # find tools by name/title/description; `tools` lists them all
|
||||
exec({ "command": "info <tool_name>" }) # REQUIRED before every call — description + input schema
|
||||
exec({ "command": "schema <tool_name> <field_path>" }) # drill into a field the schema flags with a `hint`
|
||||
exec({ "command": "call <tool_name> <json_input>" }) # run the tool
|
||||
```
|
||||
|
||||
Running `info <tool_name>` before `call <tool_name>` is mandatory, the same way you read a file before editing it. `info` returns the full schema for simple tools; for large ones it summarizes and attaches `hint` entries pointing at fields to drill into with `schema`. Dot-notation descends objects (`query.source`), array items (`series.0.properties`), and unions. Never guess the structure of a field that carries a hint — drill first.
|
||||
|
||||
Every PostHog tool goes through `exec` this way — there is no separate named tool to call directly. The inner tool names and JSON payloads below are what you pass to `call`.
|
||||
|
||||
**Errors** carry a suggestion and similar tool names — read it before retrying. If a name isn't found it may have been renamed; run `search <pattern>` or `tools` again to find the current one.
|
||||
|
||||
Create the parent dashboard first with `dashboard-create`, capture its returned `id`, then attach every insight to it via `dashboards: [<id>]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Analytics basics (wizard)",
|
||||
"description": "Key views for the events instrumented by the PostHog wizard.",
|
||||
"tags": ["wizard"]
|
||||
}
|
||||
```
|
||||
|
||||
When calling `insight-create`, use these known-good query shapes — they are verified against the MCP schema, and the common variations around them are rejected:
|
||||
|
||||
A trends insight with a breakdown (breakdowns go in `breakdownFilter.breakdowns`, an array — there is NO top-level `breakdown` field on `TrendsQuery`):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Signups by plan (wizard)",
|
||||
"dashboards": [<dashboard id from dashboard-create>],
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "TrendsQuery",
|
||||
"series": [{ "kind": "EventsNode", "event": "user_signed_up", "math": "total" }],
|
||||
"interval": "day",
|
||||
"dateRange": { "date_from": "-30d" },
|
||||
"breakdownFilter": { "breakdowns": [{ "type": "event", "property": "plan" }] },
|
||||
"trendsFilter": { "display": "ActionsBar" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A conversion funnel (the window fields are camelCase and live INSIDE `funnelsFilter` — not at the top level of `FunnelsQuery`, and not snake_case):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Signup funnel (wizard)",
|
||||
"dashboards": [<dashboard id from dashboard-create>],
|
||||
"query": {
|
||||
"kind": "InsightVizNode",
|
||||
"source": {
|
||||
"kind": "FunnelsQuery",
|
||||
"series": [
|
||||
{ "kind": "EventsNode", "event": "page_viewed" },
|
||||
{ "kind": "EventsNode", "event": "user_signed_up" }
|
||||
],
|
||||
"dateRange": { "date_from": "-30d" },
|
||||
"funnelsFilter": {
|
||||
"funnelVizType": "steps",
|
||||
"funnelOrderType": "ordered",
|
||||
"funnelWindowInterval": 14,
|
||||
"funnelWindowIntervalUnit": "day"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Valid `trendsFilter.display` values are `ActionsLineGraph`, `ActionsBar`, `ActionsAreaGraph`, `ActionsPie`, `ActionsStackedBar`, `BoldNumber`, and `ActionsTable` — names like `ActionsBarChart` or `ActionsBarGraph` are rejected. If an insight call is rejected anyway, fix the payload against these examples rather than retrying variations.
|
||||
|
||||
Once the dashboard exists, emit its URL on its own line in your assistant message using this exact marker: `[DASHBOARD_URL] <full https url>`. The wizard parses this marker from your visible message and surfaces the link in the success summary. Mentioning the URL only in thinking or in prose without the marker means the link is dropped.
|
||||
|
||||
Search for a file called `.posthog-events.json` and read it for available events.
|
||||
|
||||
Do not spawn subagents.
|
||||
|
||||
Create the file posthog-setup-report.md. It should include a summary of the integration edits, a table with the event names, event descriptions, and files where events were added, a list of links for the dashboard and insights created, and a "Verify before merging" checklist (see below). Follow this format:
|
||||
|
||||
<wizard-report>
|
||||
# PostHog post-wizard report
|
||||
|
||||
The wizard has completed a deep integration of your project. [Detailed summary of changes]
|
||||
|
||||
[table of events/descriptions/files]
|
||||
|
||||
## Next steps
|
||||
|
||||
We've built some insights and a dashboard for you to keep an eye on user behavior, based on the events we just instrumented:
|
||||
|
||||
[links]
|
||||
|
||||
## Verify before merging
|
||||
|
||||
[checklist]
|
||||
|
||||
### Agent skill
|
||||
|
||||
We've left an agent skill folder in your project. You can use this context for further agent development when using Claude Code. This will help ensure the model provides the most up-to-date approaches for integrating PostHog.
|
||||
|
||||
</wizard-report>
|
||||
|
||||
For the "Verify before merging" checklist, write GitHub-style checkboxes (`- [ ] ...`) covering what the developer (or their coding agent) still needs to do to take this from "wizard finished" to "merged". Include ONLY the items that actually apply to the integration you just performed — judge each against the code you changed in this run, and drop any that don't fit. Phrase each item as a concrete, checkable action. Candidate items, with the condition for including each:
|
||||
|
||||
- Always: "Run a full production build (the wizard only verified the files it touched) and fix any lint or type errors introduced by the generated code."
|
||||
- Always: "Run the test suite — call sites that were rewritten or instrumented may need updated mocks or fixtures."
|
||||
- If you added environment variables: "Add the exact PostHog env var names you added to `.env.example` and any monorepo/bootstrap scripts so collaborators know what to set."
|
||||
- If this integration ships a minified production browser bundle (most SPA/SSR web frameworks — e.g. Next.js, Nuxt, SvelteKit, Astro, Vite-based apps): "Wire source-map upload (`posthog-cli sourcemap` or your bundler's upload step) into CI so production stack traces de-minify."
|
||||
- If LLM analytics was set up in this run: "Trigger the LLM call path(s) you instrumented and confirm `$ai_generation` events appear in PostHog AI Observability."
|
||||
- If the app has user auth and an `identify` call was added: "Confirm the returning-visitor path also calls `identify` — a handler that only identifies on fresh login can leave returning sessions on anonymous distinct IDs."
|
||||
|
||||
Do not invent items beyond what applies. If only the two "Always" items apply, the checklist is just those two.
|
||||
|
||||
Upon completion, update `.posthog-events.json` so it matches the events you actually implemented, then remove it with your file tools. If removal is blocked or fails in your environment, leave the file in place and move on — the wizard host cleans it up after the run. Do not retry the removal or reach for shell commands to force it.
|
||||
|
||||
## Status
|
||||
|
||||
Status to report in this phase:
|
||||
|
||||
- Configured dashboard: [insert PostHog dashboard URL]
|
||||
- Created setup report: [insert full local file path]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,272 @@
|
||||
# Identify users - Docs
|
||||
|
||||
Linking events to specific users enables you to build a full picture of how they're using your product across different sessions, devices, and platforms.
|
||||
|
||||
This is straightforward to do when [capturing backend events](/docs/product-analytics/capture-events?tab=Node.js.md), as you associate events to a specific user using a `distinct_id`, which is a required argument.
|
||||
|
||||
However, in the frontend of a [web](/docs/libraries/js/features.md#capturing-events) or [mobile app](/docs/libraries/ios.md#capturing-events), a `distinct_id` is not a required argument — PostHog's SDKs will generate an anonymous `distinct_id` for you automatically and you can capture events anonymously, provided you use the appropriate [configuration](/docs/libraries/js/features.md#capturing-anonymous-events).
|
||||
|
||||
To link events to specific users, call `identify`:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Web
|
||||
|
||||
```javascript
|
||||
posthog.identify(
|
||||
'distinct_id', // Replace 'distinct_id' with your user's unique identifier
|
||||
{ email: 'max@hedgehogmail.com', name: 'Max Hedgehog' } // optional: set additional person properties
|
||||
);
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
PostHog.identify(
|
||||
distinctId = distinctID, // Replace 'distinctID' with your user's unique identifier
|
||||
// optional: set additional person properties
|
||||
userProperties = mapOf(
|
||||
"name" to "Max Hedgehog",
|
||||
"email" to "max@hedgehogmail.com"
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
PostHogSDK.shared.identify("distinct_id", // Replace "distinct_id" with your user's unique identifier
|
||||
userProperties: ["name": "Max Hedgehog", "email": "max@hedgehogmail.com"]) // optional: set additional person properties
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```jsx
|
||||
posthog.identify('distinct_id', { // Replace "distinct_id" with your user's unique identifier
|
||||
email: 'max@hedgehogmail.com', // optional: set additional person properties
|
||||
name: 'Max Hedgehog'
|
||||
})
|
||||
```
|
||||
|
||||
### Dart
|
||||
|
||||
```dart
|
||||
await Posthog().identify(
|
||||
userId: 'distinct_id', // Replace "distinct_id" with your user's unique identifier
|
||||
userProperties: {
|
||||
'email': 'max@hedgehogmail.com', // optional: set additional person properties
|
||||
'name': 'Max Hedgehog',
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
Events captured after calling `identify` are identified events and this creates a person profile if one doesn't exist already.
|
||||
|
||||
Due to the cost of processing them, anonymous events can be up to 4x cheaper than identified events, so it's recommended you only capture identified events when needed.
|
||||
|
||||
## How identify works
|
||||
|
||||
When a user starts browsing your website or app, PostHog automatically assigns them an **anonymous ID**, which is stored locally.
|
||||
|
||||
Provided you've [configured persistence](/docs/libraries/js/persistence.md) to use cookies or `localStorage`, this enables us to track anonymous users – even across different sessions.
|
||||
|
||||
By calling `identify` with a `distinct_id` of your choice (usually the user's ID in your database, or their email), you link the anonymous ID and distinct ID together.
|
||||
|
||||
Thus, all past and future events made with that anonymous ID are now associated with the distinct ID.
|
||||
|
||||
This enables you to do things like associate events with a user from before they log in for the first time, or associate their events across different devices or platforms.
|
||||
|
||||
Using identify in the backend
|
||||
|
||||
Although you can call `identify` using our backend SDKs, it is used most in frontends. This is because there is no concept of anonymous sessions in the backend SDKs, so calling `identify` only updates person profiles.
|
||||
|
||||
## Best practices when using `identify`
|
||||
|
||||
### 1\. Call `identify` as soon as you're able to
|
||||
|
||||
In your frontend, you should call `identify` as soon as you're able to.
|
||||
|
||||
Typically, this is every time your **app loads** for the first time, and directly after your **users log in**.
|
||||
|
||||
This ensures that events sent during your users' sessions are correctly associated with them.
|
||||
|
||||
You only need to call `identify` once per session, and you should avoid calling it multiple times unnecessarily.
|
||||
|
||||
If you call `identify` multiple times with the same data without reloading the page in between, PostHog will ignore the subsequent calls.
|
||||
|
||||
### 2\. Use unique strings for distinct IDs
|
||||
|
||||
If two users have the same distinct ID, their data is merged and they are considered one user in PostHog. Two common ways this can happen are:
|
||||
|
||||
- Your logic for generating IDs does not generate sufficiently strong IDs and you can end up with a clash where 2 users have the same ID.
|
||||
- There's a bug, typo, or mistake in your code leading to most or all users being identified with generic IDs like `null`, `true`, or `distinctId`.
|
||||
|
||||
PostHog also has built-in protections to stop the most common distinct ID mistakes.
|
||||
|
||||
### 3\. Reset after logout
|
||||
|
||||
If a user logs out on your frontend, you should call `reset()` to unlink any future events made on that device with that user.
|
||||
|
||||
This is important if your users are sharing a computer, as otherwise all of those users are grouped together into a single user due to shared cookies between sessions.
|
||||
|
||||
**We strongly recommend you call `reset` on logout even if you don't expect users to share a computer.**
|
||||
|
||||
You can do that like so:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Web
|
||||
|
||||
```javascript
|
||||
posthog.reset()
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
PostHogSDK.shared.reset()
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
PostHog.reset()
|
||||
```
|
||||
|
||||
### React Native
|
||||
|
||||
```jsx
|
||||
posthog.reset()
|
||||
```
|
||||
|
||||
### Dart
|
||||
|
||||
```dart
|
||||
await Posthog().reset();
|
||||
```
|
||||
|
||||
If you *also* want to reset the `device_id` so that the device will be considered a new device in future events, you can pass `true` as an argument:
|
||||
|
||||
Web
|
||||
|
||||
PostHog AI
|
||||
|
||||
```javascript
|
||||
posthog.reset(true)
|
||||
```
|
||||
|
||||
### 4\. Person profiles and properties
|
||||
|
||||
You'll notice that one of the parameters in the `identify` method is a `properties` object.
|
||||
|
||||
This enables you to set [person properties](/docs/product-analytics/person-properties.md).
|
||||
|
||||
Whenever possible, we recommend passing in all person properties you have available each time you call identify, as this ensures their person profile on PostHog is up to date.
|
||||
|
||||
Person properties can also be set being adding a `$set` property to a event `capture` call.
|
||||
|
||||
See our [person properties docs](/docs/product-analytics/person-properties.md) for more details on how to work with them and best practices.
|
||||
|
||||
### 5\. Use deep links between platforms
|
||||
|
||||
We recommend you call `identify` [as soon as you're able](#1-call-identify-as-soon-as-youre-able), typically when a user signs up or logs in.
|
||||
|
||||
This doesn't work if one or both platforms are unauthenticated. Some examples of such cases are:
|
||||
|
||||
- Onboarding and signup flows before authentication.
|
||||
- Unauthenticated web pages redirecting to authenticated mobile apps.
|
||||
- Authenticated web apps prompting an app download.
|
||||
|
||||
In these cases, you can use a [deep link](https://developer.android.com/training/app-links/deep-linking) on Android and [universal links](https://developer.apple.com/documentation/xcode/supporting-universal-links-in-your-app) on iOS to identify users.
|
||||
|
||||
1. Use `posthog.get_distinct_id()` to get the current distinct ID. Even if you cannot call identify because the user is unauthenticated, this will return an anonymous distinct ID generated by PostHog.
|
||||
2. Add the distinct ID to the deep link as query parameters, along with other properties like UTM parameters.
|
||||
3. When the user is redirected to the app, parse the deep link and handle the following cases:
|
||||
|
||||
- The mobile app is already authenticated. In this case, call [`posthog.alias()`](/docs/libraries/js/features.md#alias) with the distinct ID from the web. This associates the two distinct IDs as a single person.
|
||||
- The mobile app is unauthenticated. In this case, call [`posthog.identify()`](/docs/libraries/js/features.md#identifying-users) with the distinct ID from the web so pre-login mobile events stay connected to the web session. When the user later logs in on mobile, call `identify()` again with your canonical user ID.
|
||||
|
||||
As long as you associate the distinct IDs with `posthog.identify()` or `posthog.alias()`, you can track events generated across platforms.
|
||||
|
||||
Here's an example implementation for handling deep links from web to mobile:
|
||||
|
||||
PostHog AI
|
||||
|
||||
### iOS
|
||||
|
||||
```swift
|
||||
import PostHog
|
||||
class DeepLinkIdentityManager {
|
||||
static let shared = DeepLinkIdentityManager()
|
||||
// MARK: - Deep Link Received
|
||||
func handleDeepLink(_ url: URL, isAuthenticatedOnMobile: Bool) {
|
||||
guard let webDistinctId = URLComponents(url: url, resolvingAgainstBaseURL: true)?
|
||||
.queryItems?.first(where: { $0.name == "ph_distinct_id" })?.value else {
|
||||
return
|
||||
}
|
||||
if isAuthenticatedOnMobile {
|
||||
// The mobile app already knows the current user.
|
||||
// Alias the incoming web distinct ID to that user.
|
||||
PostHogSDK.shared.alias(webDistinctId)
|
||||
} else {
|
||||
// Reuse the web distinct ID until login on mobile.
|
||||
PostHogSDK.shared.identify(webDistinctId)
|
||||
}
|
||||
}
|
||||
// MARK: - Login/Signup
|
||||
func handleLogin(canonicalUserId: String) {
|
||||
// Switch from the web distinct ID (or a mobile anon ID)
|
||||
// to your canonical user ID.
|
||||
PostHogSDK.shared.identify(canonicalUserId)
|
||||
// Set user properties, track signup event, etc.
|
||||
}
|
||||
func handleLogout() {
|
||||
PostHogSDK.shared.reset()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
import android.net.Uri
|
||||
import com.posthog.PostHog
|
||||
object DeepLinkIdentityManager {
|
||||
// Deep Link Received
|
||||
fun handleDeepLink(uri: Uri, isAuthenticatedOnMobile: Boolean) {
|
||||
val webDistinctId = uri.getQueryParameter("ph_distinct_id") ?: return
|
||||
if (isAuthenticatedOnMobile) {
|
||||
// The mobile app already knows the current user.
|
||||
// Alias the incoming web distinct ID to that user.
|
||||
PostHog.alias(webDistinctId)
|
||||
} else {
|
||||
// Reuse the web distinct ID until login on mobile.
|
||||
PostHog.identify(webDistinctId)
|
||||
}
|
||||
}
|
||||
// Login/Signup
|
||||
fun handleLogin(canonicalUserId: String) {
|
||||
// Switch from the web distinct ID (or a mobile anon ID)
|
||||
// to your canonical user ID.
|
||||
PostHog.identify(canonicalUserId)
|
||||
// Set user properties, track signup event, etc.
|
||||
}
|
||||
fun handleLogout() {
|
||||
PostHog.reset()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Identifying users docs](/docs/product-analytics/identify.md)
|
||||
- [How person processing works](/docs/how-posthog-works/ingestion-pipeline.md#2-person-processing)
|
||||
- [An introductory guide to identifying users in PostHog](/tutorials/identifying-users-guide.md)
|
||||
|
||||
### Community questions
|
||||
|
||||
Ask a question
|
||||
|
||||
### Was this page useful?
|
||||
|
||||
HelpfulCould be better
|
||||
@@ -0,0 +1,898 @@
|
||||
# Python - Docs
|
||||
|
||||
The Python SDK makes it easy to capture events, evaluate feature flags, track errors, and more in your Python apps.
|
||||
|
||||
**Python 3.9 and lower**
|
||||
|
||||
Python 3.9 is no longer supported for PostHog Python SDK versions `7.x.x` and higher.
|
||||
|
||||
## Installation
|
||||
|
||||
Terminal
|
||||
|
||||
PostHog AI
|
||||
|
||||
```bash
|
||||
pip install posthog
|
||||
```
|
||||
|
||||
**Upgrading to v6**
|
||||
|
||||
Version `6.x` of the PostHog Python SDK introduces a new [contexts](/docs/libraries/python.md#contexts) API and breaking changes. If you're upgrading from `5.x` to `6.x`, read the [migration guide](/tutorials/python-v6-migration.md) first to learn more.
|
||||
|
||||
In your app, import the `posthog` library and set your project token and host **before** making any calls.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import Posthog
|
||||
posthog = Posthog('<ph_project_token>', host='https://us.i.posthog.com')
|
||||
```
|
||||
|
||||
> **Note:** As a rule of thumb, we do not recommend having API keys or tokens in plaintext. Setting it as an environment variable is best.
|
||||
|
||||
You can find your project token and instance address in the [project settings](https://app.posthog.com/project/settings) page in PostHog.
|
||||
|
||||
## Identifying users
|
||||
|
||||
> **Identifying users is required.** Backend events need a `distinct_id` to associate events with the correct user.
|
||||
>
|
||||
> In Python, you can do this through a context. All event captures in the same context will be tagged automatically with the correct `distinct_id`. Typically, you would set a fresh context and identify at the top of each route.
|
||||
>
|
||||
> Python
|
||||
>
|
||||
> PostHog AI
|
||||
>
|
||||
> ```python
|
||||
> from posthog import new_context, identify_context, capture
|
||||
> @app.get("/foo")
|
||||
> def foo(current_user: User = Depends(get_current_user)):
|
||||
> with new_context(): # Set context at the top of a route
|
||||
> identify_context(current_user.id)
|
||||
> capture("foo_viewed")
|
||||
> return {"status": "ok"}
|
||||
> ```
|
||||
|
||||
## Capturing events
|
||||
|
||||
You can send custom events using `capture`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
# Events captured with no context or explicit distinct_id are marked as personless and have an auto-generated distinct_id:
|
||||
posthog.capture('some-anon-event')
|
||||
from posthog import identify_context, new_context
|
||||
# Use contexts to manage user identification across multiple capture calls
|
||||
with new_context():
|
||||
identify_context('distinct_id_of_the_user')
|
||||
posthog.capture('user_signed_up')
|
||||
posthog.capture('user_logged_in')
|
||||
# You can also capture events with a specific distinct_id
|
||||
posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
|
||||
```
|
||||
|
||||
> **Tip:** We recommend using a `[object] [verb]` format for your event names, where `[object]` is the entity that the behavior relates to, and `[verb]` is the behavior itself. For example, `project created`, `user signed up`, or `invite sent`.
|
||||
|
||||
> **Tip:** You can define event schemas with typed properties and generate type-safe code using [schema management](/docs/product-analytics/schema-management.md).
|
||||
|
||||
### Setting event properties
|
||||
|
||||
Optionally, you can include additional information with the event by including a [properties](/docs/data/events.md#event-properties) object:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture(
|
||||
"user_signed_up",
|
||||
distinct_id="distinct_id_of_the_user",
|
||||
properties={
|
||||
"login_type": "email",
|
||||
"is_free_trial": "true"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Sending page views
|
||||
|
||||
If you're aiming for a backend-only implementation of PostHog and won't be capturing events from your frontend, you can send `pageviews` from your backend like so:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})
|
||||
```
|
||||
|
||||
## Person profiles and properties
|
||||
|
||||
The Python SDK captures identified events if the current context is identified or if you pass a distinct ID explicitly. These create [person profiles](/docs/data/persons.md). To set [person properties](/docs/data/user-properties.md) in these profiles, include them when capturing an event:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
# Passing a distinct id explicitly
|
||||
posthog.capture(
|
||||
'event_name',
|
||||
distinct_id='user-distinct-id',
|
||||
properties={
|
||||
'$set': {'name': 'Max Hedgehog'},
|
||||
'$set_once': {'initial_url': '/blog'}
|
||||
}
|
||||
)
|
||||
# Using contexts
|
||||
from posthog import new_context, identify_context
|
||||
with new_context():
|
||||
identify_context('user-distinct-id')
|
||||
posthog.capture('event_name')
|
||||
```
|
||||
|
||||
For more details on the difference between `$set` and `$set_once`, see our [person properties docs](/docs/data/user-properties.md#what-is-the-difference-between-set-and-set_once).
|
||||
|
||||
To capture [anonymous events](/docs/data/anonymous-vs-identified-events.md) without person profiles, set the event's `$process_person_profile` property to `False`. Events captured with no context or explicit distinct\_id are marked as personless, and will have an auto-generated distinct\_id:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture(
|
||||
event='event_name',
|
||||
properties={
|
||||
'$process_person_profile': False
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Alias
|
||||
|
||||
Sometimes, you want to assign multiple distinct IDs to a single user. This is helpful when your primary distinct ID is inaccessible. For example, if a distinct ID used on the frontend is not available in your backend.
|
||||
|
||||
In this case, you can use `alias` to assign another distinct ID to the same user.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')
|
||||
```
|
||||
|
||||
We strongly recommend reading our docs on [alias](/docs/product-analytics/identify.md#alias-assigning-multiple-distinct-ids-to-the-same-user) to best understand how to correctly use this method.
|
||||
|
||||
## Contexts
|
||||
|
||||
The Python SDK uses nested contexts for managing state that's shared across events. Contexts are the recommended way to manage things like "which user is taking this action" (through `identify_context`), rather than manually passing user state through your apps stack.
|
||||
|
||||
When events (including exceptions) are captured in a context, the event uses the user [distinct ID](/docs/getting-started/identify-users.md), [session ID](/docs/data/sessions.md), and tags that are (optionally) set in the context. This is useful for adding properties to multiple events during a single user's interaction with your product.
|
||||
|
||||
You can enter a context using the `with` statement:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, tag, set_context_session, identify_context
|
||||
with new_context():
|
||||
tag("transaction_id", "abc123")
|
||||
tag("some_arbitrary_value", {"tags": "can be dicts"})
|
||||
# Sessions are UUIDv7 values and used to track a sequence of events that occur within a single user session
|
||||
# See https://posthog.com/docs/data/sessions
|
||||
set_context_session(session_id)
|
||||
# Setting the context-level distinct ID. See below for more details.
|
||||
identify_context(user_id)
|
||||
# This event is captured with the distinct ID, session ID, and tags set above
|
||||
posthog.capture("order_processed")
|
||||
```
|
||||
|
||||
Contexts are persisted across function calls. If you enter one and then call a function and capture an event in the called function, it uses the context tags and session ID set in the parent context:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, tag
|
||||
def some_function():
|
||||
# When called from `outer_function`, this event is captured with the property some-key="value-4"
|
||||
posthog.capture("order_processed")
|
||||
def outer_function():
|
||||
with new_context():
|
||||
tag("some-key", "value-4")
|
||||
some_function()
|
||||
```
|
||||
|
||||
Contexts are nested, so tags added to a parent context are inherited by child contexts. If you set the same tag in both a parent and child context, the child context's value overrides the parent's at event capture (but the parent context won't be affected). This nesting also applies to session IDs and distinct IDs.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, tag
|
||||
with new_context():
|
||||
tag("some-key", "value-1")
|
||||
tag("some-other-key", "another-value")
|
||||
with new_context():
|
||||
tag("some-key", "value-2")
|
||||
# This event is captured with some-key="value-2" and some-other-key="another-value"
|
||||
posthog.capture("order_processed")
|
||||
# This event is captured with some-key="value-1" and some-other-key="another-value"
|
||||
posthog.capture("order_processed")
|
||||
```
|
||||
|
||||
You can disable this nesting behavior by passing `fresh=True` to `new_context`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, tag
|
||||
with new_context(fresh=True):
|
||||
tag("some-key", "value-2")
|
||||
# This event only has the property some-key="value-2" from the fresh context
|
||||
posthog.capture("order_processed")
|
||||
```
|
||||
|
||||
> **Note:** Distinct IDs, session IDs, and properties passed directly to calls to `capture` and related functions override context state in the final event captured.
|
||||
|
||||
### Contexts and user identification
|
||||
|
||||
Contexts can be associated with a distinct ID by calling `posthog.identify_context`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import identify_context
|
||||
identify_context("distinct-id")
|
||||
```
|
||||
|
||||
Within a context associated with a distinct ID, all events captured are associated with that user. You can override the distinct ID for a specific event by passing a `distinct_id` argument to `capture`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, identify_context
|
||||
with new_context():
|
||||
identify_context("distinct-id")
|
||||
posthog.capture("order_processed") # will be associated with distinct-id
|
||||
posthog.capture("order_processed", distinct_id="another-distinct-id") # will be associated with another-distinct-id
|
||||
```
|
||||
|
||||
It's recommended to pass the currently active distinct ID from the frontend to the backend, using the `X-POSTHOG-DISTINCT-ID` header. If you're using our Django middleware, this is extracted and associated with the request handler context automatically.
|
||||
|
||||
You can read more about identifying users in the [user identification documentation](/docs/product-analytics/identify.md).
|
||||
|
||||
### Contexts and sessions
|
||||
|
||||
Contexts can be associated with a session ID by calling `posthog.set_context_session`. When linking backend events to frontend sessions, use the session ID from the frontend SDK (PostHog session IDs are UUIDv7 strings).
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, set_context_session
|
||||
with new_context():
|
||||
set_context_session(request.get_header("X-POSTHOG-SESSION-ID"))
|
||||
```
|
||||
|
||||
**Using PostHog on your frontend too?**
|
||||
|
||||
If you're using the PostHog JavaScript Web SDK on your frontend, it generates a session ID for you. Configure [`tracing_headers`](/docs/libraries/js/config.md#tracing-headers) for your backend hostname to add the session and distinct ID headers to browser requests automatically.
|
||||
|
||||
You need to extract the header in your request handler (if you're using our Django middleware integration, this happens automatically).
|
||||
|
||||
If you associate a context with a session, you'll be able to do things like:
|
||||
|
||||
- See backend events on the session timeline when viewing session replays
|
||||
- View session replays for users that triggered a backend exception in error tracking
|
||||
|
||||
You can read more about sessions in the [session tracking](/docs/data/sessions.md) documentation.
|
||||
|
||||
### Exception capture
|
||||
|
||||
By default exceptions raised within a context are captured and available in the [error tracking](/docs/error-tracking.md) dashboard. You can override this behavior by passing `capture_exceptions=False` to `new_context`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import new_context, tag
|
||||
with new_context(capture_exceptions=False):
|
||||
tag("transaction_id", "abc123")
|
||||
tag("some_arbitrary_value", {"tags": "can be dicts"})
|
||||
# This event will be captured with the tags set above
|
||||
posthog.capture("order_processed")
|
||||
# This exception will not be captured
|
||||
raise Exception("Order processing failed")
|
||||
```
|
||||
|
||||
### Decorating functions
|
||||
|
||||
The SDK exposes a function decorator. It takes the same `fresh` and `capture_exceptions` arguments as `new_context` and provides a handy way to mark a whole function as being in a new context. For example:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import scoped, identify_context
|
||||
@scoped(fresh=True)
|
||||
def process_order(user, order_id):
|
||||
identify_context(user.distinct_id)
|
||||
posthog.capture("order_processed") # Associated with the user
|
||||
raise Exception("Order processing failed") # This exception is also captured and associated with the user
|
||||
```
|
||||
|
||||
## Group analytics
|
||||
|
||||
Group analytics allows you to associate an event with a group (e.g. teams, organizations, etc.). Read the [Group Analytics](/docs/user-guides/group-analytics.md) guide for more information.
|
||||
|
||||
> **Note:** This is a paid feature and is not available on the open-source or free cloud plan. Learn more on our [pricing page](/pricing.md).
|
||||
|
||||
To capture an event and associate it with a group:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture('some_event', groups={'company': 'company_id_in_your_db'})
|
||||
```
|
||||
|
||||
To update properties on a group:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.group_identify('company', 'company_id_in_your_db', {
|
||||
'name': 'Awesome Inc.',
|
||||
'employees': 11
|
||||
})
|
||||
```
|
||||
|
||||
The `name` is a special property which is used in the PostHog UI for the name of the group. If you don't specify a `name` property, the group ID will be used instead.
|
||||
|
||||
## Feature flags
|
||||
|
||||
PostHog's [feature flags](/docs/feature-flags.md) enable you to safely deploy and roll back new features as well as target specific users and groups with them.
|
||||
|
||||
There are two steps to implement feature flags in Python:
|
||||
|
||||
### Step 1: Evaluate flags once
|
||||
|
||||
Call `posthog.evaluate_flags()` once for the user, then read values from the returned snapshot.
|
||||
|
||||
#### Boolean feature flags
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
flags = posthog.evaluate_flags("distinct_id_of_your_user")
|
||||
if flags.is_enabled("flag-key"):
|
||||
# Do something differently for this user
|
||||
# Optional: fetch the payload
|
||||
matched_flag_payload = flags.get_flag_payload("flag-key")
|
||||
```
|
||||
|
||||
#### Multivariate feature flags
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
flags = posthog.evaluate_flags("distinct_id_of_your_user")
|
||||
enabled_variant = flags.get_flag("flag-key")
|
||||
if enabled_variant == "variant-key": # replace "variant-key" with the key of your variant
|
||||
# Do something differently for this user
|
||||
# Optional: fetch the payload
|
||||
matched_flag_payload = flags.get_flag_payload("flag-key")
|
||||
```
|
||||
|
||||
`flags.get_flag()` returns the variant string for multivariate flags, `True` for enabled boolean flags, `False` for disabled flags, and `None` when the flag wasn't returned by the evaluation.
|
||||
|
||||
> **Note:** `posthog.feature_enabled()`, `posthog.get_feature_flag()`, `posthog.get_feature_flag_payload()`, and `posthog.capture(send_feature_flags=True)` still work during the migration period, but they're deprecated. Prefer `posthog.evaluate_flags()` for new code.
|
||||
|
||||
### Step 2: Include feature flag information when capturing events
|
||||
|
||||
If you want use your feature flag to breakdown or filter events in your [insights](/docs/product-analytics/insights.md), you'll need to include feature flag information in those events. This ensures that the feature flag value is attributed correctly to the event.
|
||||
|
||||
> **Note:** This step is only required for events captured using our server-side SDKs or [API](/docs/api.md).
|
||||
|
||||
There are two methods you can use to include feature flag information in your events:
|
||||
|
||||
#### Method 1: Pass the evaluated flags snapshot to `capture()`
|
||||
|
||||
Pass the same `flags` object that you used for branching. This attaches the exact flag values from that evaluation and doesn't make another `/flags` request.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
flags = posthog.evaluate_flags("distinct_id_of_your_user")
|
||||
if flags.is_enabled("flag-key"):
|
||||
# Do something differently for this user
|
||||
pass
|
||||
posthog.capture(
|
||||
"event_name",
|
||||
distinct_id="distinct_id_of_your_user",
|
||||
flags=flags,
|
||||
)
|
||||
```
|
||||
|
||||
By default, this attaches every flag in the snapshot using `$feature/<flag-key>` properties and `$active_feature_flags`.
|
||||
|
||||
To reduce event property bloat, pass a filtered snapshot:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
# Attach only flags accessed with is_enabled() or get_flag() before this call
|
||||
posthog.capture(
|
||||
"event_name",
|
||||
distinct_id="distinct_id_of_your_user",
|
||||
flags=flags.only_accessed(),
|
||||
)
|
||||
# Attach only specific flags
|
||||
posthog.capture(
|
||||
"event_name",
|
||||
distinct_id="distinct_id_of_your_user",
|
||||
flags=flags.only(["checkout-flow", "new-dashboard"]),
|
||||
)
|
||||
```
|
||||
|
||||
`only_accessed()` is order-dependent. If you call it before accessing any flags with `is_enabled()` or `get_flag()`, no feature flag properties are attached.
|
||||
|
||||
#### Method 2: Include the `$feature/feature_flag_name` property manually
|
||||
|
||||
In the event properties, include `$feature/feature_flag_name: variant_key`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture(
|
||||
"event_name",
|
||||
distinct_id="distinct_id_of_the_user",
|
||||
properties={
|
||||
# Replace feature-flag-key with your flag key and "variant-key" with the key of your variant
|
||||
"$feature/feature-flag-key": "variant-key",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### Evaluating only specific flags
|
||||
|
||||
By default, `posthog.evaluate_flags()` evaluates every flag for the user. If you only need a few flags, pass `flag_keys` to request only those flags:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
flags = posthog.evaluate_flags(
|
||||
"distinct_id_of_your_user",
|
||||
flag_keys=["checkout-flow", "new-dashboard"],
|
||||
)
|
||||
```
|
||||
|
||||
### Sending `$feature_flag_called` events
|
||||
|
||||
Capturing `$feature_flag_called` events enables PostHog to know when a flag was accessed by a user and provide [analytics and insights](/docs/product-analytics/insights.md) on the flag. With `posthog.evaluate_flags()`, the SDK sends this event when you call `flags.is_enabled()` or `flags.get_flag()` for a flag.
|
||||
|
||||
The SDK deduplicates these events per `(distinct_id, flag, value)` in a local cache. If you reinitialize the PostHog client, the cache resets and `$feature_flag_called` events may be sent again. PostHog handles duplicates, so duplicate `$feature_flag_called` events don't affect your analytics.
|
||||
|
||||
`flags.get_flag_payload()` doesn't send `$feature_flag_called` events and doesn't count as an access for `only_accessed()`.
|
||||
|
||||
### Advanced: Overriding server properties
|
||||
|
||||
Sometimes, you may want to evaluate feature flags using [person properties](/docs/product-analytics/person-properties.md), [groups](/docs/product-analytics/group-analytics.md), or group properties that haven't been ingested yet, or were set incorrectly earlier.
|
||||
|
||||
You can provide properties to evaluate the flag with by using the `person properties`, `groups`, and `group properties` arguments. PostHog will then use these values to evaluate the flag, instead of any properties currently stored on your PostHog server.
|
||||
|
||||
For example:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
flags = posthog.evaluate_flags(
|
||||
"distinct_id_of_the_user",
|
||||
person_properties={"property_name": "value"},
|
||||
groups={
|
||||
"your_group_type": "your_group_id",
|
||||
"another_group_type": "your_group_id",
|
||||
},
|
||||
group_properties={
|
||||
"your_group_type": {"group_property_name": "value"},
|
||||
"another_group_type": {"group_property_name": "value"},
|
||||
},
|
||||
)
|
||||
if flags.is_enabled("flag-key"):
|
||||
# Do something differently for this user
|
||||
```
|
||||
|
||||
### Overriding GeoIP properties
|
||||
|
||||
By default, a user's GeoIP properties are set using the IP address they use to capture events on the frontend. You may want to override the these properties when evaluating feature flags. A common reason to do this is when you're not using PostHog on your frontend, so the user has no GeoIP properties.
|
||||
|
||||
You can override GeoIP properties by including them in the `person_properties` parameter when evaluating feature flags. This is useful when you're evaluating flags on your backend and want to use the client's location instead of your server's location.
|
||||
|
||||
The following GeoIP properties can be overridden:
|
||||
|
||||
- `$geoip_country_code`
|
||||
- `$geoip_country_name`
|
||||
- `$geoip_city_name`
|
||||
- `$geoip_city_confidence`
|
||||
- `$geoip_continent_code`
|
||||
- `$geoip_continent_name`
|
||||
- `$geoip_latitude`
|
||||
- `$geoip_longitude`
|
||||
- `$geoip_postal_code`
|
||||
- `$geoip_subdivision_1_code`
|
||||
- `$geoip_subdivision_1_name`
|
||||
- `$geoip_subdivision_2_code`
|
||||
- `$geoip_subdivision_2_name`
|
||||
- `$geoip_subdivision_3_code`
|
||||
- `$geoip_subdivision_3_name`
|
||||
- `$geoip_time_zone`
|
||||
|
||||
Simply include any of these properties in the `person_properties` parameter alongside your other person properties when calling feature flags.
|
||||
|
||||
### Request timeout
|
||||
|
||||
You can configure the `feature_flags_request_timeout_seconds` parameter when initializing your PostHog client to set a flag request timeout. This helps prevent your code from being blocked if PostHog's servers are too slow to respond. By default, this is set to 3 seconds.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog = Posthog(
|
||||
"<ph_project_token>",
|
||||
host="https://us.i.posthog.com",
|
||||
feature_flags_request_timeout_seconds=3, # Time in seconds. Defaults to 3.
|
||||
)
|
||||
```
|
||||
|
||||
### Local evaluation
|
||||
|
||||
Evaluating feature flags requires making a request to PostHog for each flag. However, you can improve performance by evaluating flags locally. Instead of making a request for each flag, PostHog will periodically request and store feature flag definitions locally, enabling you to evaluate flags without making additional requests.
|
||||
|
||||
It is best practice to use local evaluation flags when possible, since this enables you to resolve flags faster and with fewer API calls.
|
||||
|
||||
For details on how to implement local evaluation, see our [local evaluation guide](/docs/feature-flags/local-evaluation.md).
|
||||
|
||||
#### Distributed environments
|
||||
|
||||
In multi-worker or edge environments, you can implement custom caching for flag definitions using Redis, Cloudflare KV, or other storage backends. This enables sharing definitions across workers and coordinating fetches. See our guide for [local evaluation in distributed environments](/docs/feature-flags/local-evaluation/distributed-environments?tab=Python.md) for details.
|
||||
|
||||
## Experiments (A/B tests)
|
||||
|
||||
Since [experiments](/docs/experiments/start-here.md) use feature flags, the code for running an experiment is very similar to the feature flags code:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
flags = posthog.evaluate_flags("user_distinct_id")
|
||||
variant = flags.get_flag("experiment-feature-flag-key")
|
||||
if variant == "variant-name":
|
||||
# Do something
|
||||
```
|
||||
|
||||
It's also possible to [run experiments without using feature flags](/docs/experiments/running-experiments-without-feature-flags.md).
|
||||
|
||||
## AI Observability
|
||||
|
||||
Our Python SDK includes a built-in AI Observability feature. It enables you to capture LLM usage, performance, and more. Check out our [analytics docs](/docs/ai-observability.md) for more details on setting it up.
|
||||
|
||||
## Error tracking
|
||||
|
||||
You can [autocapture exceptions](/docs/error-tracking/installation.md) by setting the `enable_exception_autocapture` argument to `True` when initializing the PostHog client.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from posthog import Posthog
|
||||
posthog = Posthog("<ph_project_token>", enable_exception_autocapture=True, ...)
|
||||
```
|
||||
|
||||
You can also manually capture exceptions using the `capture_exception` method:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture_exception(e, distinct_id='user_distinct_id', properties=additional_properties)
|
||||
```
|
||||
|
||||
Contexts automatically capture exceptions thrown inside them, unless disable it by passing `capture_exceptions=False` to `new_context()`.
|
||||
|
||||
### Code variables capture
|
||||
|
||||
The Python SDK can automatically capture the state of local variables when an exception occurs. This gives you a debugger-like view of your application state at the time of the error:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog = Posthog(
|
||||
"<ph_project_token>",
|
||||
enable_exception_autocapture=True,
|
||||
capture_exception_code_variables=True,
|
||||
)
|
||||
```
|
||||
|
||||
You can configure which variables are captured, masked, or ignored. See the [code variables documentation](/docs/error-tracking/code-variables/python.md) for detailed configuration options.
|
||||
|
||||
## GeoIP properties
|
||||
|
||||
Before posthog-python v3.0, we added GeoIP properties to all incoming events by default. We also used these properties for feature flag evaluation, based on the IP address of the request. This isn't ideal since they are created based on your server IP address, rather than the user's, leading to incorrect location resolution.
|
||||
|
||||
As of posthog-python v3.0, the default now is to disregard the server IP, not add the GeoIP properties, and not use the values for feature flag evaluations.
|
||||
|
||||
You can go back to previous behavior by doing setting the `disable_geoip` argument in your initialization to `False`:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog = Posthog('api_key', disable_geoip=False)
|
||||
```
|
||||
|
||||
The list of properties that this overrides:
|
||||
|
||||
1. `$geoip_city_name`
|
||||
2. `$geoip_country_name`
|
||||
3. `$geoip_country_code`
|
||||
4. `$geoip_continent_name`
|
||||
5. `$geoip_continent_code`
|
||||
6. `$geoip_postal_code`
|
||||
7. `$geoip_time_zone`
|
||||
|
||||
You can also explicitly chose to enable or disable GeoIP for a single capture request like so:
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.capture('test_event', disable_geoip=True|False)
|
||||
```
|
||||
|
||||
## Debug mode
|
||||
|
||||
If you're not seeing the expected events being captured, the feature flags being evaluated, or the surveys being shown, you can enable debug mode to see what's happening.
|
||||
|
||||
You can enable debug mode by setting the `debug` option to `True` in the `PostHog` object. This will enable verbose logs about the inner workings of the SDK.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
posthog.debug = True
|
||||
```
|
||||
|
||||
## Disabling requests during tests
|
||||
|
||||
You can disable requests during tests by setting the `disabled` option to `True` in the `PostHog` object. This means no events will be captured or no requests will be sent to PostHog.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
if settings.TEST:
|
||||
posthog.disabled = True
|
||||
```
|
||||
|
||||
## Connection configuration
|
||||
|
||||
The SDK uses HTTP connection pooling internally for better performance. These settings typically need not be changed, but in some environments, such as when running behind NAT gateways, pooled connections may be terminated non-gracefully, causing request failures.
|
||||
|
||||
You can configure connection behavior in several ways. The following settings should be called during initialization, before any API requests are made.
|
||||
|
||||
### Enable TCP keepalive
|
||||
|
||||
TCP keepalive probes help prevent idle connections from being dropped by network infrastructure. This is the recommended approach for most cases where idle connections are terminated.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
import posthog
|
||||
posthog.enable_keep_alive()
|
||||
```
|
||||
|
||||
This enables TCP keepalive with sensible defaults (60 second idle time, 60 second probe interval, 3 probes before timeout).
|
||||
|
||||
### Disable connection pooling
|
||||
|
||||
If you need each request to use a fresh connection, you can disable connection reuse entirely. This will incur additional overhead per request but may be desirable in some circumstances.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
import posthog
|
||||
posthog.disable_connection_reuse()
|
||||
```
|
||||
|
||||
### Custom HTTP socket options
|
||||
|
||||
For advanced use cases, you can configure arbitrary socket options on the underlying HTTP connection.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
import socket
|
||||
import posthog
|
||||
posthog.set_socket_options([
|
||||
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
|
||||
# Add additional socket options as needed
|
||||
])
|
||||
```
|
||||
|
||||
Pass `None` to `set_socket_options()` to reset to default behavior.
|
||||
|
||||
## Filtering or modifying events before sending
|
||||
|
||||
Use `before_send` to modify or drop events before they are queued for delivery. Return the modified event dictionary to send it, or `None` to drop it.
|
||||
|
||||
Python
|
||||
|
||||
PostHog AI
|
||||
|
||||
```python
|
||||
from typing import Any
|
||||
import posthog
|
||||
def scrub_pii(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
properties = event.get("properties", {})
|
||||
if "email" in properties:
|
||||
email = properties["email"]
|
||||
properties["email"] = f"***@{email.split('@', 1)[1]}" if "@" in email else "***"
|
||||
if event.get("event") == "test_event":
|
||||
return None
|
||||
return event
|
||||
client = posthog.Client(
|
||||
"<ph_project_api_key>",
|
||||
before_send=scrub_pii,
|
||||
)
|
||||
```
|
||||
|
||||
If your callback raises an exception, the SDK logs the error and continues with the original unmodified event.
|
||||
|
||||
## Historical migrations
|
||||
|
||||
You can use the Python or Node SDK to run [historical migrations](/docs/migrate.md) of data into PostHog. To do so, set the `historical_migration` option to `true` when initializing the client.
|
||||
|
||||
PostHog AI
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from posthog import Posthog
|
||||
from datetime import datetime
|
||||
posthog = Posthog(
|
||||
'<ph_project_token>',
|
||||
host='https://us.i.posthog.com',
|
||||
debug=True,
|
||||
historical_migration=True
|
||||
)
|
||||
events = [
|
||||
{
|
||||
"event": "batched_event_name",
|
||||
"properties": {
|
||||
"distinct_id": "user_id",
|
||||
"timestamp": datetime.fromisoformat("2024-04-02T12:00:00")
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": "batched_event_name",
|
||||
"properties": {
|
||||
"distinct_id": "used_id",
|
||||
"timestamp": datetime.fromisoformat("2024-04-02T12:00:00")
|
||||
}
|
||||
}
|
||||
]
|
||||
for event in events:
|
||||
posthog.capture(
|
||||
distinct_id=event["properties"]["distinct_id"],
|
||||
event=event["event"],
|
||||
properties=event["properties"],
|
||||
timestamp=event["properties"]["timestamp"],
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
import { PostHog } from 'posthog-node'
|
||||
const client = new PostHog(
|
||||
'<ph_project_token>',
|
||||
{
|
||||
host: 'https://us.i.posthog.com',
|
||||
historicalMigration: true
|
||||
}
|
||||
)
|
||||
client.debug()
|
||||
client.capture({
|
||||
event: "batched_event_name",
|
||||
distinctId: "user_id",
|
||||
properties: {},
|
||||
timestamp: "2024-04-03T12:00:00Z"
|
||||
})
|
||||
client.capture({
|
||||
event: "batched_event_name",
|
||||
distinctId: "user_id",
|
||||
properties: {},
|
||||
timestamp: "2024-04-03T13:00:00Z"
|
||||
})
|
||||
await client.shutdown()
|
||||
```
|
||||
|
||||
## Serverless environments (Render/Lambda/...)
|
||||
|
||||
By default, the library buffers events before sending them to the capture endpoint, for better performance. This can lead to lost events in serverless environments, if the Python process is terminated by the platform before the buffer is fully flushed. To avoid this, you can either:
|
||||
|
||||
- Ensure that `posthog.shutdown()` is called after processing every request by adding a middleware to your server. This allows `posthog.capture()` to remain asynchronous for better performance. `posthog.shutdown()` is blocking.
|
||||
- Enable the `sync_mode` option when initializing the client, so that all calls to `posthog.capture()` become synchronous.
|
||||
|
||||
## Django
|
||||
|
||||
See our [Django docs](/docs/libraries/django.md) for how to set up PostHog in Django. Our library includes a [contexts middleware](/docs/libraries/django.md#django-contexts-middleware) that can automatically capture distinct IDs, session IDs, and other properties you can set up with tags.
|
||||
|
||||
## Alternative name
|
||||
|
||||
As our open source project [PostHog](https://github.com/PostHog/posthog) shares the same module name, we created a special `posthoganalytics` package, mostly for internal use to avoid module collision. It is the exact same.
|
||||
|
||||
## Thank you
|
||||
|
||||
This library is largely based on the `analytics-python` package.
|
||||
|
||||
### Community questions
|
||||
|
||||
Ask a question
|
||||
|
||||
### Was this page useful?
|
||||
|
||||
HelpfulCould be better
|
||||
@@ -1,23 +1,20 @@
|
||||
---
|
||||
name: omnivoice
|
||||
description: "Local TTS, voice cloning, voice design, and video dubbing via the VoiceStudio MCP server (open-source ElevenLabs alternative; nothing leaves the machine, runs on MPS/CUDA/CPU). Use when: (1) generating speech from text in any of 646 languages, (2) cloning a voice from a 3-second reference clip, (3) designing a voice by gender/age/accent/pitch/style, (4) dubbing a video into another language, (5) listing voice profiles or personality presets, (6) producing narration where privacy, cost, or absent API keys matter, (7) non-English narration where Edge TTS/kokoro fall short, (8) batch audio for blog posts or content pipelines. Triggers: 'omnivoice', 'voice clone', 'clone this voice', 'tts', 'narrate', 'generate speech', 'voice synthesis', 'dub video', 'voice design', 'local tts', 'multilingual voice', 'narrate this post', 'elevenlabs alternative'."
|
||||
description: "Local TTS, voice cloning, voice design, and video dubbing via the OmniVoice Studio MCP server (open-source ElevenLabs alternative; nothing leaves the machine, runs on MPS/CUDA/CPU). Use when: (1) generating speech from text in any of 646 languages, (2) cloning a voice from a 3-second reference clip, (3) designing a voice by gender/age/accent/pitch/style, (4) dubbing a video into another language, (5) listing voice profiles or personality presets, (6) producing narration where privacy, cost, or absent API keys matter, (7) non-English narration where Edge TTS/kokoro fall short, (8) batch audio for blog posts or content pipelines. Triggers: 'omnivoice', 'voice clone', 'clone this voice', 'tts', 'narrate', 'generate speech', 'voice synthesis', 'dub video', 'voice design', 'local tts', 'multilingual voice', 'narrate this post', 'elevenlabs alternative'."
|
||||
---
|
||||
|
||||
# VoiceStudio
|
||||
|
||||
The canonical cross-agent package lives at `skills/omnivoice/SKILL.md`. This
|
||||
Claude-specific package retains the MCP lifecycle helpers and references.
|
||||
# OmniVoice
|
||||
|
||||
## Overview
|
||||
|
||||
Generate audio locally via the VoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
|
||||
Generate audio locally via the OmniVoice Studio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
|
||||
|
||||
## Prerequisites — Backend Must Be Running
|
||||
|
||||
The MCP tools all hit `$OMNIVOICE_API_URL` (default `http://localhost:3900`). If the backend is down, every tool returns a connection error. Install + boot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git "$OMNIVOICE_HOME"
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git "$OMNIVOICE_HOME"
|
||||
cd "$OMNIVOICE_HOME"
|
||||
uv sync
|
||||
VIRTUAL_ENV="$(pwd)/.venv" uv pip install 'mcp[cli]'
|
||||
@@ -44,7 +41,7 @@ First synthesis call lazy-downloads the `k2-fsa/OmniVoice` model (~2.4 GB) from
|
||||
| List personality presets | `list_personalities` | Returns narrator / casual / news-anchor / etc. with their `instruct` strings |
|
||||
| List supported languages | `list_languages` | 646 total; returns 20 popular + the full count |
|
||||
|
||||
For non-trivial decisions (which engine to use, when to pick VoiceStudio over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
|
||||
For non-trivial decisions (which engine to use, when to pick OmniVoice over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
|
||||
|
||||
For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown, see [references/mcp-setup.md](references/mcp-setup.md).
|
||||
|
||||
@@ -55,7 +52,7 @@ For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown
|
||||
```python
|
||||
# As called through the MCP client (your agent will do this for you):
|
||||
result = generate_speech(
|
||||
text="Hello — this is VoiceStudio generating speech locally.",
|
||||
text="Hello — this is OmniVoice generating speech locally.",
|
||||
profile_id="demo0001",
|
||||
language="English",
|
||||
steps=16, # 8 = fast/draft · 16 = balanced · 32 = quality
|
||||
@@ -151,16 +148,16 @@ Get pre-made instructs via `list_personalities` and copy the one matching the br
|
||||
|
||||
The MCP server does not expose the dubbing endpoint. The full transcribe → translate → re-voice → mux pipeline lives behind the desktop UI (`bun run desktop` in `$OMNIVOICE_HOME`) and the `/dub/*` REST routes. When the user asks to dub a video, point them to the UI; surface this skill only for the synthesis primitives above.
|
||||
|
||||
## When NOT to use VoiceStudio
|
||||
## When NOT to use OmniVoice
|
||||
|
||||
- **Fast English-only narration on weak hardware** → `kokoro-tts` is ~10× smaller and 2× realtime on CPU (see [references/engines-comparison.md](references/engines-comparison.md))
|
||||
- **Lowest-friction one-off TTS** → Edge TTS needs no install or backend
|
||||
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; VoiceStudio ties or wins on multilingual + cloning
|
||||
- **Real-time streaming dictation** → use the VoiceStudio desktop widget (`⌘+⇧+Space`), not the MCP server
|
||||
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; OmniVoice ties or wins on multilingual + cloning
|
||||
- **Real-time streaming dictation** → use the OmniVoice desktop widget (`⌘+⇧+Space`), not the MCP server
|
||||
|
||||
## Resources
|
||||
|
||||
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across VoiceStudio / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
|
||||
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across OmniVoice / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
|
||||
- [references/mcp-setup.md](references/mcp-setup.md) — MCP wiring, backend lifecycle, env vars, troubleshooting
|
||||
- [scripts/check-health.sh](scripts/check-health.sh) — `curl /health`, exit 0/1
|
||||
- [scripts/start-backend.sh](scripts/start-backend.sh) — Start uvicorn on 127.0.0.1:3900 with health probe
|
||||
@@ -169,4 +166,4 @@ The MCP server does not expose the dubbing endpoint. The full transcribe → tra
|
||||
|
||||
Backend Swagger / OpenAPI: `http://127.0.0.1:3900/docs` (when backend is up).
|
||||
|
||||
Upstream: github.com/debpalash/VoiceStudio. The app uses AGPL-3.0-only; optional engines and downloaded models retain their own licenses. See `LICENSE-NOTICE.md` in the repository.
|
||||
Upstream: github.com/debpalash/OmniVoice-Studio — FSL-1.1-ALv2 (free for personal/internal/non-commercial; auto-converts to Apache-2.0 two years after each release).
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# TTS Engine Selection — Decision Tree
|
||||
|
||||
When to pick VoiceStudio vs other engines available in this workspace. Match the user's constraint to the right column.
|
||||
When to pick OmniVoice vs other engines available in this workspace. Match the user's constraint to the right column.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
Is voice cloning required?
|
||||
├─ yes → VoiceStudio (3-sec ref clip, zero-shot, 646 langs)
|
||||
├─ yes → OmniVoice (3-sec ref clip, zero-shot, 646 langs)
|
||||
└─ no →
|
||||
Is the language non-English?
|
||||
├─ yes → VoiceStudio (646 langs) or Edge TTS (subset, cloud)
|
||||
├─ yes → OmniVoice (646 langs) or Edge TTS (subset, cloud)
|
||||
└─ no (English) →
|
||||
Is privacy required (no cloud)?
|
||||
├─ yes →
|
||||
│ Is GPU available?
|
||||
│ ├─ yes (CUDA/MPS) → VoiceStudio (best quality) or Voicebox
|
||||
│ └─ no (CPU only) → kokoro-tts (2× realtime CPU) or VoiceStudio on CPU (slow)
|
||||
│ ├─ yes (CUDA/MPS) → OmniVoice (best quality) or Voicebox
|
||||
│ └─ no (CPU only) → kokoro-tts (2× realtime CPU) or OmniVoice on CPU (slow)
|
||||
└─ no (cloud OK) →
|
||||
Is cost-no-object?
|
||||
├─ yes → ElevenLabs (best polish), then OpenAI TTS
|
||||
@@ -26,9 +26,9 @@ Is voice cloning required?
|
||||
|
||||
| Engine | Quality | Clone | Multilingual | Cost | Privacy | Setup | Best for |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **VoiceStudio** | 8-9/10 | ✅ 3-sec ref | 646 langs | Free | Local | Bun + uv install | Multilingual, cloning, privacy-critical |
|
||||
| **OmniVoice** | 8-9/10 | ✅ 3-sec ref | 646 langs | Free | Local | Bun + uv install | Multilingual, cloning, privacy-critical |
|
||||
| ElevenLabs | 9-10/10 | ✅ 3-sec ref | 32 langs | $5-330/mo | Cloud | API key | Best English polish, fastest cloud TTS |
|
||||
| Voicebox (Qwen3-TTS) | 8-9/10 | ✅ | Multi | Free | Local | Docker | Self-hosted alternative to VoiceStudio |
|
||||
| Voicebox (Qwen3-TTS) | 8-9/10 | ✅ | Multi | Free | Local | Docker | Self-hosted alternative to OmniVoice |
|
||||
| Voicebox (LuxTTS) | 7/10 | ❌ | Multi | Free | Local | Docker | CPU at 150× realtime |
|
||||
| kokoro-tts | 7-8/10 | ❌ | Multi (limited) | Free | Local | pip | Fast English narration on CPU |
|
||||
| mlx-audio | 7-8/10 | varies | Multi | Free | Local | pip | Apple Silicon native, 14+ sub-engines |
|
||||
@@ -38,34 +38,34 @@ Is voice cloning required?
|
||||
|
||||
*Edge TTS is unofficial. Microsoft could block it at any time.
|
||||
|
||||
## When VoiceStudio wins decisively
|
||||
## When OmniVoice wins decisively
|
||||
|
||||
1. **Voice cloning** — 3-sec reference clip, zero-shot, no fine-tuning. ElevenLabs is the only competitor; VoiceStudio is free and local.
|
||||
1. **Voice cloning** — 3-sec reference clip, zero-shot, no fine-tuning. ElevenLabs is the only competitor; OmniVoice is free and local.
|
||||
2. **Long-tail languages** — 646 supported. ElevenLabs covers 32; everything else fewer.
|
||||
3. **Privacy / regulatory** — Nothing leaves the machine. ElevenLabs and OpenAI ship audio to their servers.
|
||||
4. **No-API-key constraint** — Local-first. No accounts.
|
||||
5. **Bulk generation without metered cost** — ElevenLabs bills per character. VoiceStudio is free at any volume.
|
||||
5. **Bulk generation without metered cost** — ElevenLabs bills per character. OmniVoice is free at any volume.
|
||||
|
||||
## When VoiceStudio loses
|
||||
## When OmniVoice loses
|
||||
|
||||
1. **Lowest-friction one-off TTS** — Backend install + ~3 GB model + uvicorn boot. Edge TTS or OpenAI TTS is one command.
|
||||
2. **Fast English narration on weak hardware** — kokoro-tts is ~30 MB vs VoiceStudio's 2.4 GB and runs 2× realtime on CPU. Use kokoro for blog-narration batch jobs unless you need cloning.
|
||||
3. **Streaming real-time TTS** — VoiceStudio is diffusion-based and not streaming. Use Edge TTS or cloud APIs for true streaming.
|
||||
2. **Fast English narration on weak hardware** — kokoro-tts is ~30 MB vs OmniVoice's 2.4 GB and runs 2× realtime on CPU. Use kokoro for blog-narration batch jobs unless you need cloning.
|
||||
3. **Streaming real-time TTS** — OmniVoice is diffusion-based and not streaming. Use Edge TTS or cloud APIs for true streaming.
|
||||
4. **Apple Silicon-only specialized voices** — `mlx-audio` ships 14 engines (Kokoro, CSM, Dia, Qwen3-TTS, etc.) that may match a specific voice better.
|
||||
|
||||
## Composition with content pipelines
|
||||
|
||||
VoiceStudio fits between visual asset generation and video assembly:
|
||||
OmniVoice fits between visual asset generation and video assembly:
|
||||
|
||||
```
|
||||
research → narrative → visual assets → AUDIO (VoiceStudio) → video assembly → distribution
|
||||
research → narrative → visual assets → AUDIO (OmniVoice) → video assembly → distribution
|
||||
```
|
||||
|
||||
Default for blog-post audio narration:
|
||||
|
||||
- **English, no cloning needed, fast** → kokoro-tts (cheap CPU)
|
||||
- **English, want a specific cloned voice** → VoiceStudio with a saved profile
|
||||
- **Non-English** → VoiceStudio
|
||||
- **English, want a specific cloned voice** → OmniVoice with a saved profile
|
||||
- **Non-English** → OmniVoice
|
||||
- **One-time, no install** → Edge TTS
|
||||
|
||||
For Remotion-based video pipelines that previously required ElevenLabs, VoiceStudio closes the last cloud dependency — pair it with any local image/video generator for a fully self-hosted multimedia stack.
|
||||
For Remotion-based video pipelines that previously required ElevenLabs, OmniVoice closes the last cloud dependency — pair it with any local image/video generator for a fully self-hosted multimedia stack.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# VoiceStudio MCP Setup, Lifecycle, Troubleshooting
|
||||
# OmniVoice MCP Setup, Lifecycle, Troubleshooting
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Pick any location. The scripts in this skill default to ~/VoiceStudio if
|
||||
# Pick any location. The scripts in this skill default to ~/OmniVoice-Studio if
|
||||
# $OMNIVOICE_HOME is unset.
|
||||
export OMNIVOICE_HOME="${HOME}/VoiceStudio"
|
||||
export OMNIVOICE_HOME="${HOME}/OmniVoice-Studio"
|
||||
|
||||
git clone https://github.com/debpalash/VoiceStudio.git "$OMNIVOICE_HOME"
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git "$OMNIVOICE_HOME"
|
||||
cd "$OMNIVOICE_HOME"
|
||||
uv sync # ~1.6 GB venv on darwin arm64
|
||||
VIRTUAL_ENV="$(pwd)/.venv" uv pip install 'mcp[cli]' # SDK not in their lockfile yet
|
||||
@@ -37,7 +37,7 @@ Drop into your MCP client config (Claude Desktop, Claude Code at `~/.claude.json
|
||||
|
||||
Restart the MCP client. The server only starts at client launch — in-session edits do not hot-reload.
|
||||
|
||||
> **Note (mcp SDK ≥ 1.10):** If you see `TypeError: FastMCP.__init__() got an unexpected keyword argument 'version'`, your `VoiceStudio` checkout is older than [debpalash/VoiceStudio#112](https://github.com/debpalash/VoiceStudio/pull/112). Either `git pull` once that PR lands, or apply the 3-line patch manually: replace `version="…", description=(…)` with `instructions=(…)` in `backend/mcp_server.py`.
|
||||
> **Note (mcp SDK ≥ 1.10):** If you see `TypeError: FastMCP.__init__() got an unexpected keyword argument 'version'`, your `OmniVoice-Studio` checkout is older than [debpalash/OmniVoice-Studio#112](https://github.com/debpalash/OmniVoice-Studio/pull/112). Either `git pull` once that PR lands, or apply the 3-line patch manually: replace `version="…", description=(…)` with `instructions=(…)` in `backend/mcp_server.py`.
|
||||
|
||||
## Backend Lifecycle
|
||||
|
||||
@@ -61,7 +61,7 @@ First boot runs alembic migrations on the SQLite settings DB at `<data_dir>/omni
|
||||
First synthesis call lazy-downloads the `k2-fsa/OmniVoice` model (~2.4 GB) into the HuggingFace cache. Path varies by OS:
|
||||
|
||||
- **macOS / Linux**: `~/.cache/huggingface/hub/`
|
||||
- **Windows**: `%LOCALAPPDATA%\OmniVoice\hf_cache` (VoiceStudio redirects via `backend/core/config.py` to keep the cache off the system drive root)
|
||||
- **Windows**: `%LOCALAPPDATA%\OmniVoice\hf_cache` (OmniVoice redirects via `backend/core/config.py` to keep the cache off the system drive root)
|
||||
|
||||
Cached on subsequent boots.
|
||||
|
||||
@@ -73,7 +73,7 @@ Cached on subsequent boots.
|
||||
|
||||
| Var | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `OMNIVOICE_HOME` | `~/VoiceStudio` | Where the VoiceStudio repo is cloned (used by scripts in this skill) |
|
||||
| `OMNIVOICE_HOME` | `~/OmniVoice-Studio` | Where the OmniVoice Studio repo is cloned (used by scripts in this skill) |
|
||||
| `OMNIVOICE_API_URL` | `http://localhost:3900` | MCP server's target backend URL |
|
||||
| `OMNIVOICE_TTS_BACKEND` | `omnivoice` | Switch engine: `cosyvoice`, `mlx-audio`, `voxcpm2`, `moss-tts-nano`, `kittentts` |
|
||||
| `HF_TOKEN` | (none) | Only needed for gated pyannote diarization models — basic TTS does not require one |
|
||||
@@ -84,7 +84,7 @@ Cached on subsequent boots.
|
||||
|---|---|---|
|
||||
| MCP tool returns connection error | Backend not running | `scripts/start-backend.sh` |
|
||||
| `address already in use` | Stale uvicorn on 3900 | `lsof -nP -iTCP:3900 -sTCP:LISTEN` → `kill -TERM <pid>` |
|
||||
| `FastMCP.__init__() got unexpected keyword argument 'version'` | mcp SDK ≥ 1.10 dropped `version`/`description`, checkout pre-dates [#112](https://github.com/debpalash/VoiceStudio/pull/112) | Update the checkout or apply the 3-line patch manually |
|
||||
| `FastMCP.__init__() got unexpected keyword argument 'version'` | mcp SDK ≥ 1.10 dropped `version`/`description`, checkout pre-dates [#112](https://github.com/debpalash/OmniVoice-Studio/pull/112) | Update the checkout or apply the 3-line patch manually |
|
||||
| First call hangs 5-10 min | Model download from HuggingFace | Watch `~/.cache/huggingface/hub/models--k2-fsa--OmniVoice/` grow |
|
||||
| `/health` returns 500 | Alembic migration failed | Inspect `<data_dir>/crash_log.txt` |
|
||||
| Voice profile not found | `profile_id` invalid or profile not yet created | `list_voices` first to get valid IDs |
|
||||
@@ -99,4 +99,4 @@ scripts/stop-backend.sh # graceful shutdown
|
||||
# Remove the `omnivoice` entry from your MCP client config
|
||||
```
|
||||
|
||||
User profiles + history live in the platform data dir (`~/Library/Application Support/OmniVoice/` on macOS; `~/.local/share/VoiceStudio/` on Linux). Preserve across reinstalls if you want to keep your saved voice profiles.
|
||||
User profiles + history live in the platform data dir (`~/Library/Application Support/OmniVoice/` on macOS; `~/.local/share/OmniVoice/` on Linux). Preserve across reinstalls if you want to keep your saved voice profiles.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the OmniVoice FastAPI backend on 127.0.0.1:3900, detached, idempotent.
|
||||
# Honors $OMNIVOICE_HOME (default ~/VoiceStudio).
|
||||
# Honors $OMNIVOICE_HOME (default ~/OmniVoice-Studio).
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 success (already running, or freshly started + healthy within 60s)
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
HOME_DIR="${OMNIVOICE_HOME:-$HOME/VoiceStudio}"
|
||||
HOME_DIR="${OMNIVOICE_HOME:-$HOME/OmniVoice-Studio}"
|
||||
URL="${OMNIVOICE_API_URL:-http://127.0.0.1:3900}"
|
||||
LOG="$HOME_DIR/backend.log"
|
||||
|
||||
|
||||
+14
-35
@@ -8,25 +8,26 @@ language: "en-US"
|
||||
early_access: false
|
||||
|
||||
# The review voice: a panel of senior domain experts, not a linter.
|
||||
# Brevity is a hard requirement (owner directive 2026-07-20): comment ONLY
|
||||
# when a finding would change what gets merged.
|
||||
tone_instructions: >-
|
||||
Comment only on findings that change what gets merged: a bug, a violated house rule, a real risk. Max three sentences each: failure mode, line, fix. No praise, no diff restating, no style nits, no emojis.
|
||||
Review as a panel of principal engineers: ML inference, audio DSP, desktop
|
||||
systems, product polish. Cite exact lines, name the failure mode, give the
|
||||
concrete fix. No filler praise; raise nits only when they change a decision.
|
||||
|
||||
reviews:
|
||||
# "chill" keeps the bot from blocking merges — it comments, it does not gate.
|
||||
# Hard gating lives in CI (security.yml) and the constitution's human bar.
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
# Keep the walkthrough minimal: a short summary, no diagrams, no per-push
|
||||
# status chatter, collapsed by default (owner: no fluff on PRs).
|
||||
high_level_summary: true
|
||||
# Every walkthrough gets a visual: mermaid sequence diagrams for the
|
||||
# mechanics, plus (via the summary instructions) an ASCII before/after
|
||||
# sketch when the PR touches UI — so each PR is reviewable at a glance.
|
||||
sequence_diagrams: true
|
||||
high_level_summary_instructions: >-
|
||||
Three sentences maximum: what changed, why, and any risk worth a human
|
||||
look. No diagrams, no sketches, no file-by-file narration.
|
||||
sequence_diagrams: false
|
||||
collapse_walkthrough: true
|
||||
review_status: false
|
||||
If the PR changes UI (JSX/TSX/CSS/Tauri windows), include a compact ASCII
|
||||
before/after sketch of the affected layout or component. If it changes
|
||||
behavior, include a short mermaid flowchart of the new mechanism.
|
||||
review_status: true
|
||||
poem: false
|
||||
|
||||
auto_review:
|
||||
@@ -98,11 +99,7 @@ reviews:
|
||||
access accordingly; window and webview lifecycle on all three OSes;
|
||||
child-process spawn/exit-code/stderr handling; no unwrap/expect on
|
||||
user-controlled input; platform cfg blocks keep user-visible defaults
|
||||
identical across macOS/Windows/Linux. The parity rule covers BEHAVIOUR,
|
||||
not PERFORMANCE: hardware acceleration is host-dependent by design
|
||||
(CUDA/MPS/DirectML, Triton availability, torch.compile), so an
|
||||
optimization skipped where it cannot work is NOT a parity violation and
|
||||
must not be reported as one.
|
||||
identical across macOS/Windows/Linux.
|
||||
- path: "tests/**/*.py"
|
||||
instructions: >-
|
||||
Review as a test-infrastructure engineer. Check: the test would fail
|
||||
@@ -116,24 +113,6 @@ reviews:
|
||||
instructions: >-
|
||||
Pin actions to a major version tag at minimum. Flag any workflow that
|
||||
grants write permissions it does not need.
|
||||
- path: "CHANGELOG.md"
|
||||
instructions: >-
|
||||
Hard rule (owner-restyled 2026-07-17): the Unreleased section is a
|
||||
short **Highlights** bullet list followed by ### Changed/Added/Docs/
|
||||
Fixed sections whose entries are each a SINGLE one-liner ending with
|
||||
the (#NNN) ref and, for community contributions, a "— thanks @user!"
|
||||
credit. Flag multi-line or bold-lead paragraph entries, missing refs,
|
||||
and missing credits.
|
||||
- path: "frontend/package.json"
|
||||
instructions: >-
|
||||
This is a bun workspace monorepo: any dependency change here requires
|
||||
regenerating the repo-root bun.lock in the same PR —
|
||||
deploy/Dockerfile runs `bun install --frozen-lockfile`, so CI-green
|
||||
does not imply Docker-green. Flag package.json dependency changes
|
||||
without a matching root bun.lock diff. Also: this file is the single
|
||||
source of truth for the app version — flag any version change not
|
||||
mirrored in pyproject.toml, frontend/src-tauri/Cargo.toml and
|
||||
backend/core/version.py in lockstep.
|
||||
|
||||
# Non-gating pre-merge audits of the project's hard rules (warning mode —
|
||||
# the human owner is the gate, these make the checklist visible per-PR).
|
||||
@@ -179,9 +158,9 @@ reviews:
|
||||
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: false
|
||||
enabled: true
|
||||
unit_tests:
|
||||
enabled: false
|
||||
enabled: true
|
||||
|
||||
# Feed the bot the project constitution and docs, and let it accumulate
|
||||
# learnings from review conversations ("@coderabbitai always/never …").
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
ko_fi: debpalash
|
||||
custom:
|
||||
- "https://paypal.me/palashCoder"
|
||||
- "https://github.com/debpalash/VoiceStudio/blob/main/SPONSORS.md"
|
||||
- "https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md"
|
||||
# github: [debpalash] # not available
|
||||
# open_collective: omnivoice-studio
|
||||
|
||||
@@ -6,7 +6,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping improve VoiceStudio! 🎙️
|
||||
Thanks for helping improve OmniVoice Studio! 🎙️
|
||||
|
||||
**Fastest path to a fix:** **Settings → About → "Save diagnostic bundle"** makes a
|
||||
zip (self-check + recent errors + scrubbed log tails) — drag it onto this issue and
|
||||
@@ -17,7 +17,7 @@ body:
|
||||
attributes:
|
||||
label: Before filing
|
||||
options:
|
||||
- label: I searched [existing issues](https://github.com/debpalash/VoiceStudio/issues?q=is%3Aissue) and this isn't a duplicate.
|
||||
- label: I searched [existing issues](https://github.com/debpalash/OmniVoice-Studio/issues?q=is%3Aissue) and this isn't a duplicate.
|
||||
required: true
|
||||
- label: I'm on the latest release (or `main`) — older builds may already be fixed.
|
||||
required: false
|
||||
|
||||
@@ -4,8 +4,8 @@ contact_links:
|
||||
url: https://discord.gg/bzQavDfVV9
|
||||
about: Usage questions, setup help, and chat. Faster than an issue for "how do I…".
|
||||
- name: 🗣️ GitHub Discussions
|
||||
url: https://github.com/debpalash/VoiceStudio/discussions
|
||||
url: https://github.com/debpalash/OmniVoice-Studio/discussions
|
||||
about: Ideas, show-and-tell, and open-ended Q&A that isn't a bug or a specific feature ask.
|
||||
- name: 🔒 Security vulnerability
|
||||
url: https://github.com/debpalash/VoiceStudio/security/policy
|
||||
url: https://github.com/debpalash/OmniVoice-Studio/security/policy
|
||||
about: Please report security issues privately — do NOT open a public issue.
|
||||
|
||||
@@ -8,7 +8,7 @@ body:
|
||||
attributes:
|
||||
label: Before filing
|
||||
options:
|
||||
- label: I searched [existing issues](https://github.com/debpalash/VoiceStudio/issues?q=is%3Aissue) and [discussions](https://github.com/debpalash/VoiceStudio/discussions) for this idea.
|
||||
- label: I searched [existing issues](https://github.com/debpalash/OmniVoice-Studio/issues?q=is%3Aissue) and [discussions](https://github.com/debpalash/OmniVoice-Studio/discussions) for this idea.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -45,6 +45,6 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> VoiceStudio is **local-first** — core features work offline without an account,
|
||||
> OmniVoice is **local-first** — features must work fully offline with no accounts,
|
||||
API keys, or cloud calls, and behave identically on macOS/Windows/Linux. Proposals
|
||||
that fit those constraints are easiest to land.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
name: 🤝 Sponsorship inquiry
|
||||
description: Support VoiceStudio and (optionally) claim a logo slot. Not for bugs or feature requests.
|
||||
description: Support OmniVoice and (optionally) claim a logo slot. Not for bugs or feature requests.
|
||||
title: "Sponsorship inquiry: "
|
||||
labels: ["sponsor"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for considering sponsoring **VoiceStudio** 💛
|
||||
Thanks for considering sponsoring **OmniVoice Studio** 💛
|
||||
|
||||
VoiceStudio is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
|
||||
See **[SPONSORS.md](https://github.com/debpalash/VoiceStudio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
|
||||
OmniVoice is free, local-first, and AGPL-3.0 — sponsorship keeps development going.
|
||||
See **[SPONSORS.md](https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md)** for tiers, placements, and logo guidelines.
|
||||
Prefer to just donate? [Ko-fi](https://ko-fi.com/debpalash) (recurring) or [PayPal](https://paypal.me/palashCoder) (one-time) — you don't need this form for that.
|
||||
- type: input
|
||||
id: name
|
||||
@@ -72,7 +72,7 @@ body:
|
||||
attributes:
|
||||
label: Acknowledgements
|
||||
options:
|
||||
- label: I understand sponsorship is a thank-you, not a paywall — VoiceStudio stays fully free and AGPL-3.0, and sponsors don't get gated features.
|
||||
- label: I understand sponsorship is a thank-you, not a paywall — OmniVoice stays fully free and AGPL-3.0, and sponsors don't get gated features.
|
||||
required: true
|
||||
- label: If I provide a logo, I have the right to use it and grant VoiceStudio permission to display it in the README, the app, and the project website.
|
||||
- label: If I provide a logo, I have the right to use it and grant OmniVoice permission to display it in the README, the app, and the project website.
|
||||
required: false
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## Release cadence
|
||||
|
||||
VoiceStudio ships **continuous-to-main** — no release candidates, no soak windows.
|
||||
OmniVoice ships **continuous-to-main** — no release candidates, no soak windows.
|
||||
Every merged PR is immediately part of the rolling preview (`main`, Docker
|
||||
`:latest`, the desktop Preview channel). Versioned releases are tagged from
|
||||
`main` when it's ready; `main` then bumps to the next patch automatically.
|
||||
|
||||
@@ -51,13 +51,6 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x86_64
|
||||
experimental: false
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux-aarch64
|
||||
# Apple Silicon under Asahi Linux. Experimental: the Vulkan
|
||||
# (Honeykrisp GPU) build path is new and the hosted arm64
|
||||
# runner has no GPU — it validates that the binary builds;
|
||||
# on-host Vulkan acceleration is exercised by users.
|
||||
experimental: true
|
||||
- os: windows-latest
|
||||
platform: windows-x86_64
|
||||
experimental: false
|
||||
@@ -87,18 +80,11 @@ jobs:
|
||||
# Linux-only: upstream `buildcpu.sh` enables `-DGGML_BLAS=ON` which
|
||||
# requires a system BLAS implementation at cmake configure time.
|
||||
- name: Linux system deps (BLAS for ggml-blas backend)
|
||||
if: startsWith(matrix.platform, 'linux')
|
||||
if: matrix.platform == 'linux-x86_64'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libopenblas-dev pkg-config
|
||||
|
||||
# linux-aarch64: let the build script's Vulkan path (Honeykrisp GPU
|
||||
# on Asahi) engage instead of silently falling back to CPU.
|
||||
- name: Vulkan dev deps (linux-aarch64 GPU backend)
|
||||
if: matrix.platform == 'linux-aarch64'
|
||||
run: |
|
||||
sudo apt-get install -y glslc libvulkan-dev spirv-headers
|
||||
|
||||
- name: Build omnivoice-tts
|
||||
shell: bash
|
||||
# Pass values through env (quoted) rather than ${{ }} interpolation
|
||||
@@ -116,6 +102,4 @@ jobs:
|
||||
name: omnivoice-tts-${{ matrix.platform }}
|
||||
path: |
|
||||
bin/omnivoice-tts-${{ matrix.platform }}*
|
||||
bin/libggml*
|
||||
bin/ggml*.dll
|
||||
bin/checksums.sha256
|
||||
|
||||
+14
-209
@@ -11,11 +11,6 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
windows_wix_diagnostic:
|
||||
description: Run only the tiny nonpublishing Windows MSI authoring diagnostic
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -26,15 +21,8 @@ env:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{ !inputs.windows_wix_diagnostic }}
|
||||
name: Tests (backend + frontend)
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
# Same restricted-network resilience the smoke matrix already sets. This
|
||||
# job resolves the same direct-URL dependency and had none of it, which
|
||||
# is why it was the one that kept dying (see scripts/uv-sync-retry.sh).
|
||||
UV_HTTP_TIMEOUT: "120"
|
||||
UV_HTTP_RETRIES: "5"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -63,7 +51,7 @@ jobs:
|
||||
|
||||
# apt install ffmpeg is ~30 s every run; cache the resolved .debs.
|
||||
- name: System deps (ffmpeg)
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
@@ -73,26 +61,10 @@ jobs:
|
||||
# so their tests can exercise the real import path, not the
|
||||
# "package not installed" fallback. Smoke job below stays on bare
|
||||
# `uv sync` because smoke only hits /health + fixture profiles.
|
||||
#
|
||||
# Retried because one dependency — en-core-web-sm — resolves to a
|
||||
# direct GitHub release URL, and github.com intermittently answers
|
||||
# `http2 error: refused stream before processing any application
|
||||
# logic`. uv's own 3 retries all land inside the same few seconds and
|
||||
# fail together, which has cost otherwise-green runs (#1517, #1518).
|
||||
# Backing off between whole attempts is what actually clears it.
|
||||
run: bash scripts/uv-sync-retry.sh --all-extras
|
||||
run: uv sync --all-extras
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
# downloading model weights mid-suite (the preload_model() Hub-probe
|
||||
# bug pulled the full 2.3 GB k2-fsa/OmniVoice checkpoint into every
|
||||
# networked empty-cache run before it was caught). All legitimate HF
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
|
||||
# Docs-drift CI gate (Phase 1 INST-06). The validator extracts code
|
||||
# blocks tagged `<!-- validate -->` from docs/install/*.md and asserts
|
||||
@@ -102,25 +74,12 @@ jobs:
|
||||
- name: Validate install docs against desktop-prod.sh
|
||||
run: python scripts/validate-install-docs.py
|
||||
|
||||
# The AppImage launcher decides which WebKitGTK actually runs — the wrong
|
||||
# answer is a permanently blank window on Linux (#56, #961, #1258), and
|
||||
# the only place that logic is exercised is this shell harness. It had
|
||||
# never been wired into CI, so its cases were a regression test nothing
|
||||
# ran. Cheap (pure bash, stubs pkg-config) and it gates the class.
|
||||
- name: AppImage launcher (AppRun) unit tests
|
||||
run: |
|
||||
bash frontend/src-tauri/appimage/AppRun.test.sh
|
||||
bash scripts/inject-apprun.test.sh
|
||||
bash scripts/verify-apprun-bundle.test.sh
|
||||
|
||||
# `backend/tests/` mounts routers on bare FastAPI apps (no heavy main
|
||||
# import chain) with a hermetic data dir from its conftest.py. It no
|
||||
# longer stubs sys.modules, so mixed sessions with tests/ are safe;
|
||||
# the separate session is kept for cheaper, clearer CI output.
|
||||
- name: Run pytest (backend/tests, isolated)
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
run: uv run pytest backend/tests/ -q --tb=short
|
||||
|
||||
# Cache ~/.bun/install/cache keyed on bun.lock — `bun install` drops
|
||||
# from ~15 s cold to near-instant on warm cache.
|
||||
@@ -172,30 +131,13 @@ jobs:
|
||||
working-directory: frontend
|
||||
run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs
|
||||
|
||||
# Production-bundle blank-screen gate. Everything above runs UN-minified
|
||||
# (dev server + Vitest/jsdom), so a crash that exists ONLY in the minified
|
||||
# release bundle — a TDZ reorder that throws before React mounts — passes
|
||||
# every check and ships a black screen. That is how v0.3.22 went out (#1178),
|
||||
# and it recurred pre-0.3.23. This builds the real dist/ and asserts the app
|
||||
# actually mounts into #root. See frontend/e2e-prod/prod-bundle-smoke.spec.ts.
|
||||
# (The in-app root <ErrorBoundary> in main-app.jsx catches such throws at
|
||||
# runtime; this gate stops them reaching a release in the first place.)
|
||||
- name: Install Playwright chromium
|
||||
working-directory: frontend
|
||||
run: bunx playwright install --with-deps chromium
|
||||
- name: Production-bundle smoke — no blank screen
|
||||
working-directory: frontend
|
||||
run: bun run test:prod-bundle
|
||||
|
||||
# ── Cross-platform Tauri shell check ────────────────────────────────────
|
||||
# Catches platform-specific Rust regressions on PR (cfg(target_os=...)
|
||||
# gates, missing Windows/macOS deps, etc.) without spending the 15+ min
|
||||
# per-platform that a full `tauri build` takes. `cargo check` is the
|
||||
# lightest gate that exercises type-checking + linking for each target,
|
||||
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
|
||||
# lightest gate that exercises type-checking + linking for each target.
|
||||
# Full bundling stays in release.yml on tag push.
|
||||
tauri-cross-platform:
|
||||
if: ${{ !inputs.windows_wix_diagnostic }}
|
||||
name: Tauri shell check (${{ matrix.label }})
|
||||
needs: test
|
||||
strategy:
|
||||
@@ -269,26 +211,6 @@ jobs:
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo check --target ${{ matrix.rust_target }} --message-format=short
|
||||
|
||||
# `cargo check` never compiles #[cfg(test)] code, so without this the
|
||||
# shell's unit tests (crash.rs, reset.rs, commands.rs, …) neither build
|
||||
# nor run anywhere in CI. --lib scopes it to the unit tests; each
|
||||
# matrix target equals its host triple, so the test binary runs
|
||||
# natively. Codegen is warmed by the rust-cache above.
|
||||
- name: Cargo test (Tauri shell unit tests)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test --lib --target ${{ matrix.rust_target }} --message-format=short
|
||||
|
||||
# Backend-lifecycle fault-injection harness: real child processes die
|
||||
# scripted deaths through the OMNIVOICE_BACKEND_CMD seam, and each
|
||||
# scenario asserts the user-visible diagnosis names the actual cause
|
||||
# (port conflict / traceback root cause / spawn failure / timeout /
|
||||
# crash-loop exhaustion / signal 9 / deliberate replace / deferred-
|
||||
# startup step). Serial: the scenarios share process-global state
|
||||
# (env vars, crash store, kill-intended flag) by design.
|
||||
- name: Cargo test (backend lifecycle harness)
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test --test backend_lifecycle --target ${{ matrix.rust_target }} --message-format=short -- --test-threads=1
|
||||
|
||||
# ── Cross-platform Python runtime smoke (Phase 0 GATE-02) ───────────────
|
||||
# Loads the frozen tests/fixtures/omnivoice_data/ fixture and boots the
|
||||
# FastAPI app in-process via TestClient on macOS/Windows/Linux. Catches
|
||||
@@ -296,7 +218,6 @@ jobs:
|
||||
# job above misses. Narrow scope (tests/smoke/ only) — full pytest stays
|
||||
# on Linux until Phase 1's INST-01 lands setuptools for WhisperX.
|
||||
smoke-matrix:
|
||||
if: ${{ !inputs.windows_wix_diagnostic }}
|
||||
name: Smoke (${{ matrix.label }})
|
||||
needs: test
|
||||
strategy:
|
||||
@@ -305,33 +226,12 @@ jobs:
|
||||
include:
|
||||
- os: macos-14
|
||||
label: macOS
|
||||
backend_supported: true
|
||||
- os: macos-15-intel
|
||||
label: macOS Intel
|
||||
backend_supported: false
|
||||
- os: windows-2022
|
||||
label: Windows
|
||||
backend_supported: true
|
||||
- os: ubuntu-22.04
|
||||
label: Linux
|
||||
backend_supported: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Priced for a COLD `uv sync`, on every platform.
|
||||
#
|
||||
# The previous split (Windows 25, Linux/macOS 10) came from a warm-cache
|
||||
# measurement — Linux and macOS finish in ~65 s when setup-uv restores its
|
||||
# cache, so 10 looked generous. Then run 30439640107 hit
|
||||
# "Failed to restore: Cache service responded with 400", Linux installed
|
||||
# torch from scratch, and the leg was killed at 10m17s. The 65 s was the
|
||||
# cache, not the platform.
|
||||
#
|
||||
# A cache miss is not rare enough to treat as an outage (GitHub's cache
|
||||
# service 400s, a lockfile change invalidates the key, a new runner image
|
||||
# starts empty), and a timeout here is self-perpetuating: the leg dies
|
||||
# before the post-step saves the cache, so the next run is cold too.
|
||||
# 25 everywhere is still bounded — a genuinely wedged job is caught in
|
||||
# minutes, not hours — and warm runs land nowhere near it.
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Restricted-network resilience (RESEARCH Pitfall #6) — keeps uv from
|
||||
# giving up on the first slow PyPI / python-build-standalone fetch.
|
||||
@@ -355,120 +255,25 @@ jobs:
|
||||
# though the silence WAV doesn't decode anything heavy — keeps test
|
||||
# collection from import-erroring on optional audio modules.
|
||||
- name: System deps (macOS)
|
||||
if: runner.os == 'macOS' && matrix.backend_supported
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install ffmpeg libsndfile || true
|
||||
|
||||
- name: System deps (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
# The community chocolatey feed 50x's intermittently (broke PR runs on
|
||||
# 2026-07-20 and 2026-07-28) — retry with backoff before failing.
|
||||
#
|
||||
# Test the OUTCOME, not choco's exit code. On 2026-07-28 the feed
|
||||
# returned 503, choco reported "Unable to find package 'ffmpeg'" and
|
||||
# "installed 0/0 packages" — and still exited 0. The `&& break` that
|
||||
# was supposed to guard this fired on the first attempt, no retry ran,
|
||||
# and the job died one line later on `ffmpeg: command not found`.
|
||||
# A retry that trusts a lying exit code is not a retry.
|
||||
for i in 1 2 3; do
|
||||
choco install ffmpeg -y --no-progress || true
|
||||
hash -r 2>/dev/null || true
|
||||
if command -v ffmpeg >/dev/null 2>&1; then break; fi
|
||||
# No backoff after the last attempt — there is no fourth try to
|
||||
# wait for, and sleeping 90s only delays an already-doomed job.
|
||||
if [ "$i" -eq 3 ]; then
|
||||
echo "choco failed to produce ffmpeg after 3 attempts"
|
||||
break
|
||||
fi
|
||||
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
|
||||
choco install ffmpeg -y --no-progress
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
if: runner.os == 'Linux' && matrix.backend_supported
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
if: runner.os == 'Linux'
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg libsndfile1
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps (including PocketTTS)
|
||||
# PocketTTS is an opt-in engine, but installing its pinned extra here
|
||||
# proves that the same dependency set resolves on every supported local
|
||||
# backend host. The Intel-Mac leg separately pins the documented
|
||||
# unsupported contract: its UI is a remote-backend client only (#889).
|
||||
if: matrix.backend_supported
|
||||
run: bash scripts/uv-sync-retry.sh --extra pockettts
|
||||
|
||||
- name: Verify the documented Intel Mac contract
|
||||
if: ${{ !matrix.backend_supported }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import tomllib
|
||||
|
||||
assert platform.system() == "Darwin"
|
||||
assert platform.machine() == "x86_64"
|
||||
root = Path.cwd()
|
||||
project = tomllib.loads((root / "pyproject.toml").read_text("utf-8"))
|
||||
extra = project["project"]["optional-dependencies"]["pockettts"]
|
||||
assert extra == [
|
||||
"pocket-tts==2.1.0 ; sys_platform != 'darwin' or platform_machine != 'x86_64'"
|
||||
]
|
||||
docs = (root / "docs/install/macos.md").read_text("utf-8")
|
||||
assert "Intel Macs are not supported" in docs
|
||||
PY
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
|
||||
- name: Run smoke tests
|
||||
# Exercise credential paths on native Windows as well as POSIX hosts.
|
||||
if: matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/smoke/ tests/test_hf_token_cache_paths.py -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
|
||||
|
||||
# Artifact commits depend on native Windows rename/replace semantics;
|
||||
# Linux emulation cannot exercise sharing rules or path parsing.
|
||||
- name: Remote-worker artifact paths (Windows)
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/test_worker_upload_server.py tests/test_worker_server_integrity.py -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
|
||||
|
||||
windows-wix-diagnostic:
|
||||
name: Windows MSI authoring (no publishing)
|
||||
needs: test
|
||||
if: ${{ !cancelled() && (inputs.windows_wix_diagnostic || needs.test.result == 'success') }}
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- name: Bundle canonical system and per-user templates with a tiny payload
|
||||
shell: pwsh
|
||||
run: ./scripts/diagnose-windows-wix.ps1
|
||||
- name: Preserve verbose linker output and rendered authoring
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-wix-diagnostic
|
||||
path: wix-diagnostic-artifacts/
|
||||
if-no-files-found: warn
|
||||
retention-days: 3
|
||||
run: uv run pytest tests/smoke/ -q --tb=short
|
||||
|
||||
@@ -14,14 +14,6 @@
|
||||
# :0.3 — major.minor floating tag (updated on every patch within the minor)
|
||||
# :sha-xxxx — specific commit SHA; produced by workflow_dispatch
|
||||
#
|
||||
# ROCm/AMD GPU variant (#1165) — same semantics, `-rocm` suffixed, built by the
|
||||
# build-and-push-rocm job from the same Dockerfile via the BASE_IMAGE build-arg:
|
||||
# :rocm — rolling preview from main (the ROCm analogue of :latest)
|
||||
# :stable-rocm — most recent versioned release, ROCm build
|
||||
# :0.3.6-rocm — exact version, ROCm build
|
||||
# :0.3-rocm — major.minor floating tag, ROCm build
|
||||
# :sha-xxxx-rocm — specific commit SHA, ROCm build
|
||||
#
|
||||
# Images land at: ghcr.io/debpalash/omnivoice-studio AND docker.io/palashdeb/omnivoice-studio
|
||||
# (Docker Hub push gated on the DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets;
|
||||
# if unset the build still pushes to GHCR.)
|
||||
@@ -29,7 +21,7 @@
|
||||
# On main pushes the Docker Hub repository overview is also synced from
|
||||
# deploy/dockerhub-overview.md (source of truth for the hub.docker.com page).
|
||||
#
|
||||
# NOTE: the Docker image is the headless web-server build of VoiceStudio (FastAPI
|
||||
# NOTE: the Docker image is the headless web-server build of OmniVoice (FastAPI
|
||||
# backend + pre-built React frontend served over HTTP). The Tauri desktop
|
||||
# auto-updater and its update-channel toggle are desktop-only features; they do
|
||||
# NOT apply to the Docker image.
|
||||
@@ -48,16 +40,7 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
# PINNED, not ${{ github.repository }}. The repository was renamed to
|
||||
# `VoiceStudio`, and deriving the image path from it would have silently
|
||||
# moved published images to ghcr.io/debpalash/voicestudio — while Docker Hub
|
||||
# (a hardcoded literal below) stayed put. Everyone pulling the documented
|
||||
# GHCR path would have kept getting the last pre-rename image forever: no
|
||||
# error, no warning, just a channel that quietly stopped updating. A
|
||||
# published image path is a promise to users, not a mirror of the repo name.
|
||||
# Renaming it is a deliberate migration (publish to both, document the move,
|
||||
# then retire the old), not a side effect of renaming the repo.
|
||||
IMAGE_NAME: debpalash/omnivoice-studio
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: palashdeb/omnivoice-studio
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
@@ -67,19 +50,6 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Both image builds run right at the runner's disk ceiling (CUDA hit
|
||||
# ENOSPC 2026-07-16 morning; ROCm hit it the same afternoon even with
|
||||
# the original reclaim list). Reclaim everything these jobs can never
|
||||
# use — ~40-45 GB total. Keep this list identical in both jobs.
|
||||
- name: Free runner disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/usr/local/.ghcup /opt/hostedtoolcache /usr/share/swift \
|
||||
/usr/local/share/boost /usr/local/lib/node_modules
|
||||
sudo docker image prune --all --force
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
|
||||
# QEMU enables cross-platform builds (arm64 on x64 runner).
|
||||
# Skipped for now — only building linux/amd64.
|
||||
# - uses: docker/setup-qemu-action@v3
|
||||
@@ -166,87 +136,3 @@ jobs:
|
||||
repository: ${{ env.DOCKERHUB_IMAGE }}
|
||||
short-description: "Local ElevenLabs alternative: voice cloning, design & video dubbing in 646 languages. No API keys."
|
||||
readme-filepath: ./deploy/dockerhub-overview.md
|
||||
|
||||
# ── ROCm/AMD GPU image variant (#1165) ──────────────────────────────────
|
||||
# Same Dockerfile, ROCm PyTorch base swapped in via the BASE_IMAGE
|
||||
# build-arg; tags mirror the CUDA job's with a `-rocm` suffix (table in the
|
||||
# header comment). Kept as a SEPARATE job — not a matrix leg or a second
|
||||
# build step — so it gets a full runner disk to itself: the ROCm base alone
|
||||
# is ~10 GB compressed / ~25 GB unpacked.
|
||||
build-and-push-rocm:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The ROCm base (~25 GB unpacked) plus build layers need every GB.
|
||||
# Same reclaim list as the CUDA job above — keep them identical
|
||||
# (2026-07-16: ROCm hit ENOSPC with the shorter list).
|
||||
- name: Free runner disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
|
||||
/usr/local/.ghcup /opt/hostedtoolcache /usr/share/swift \
|
||||
/usr/local/share/boost /usr/local/lib/node_modules
|
||||
sudo docker image prune --all --force
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Same Docker Hub gating as the CUDA job: push there only when the
|
||||
# secret exists, so forks still publish to GHCR.
|
||||
- name: Check Docker Hub credentials
|
||||
id: dockerhub
|
||||
run: echo "enabled=${{ secrets.DOCKERHUB_TOKEN != '' }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Mirrors the CUDA job's tag rules (incl. the workflow_dispatch and
|
||||
# prerelease gating) with a `-rocm` suffix on every tag.
|
||||
- name: Extract metadata (tags, labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ steps.dockerhub.outputs.enabled == 'true' && env.DOCKERHUB_IMAGE || '' }}
|
||||
# latest=false is load-bearing: metadata-action's default (`auto`)
|
||||
# would add a bare un-suffixed `:latest` on release-tag pushes,
|
||||
# clobbering the CUDA preview channel with a ROCm image.
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}},suffix=-rocm,enable=${{ github.event_name == 'push' }}
|
||||
type=semver,pattern={{major}}.{{minor}},suffix=-rocm,enable=${{ github.event_name == 'push' }}
|
||||
type=raw,value=stable-rocm,enable=${{ github.event_name == 'push' && github.ref_type == 'tag' && !contains(github.ref, '-') }}
|
||||
type=raw,value=rocm,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
type=sha,prefix=sha-,suffix=-rocm,format=short
|
||||
|
||||
# cache-from only: reading the CUDA job's exported cache reuses the
|
||||
# identical frontend-builder stage. Deliberately NO cache-to — the
|
||||
# multi-GB ROCm runtime layers would blow GitHub's 10 GB per-repo
|
||||
# Actions cache budget and evict the CUDA job's cache.
|
||||
- name: Build and push (ROCm)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: deploy/Dockerfile
|
||||
build-args: |
|
||||
BASE_IMAGE=rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0
|
||||
GPU_FLAVOR=rocm
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
run: uv sync
|
||||
|
||||
- name: Run eval suites (non-gating)
|
||||
continue-on-error: true
|
||||
env:
|
||||
TRANSLATE_BASE_URL: ${{ secrets.EVALS_LLM_BASE_URL }}
|
||||
TRANSLATE_API_KEY: ${{ secrets.EVALS_LLM_API_KEY }}
|
||||
run: uv run --no-sync python tests/evals/run_evals.py --output eval-report.json
|
||||
run: uv run python tests/evals/run_evals.py --output eval-report.json
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
# Installer smoke — runs scripts/install.sh / scripts/install.ps1 end-to-end
|
||||
# on all three desktop platforms so the one-liner installers can't rot.
|
||||
#
|
||||
# Gated by `paths` because a cold run downloads multi-GB wheels (torch) and
|
||||
# takes ~15-30 min per OS; it only needs to fire when an installer or this
|
||||
# workflow changes. The heavy Tauri bundles stay in release.yml (tag push).
|
||||
|
||||
name: Install smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
- ".github/workflows/install-smoke.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "scripts/install.sh"
|
||||
- "scripts/install.ps1"
|
||||
- ".github/workflows/install-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
install:
|
||||
name: Install (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Running `sh scripts/install.sh` from the repo root exercises the
|
||||
# repo-root resolution (script dir is scripts/, project root one level
|
||||
# up) — the exact bug that made a local run clone a duplicate repo.
|
||||
# Binary mode is the default: prebuilt release asset, checksum verified.
|
||||
- name: Run installer — binary (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: sh scripts/install.sh
|
||||
|
||||
- name: Verify install — binary (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: |
|
||||
if [ "$(uname)" = "Darwin" ]; then
|
||||
test -d "/Applications/VoiceStudio.app" || { echo "::error::VoiceStudio.app missing from /Applications"; exit 1; }
|
||||
echo "✓ VoiceStudio.app installed in /Applications"
|
||||
else
|
||||
test -x "$HOME/.local/bin/VoiceStudio" || { echo "::error::AppImage missing from ~/.local/bin"; exit 1; }
|
||||
"$HOME/.local/bin/VoiceStudio" --appimage-help >/dev/null 2>&1 || true
|
||||
echo "✓ AppImage installed and executable"
|
||||
fi
|
||||
|
||||
# Source mode stays covered end-to-end behind --source.
|
||||
- name: Run installer — source (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
run: sh scripts/install.sh --source
|
||||
|
||||
- name: Verify install — source (macOS/Linux)
|
||||
if: runner.os != 'Windows'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
test -d .venv || { echo "::error::.venv missing"; exit 1; }
|
||||
test -f frontend/dist/index.html || { echo "::error::frontend build missing"; exit 1; }
|
||||
echo "✓ venv + frontend bundle present"
|
||||
|
||||
# Binary mode is the default; CI runs msiexec silently.
|
||||
- name: Run installer — binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
CI: true
|
||||
shell: pwsh
|
||||
run: '& { $ErrorActionPreference = "Stop"; & "${{ github.workspace }}\scripts\install.ps1" }'
|
||||
|
||||
- name: Verify install — binary (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$paths = @(
|
||||
"HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
|
||||
"HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"
|
||||
)
|
||||
$key = Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.DisplayName -match "VoiceStudio|OmniVoice" } |
|
||||
Select-Object -First 1
|
||||
if (-not $key) {
|
||||
Get-ItemProperty $paths -ErrorAction SilentlyContinue |
|
||||
Where-Object DisplayName | ForEach-Object { Write-Host " installed: $($_.DisplayName)" }
|
||||
Write-Host "::error::MSI product not registered"; exit 1
|
||||
}
|
||||
Write-Host "✓ MSI product registered: $($key.DisplayName)"
|
||||
|
||||
# Source mode stays covered end-to-end behind -Source.
|
||||
- name: Run installer — source (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
env:
|
||||
VOICESTUDIO_INSTALL_MODE: source
|
||||
shell: pwsh
|
||||
run: '& { $ErrorActionPreference = "Stop"; & "${{ github.workspace }}\scripts\install.ps1" }'
|
||||
|
||||
- name: Verify install — source (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path .venv)) { Write-Host "::error::.venv missing"; exit 1 }
|
||||
if (-not (Test-Path frontend\dist\index.html)) { Write-Host "::error::frontend build missing"; exit 1 }
|
||||
Write-Host "✓ venv + frontend bundle present"
|
||||
|
||||
- name: Upload install log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install-log-${{ matrix.os }}
|
||||
path: |
|
||||
/Users/runner/Library/Application Support/OmniVoice/*.log
|
||||
/home/runner/.local/share/VoiceStudio/*.log
|
||||
${{ runner.temp }}/VoiceStudio/**/*.log
|
||||
if-no-files-found: ignore
|
||||
+35
-557
@@ -43,7 +43,7 @@ on:
|
||||
required: false
|
||||
default: "true"
|
||||
publish_preview:
|
||||
description: "Publish a rolling 'preview' prerelease (updater Preview channel). Previews ALWAYS build from main — dispatching from any other branch fails the preview-gate."
|
||||
description: "Publish a rolling 'preview' prerelease (updater Preview channel) from the selected branch"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
@@ -51,19 +51,6 @@ on:
|
||||
permissions:
|
||||
contents: write # needed to attach artifacts + updater manifest to GH Release
|
||||
|
||||
# Every preview build publishes to the SAME rolling `preview` release, and the
|
||||
# updater manifest is rebuilt from whatever assets are on it. Two overlapping
|
||||
# preview runs (the nightly schedule and a manual dispatch, say) would upload
|
||||
# into each other's asset set, and the version-less macOS tarballs carry
|
||||
# nothing saying which run produced them — so one run could publish a manifest
|
||||
# advertising its own version while serving the other run's macOS binaries
|
||||
# (greptile). Serialize instead. Keyed on the ref, so a `v*` tag push (which
|
||||
# builds its own release and never touches `preview`) is never queued behind a
|
||||
# nightly.
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
# Run all JavaScript actions on Node 24 (GH deprecates Node 20 in Sep 2026).
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
@@ -103,16 +90,16 @@ jobs:
|
||||
# Backend tests need ffmpeg (subprocess calls in fixtures). Cache the
|
||||
# resolved .debs so warm runs skip the apt-get update + install.
|
||||
- name: System deps (ffmpeg)
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
run: uv sync
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
@@ -148,41 +135,20 @@ jobs:
|
||||
preview-gate:
|
||||
name: Preview gate
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
is_preview: ${{ steps.decide.outputs.is_preview }}
|
||||
proceed: ${{ steps.decide.outputs.proceed }}
|
||||
stable_tag: ${{ steps.decide.outputs.stable_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- id: decide
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
event="${{ github.event_name }}"
|
||||
if [ "$event" = "schedule" ] || { [ "$event" = "workflow_dispatch" ] && [ "${{ inputs.publish_preview }}" = "true" ]; }; then
|
||||
# Preview channel policy (owner-set 2026-07-16): previews ALWAYS
|
||||
# build from main. The preview updater manifest and the Docker
|
||||
# rolling tags (:latest/:main/:rocm) all track main — a preview
|
||||
# cut from a side branch would desync the channels and could
|
||||
# ship code that never merged. Merge to main first.
|
||||
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
|
||||
echo "::error::Preview builds publish from main only (got '${{ github.ref }}'). Merge to main, then dispatch with publish_preview=true."
|
||||
exit 1
|
||||
fi
|
||||
echo "is_preview=true" >> "$GITHUB_OUTPUT"
|
||||
# Resolve once before the matrix starts so every platform stamps
|
||||
# against the same immutable Stable-channel snapshot.
|
||||
STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
|
||||
[[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
|
||||
echo "::error::latest stable release has an invalid tag"; exit 1;
|
||||
}
|
||||
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_preview=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -509,126 +475,41 @@ jobs:
|
||||
echo "APPLE_TEAM_ID=$TID"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# Stamp each preview with a numeric prerelease that is strictly above the
|
||||
# latest stable release. Main may intentionally retain the released
|
||||
# version while AUTO_VERSION_BUMP is disabled; in that case the helper
|
||||
# advances the preview base by one patch so stable users can still opt in
|
||||
# and receive it. The edit is ephemeral and never committed.
|
||||
# Stamp each preview build with a unique, monotonically increasing semver
|
||||
# PRERELEASE so the updater actually offers it (a rolling preview that
|
||||
# always reported the static 0.3.0 never looked "newer", so no update was
|
||||
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
|
||||
# bundle + updater version from tauri.conf.json, so rewriting it here
|
||||
# stamps the artifacts + latest.json. Under the versioning hard rule
|
||||
# (owner-set 2026-06-11) main is always last-release + 1, so BASE-N is a
|
||||
# prerelease of the NEXT version and semver-sorts ABOVE the last stable
|
||||
# (0.3.6-N > 0.3.5) — preview users naturally upgrade past stable, and
|
||||
# the Windows MSI ProductVersion (which strips the prerelease → 0.3.6)
|
||||
# is also correctly above the last stable.
|
||||
- name: Stamp preview version
|
||||
if: needs.preview-gate.outputs.is_preview == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \
|
||||
--package-json frontend/package.json \
|
||||
--stable-tag "$STABLE_TAG" \
|
||||
--run-number "${{ github.run_number }}")
|
||||
# package.json is the single source of truth; tauri.conf.json reads its
|
||||
# version from it ("version": "../package.json"), so stamping
|
||||
# package.json restamps the whole bundle.
|
||||
CONF=frontend/package.json
|
||||
BASE=$(jq -r .version "$CONF")
|
||||
# MSI/WiX requires the semver pre-release identifier to be numeric-only
|
||||
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
|
||||
# preview stamp is BASE-N — still sorts below the stable BASE for the
|
||||
# updater, still unique per run.
|
||||
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
|
||||
tmp=$(mktemp)
|
||||
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
|
||||
mv "$tmp" "$CONF"
|
||||
echo "Stamped preview version: $PREVIEW_VERSION"
|
||||
|
||||
# The rolling `preview` release is REUSED every night, and macOS updater
|
||||
# artifacts are the only ones Tauri names WITHOUT the version:
|
||||
#
|
||||
# VoiceStudio_0.4.1-103_x64.dmg <- unique per run, uploads fine
|
||||
# VoiceStudio_x64.app.tar.gz <- constant, collides
|
||||
#
|
||||
# So every preview build after the first failed the macOS legs with
|
||||
# `Validation Failed: {"resource":"ReleaseAsset","code":"already_exists"}`
|
||||
# — and it failed AFTER the dmg upload, so the run went red while looking
|
||||
# partially successful. The macOS updater bundles on `preview` went stale
|
||||
# on 2026-07-04/05 and stayed that way for three weeks: Preview-channel
|
||||
# macOS users had no working update path, and the nightly run was red
|
||||
# every night.
|
||||
#
|
||||
# Delete this arch's updater bundle before uploading the new one. Scoped
|
||||
# to the preview path (a `v*` tag makes a fresh release, nothing to
|
||||
# collide with) and to this job's own arch, so the parallel aarch64/x64
|
||||
# legs never touch each other's assets.
|
||||
#
|
||||
# ONLY an absent release/asset is benign. Auth, permission, rate-limit and
|
||||
# network failures must not be swallowed: the step would report success
|
||||
# while the stale asset survived, the upload would then die with
|
||||
# `already_exists`, and we would be back to the exact outage this step
|
||||
# exists to prevent — minus the red step that explains why. Since GH_TOKEN
|
||||
# is scoped to this same repo, a 404 really does mean "not there".
|
||||
- name: Clear this arch's stale preview updater bundle (macOS)
|
||||
if: needs.preview-gate.outputs.is_preview == 'true' && runner.os == 'macOS'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# aarch64-apple-darwin -> aarch64 ; x86_64-apple-darwin -> x64
|
||||
case "${{ matrix.arch }}" in
|
||||
aarch64-*) SUFFIX=aarch64 ;;
|
||||
x86_64-*) SUFFIX=x64 ;;
|
||||
*) echo "::error::unexpected arch ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
# Match the STORED name, not the uploaded one. GitHub rewrites
|
||||
# spaces to dots, which is why the pre-rename product ("OmniVoice
|
||||
# Studio") was stored as "OmniVoice.Studio_x64.app.tar.gz".
|
||||
# "VoiceStudio" has no space and so needs no translation — the
|
||||
# pattern below matches both, so a preview release still holding
|
||||
# pre-rename assets is still cleaned up.
|
||||
if ! gh release view preview --json assets -q '.assets[].name' \
|
||||
> /tmp/preview-assets.txt 2> /tmp/gh-view-err.txt; then
|
||||
if grep -qiE 'not found|HTTP 404' /tmp/gh-view-err.txt; then
|
||||
echo "No preview release yet — nothing to clear."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Could not read the preview release, so a stale ${SUFFIX} bundle may still be there."
|
||||
echo "Refusing to continue blind — the Tauri upload would fail with already_exists."
|
||||
cat /tmp/gh-view-err.txt
|
||||
exit 1
|
||||
fi
|
||||
grep -E "(^VoiceStudio|[ .]Studio)_${SUFFIX}\.app\.tar\.gz(\.sig)?$" /tmp/preview-assets.txt \
|
||||
> /tmp/stale.txt || true
|
||||
if [ ! -s /tmp/stale.txt ]; then
|
||||
echo "No stale ${SUFFIX} updater bundle on preview — nothing to clear."
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r name; do
|
||||
echo "Removing stale preview asset: $name"
|
||||
if ! gh release delete-asset preview "$name" --yes \
|
||||
2> /tmp/gh-del-err.txt; then
|
||||
# Already gone is fine — a re-run or the sibling leg beat us to
|
||||
# it, and the goal (no asset under this name) is met either way.
|
||||
if grep -qiE 'not found|HTTP 404' /tmp/gh-del-err.txt; then
|
||||
echo " (already gone — nothing to collide with)"
|
||||
continue
|
||||
fi
|
||||
echo "::error::Failed to delete stale preview asset $name."
|
||||
cat /tmp/gh-del-err.txt
|
||||
exit 1
|
||||
fi
|
||||
done < /tmp/stale.txt
|
||||
|
||||
# A retried job reuses its version and can collide with installers it
|
||||
# uploaded before a later step failed. Keep other versions/arches intact;
|
||||
# macOS versionless updater archives are scoped by release tag and arch.
|
||||
- name: Clear this target's installer assets on retry
|
||||
if: github.run_attempt > 1
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
RELEASE_TARGET: ${{ matrix.rust_target }}
|
||||
run: |
|
||||
VERSION=$(python -c 'import json; print(json.load(open("frontend/package.json"))["version"])')
|
||||
python scripts/clear-release-rerun-assets.py \
|
||||
--tag "$RELEASE_TAG" --version "$VERSION" --target "$RELEASE_TARGET"
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Analytics destination, injected at BUILD time (never committed — a
|
||||
# token-shaped literal in the repo trips the secret scanner, and the
|
||||
# frontend bundle is where a publishable client key belongs). Absent =>
|
||||
# the build has no destination, the Privacy toggle isn't offered, and
|
||||
# nothing can be sent. Analytics still requires the user to opt in.
|
||||
VITE_POSTHOG_KEY: ${{ secrets.POSTHOG_PROJECT_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# macOS Apple signing (#134 / #72) is configured by the preceding
|
||||
@@ -656,54 +537,13 @@ jobs:
|
||||
tagName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
# Version-first so the tag is readable in GitHub's truncated
|
||||
# release-list sidebar (which clips the title mid-string).
|
||||
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — VoiceStudio' || format('{0} — VoiceStudio', github.ref_name) }}
|
||||
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — OmniVoice Studio' || format('{0} — OmniVoice Studio', github.ref_name) }}
|
||||
releaseBody: ${{ steps.changelog.outputs.body }}
|
||||
releaseDraft: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'false' || (inputs.draft || 'true') }}
|
||||
prerelease: ${{ needs.preview-gate.outputs.is_preview == 'true' }}
|
||||
updaterJsonPreferNsis: false
|
||||
includeUpdaterJson: true
|
||||
|
||||
- name: Build per-user Windows MSI
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
working-directory: frontend
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python ../scripts/render-per-user-wix.py \
|
||||
--source src-tauri/wix/main.wxs \
|
||||
--system-wxs src-tauri/target/${{ matrix.rust_target }}/release/wix/x64/main.wxs \
|
||||
--output src-tauri/target/wix-per-user/main.wxs
|
||||
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
|
||||
--config src-tauri/tauri.per-user.conf.json
|
||||
DIR="src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
|
||||
while IFS= read -r artifact; do
|
||||
safe=${artifact// (Current User)/_Current_User}
|
||||
[ "$safe" = "$artifact" ] || mv "$artifact" "$safe"
|
||||
done < <(find "$DIR" -maxdepth 1 -type f -name '*Current*User*.msi*')
|
||||
|
||||
- name: Publish per-user Windows updater channel
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIR="frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi"
|
||||
MSI=$(find "$DIR" -name '*Current*User*.msi' -type f | head -1)
|
||||
[ -n "$MSI" ] || { echo "per-user MSI missing"; find "$DIR" -type f; exit 1; }
|
||||
[ -f "$MSI.sig" ] || { echo "per-user MSI signature missing"; exit 1; }
|
||||
VERSION=$(jq -r .version frontend/package.json)
|
||||
python scripts/build_windows_user_manifest.py \
|
||||
--repo "$GITHUB_REPOSITORY" --tag "$RELEASE_TAG" --version "$VERSION" \
|
||||
--asset "$(basename "$MSI")" --signature-file "$MSI.sig" \
|
||||
--output latest-user.json
|
||||
gh release upload "$RELEASE_TAG" "$MSI" "$MSI.sig" latest-user.json \
|
||||
--clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# ── Installer smoke (Phase 0 GATE-03) ─────────────────────────────
|
||||
# Structural verification of the installed/extracted bundle. The thin
|
||||
# uv-venv installer ships NO frozen backend binary (the venv is built on
|
||||
@@ -720,10 +560,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
DMG=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg -name "*.dmg" | head -1)
|
||||
echo "Smoke-testing DMG: $DMG"
|
||||
# Grab the full mount path with a grep rather than `awk '{print $3}'`.
|
||||
# The volume name used to contain a space ("OmniVoice Studio"), which
|
||||
# awk truncated to /Volumes/OmniVoice; "VoiceStudio" has no space, so
|
||||
# this is now belt-and-braces rather than load-bearing.
|
||||
# Grab the full mount path — the volume name has a space ("OmniVoice
|
||||
# Studio"), so `awk '{print $3}'` would truncate it to /Volumes/OmniVoice.
|
||||
MOUNT=$(hdiutil attach -nobrowse -readonly "$DMG" | tail -1 | grep -oE '/Volumes/.*$')
|
||||
APP=$(find "$MOUNT" -maxdepth 2 -name "*.app" | head -1)
|
||||
fail() { echo "FAIL — $1"; find "$APP/Contents" -maxdepth 4 -type f 2>/dev/null | head -40; hdiutil detach "$MOUNT" || true; exit 1; }
|
||||
@@ -772,12 +610,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" ! -name '*Current*User*' | head -1)
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name "*.msi" | head -1)
|
||||
echo "Smoke-testing MSI: $MSI"
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/verify-windows-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
|
||||
# /quiet = no UI, /norestart = don't reboot the runner if a dep asks
|
||||
msiexec.exe //i "$(cygpath -w "$MSI")" //quiet //norestart
|
||||
INSTALL="/c/Program Files/VoiceStudio"
|
||||
INSTALL="/c/Program Files/OmniVoice Studio"
|
||||
fail() { echo "FAIL — $1. Contents:"; find "$INSTALL" -maxdepth 4 -type f 2>/dev/null | head -40; exit 1; }
|
||||
# Thin uv-venv installer ships no frozen backend .exe — verify the
|
||||
# install is complete: shell exe + bundled uv + backend source resources.
|
||||
@@ -787,72 +624,6 @@ jobs:
|
||||
find "$INSTALL" -type f -path '*backend*main.py' | grep -q . || fail "backend source main.py missing"
|
||||
echo "OK — MSI installed shell + uv + backend resources"
|
||||
|
||||
- name: Per-user installer smoke (Windows, non-admin account)
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 8
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MSI=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/msi -name '*Current*User*.msi' | head -1)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass \
|
||||
-File scripts/smoke-per-user-msi.ps1 -MsiPath "$(cygpath -w "$MSI")"
|
||||
|
||||
# linuxdeploy re-links .DirIcon as an ABSOLUTE symlink into the build
|
||||
# machine AFTER tauri's files-map has placed the real icon bytes — the
|
||||
# exact bug #1518 guarded against, resurfacing on the first real tag
|
||||
# build (v0.5.0). The seam tauri-action leaves us is post-upload: repack
|
||||
# the AppImage with the icon as a REGULAR FILE, re-sign it (the updater
|
||||
# signature covered the old bytes), and clobber the draft release's
|
||||
# asset + the linux signature inside latest.json. The smoke below then
|
||||
# validates the repaired artifact, not the broken one.
|
||||
- name: Repair AppImage .DirIcon, re-sign, re-upload
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 10
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
# Data, not shell source (zizmor template-injection): a crafted ref
|
||||
# must never expand inside a script that holds the signing key.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPIMAGE=$(find frontend/src-tauri/target/${{ matrix.rust_target }}/release/bundle/appimage -name "*.AppImage" | head -1)
|
||||
APPIMAGE=$(realpath "$APPIMAGE")
|
||||
WORK="$(mktemp -d)"; cd "$WORK"
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$WORK/squashfs-root"
|
||||
ICON=$(readlink -f "$ROOT/.DirIcon" 2>/dev/null || true)
|
||||
if [ -n "$ICON" ] && [ -f "$ICON" ] && case "$ICON" in "$ROOT"/*) true;; *) false;; esac; then
|
||||
echo ".DirIcon already resolves inside the bundle — no repair needed"
|
||||
exit 0
|
||||
fi
|
||||
# The real bytes are at the AppDir root (linuxdeploy put them there
|
||||
# before mislinking). Ship a regular file: nothing left to dangle.
|
||||
SRC=$(find "$ROOT" -maxdepth 1 -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || SRC=$(find "$ROOT/usr/share/icons" -name "*.png" | head -1)
|
||||
[ -n "$SRC" ] || { echo "no icon bytes found in bundle"; exit 1; }
|
||||
rm -f "$ROOT/.DirIcon"
|
||||
cp "$SRC" "$ROOT/.DirIcon"
|
||||
# Pinned immutable release + checksum: this binary runs with the
|
||||
# updater signing key and a release-write token in its environment,
|
||||
# so a mutable 'continuous' asset is not acceptable supply chain.
|
||||
AIT_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.1/appimagetool-x86_64.AppImage"
|
||||
AIT_SHA256="ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0"
|
||||
curl -fsSL --retry 3 -o "$WORK/appimagetool" "$AIT_URL"
|
||||
echo "$AIT_SHA256 $WORK/appimagetool" | sha256sum -c - || { echo "appimagetool checksum mismatch"; exit 1; }
|
||||
chmod +x "$WORK/appimagetool"
|
||||
# Same FUSE-less trick the build itself uses.
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ARCH=x86_64 "$WORK/appimagetool" --no-appstream "$ROOT" "$APPIMAGE"
|
||||
cd "$GITHUB_WORKSPACE/frontend"
|
||||
bunx tauri signer sign "$APPIMAGE"
|
||||
gh release upload "$TAG" "$APPIMAGE" "$APPIMAGE.sig" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
# latest.json is NOT patched here: every tauri-action leg re-uploads
|
||||
# the shared manifest, so an in-leg patch races the other platforms —
|
||||
# the repair-updater-manifest job below is the single final writer.
|
||||
echo "repacked, re-signed, re-uploaded"
|
||||
|
||||
- name: Installer smoke (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
timeout-minutes: 5
|
||||
@@ -873,16 +644,9 @@ jobs:
|
||||
"$APPIMAGE" --appimage-extract >/dev/null
|
||||
ROOT="$EXTRACT_DIR/squashfs-root"
|
||||
fail() { echo "FAIL — $1"; find "$ROOT" -maxdepth 5 -type f 2>/dev/null | head -40; exit 1; }
|
||||
# linuxdeploy's GTK/GStreamer hooks wrap the seeded launcher as
|
||||
# AppRun.wrapped. Verify the complete launcher chain, not only the
|
||||
# small hook runner installed at the AppImage root.
|
||||
bash "$GITHUB_WORKSPACE/scripts/verify-apprun-bundle.sh" \
|
||||
"$ROOT" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|
||||
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version"
|
||||
# Thin uv-venv installer: verify the AppImage carries the shell binary,
|
||||
# the bundled uv sidecar, and the backend source resources.
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
find "$ROOT" -type f -name 'uv' | grep -q . || fail "bundled uv sidecar missing"
|
||||
find "$ROOT" -type f -name 'pyproject.toml' | grep -q . || fail "backend resource pyproject.toml missing"
|
||||
find "$ROOT" -type f -path '*/backend/main.py' | grep -q . || fail "backend source backend/main.py missing"
|
||||
@@ -966,50 +730,6 @@ jobs:
|
||||
# the tag (v0.3.20 shipped with only the Linux AppImage that way). `needs:
|
||||
# [build]` guarantees the release already exists; `--clobber` makes a re-run
|
||||
# idempotent. This can never create a second release.
|
||||
# The Linux leg may repack + re-sign its AppImage (see the repair step in
|
||||
# the build matrix); every tauri-action leg also re-uploads the SHARED
|
||||
# latest.json, so patching the manifest inside any leg races the others.
|
||||
# This job runs once after the whole matrix as the single final writer:
|
||||
# it makes the manifest's linux signature agree with the .sig asset that
|
||||
# actually shipped, and refuses to leave a mismatch behind.
|
||||
repair-updater-manifest:
|
||||
needs: [build, preview-gate]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Data, not shell source — same zizmor rule as the leg step.
|
||||
TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
|
||||
steps:
|
||||
- name: Align latest.json's linux signature with the shipped .sig asset
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
WORK="$(mktemp -d)"
|
||||
HAS_MANIFEST=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name]|contains(["latest.json"])')
|
||||
if [ "$HAS_MANIFEST" != "true" ]; then
|
||||
echo "no latest.json on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern latest.json --output "$WORK/latest.json" --repo "$GITHUB_REPOSITORY"
|
||||
# Same fail-closed rule as the manifest: absence is checked against
|
||||
# the asset LIST; an actual download failure must fail the job, or
|
||||
# the manifest keeps a signature nobody shipped.
|
||||
HAS_SIG=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '[.assets[].name|select(endswith(".AppImage.sig"))]|length > 0')
|
||||
if [ "$HAS_SIG" != "true" ]; then
|
||||
echo "no AppImage .sig asset on the release — nothing to align"; exit 0
|
||||
fi
|
||||
gh release download "$TAG" --pattern "*.AppImage.sig" --dir "$WORK" --repo "$GITHUB_REPOSITORY"
|
||||
SIG_FILE=$(find "$WORK" -name "*.AppImage.sig" | head -1)
|
||||
[ -n "$SIG_FILE" ] || { echo "sig asset listed but download produced nothing"; exit 1; }
|
||||
NEW_SIG=$(cat "$SIG_FILE")
|
||||
CHANGED=$(python3 -c 'import json,sys; p,sig=sys.argv[1],sys.argv[2]; d=json.load(open(p)); n=sum(1 for k,v in d.get("platforms",{}).items() if k.startswith("linux") and v.get("signature")!=sig and not v.update({"signature":sig})); json.dump(d,open(p,"w"),indent=2); print(n)' "$WORK/latest.json" "$NEW_SIG")
|
||||
if [ "$CHANGED" -ge 1 ]; then
|
||||
gh release upload "$TAG" "$WORK/latest.json" --clobber --repo "$GITHUB_REPOSITORY"
|
||||
echo "aligned $CHANGED linux signature(s) with the shipped .sig"
|
||||
else
|
||||
echo "manifest already agrees with the shipped .sig — no write"
|
||||
fi
|
||||
|
||||
uninstall-scripts:
|
||||
needs: [build]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -1026,75 +746,6 @@ jobs:
|
||||
scripts/uninstall.sh scripts/uninstall.ps1 \
|
||||
--clobber --repo "${{ github.repository }}"
|
||||
|
||||
# ── Contributors avatar strip on STABLE releases ──────────────────────────
|
||||
# Stable v* releases keep their curated CHANGELOG body + the per-platform
|
||||
# checksums; this appends ONE "## Contributors" avatar strip crediting every
|
||||
# PR author for the tag — including the owner — the courtesy the preview
|
||||
# channel already gets (preview-notes job). It closes the gap where stable
|
||||
# releases credited nobody.
|
||||
#
|
||||
# Two things the naive version got wrong (fixed here):
|
||||
# 1. RANK by contribution. Authors are ordered by merged-PR count for the
|
||||
# tag (descending, ties broken by handle), not alphabetically — the
|
||||
# owner with 30+ PRs should not sort under a one-PR contributor.
|
||||
# 2. Exactly ONE section. GitHub auto-renders its OWN "Contributors" widget
|
||||
# from any plain `@handle` TEXT mention in the body (the CHANGELOG's
|
||||
# "— thanks @user!" credits → `mentions_count`), which duplicates ours
|
||||
# and can't be ranked or include the owner. We neutralise those inline
|
||||
# text mentions in the RELEASE body only (`thanks @u` → `thanks u`; the
|
||||
# repo CHANGELOG keeps the @handles) so GitHub renders no native widget —
|
||||
# our ranked strip's @handles live in HTML attributes, which GitHub does
|
||||
# not count as mentions, so the linked avatars stay clickable.
|
||||
#
|
||||
# MUST append via `gh release edit` on the EXISTING release (never a second
|
||||
# softprops publish — that races tauri-action's per-matrix draft and splits
|
||||
# installers across two releases; see uninstall-scripts). `needs: [build]`
|
||||
# guarantees the release + all checksum appends already landed, and this job
|
||||
# is single (no matrix) so there is no write race. Idempotent: it strips any
|
||||
# prior "## Contributors" block before re-appending, so re-runs don't stack.
|
||||
contributors-strip:
|
||||
needs: [build]
|
||||
if: >-
|
||||
github.event_name == 'push'
|
||||
&& startsWith(github.ref, 'refs/tags/v')
|
||||
&& !contains(github.ref, '-')
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Append ranked Contributors avatar strip to the stable release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTES=$(gh api --method POST "repos/$REPO/releases/generate-notes" -f tag_name="$TAG" --jq .body)
|
||||
# Rank PR authors by merged-PR count (desc), ties broken by handle.
|
||||
RANKED=$(printf '%s\n' "$NOTES" | grep -oE 'by @[A-Za-z0-9-]+' | sed 's/^by @//' \
|
||||
| sort | uniq -c | sort -k1,1nr -k2,2 | awk '{print $2}' || true)
|
||||
if [ -z "$RANKED" ]; then
|
||||
echo "No PR-author handles in the generated notes for $TAG — nothing to append."
|
||||
exit 0
|
||||
fi
|
||||
# Current body: drop any prior Contributors block (idempotent re-runs;
|
||||
# the strip is always the tail, and checksum sections use '### '
|
||||
# headers so they never match), then neutralise inline @thanks so
|
||||
# GitHub renders no duplicate native contributors widget.
|
||||
BODY=$(gh release view "$TAG" --repo "$REPO" --json body --jq .body)
|
||||
BODY=$(printf '%s\n' "$BODY" | sed '/^## Contributors$/,$d' | sed 's/thanks @/thanks /g')
|
||||
{
|
||||
printf '%s' "$BODY"
|
||||
printf '\n## Contributors\n\nThank you all 💜\n\n'
|
||||
while IFS= read -r h; do
|
||||
[ -z "$h" ] && continue
|
||||
printf '<a href="https://github.com/%s" title="@%s"><img src="https://github.com/%s.png?size=64" width="48" alt="@%s"/></a> ' "$h" "$h" "$h" "$h"
|
||||
done <<< "$RANKED"
|
||||
printf '\n'
|
||||
} > /tmp/stable-notes.md
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file /tmp/stable-notes.md
|
||||
echo "Appended ranked Contributors strip ($(printf '%s' "$RANKED" | tr '\n' ' ')) to $TAG."
|
||||
|
||||
# ── Auto-generated preview release notes ──────────────────────────────────
|
||||
# tauri-action publishes the rolling `preview` release with the plain
|
||||
# changelog-fallback body ("Auto-generated release for main…"). Replace it
|
||||
@@ -1108,162 +759,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
# The manifest rebuild reads this run's `created_at` from the Actions
|
||||
# Runs API to tie the version-less macOS bundles to this build. Without
|
||||
# this scope the call 403s and, under `set -e`, takes the whole publish
|
||||
# down (greptile).
|
||||
actions: read
|
||||
steps:
|
||||
# Needed by the manifest rebuild + signature check below: the updater
|
||||
# pubkey lives in frontend/src-tauri/tauri.conf.json.
|
||||
#
|
||||
# persist-credentials: false — nothing in this job pushes to git, and the
|
||||
# steps that follow shell out to `gh` and install from PyPI, so leaving a
|
||||
# token in .git/config only widens the blast radius (CodeRabbit).
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# ── Rebuild the preview updater manifest from what is ACTUALLY published ──
|
||||
# Since ~2026-07-13 every matrix leg has logged "Signature not found for
|
||||
# the updater JSON. Skipping upload..." — tauri-action uploads the bundles
|
||||
# + .sig companions but never refreshes latest.json. Meanwhile the macOS
|
||||
# updater bundles (version-less filenames) are deleted + replaced every
|
||||
# night by "Clear this arch's stale preview updater bundle", so the
|
||||
# manifest's darwin signatures stopped matching the published files:
|
||||
# macOS Preview users hit "The signature verification failed" on every
|
||||
# update attempt (latest.json frozen at 2026-07-13, tar.gz replaced
|
||||
# nightly).
|
||||
#
|
||||
# Root fix: after the matrix completes, rebuild latest.json HERE — one
|
||||
# job, no per-leg race — from the release's real assets and their .sig
|
||||
# companions, then clobber-upload. The manifest can no longer drift from
|
||||
# the files it describes, regardless of what tauri-action's own
|
||||
# updater-JSON path does or skips.
|
||||
- name: Rebuild + verify the preview updater manifest, then publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Floor-pinned, matching docs-drift.yml's `pyyaml>=6`: this step
|
||||
# decides whether a signed release manifest is trustworthy, so it is
|
||||
# the one dependency worth a bound. Only Ed25519 verify is used.
|
||||
pip install --quiet "cryptography>=42"
|
||||
|
||||
# name + updatedAt: the timestamp is how the version-less darwin
|
||||
# tarballs get tied to this run — see scripts/build_preview_manifest.py.
|
||||
gh release view preview --repo "$REPO" --json assets \
|
||||
-q '[.assets[] | {name, updatedAt}]' > /tmp/assets.json
|
||||
|
||||
# The anchor for "this upload belongs to this run" is the moment this
|
||||
# run's FIRST JOB began executing. Two wrong answers were considered:
|
||||
#
|
||||
# * `run_started_at` RESETS on re-run — re-running just this job
|
||||
# would judge the macOS bundles its own earlier attempt uploaded
|
||||
# as stale, and refuse a healthy build.
|
||||
# * the run's `created_at` is stamped when the run is QUEUED. With
|
||||
# the concurrency group above, a run can sit queued while the
|
||||
# previous one uploads — so the queued run's created_at predates
|
||||
# the OTHER run's macOS bundles and would accept them as its own
|
||||
# (coderabbit).
|
||||
#
|
||||
# The earliest job start is after the queue wait (concurrency holds
|
||||
# the whole run, so no job of ours has started) and before any of our
|
||||
# own uploads. Jobs that were not re-run keep their original
|
||||
# timestamps, so taking the MINIMUM stays correct across partial
|
||||
# re-runs too.
|
||||
#
|
||||
# Needs the job's `actions: read` scope. If it ever 403s anyway, do
|
||||
# not take the whole publish down with `set -e`: warn loudly and let
|
||||
# build_manifest fall back to its leg-to-leg comparison, which is
|
||||
# merely stricter than it should be, never laxer.
|
||||
if ! RUN_CREATED_AT=$(gh api --paginate \
|
||||
"repos/$REPO/actions/runs/${{ github.run_id }}/jobs?filter=latest" \
|
||||
--jq '[.jobs[].started_at] | map(select(. != null)) | min // empty' \
|
||||
2> /tmp/gh-run-err.txt); then
|
||||
echo "::warning::Could not read this run's job start times (needs actions: read) — falling back to the stricter sibling-timestamp check, which can refuse a healthy build."
|
||||
cat /tmp/gh-run-err.txt
|
||||
RUN_CREATED_AT=""
|
||||
fi
|
||||
echo "This run began executing at ${RUN_CREATED_AT:-<unknown>}"
|
||||
export RUN_CREATED_AT
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
python3 - "$WORK" <<'PY'
|
||||
import base64, hashlib, json, os, subprocess, sys
|
||||
from urllib.parse import unquote
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
sys.path.insert(0, "scripts")
|
||||
from build_preview_manifest import ManifestRefused, build_manifest, required_assets
|
||||
|
||||
work, repo = sys.argv[1], os.environ["REPO"]
|
||||
assets = json.load(open("/tmp/assets.json"))
|
||||
|
||||
def fetch(pattern):
|
||||
subprocess.run(["gh", "release", "download", "preview", "--repo", repo,
|
||||
"-p", pattern, "-D", work], check=True)
|
||||
|
||||
signatures = {}
|
||||
for name in required_assets(assets):
|
||||
fetch(name + ".sig")
|
||||
signatures[name] = open(os.path.join(work, name + ".sig")).read()
|
||||
|
||||
try:
|
||||
manifest = build_manifest(
|
||||
assets, repo, signatures=signatures,
|
||||
run_started_at=os.environ.get("RUN_CREATED_AT") or None,
|
||||
)
|
||||
except ManifestRefused as e:
|
||||
sys.exit(f"Refusing to publish a preview manifest: {e}")
|
||||
print(f"Built preview latest.json: version={manifest['version']}")
|
||||
|
||||
# ── Verify BEFORE publishing ──────────────────────────────────────
|
||||
# Order is the whole point (greptile). Uploading first and checking
|
||||
# afterwards leaves a manifest that fails the check live and served:
|
||||
# the job goes red, and every macOS Preview user is broken until
|
||||
# someone notices. Verify the file we are about to publish.
|
||||
conf = json.load(open("frontend/src-tauri/tauri.conf.json"))
|
||||
pub_doc = base64.b64decode(conf["plugins"]["updater"]["pubkey"]).decode()
|
||||
pub = base64.b64decode(pub_doc.strip().splitlines()[1])
|
||||
assert pub[:2] == b"Ed", "unexpected pubkey algorithm"
|
||||
pk = Ed25519PublicKey.from_public_bytes(pub[10:42])
|
||||
|
||||
digests, failures = {}, []
|
||||
for plat, info in sorted(manifest["platforms"].items()):
|
||||
name = unquote(info["url"].rsplit("/", 1)[-1])
|
||||
path = os.path.join(work, name)
|
||||
if name not in digests:
|
||||
fetch(name)
|
||||
h = hashlib.blake2b(digest_size=64)
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
digests[name] = h.digest()
|
||||
lines = base64.b64decode(info["signature"]).decode().splitlines()
|
||||
sig = base64.b64decode(lines[1])
|
||||
tc = lines[2].split("trusted comment: ", 1)[1]
|
||||
gsig = base64.b64decode(lines[3])
|
||||
try:
|
||||
pk.verify(sig[10:74], digests[name])
|
||||
pk.verify(gsig, sig[10:74] + tc.encode())
|
||||
print(f"OK {plat}: signature matches {name}")
|
||||
except Exception:
|
||||
failures.append(plat)
|
||||
print(f"FAIL {plat}: signature does NOT match {name}")
|
||||
if failures:
|
||||
sys.exit("Refusing to publish: manifest is broken for "
|
||||
+ ", ".join(failures)
|
||||
+ ". The previously published manifest is left in place.")
|
||||
|
||||
json.dump(manifest, open(os.path.join(work, "latest.json"), "w"), indent=2)
|
||||
print("All signatures verified — safe to publish.")
|
||||
PY
|
||||
|
||||
gh release upload preview "$WORK/latest.json" --clobber --repo "$REPO"
|
||||
echo "Uploaded verified latest.json to the preview release."
|
||||
|
||||
- name: Generate + apply GitHub release notes to the preview release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1297,7 +793,7 @@ jobs:
|
||||
gh release edit preview --repo "$REPO" --prerelease --notes-file /tmp/preview-notes.md
|
||||
echo "Applied auto-generated release notes + contributors to the preview release."
|
||||
|
||||
- name: Verify the published preview manifest (prerelease + parity + served bytes)
|
||||
- name: Verify preview updater manifest (prerelease + platform parity)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
@@ -1322,24 +818,6 @@ jobs:
|
||||
assert not missing, f"preview manifest missing platforms vs stable: {sorted(missing)}"
|
||||
print(f"preview manifest OK: {v} platforms={sorted(pk)}")
|
||||
PY
|
||||
# The signatures were verified BEFORE publishing (see the rebuild
|
||||
# step). What is worth checking here is different: that the file
|
||||
# actually being SERVED is the one that passed. A CDN or a partial
|
||||
# upload can leave something else at that URL, and the whole class of
|
||||
# bug this job addresses is "the manifest does not describe what is
|
||||
# published".
|
||||
python3 - <<'PY'
|
||||
import hashlib, json, sys
|
||||
served = open("/tmp/preview-latest.json", "rb").read()
|
||||
m = json.loads(served)
|
||||
plats = sorted(m.get("platforms", {}))
|
||||
print(f"served manifest: version={m.get('version')} platforms={plats}")
|
||||
print(f"served sha256={hashlib.sha256(served).hexdigest()}")
|
||||
missing = [p for p in plats if not m["platforms"][p].get("signature")]
|
||||
if missing:
|
||||
sys.exit(f"served manifest has empty signatures for: {missing}")
|
||||
PY
|
||||
|
||||
|
||||
# ── Post-release version bump (OWNER-GATED as of 2026-07-01) ──────────────
|
||||
# Previously auto-ran after every stable v* tag to keep main = release + 1.
|
||||
|
||||
@@ -174,7 +174,7 @@ jobs:
|
||||
- name: pip-audit (Python)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
bash scripts/uv-sync-retry.sh
|
||||
uv sync
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
# Pin a floor: `bun audit` was added in bun 1.2.x, so guarantee it exists.
|
||||
|
||||
+1
-47
@@ -18,7 +18,6 @@ build/
|
||||
# Node / Turborepo / Tauri
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
node_modules
|
||||
.turbo/
|
||||
bun.lockb
|
||||
frontend/src-tauri/target/
|
||||
@@ -36,23 +35,14 @@ frontend/src-tauri/target/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Local agent-memory DB (memxt) — per-machine state, never committed
|
||||
memxt.db
|
||||
memxt.db-shm
|
||||
memxt.db-wal
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Editor / tool caches
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Ignore ad-hoc Claude Code state, but allow project-bundled skills
|
||||
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`) and project-bundled
|
||||
# review agents — the owner's review standards belong with the code they
|
||||
# govern, not in one machine's local state.
|
||||
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`).
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/skills/**
|
||||
!.claude/agents/
|
||||
!.claude/agents/**
|
||||
/.cache*
|
||||
/.tmp/
|
||||
|
||||
@@ -138,45 +128,9 @@ marketing.md
|
||||
.specify/
|
||||
.claude/skills/speckit-*/
|
||||
.antigravitycli/
|
||||
# `backlog` (the CLI task tracker) writes a config + one markdown file per task
|
||||
# into the repo root. A contributor running it locally had those three files
|
||||
# swept into a PR that was otherwise a single script (#1322 / #1306).
|
||||
backlog/
|
||||
|
||||
# Locally-installed third-party skill packs (marketingskills, hallmark,
|
||||
# mattpocock/skills, …) — ignore every skill dir by default; a skill that
|
||||
# SHOULD ship with the repo must be re-negated here (like omnivoice below).
|
||||
.claude/skills/*/
|
||||
!.claude/skills/omnivoice/
|
||||
|
||||
playwright-report/
|
||||
.last-run.json
|
||||
|
||||
# probe — generated HTML reports
|
||||
tests/probe/reports/
|
||||
|
||||
# Local architecture/planning scratch (goal docs, review briefs, council
|
||||
# reports). Working notes for whoever is driving a change, not a repo artifact.
|
||||
/remote/
|
||||
|
||||
# OmniVoice GGUF runtime build artifacts (scripts/build-omnivoice-tts.sh).
|
||||
# Only 0-byte placeholders of omnivoice-tts-* are tracked; real binaries,
|
||||
# the checksums manifest and the copied libggml shared libs ship via CI.
|
||||
bin/libggml*
|
||||
bin/checksums.sha256
|
||||
bin/omnivoice-tts-linux-aarch64
|
||||
|
||||
# Dubbing-demo intermediates. The .mp4/.srt/manifest.json in this directory ARE
|
||||
# committed (they ship with the app); the per-language source WAVs are just the
|
||||
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
|
||||
backend/assets/samples/demo/dubbing/*.src.wav
|
||||
|
||||
# Stray sqlite session artifacts (`<db-path>.ses`). An in-memory DB yields the
|
||||
# literal name `:memory:.ses`, and a path containing `:` cannot be checked out
|
||||
# on Windows at all — committing one fails every Windows CI job at the git
|
||||
# checkout step, before a single test runs. Guarded by
|
||||
# tests/test_no_windows_hostile_paths.py.
|
||||
*.ses
|
||||
|
||||
# Generated Windows MSI diagnostic logs and installer payloads
|
||||
/wix-diagnostic-artifacts/
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# Gitleaks config — extends the default ruleset.
|
||||
#
|
||||
# Every entry below is an exact, anchored non-secret value. PostHog's
|
||||
# publishable project token is public by design (owner decision 2026-07-20,
|
||||
# #1193). Per PostHog's docs the `phc_` project
|
||||
# token is a write-only client key with "no access to your private data" —
|
||||
# it ships in every release binary and every official PostHog SDK snippet.
|
||||
# It is NOT a credential. Personal keys (`phx_`) remain fully banned.
|
||||
# `tests/test_no_committed_analytics_token.py` separately pins the literal to
|
||||
# exactly two canonical files and requires both to carry the same value.
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "Exact public/test literals misclassified as generic API keys"
|
||||
regexes = [
|
||||
# Public PostHog project token; personal `phx_` keys remain banned.
|
||||
'''^phc_v5wMjnYMPMaEcRNLRKQsTYCzPaYWh7wcHPhXNkNajVf9$''',
|
||||
# Reviewed immutable Hugging Face commit for the Higgs tokenizer.
|
||||
'''^528e871c2a26c4f0f7773b9754e2e1acae20899d$''',
|
||||
# Deliberately synthetic fixtures that exercise HF-token redaction/storage.
|
||||
'''^hf_abcdefghijklmnopqrstuvwxyz01234567890abcd$''',
|
||||
'''^hf_abcdefghijklmnopqrstuvwxyz0123456789ABCDEF$''',
|
||||
'''^hf_QWERTYUIOPasdfghjklZXCVBNM0123456789xyzAB$''',
|
||||
# NLLB generation length argument, not the value of a credential.
|
||||
'''^max_length=400$''',
|
||||
# Dubbing pane split-position localStorage key, not a credential.
|
||||
'''^omnivoice\.dubSplit\.v1$''',
|
||||
# cryptography's Ed25519 private-key type name, not key material.
|
||||
'''^Ed25519PrivateKey$''',
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
# Agent Rules — VoiceStudio
|
||||
|
||||
Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md is the full constitution; this is the operating contract. When they conflict, CLAUDE.md wins.
|
||||
|
||||
## Token economy (owner directive, 2026-07-20; tightened 2026-07-28)
|
||||
- **Default to the shortest response that fully answers.** Outlines and tables over prose; no preamble, no recap of what you just did, no re-explaining a fix the diff already shows. Applies to every response, not just status updates.
|
||||
- Lead with the outcome. No narration, no restating diffs, no filler praise, no plans you're about to execute anyway.
|
||||
- Status updates: one line. Final reports: only what changes the reader's next action.
|
||||
- Don't re-derive what CI, linters, or review bots already computed — read their output first (`gh pr checks`, bot comments via `gh api .../pulls/N/comments`).
|
||||
- Mechanical rules live in deterministic tests, never in agent effort: changelog style (`tests/test_changelog_style.py`), locale parity (`tests/test_locale_parity.py`), version lockstep (`tests/test_app_version.py`), CJK (`tests/test_no_hardcoded_cjk.py`).
|
||||
- Run targeted tests while iterating; full suites only before landing.
|
||||
- Tests and CI simulate CI honestly: `HF_HUB_OFFLINE=1` + empty `HF_HUB_CACHE` — a populated dev cache masks real failures.
|
||||
|
||||
## Cross-platform parity: behaviour, not performance
|
||||
- The parity rule covers user-visible BEHAVIOUR. Hardware acceleration varies by host by design (CUDA/MPS/DirectML, Triton availability, `torch.compile`); skipping an optimization where it physically cannot work is not a parity violation.
|
||||
- Do not "fix" a parity finding by disabling a working optimization everywhere. That trades a real regression for a semantic one.
|
||||
- A feature the user can see and use on one OS but not another IS a violation. Judge by what the user can do, not by how fast it runs.
|
||||
|
||||
## Merge protocol (hard rules)
|
||||
1. Never merge without review. Harvest CodeRabbit + Greptile comments first; never merge with an unread Critical/P1.
|
||||
2. Never accept a PR as-is: fix findings ON the PR branch pre-merge (maintainer commits fine; credit contributors in CHANGELOG). No merge-then-fix, no comment-and-walk-away.
|
||||
3. Merge current `main` into stale branches before judging their CI — PR-green under an old workflow ≠ main-green.
|
||||
4. Gate: "Tests (backend + frontend)" green + MERGEABLE.
|
||||
5. After EVERY merge: watch `main`'s own post-merge runs to green (`gh run list --branch main`). Red main = drop everything and fix.
|
||||
|
||||
## Change rules (see CLAUDE.md for full text)
|
||||
- Root-cause the class, not the instance; fail-before/pass-after regression test; smallest correct change.
|
||||
- Default behavior identical on macOS/Windows/Linux; platform-only features go behind explicit opt-in. Divergent default = P0.
|
||||
- Local-first: no new required network calls; any HF download gated on installed-ness or explicit user action; all synthetic audio through the `mark_synthetic` chokepoint.
|
||||
- Every user-facing string via i18n, present in ALL 21 `frontend/src/i18n/locales/*.json` with real translations.
|
||||
- Docs-sync in the same PR. CHANGELOG Unreleased: quiet one-liners ending `(#N)` + `— thanks @user!` for community work, under a short `**Highlights**` list.
|
||||
- Versioning: `frontend/package.json` is the single source of truth; never bump without the owner asking.
|
||||
- `frontend/package.json` dep changes require regenerating root `bun.lock` (Docker runs `--frozen-lockfile`).
|
||||
- Issues: absorb or decline — never defer to a future version. Check the open-PR queue before implementing community-reported fixes.
|
||||
|
||||
## 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`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
The five canonical roles, each label string equal to its name. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
|
||||
+35
-777
@@ -1,748 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to VoiceStudio.
|
||||
All notable changes to OmniVoice Studio.
|
||||
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
|
||||
the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
|
||||
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
|
||||
- Gallery voice previews play again — the quality guard was rejecting good renders as silent (#1819) — thanks @flutterkage2k!
|
||||
- Tilde-separated number ranges are spoken clearly without running their endpoints together (#1821) — thanks @flutterkage2k!
|
||||
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
|
||||
- Fix current-user Windows installer validation and nested resource cleanup (#1873)
|
||||
- Keep generated frontend assets available while building the current-user Windows installer (#1873)
|
||||
|
||||
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
|
||||
### Changed
|
||||
|
||||
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
|
||||
- Dubbing aligns output settings, brings review status forward, and simplifies transcript and glossary editing; Launchpad files and voices reflow into responsive grids (#1823)
|
||||
- Transcript segments use three readable rows for text, timing/status and voice controls, with heights that adapt to wrapping (#1823)
|
||||
- Dragging the waveform pans horizontally while a click still seeks, keeping the timed transcript aligned (#1823)
|
||||
- Bulk segment editing uses searchable voice and language menus, readable language names and a responsive selection toolbar (#1823)
|
||||
- Dubbing overlays playback controls on video, combines waveform and transcript in a compact timeline, and removes header/action background fills (#1823)
|
||||
- Dubbing uses compact casting, translation and output controls with responsive rows to leave more room for editing (#1823)
|
||||
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
|
||||
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
|
||||
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
|
||||
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
|
||||
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
|
||||
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
|
||||
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
|
||||
- The expandable sidebar reveals workspace labels with restrained active states; language menus adapt to multiple columns on wider screens (#1823)
|
||||
- Voice design and recording use themed, keyboard-accessible selectors with clearer spacing and labels (#1823)
|
||||
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
|
||||
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
|
||||
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
|
||||
### Added
|
||||
|
||||
### Docs
|
||||
|
||||
### Fixed
|
||||
|
||||
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
|
||||
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
|
||||
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
|
||||
|
||||
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
|
||||
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
|
||||
|
||||
- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809)
|
||||
- Backend connection errors report crashes only when recorded evidence exists, and diagnostic waits honor cancellation (#1810)
|
||||
- A CUDA or ROCm GPU with less VRAM than the engine needs now gets the CPU compute-time budget instead of the shorter accelerated one, since it pages to system RAM and renders slower than the CPU would — applied to local generation, voice conversion, and remote worker deadlines alike (#1806) — thanks @VishvakR!
|
||||
- Gallery previews no longer fail with "the voice engine returned no audible audio" on perfectly good renders: the degenerate-buzz guard measured spectral flatness over the whole clip (so the value tracked clip length) against a threshold calibrated on a synthetic signal, and rejected real speech in every language tested (#1819) — thanks @flutterkage2k!
|
||||
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
|
||||
|
||||
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
|
||||
|
||||
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
|
||||
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
|
||||
- Long dubbing segment errors wrap in a bounded scrollable notice instead of widening the editor (#1823)
|
||||
- Voice dropdowns match their field width, use theme accents, and show recent voices only once (#1823)
|
||||
- Language menus no longer show a pale frame around their search header (#1823)
|
||||
- The notification count stays inside the title bar instead of clipping above the bell (#1823)
|
||||
- The workspace engine menu opens beside its button instead of at the opposite edge of the page (#1823)
|
||||
- Cloning reuses the dubbing language picker with flags, search, and single selection, opening above the pinned synthesis controls (#1823)
|
||||
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
|
||||
- The header status dot now honors OS Reduce Motion instead of pulsing regardless (#1862) — thanks @psiberfunk!
|
||||
- Onboarding reads Hugging Face tokens locally, preserves Windows CLI logins, and requires successful discovery before replacing saved credentials (#1852) — thanks @psiberfunk!
|
||||
- The logs panel no longer reports “All clear” before log retrieval succeeds or while logs contain warnings or errors (#1870) — thanks @motodriver!
|
||||
- MOSS accelerator routing and status match runtime selection, with CPU fallback when device probing fails (#1830) — thanks @li-lizhe!
|
||||
- Confucius accelerator routing tolerates failed device probes, and dots.tts keeps safe default precision on non-CUDA hosts (#1831) — thanks @li-lizhe!
|
||||
- On macOS, the header status dot and kicker no longer render underneath the overlaid traffic lights (#1863) — thanks @psiberfunk!
|
||||
- The capture widget can hide after recording and recover from being left visible while idle (#1865) — thanks @psiberfunk!
|
||||
|
||||
- macOS retains the shared desktop window sizing, resize limits, and file-drop behavior when native chrome is applied (#1865) — thanks @psiberfunk!
|
||||
|
||||
- On macOS, the header no longer shows Windows-style minimize/maximize/close buttons alongside the native traffic lights (#1865) — thanks @psiberfunk!
|
||||
|
||||
- Release retries replace their own partially uploaded installers without colliding with existing assets (#1871)
|
||||
- Timed-out voice engines finish process cleanup before retrying, and old timeout callbacks cannot kill replacement engines (#1872)
|
||||
|
||||
|
||||
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
|
||||
|
||||
## [0.5.2] — 2026-09-02
|
||||
|
||||
**Highlights**
|
||||
|
||||
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
|
||||
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
|
||||
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
|
||||
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
|
||||
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
|
||||
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
|
||||
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
|
||||
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
|
||||
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
|
||||
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
|
||||
|
||||
### Changed
|
||||
|
||||
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
|
||||
|
||||
### Added
|
||||
|
||||
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
|
||||
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
|
||||
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
|
||||
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
|
||||
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
|
||||
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
|
||||
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
|
||||
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
|
||||
|
||||
### Docs
|
||||
|
||||
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
|
||||
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
|
||||
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
|
||||
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
|
||||
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
|
||||
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
|
||||
- Voice Design no longer lets you pick a Chinese dialect and an English accent together — the picker keeps them mutually exclusive instead of round-tripping a 400 (#1771)
|
||||
- The desktop app no longer attaches to an already-running backend on version string alone: it now verifies the backend's actual code fingerprint too, so an orphaned or manually started backend reporting the current version but running older code (e.g. a stale `destination_path` export 422) gets replaced instead of adopted (#1770)
|
||||
- Korean locale overhauled: 231 mistranslations corrected and all 493 missing keys translated (#1776) — thanks @j30231!
|
||||
- Japanese "Cleaning…" clone status now reads as denoising instead of housekeeping (#1775) — thanks @j30231!
|
||||
- The batch dubbing queue now has a UI entry point — a quiet link on the Dub landing (it was previously unreachable: the app switched on a mode nothing ever set) (#1768) — thanks @mvanhorn!
|
||||
- OpenAI-compatible ASR now requires HTTPS outside loopback and refuses redirects so audio stays on the configured origin (#1736)
|
||||
- Windows isolated engines now retain direct Job ownership without an extra Python supervisor process that can deadlock the child loader (#1734)
|
||||
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
|
||||
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
|
||||
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
|
||||
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
|
||||
- SRT files selected during source analysis now wait for speaker cloning, then replace transcript text without losing voices (#1709)
|
||||
- Windows MSI deployments can now prohibit WebView2 bootstrap with `DISABLEWEBVIEW2BOOTSTRAP=1`, and `AUTOLAUNCHAPP=0` reliably suppresses first launch (#1714)
|
||||
- Subtitle rows now provide 100 ms timing steppers and flag adjacent overlaps without requiring precise timeline dragging (#1710)
|
||||
- Repair-sync failures now retain uv's final dependency error instead of reporting only an opaque exit status (#1705)
|
||||
- YouTube ingest now retries yt-dlp's transient “page needs to be reloaded” response (#1706)
|
||||
- Dictation model readiness now follows the live Hugging Face cache selected in Settings (#1707)
|
||||
- Dictation capture now queues native events whenever its webview listener unmounts or reloads instead of emitting them to nobody (#1707)
|
||||
- Desktop-contained backends now exit when their owning app disappears instead of surviving as stale port-3900 processes (#1707)
|
||||
|
||||
## [0.5.1] — 2026-08-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
- OmniVoice generation on Apple Silicon now runs in a crash-isolated child, so fatal MPS memory exits no longer take down the local backend (#1697, #1698) — thanks @ndntran14!
|
||||
- Model-load GPU exhaustion now returns a sanitized, actionable dubbing error, and readiness correctly attributes the shared model status to TTS (#1695)
|
||||
- Source-mode development now restarts an isolated backend crash without tearing down the UI, while repeated crash loops still stop loudly with diagnostics (#1690)
|
||||
- Dubbing playback now keeps an audible companion source when a WebView can render the preview picture but cannot decode its audio (#1692)
|
||||
- Model Catalogue engine rows now use the available desktop width and keep identity, runtime state, and actions from crowding one another (#1689)
|
||||
- VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646)
|
||||
- A timed-out in-process dub transcription no longer starts a second WhisperX/CTranslate2 call over the abandoned native worker, preventing the overlapping access that preceded Windows `0xC0000005` exits (#1669)
|
||||
- Windows debugger termination code `0x40010004` is no longer misreported as a backend crash or charged against automatic restart recovery (#1663)
|
||||
- Studio now keeps one generation reservation across page changes, preventing a remount from stacking native jobs until the backend reports capacity busy or is killed under memory pressure (#1670)
|
||||
- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644)
|
||||
- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter!
|
||||
- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y!
|
||||
- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd!
|
||||
- Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui!
|
||||
- WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui!
|
||||
- Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd!
|
||||
- Dictation now stays bound to the app where it started and recovers locally from silent recognizer output (#1175)
|
||||
- The backend now answers within a second of launch and narrates its startup step by step (#1550)
|
||||
- Reporting a bug from an outdated build now offers the latest release first (#1547)
|
||||
- The backend is only announced ready once it can actually serve, and crash-loop restarts now pace themselves (#1548)
|
||||
- Invisible watermarking no longer stalls — or silently skips — the first take of a session (#1615)
|
||||
- Dub subtitles can be retimed, inserted, and merged in either direction from the segment table (#1612) — thanks @invio-a11y!
|
||||
|
||||
### Changed
|
||||
- Model Catalogue now uses one breathable workspace canvas with simpler pane and engine-family navigation instead of nested cards and scroll regions (#1685)
|
||||
- Linux source launchers now catch missing libxdo and GStreamer audio plugins before they can cause a linker error or an aborted, blank WebKit renderer (#1680, #1682)
|
||||
- Dictation now carries one native output session from shortcut-down through final delivery, restores text, HTML, image, or file-list clipboards only when untouched, keeps Wayland copy-safe unless current-focus insertion is explicitly enabled, and retries silent Sherpa speech only through an already-installed local ASR model (#1175)
|
||||
- The backend binds its port immediately and reports startup progress live — `/health` answers 503-with-step and a new `/startup/progress` endpoint lists every step while PyTorch, API routes, and database migrations load in the background, so "starting at step X" is never mistakable for "dead"; the desktop splash narrates each step (#1550)
|
||||
|
||||
### Added
|
||||
- A bundled Rust loopback sidecar exposes dictation start/stop/toggle, focused-output sessions, discovery, and JSON-RPC; the backend adds versioned streaming events and a dependency-free CLI bridge for Herdr, coding agents, editors, desktop apps, and TUIs (#1646)
|
||||
- Headless NVIDIA and ROCm machines can now join as worker-only Docker Compose services with no published UI and durable protocol-v2 enrollment; update both machines together before reconnecting (#1638) — thanks @jkrogers9862!
|
||||
- Linux ARM64 (Asahi Apple Silicon) support for the OmniVoice GGUF engine — a `linux-aarch64` binary built with GGML Vulkan where the toolchain allows it, so Apple GPUs accelerate generation through the open-source Honeykrisp driver instead of falling back to CPU-only (#1641)
|
||||
- One-command install on every desktop OS: `curl -fsSL https://voicestudio.sh/install | sh` (macOS/Linux/WSL) or `irm https://voicestudio.sh/install | iex` (Windows) — the URL serves the right script per platform, and Windows gains a source installer (`scripts/install.ps1`) with a 3-OS CI smoke (#1626)
|
||||
- Per-line subtitle management in the dub table: a line's end time is editable alongside its start (typing a time and dragging its timeline edge now take the same path), lines merge with the previous row as well as the next (`Ctrl/Cmd+Shift+M`), and a new line can be inserted into the gap after any row (#1612) — thanks @invio-a11y!
|
||||
- CI now enforces performance regression budgets on the hot paths — operation-count tests pin streaming TTS to one synthesis per sentence and cached dub re-mixes to zero re-synthesis; fast-path guards cover zero re-decoding and ⌈N/W⌉ native batch calls when enabled (#1594)
|
||||
- Default-engine dubbing now synthesizes several segments per forward pass instead of one call per line — the width follows the host's device headroom (1 on CPU and low-VRAM cards, up to 8), `OMNIVOICE_DUB_BATCH_WIDTH` overrides it, and engines without native batching keep the single-segment path (#1594)
|
||||
- `/ws/tts` now reports real time-to-first-audio, and its RTF measures synthesis alone so a slow client can't inflate it (#1594)
|
||||
- The locally cached AudioSeal watermark generator warms on a background thread ~35s after boot (`OMNIVOICE_PRELOAD_WATERMARK=0` opts out; explicitly setting `=1` may download it), so the first synthesis no longer serializes the audioseal import + model load inline — measured at ~42s on a cold filesystem, 3s short of a 90s client timeout (#1576) — thanks @paoloantinori!
|
||||
- Voices you've cloned stay "warm" across restarts — encoded references now persist to disk (~10 KB each), so the first generation of a session skips the re-encode and any transcription pass; `OMNIVOICE_PROMPT_DISK_CACHE=0` opts out (#1565)
|
||||
- Optional FlashInfer acceleration for the default engine on CUDA (`OMNIVOICE_FLASHINFER=1`, ~2.2x measured) — needs the optional `flashinfer-python` package; missing package or kernel failure logs why and falls back to the standard path (#1565)
|
||||
- The bug reporter notices when you're on an outdated build and offers the latest release before filing — with a "File anyway" escape hatch — and stamps a `Build status` line into every report so up-to-date reports are tellable from stale ones (#1547)
|
||||
- Settings → Performance & Device gains a compute-device override (Auto / CUDA / ROCm / XPU / MPS / CPU, or `OMNIVOICE_DEVICE`) — pin the device when auto-detect picks wrong; only devices your machine actually has are offered (#1557)
|
||||
- Opt-in 24-layer PocketTTS checkpoints via `OMNIVOICE_POCKETTTS_24L` — better prosody for it/de/es/pt at roughly 2x render time (still faster than real-time); the fast 6-layer model stays the default (#1613) — thanks @paoloantinori!
|
||||
|
||||
### Docs
|
||||
- Supported-version and install guidance now identifies 0.5.1 as the stable desktop and container release (#1687)
|
||||
- The Docker Hub overview now shows the current engine-switching demo, Model Catalogue, and gallery voice workflow (#1593)
|
||||
- The Docker Hub overview and install guide now show the v0.5 tags and the built-in API-key/share-PIN security model instead of obsolete v0.4 and no-authentication guidance (#1592)
|
||||
- The READMEs now lead with download buttons and a three-step first-clone walkthrough, and a new benchmarks page anchors measured per-engine/per-device numbers on the in-repo harness (#1555)
|
||||
- Every engine now has its own guide — 21 new pages under docs/engines plus an index covering all 16 TTS and 11 ASR engines, linked from both READMEs (#1556)
|
||||
- The OmniVoice guide now covers combining style attributes with a reference clip (consistent instruct stabilizes cloning; the reference wins conflicts), inline pronunciation control (pinyin / CMU phonemes), and corrects the claim that the default engine can't do voice design — it can, from attributes (#1565)
|
||||
|
||||
### Fixed
|
||||
- Workspaces now measure their responsive width when the post-bootstrap shell actually mounts, so native UI scaling reflows Projects and History instead of crushing the Dubbing demo into unreadable columns (#1683)
|
||||
- Dubbing keeps the source-language selector visible after a local file is chosen, so ASR can be pinned before transcription starts (#1678) — thanks @Lonki-lomki-cloud!
|
||||
- First-run media-engine downloads become available to TTS immediately without a restart, and missing media-process failures now point to repair controls (#1677) — thanks @farhataligpt-dev!
|
||||
- Source installs on AMD GPUs honour `OMNIVOICE_TORCH_VARIANT=rocm`: `bun run desktop` now swaps in the ROCm torch wheel after `uv sync` and launches the backend without re-syncing, instead of silently reverting to the CPU-only CUDA build on every start (#1665) — thanks @uberclokr!
|
||||
- `bun run desktop` on a fresh clone no longer fails with "resource path `../../frontend/dist` doesn't exist" — the dev launcher creates the placeholder Tauri resource directory before compiling (#1664) — thanks @uberclokr!
|
||||
- macOS no longer loses TTS after the first request when Python lacks `os.waitid`; subprocess ownership now uses a safe `waitpid` fallback without risking reused process groups (#1656) — thanks @paoloantinori!
|
||||
- Desktop startup, Retry, reset, uninstall, shutdown, and crash recovery now share one backend lifecycle owner; quitting interrupts first-run installers and gracefully drains then force-cleans the full backend process tree, so overlaps cannot duplicate or orphan it (#1635) — thanks @Xohaibxobi!
|
||||
- Large Stories and Audiobook projects now persist in IndexedDB instead of overflowing the `omnivoice.app` localStorage envelope, with quota-safe migration and orderly exit/reload flushing (#1636) — thanks @leodzai!
|
||||
- OmniVoice and its crash-isolated subprocess now route to AMD ROCm GPUs instead of warning and falling back to CPU (#1629) — thanks @j4r3kb!
|
||||
- Dictation now cancels pending startup work, capture resources, sockets, and timers when the capture widget closes, preventing late work against a destroyed webview (#1645)
|
||||
- Streaming generation failures now show recognized recovery guidance and appear in Diagnostics instead of only returning a generic error (#1607)
|
||||
- The worker-capacity transport test no longer races its own setup: the 1-slot limit now goes through the enrollment handshake instead of mutating client config after connect, where the server's stream-open ConfigUpdate (carrying the registered capacity of 2) could overwrite it and fake an over-accept; failed CI twice on 2026-08-21 (#1630)
|
||||
- Moving words across a speaker boundary in a dub — merging two lines and splitting them again — no longer dubs the second half in the first speaker's voice; each half now keeps the speaker, voice, direction, gain, and language of whoever actually says it (#1612) — thanks @invio-a11y!
|
||||
- Dictation on a WebView that refuses a 16 kHz audio context (WKWebView) now low-passes before downsampling, so frequencies above 8 kHz stop folding into the speech the recognizer is fed (#1610)
|
||||
- A microphone context that cannot be resumed now reports a mic error instead of leaving the dictation pill on "Listening" while capturing nothing (#1610)
|
||||
- Dictation no longer retains a whole session's audio for silent-model recovery — an open mic grew that buffer by ~115 MB an hour; the recent two minutes are kept instead (#1610)
|
||||
- The clipboard-delivery status is now translated in all 21 languages, so Wayland users — where clipboard delivery is the default — no longer see an English string (#1610)
|
||||
- A native sherpa-onnx load failure of any exception type now degrades to "engine unavailable" instead of taking the dictation WebSocket down (#1610)
|
||||
- Dictation now ships Whisper Tiny as its one cross-platform default, avoiding Parakeet's measured empty decoding on Windows while keeping Parakeet selectable behind runtime fallback (#1175)
|
||||
- Re-mixing a dub no longer decodes, rewrites, and re-reads every cached segment — same-rate cached audio is reused directly (and rejected if truncated), switching timing modes can't reuse slot-truncated audio as natural-rate, and RVC respects natural-rate modes (#1594)
|
||||
- PocketTTS French works again — pocket-tts only ships a 24-layer French model and rejected the name the sidecar asked for, so every French request failed at model load; French now always loads `french_24l` (#1613) — thanks @paoloantinori!
|
||||
- Installing IndexTTS 2.5 no longer fails claiming an interrupted download — the weights repo ships `config.yaml` and VoiceStudio demanded a `config_v2_5.yaml` that exists in no upstream release; both names are accepted, so a hand-renamed checkout keeps working (#1611) — thanks @zuiaiyutu!
|
||||
- IndexTTS 2.5 no longer has long-text generation killed at 60 seconds — the sidecar now proves it is alive every 5 seconds while `infer()` runs, and its deadline rises to 900s (`OMNIVOICE_INDEXTTS_RECV_TIMEOUT_S`) (#1611) — thanks @zuiaiyutu!
|
||||
- The OpenAI-compatible `/v1/audio/speech` route now reuses the shared cached engine for explicit `model` ids instead of constructing a fresh engine — and its sidecar/model load, a ~28s floor per call for subprocess engines — on every request, with the same single-engine-resident discipline `/generate` applies (#1614) — thanks @paoloantinori!
|
||||
- The setup wizard's RAM check no longer blocks 8 GB machines whose OS reports ~7.8 GB usable — the thresholds now tolerate reserved memory, and `OMNIVOICE_RAM_PREFLIGHT=0` turns a genuine block into a warning for those who accept the OOM risk (#1618)
|
||||
- Invisible watermarking now runs eagerly instead of through `torch.compile` — AudioSeal's lazy compile sent the first embed of every session into Inductor's C++ codegen, which failed outright on macOS hosts whose toolchain couldn't serve it and shipped the audio unmarked after a 30-40s wait; first embed drops from 9.70s to 0.26s (#1615) — thanks @paoloantinori!
|
||||
- The macOS Accessibility blocker now rechecks while visible and closes as soon as the grant is enabled instead of keeping a stale permission prompt on screen (#1609)
|
||||
- The dubbing editor's video and transcript columns can now be resized by pointer or keyboard, and the chosen split persists across launches (#1571) — thanks @invio-a11y!
|
||||
- CPU-only synthesis now gets a bounded ten-minute execution budget, and a render that exhausts it is reported as a compute timeout instead of misleading "generation capacity is busy" queue pressure (#1588) — thanks @ChienNguyen1111!
|
||||
- Rapid Launchpad ↔ Dub navigation now replaces the workspace DOM owner cleanly, so late media/waveform cleanup cannot trigger React's `insertBefore` crash (#1590) — thanks @nicolas-jacques!
|
||||
- Watermark embedding failures now log the full traceback instead of just the exception message, so a silently-unmarked-audio incident (audio passes through unmarked by design) is diagnosable from the log alone (#1576) — thanks @paoloantinori!
|
||||
- Dubbing now recovers rapid two-speaker exchanges when diarization collapses them, defaults new projects to lip sync without overwriting saved timing choices, and keeps the editor usable on narrow screens (#1584) — thanks @victordonat0!
|
||||
- `OMNIVOICE_ASR_BACKEND=omnivoice` now selects the PyTorch-native Whisper path, so the documented ROCm escape hatch no longer fails as an unknown engine (#1582) — thanks @patmansk!
|
||||
- Network Sharing from Windows MSI/portable installs now serves the bundled web interface to LAN devices instead of redirecting them to their own `localhost` (#1589) — thanks @TWIISTED-STUDIOS!
|
||||
- Exported dubbed videos now mark the dubbed language as the default audio stream while keeping Original available as an explicit choice (#1575) — thanks @invio-a11y!
|
||||
- Cloning references can no longer exhaust system memory: transcript-free clips up to 75 seconds are searched in five bounded passages, longer clips ask to be trimmed, and supplied transcripts remain capped at 20 seconds to preserve alignment (#1578) — thanks @ACKAPOB!
|
||||
- Stored artifact subpaths now resolve after moving a data directory between Windows, macOS, Linux, and Docker, while traversal and symlink escapes remain blocked (#1559) — thanks @Eman-Yousaf!
|
||||
- A remote browser hitting an API-key-configured server's admin 403 now gets the API-key login form instead of endless console 403s, while desktop and PIN-only/no-key servers keep the plain loopback error so guests are never offered a login no key can satisfy (#1568) — thanks @paoloantinori!
|
||||
- The crash-isolated ASR sidecar and its download preflight now agree on which model to load — setting the shared faster-whisper model variable applies to both variants instead of the sidecar quietly using a different one (#1556)
|
||||
- "Ready" now requires the deep health probe (a working database-backed route), not just the identity probe — a backend whose install broke underneath can no longer be announced up while every real request fails (#1548)
|
||||
- Supervisor restarts after repeat crashes now back off (immediate, then 5s, then 15s) instead of respawning back-to-back, so a tight crash loop can't burn the whole restart budget in seconds (#1548)
|
||||
- The Linux desktop cleanup regression test now isolates build artifacts, so an existing developer build can no longer change its result (#1566)
|
||||
|
||||
- Renaming, deleting, or revoking consent on a voice (and starring/clearing history, recording exports) now live-updates every open tab again — the sync routes' WebSocket events were silently dropped, which could look like "all my voices are gone" (#1561) — thanks @paoloantinori!
|
||||
|
||||
### CI
|
||||
- Project agents now share pinned Vite and FastAPI skills from skills.sh (#1594)
|
||||
- Weekly full-history secret scans no longer mistake the Ed25519 private-key type name for committed key material (#1591)
|
||||
|
||||
## [0.5.0] — 2026-08-13
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The app is now **VoiceStudio** (previously OmniVoice-Studio) — one waveform-and-spark identity across the app, docs and installers. Your data folder, settings and Docker image paths stay put.
|
||||
- **Model Catalogue** — engines and models in one workspace: every TTS, transcription and LLM engine with its device routing and install state, defaults picked there.
|
||||
- Switch TTS, ASR and LLM engines from the status bar or any workspace — ready-only choices, memory status, environment-pin protection, `Ctrl/Cmd+E`. (#1530)
|
||||
- Lend another machine's GPU with a join code and a QR scan — a Compute control in the status bar picks where jobs run, and several people can share one GPU box with revocable, certificate-pinned connections. (#1516, #1496)
|
||||
- Server mode is locked down: admin actions require an API key (#1525), and the remote UI exchanges it for short-lived sessions that never sit in browser storage or WebSocket URLs (#1528) — thanks @bultodepapas!
|
||||
- A faster, cleaner Dub workspace for multilingual production, with a production command bar and per-language cards. (#1489)
|
||||
- The demo audio and video the app always advertised now actually ship, rendered by VoiceStudio's own engine. (#1517)
|
||||
- Dictation works on Wayland now — the portal shortcut actually fires (#1490, #1526) — and the recording pill is back on every desktop.
|
||||
- The Launchpad wears the project's signal-field waveform artwork over a quieter, borderless layout. (#1533)
|
||||
- The catalogue reads as headroom, not breakage: available engines sort first, uninstalled ones say what they need (#1531), and the LLM row names the provider that actually answers (#1538).
|
||||
- Gallery voices can be saved as local profiles — audio lands in your profile store with validated, content-addressed references. (#1542)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/quick-switch.gif" alt="Switching TTS engines from the status bar" width="820" />
|
||||
|
||||
| The Model Catalogue | The Voice Gallery |
|
||||
| --- | --- |
|
||||
| <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/catalogue.png" alt="Model Catalogue — engines pane" width="420" /> | <img src="https://raw.githubusercontent.com/debpalash/VoiceStudio/main/docs/media/0.5.0/gallery-save.png" alt="Voice Gallery — save a voice as a profile" width="420" /> |
|
||||
|
||||
### Changed
|
||||
|
||||
- Gallery personas now preview through the local backend, retain their complete voice-design recipe, and open directly in Voice, Stories, or Audiobook. (#1542)
|
||||
- Typing and large workspace edits no longer serialize and rewrite persisted documents on every input; writes are coalesced off the interaction path — thanks @bultodepapas! (#1541)
|
||||
- Support amount choices now use every theme's shared card, accent and focus tokens. (#1530)
|
||||
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
|
||||
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
|
||||
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
|
||||
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
|
||||
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
|
||||
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
|
||||
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
|
||||
- Linux release smoke now validates linuxdeploy's wrapped custom launcher instead of rejecting a healthy AppImage. (#1506)
|
||||
- Remote GPU workers render audiobooks chapter by chapter, with automatic per-chapter local fallback and one combined notice if the worker drops out. (#1478)
|
||||
- Remote GPU workers can now run a job to completion: long renders no longer die at two minutes, a worker that drops and reconnects mid-render keeps its work, and a timed-out job no longer takes the worker offline for good. Placing a job still needs the development-only `POST /workers/tasks`; wiring the app's own Synthesize button to it comes next.
|
||||
- Voice, Stories, Audiobook, Gallery, Settings, profiles, and Launchpad now use compact, responsive layouts with accessible controls. (#1491)
|
||||
- Dubbing's Generate Dub, Verify, and Export actions now use a compact hierarchy with visible labels, responsive reflow, and motion-safe feedback. (#1493)
|
||||
- The Dub workspace now has a compact production command bar, responsive flag-based language cards, media previews in Dub History, and a narrower Projects rail. (#1489)
|
||||
- VoiceStudio now uses one waveform-and-spark mark across the title bar, About screen, README, browser favicon, and every desktop/platform icon. (#1487)
|
||||
- PocketTTS now asks you to review its code license, model license and gated-access conditions before first use, and explains how to unlock the model instead of showing a raw download failure — thanks @paoloantinori! (#1442)
|
||||
- The repository moved to github.com/debpalash/VoiceStudio. Every link in the app, docs and scripts now points there; GitHub redirects the old URLs, and the Docker image paths, the app bundle identifier and your data folder are all deliberately unchanged. (#1394)
|
||||
- The app is now **VoiceStudio** (previously OmniVoice-Studio). Only the name you see changes — your data folder, settings and the Docker image paths stay put, so upgrading needs nothing from you. On Linux the .deb is now `voicestudio`; remove the old `omnivoice-studio` package once.
|
||||
- macOS floor raised to 13.3 (Ventura) — the frontend has required Safari 16.4 for some time, so macOS 12 was a promise the stack could not keep (#1268)
|
||||
- The first-run setup screen no longer overpromises. It claimed "no account, no cloud, no telemetry" without qualification — untrue for anyone who opts into analytics — and now says what actually holds either way: your voices, recordings and projects never leave the machine, and no processing happens in the cloud.
|
||||
- Dictation no longer shows a floating pill. The hotkey records, transcribes and pastes with nothing on screen; the tray icon still marks recording, and anything needing your attention (Accessibility, microphone, a failed transcription) now arrives as a notification in the main window.
|
||||
|
||||
### Added
|
||||
|
||||
- Gallery personas preview through the local backend, keep their full voice-design recipe, and open directly in Voice, Stories, or Audiobook — and can be saved as local profiles with validated audio references. (#1542)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- **Model Catalogue** — a workspace of its own for engines and models: browse every TTS, transcription and LLM engine with its device routing and install state, pick the default for each, and install or remove model weights, all from one screen instead of two Settings categories.
|
||||
- Remote GPU machines can now accept connections instead of dialling out, so several people can use the same box at once — each gets their own revocable connection string, with certificate-pinned TLS, a live list of who is connected, and a disconnect button. (#1496)
|
||||
- Remote GPU model downloads now use the normal Models install flow and show per-worker progress. (#1478)
|
||||
- Settings → System → **Remote workers** sends individual jobs to GPUs on your other machines while everything else stays here. Off by default; each machine is added with a single-use token and approved before any audio reaches it. See [docs/remote-workers.md](docs/remote-workers.md).
|
||||
- First-run setup now recommends a screen-aware interface scale, with compact controls available throughout setup. (#1502)
|
||||
- OrcaRouter is now available as a named OpenAI-compatible LLM provider — thanks @Marc-oss-hub! (#1499)
|
||||
- IndexTTS 2.5 is available as a pinned one-click sidecar with five-language dubbing, expressive cloning, and backward-compatible IndexTTS-2 support. (#1482) — thanks @marwanlhabti5-coder!
|
||||
- Voice recording now offers microphone and channel selection with a live input-level meter on every desktop platform. (#1481)
|
||||
- Settings → Appearance → **Navigation style** switches the workspace switcher between the icon rail down the window edge and browser-style tabs across the title bar. Both offer the same workspaces; the choice sticks across launches, and the rail stays the default. Tab labels fold down to icons when the title bar runs out of room — the workspace you're in keeps its name. (#1412)
|
||||
- Portable mode lets you choose the folder — press **Change…** on the first-run setup screen and put the whole install on an external drive. It also stops being greyed out after a default Program Files install. (#766)
|
||||
- Settings → Privacy now has an **Invisible watermark** toggle. On by default, available to everyone, and it only affects audio generated after the change. (#1308)
|
||||
- A new opt-in crash-isolated TTS engine, so a native crash takes down the sidecar instead of the whole backend — thanks @paoloantinori! (#1292, #1298, #1304)
|
||||
- **PocketTTS** (Kyutai), an opt-in CPU-only engine for fast, low-latency renders in six languages (en/fr/de/pt/it/es) with zero-shot cloning from a reference clip. Enable in Settings → Engines — thanks @paoloantinori! (#1306, #1328)
|
||||
- A warning before a slow generation, rather than after a five-minute wait. (#1280)
|
||||
|
||||
### Docs
|
||||
|
||||
- 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
|
||||
|
||||
- AMD/ROCm hosts no longer crash ASR with "CUDA driver version is insufficient": ROCm torch reports itself as CUDA, but whisperx/faster-whisper run on CTranslate2, which is NVIDIA-only — they now take the CPU path there, and auto-detect prefers pytorch-whisper, which genuinely uses the HIP GPU. (#1529)
|
||||
- Crash reports now carry the crashed run's own stderr: the shared error log is append-only with per-run offsets, so a restart can no longer overwrite the dying process's final output with the replacement's healthy startup. (#1510)
|
||||
- Wayland: a stale portal identity no longer kills the dictation shortcut for the whole session. The desktop entry the app writes for the GlobalShortcuts portal could point at a binary that has since moved (a `cargo clean`, a relocated AppImage) — GNOME then refuses the bind with "App info not found" and the hotkey silently dies. The entry is validated and rewritten at startup now. (#1526)
|
||||
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
|
||||
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
|
||||
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
|
||||
- Transcription now moves to the next working engine when the auto-picked one passes its availability check but breaks on first real use, instead of returning an internal error — the recovery dubbing already had. Affected accurate-mode transcription, the OpenAI-compatible API, batch, dub verify, and voice-clone reference text. (#1512)
|
||||
- A malformed request now gets a clear 422 instead of an internal error, and uploading a file to an endpoint that expects JSON no longer copies the whole upload into the app log — a 145 KB clip wrote roughly 500 KB of log, recording your audio in the file people paste into bug reports. (#1513)
|
||||
- The Simplified Chinese (zh-CN) translation no longer mistranslates brand names and technical terms — Discord, Tailscale, Hugging Face, IPA, and LLM (Cinematic) were rendered as nonsensical literal translations, and ~250 more awkward machine-translation strings are now natural Chinese. (#1508) — thanks @anyingiit!
|
||||
- Worker restart coverage now waits for the registration response to persist its identity instead of racing the client callback in CI. (#1505)
|
||||
- Dub language and export selections now restore without false schema warnings, and remote-worker port 7443 is identified instead of reported as a generic timeout. (#1504)
|
||||
- A configured remote backend now bypasses local first-run setup, verifies itself before app requests begin, and shows recovery instead of leaving the desktop stuck on Setup. (#1503)
|
||||
- An idle voice model now actually hands its memory back. The unload emptied the GPU cache a moment before releasing the model, so it freed nothing while reporting success — a GPU machine lending its card sat on 3.6 GB indefinitely. (#1495)
|
||||
- Unloading a model on an NVIDIA GPU now returns the last ~770 MB too. A single 8.5 MB cuBLAS workspace sat inside the model's memory block and kept the whole block reserved, so an idle machine held 1.2 GB instead of 470 MB no matter how often you pressed Flush Memory. (#1495)
|
||||
- Flush Memory reports reserved GPU memory alongside allocated. Allocated alone reads near zero right after an unload while the GPU still shows gigabytes, which is exactly the case people were reporting. (#1495)
|
||||
- The AudioSeal watermark models are released after the same idle period as everything else, instead of staying in memory for the life of the app once anything was watermarked. (#1495)
|
||||
- Remote GPU workers now synthesize a dub's fresh segments as one coarse job with live progress and cancellation; fitting, assembly and RVC remain local. (#1478)
|
||||
- Gallery voice previews now fall back to a local render when a downloaded clip cannot be decoded, instead of failing silently. (#1478)
|
||||
- A second VoiceStudio instance can no longer silently share the remote-worker port; it keeps running locally and explains how to resolve the conflict. (#1478)
|
||||
- Remote GPU jobs stay pinned to the selected worker across retries and restarts, stop when their caller leaves, and cannot return from cancellation as completed. (#1478)
|
||||
- Remote GPU model labels now survive registration, legacy blank model IDs share one capacity slot, long jobs retain bounded leases, and idle cleanup cannot evict a live local render. (#1478)
|
||||
- Remote GPU jobs now stop before dispatch when that worker lacks the model, offer the download there, and refresh scheduling as soon as it finishes. (#1478)
|
||||
- Leaving a screen while its waveform is still loading no longer opens a bug-report prompt for a normal cancelled request. (#1498)
|
||||
- An unreachable remote backend now opens a retryable recovery screen instead of sending the app into local model setup, with clear TLS, CORS, network, HTTP, and wrong-port guidance — thanks @debpalash! (#1501)
|
||||
- Linux production test launches now stop their own extracted AppImage before resetting SQLite and logs. (#1494)
|
||||
- Restored the pre-release version to 0.4.2 while the next release remains in preparation. (#1488)
|
||||
- Large multi-language dubbing batches now use compact searchable language and track managers instead of overflowing the editor. (#1492)
|
||||
- Dictation shortcuts now register and rebind through the desktop portal on Wayland, honor custom keys in focused app views, and show the effective platform keys. (#1490)
|
||||
- Multi-language dubbing now translates, edits, generates, retains, and exports every selected language, and its language picker stays visible at viewport edges. (#1486)
|
||||
- Dubbing's **From video** cast now uses available source-audio samples for every speaker and short line, including jobs without a pooled diarization clone. (#1484)
|
||||
- Basic Dubbing translation remains available without an LLM; Cinematic and Autofit now degrade through the existing Fast translation path instead of blocking the quality choice. (#1481)
|
||||
- Linux microphone recording now falls back to WAV when WebKit cannot encode MediaRecorder audio, and desktop scaling/titlebar controls remain responsive at every UI scale. (#1481)
|
||||
- Dubbing can install a missing ASR model and retry the same job, navigate back through completed stages, and finish transcription under low GPU memory without producing an empty transcript. (#1481)
|
||||
- Filenames and other outside data can no longer forge extra lines or terminal commands in backend and frontend diagnostic logs. (#1457)
|
||||
- Backend journal, dictation reset, voice-catalog, and crash-notification failures are now visible and retryable instead of being silently ignored. (#1459)
|
||||
- Backend failures keep raw tracebacks, local paths and credentials in the local log instead of returning them in API responses. (#1454)
|
||||
- GPT-SoVITS connections now stay on loopback or explicitly trusted networks and cannot escape through redirects or DNS rebinding. (#1463)
|
||||
- Engine discovery no longer exposes probe exceptions, local paths or credentials in API responses and logs. (#1460)
|
||||
- Failed gallery, batch-video, and desktop-log cleanup is now reported instead of silently claiming success, and diagnostic redaction fails closed if a scrubber breaks. (#1458)
|
||||
- Remote backends can no longer probe or overwrite arbitrary host files through native-only tools, and imported or persisted paths cannot escape their VoiceStudio data folders. (#1455)
|
||||
- Linux releases now verify that the AppImage actually contains the compatibility launcher, instead of silently shipping Tauri's stock launcher and opening as a blank window on newer Mesa systems. (#1464)
|
||||
- Patched dependency releases now cover 35 Python and Rust security advisories without weakening VoiceStudio's GPU or offline-runtime compatibility. (#1456, #1472, #1473, #1474, #1475, #1476, #1477)
|
||||
- Curated models now install and repair from reviewed, immutable revisions; custom MOSS remote code requires an explicit safety opt-in. (#1453)
|
||||
- YouTube imports that require a signed-in session can now use an explicitly selected `cookies.txt` export for one import; VoiceStudio never reads browser cookies silently and makes two best-effort attempts to delete its temporary copy. (#1429, #1432) — thanks @dongqing1968-sudo and @phamvandu9595-tech!
|
||||
- First-run source builds no longer stop after uv was successfully downloaded just because its installer failed during a later shell-profile step; app-private uv installs no longer touch shell profiles at all. (#1438) — thanks @AdrianoCahete!
|
||||
- Model files damaged by an interrupted download now repair themselves instead of failing every generation, including invalid `config.json` files and corrupt weight headers. — thanks @overrunau and @zherunh! (#1406, #1437)
|
||||
- ROCm Docker now installs and starts the backend with the same Python whose AMD torch build was validated, instead of launching a second CUDA-only environment and silently running on CPU. (#1274) — thanks @simmessa and @spicchio72!
|
||||
- An error whose text merely contained the digits 401 — a file path, a byte count, a job id — no longer tells you to fix your Hugging Face token. (#1427)
|
||||
- Custom MLX model IDs and saved voice instructions are now validated in bounded time, so malformed input cannot stall the backend. (#1446)
|
||||
- Streaming and provider failures now return stable recovery guidance without exposing exception details. (#1462)
|
||||
- Server-mode settings mutations require the admin API key, while host destinations and executable paths can only be selected through the native desktop app. (#1448)
|
||||
- Automatic model-mirror checks now reject untrusted URLs before opening a network connection. (#1447)
|
||||
- Sidecar engines no longer break when a library they load prints to the console. Those bytes landed in the middle of the engine's data stream, failing the generation and leaving the connection scrambled for every request after it. (#1428) — thanks @1335-Group!
|
||||
- A generation abandoned while stuck on an internal lock now says so, instead of blaming your hardware and suggesting shorter text. Nothing had been computed, so none of that advice applied. (#1416, #1419)
|
||||
- A machine with a GPU that ends up on CPU now says why — a missing device node, a permissions problem, a card newer than the installed ROCm, an `HSA_OVERRIDE_GFX_VERSION` that is doing more harm than good, or an NVIDIA driver the container can't reach each read differently. Before, all of them looked identical to having no GPU at all. (#1274, #1228)
|
||||
- The first generation on an engine that still has to install itself no longer gives up part-way. The install reports progress now, so the generation waits for it instead of hitting its own five-minute limit. (#1414)
|
||||
- A slow machine is no longer told its IndexTTS-2 install isn't there. The check that confirms an engine's virtualenv gave up after 10 seconds and counted that as a broken install, so a cold first run 500'd; it now waits longer and treats slow as unproven, not broken. (#1414) — thanks @OracleNightmare!
|
||||
- A broken Python environment now says so, instead of blaming the app's own install. A missing or mismatched torch/transformers surfaced as "omnivoice not importable" and sent people reinstalling the wrong thing. (#1415)
|
||||
- A model that fails to load at startup no longer leaves the app looking healthy while producing nothing — the failure and its remedy now show up in the model status. (#1415)
|
||||
- Generating with the default engine works again on everything built from `main` since the rename — source checkouts, preview builds and Docker `:latest` all run the same backend, whose model import had been rewritten to a class name the library doesn't export, failing every generation with "cannot import name 'VoiceStudio'". The class keeps its library name, and a guard test now pins it. (#1420)
|
||||
- Running from source no longer dies at startup when a database migration is pending. Alembic resolved the migrations folder relative to wherever the app was launched from — fine from the repo root, fatal from the desktop shell (`tauri dev`), which reported "Path doesn't exist: backend/migrations" and stopped. The path is now anchored to the repo, wherever you start it. (#1420)
|
||||
- The first generation after startup no longer stalls or 500s while the model is still loading. A cold load reached from a worker thread waited on a lock owned by a different event loop, which either errored outright or deadlocked until the job was abandoned. (#1417)
|
||||
- The voice-design model on Apple Silicon works again. Its description was being dropped before it reached the engine, so every generation failed with a raw 400 no matter what you typed. (#1405)
|
||||
- The first-run setup screen no longer times out while it waits for you. Taking more than two minutes to choose an install location, region or mirror made the app declare "Setup failed", and Retry landed back on the same screen with the same clock — so a first install could never be completed. (#1376)
|
||||
- Transcription on an NVIDIA machine whose cuDNN 8 libraries are missing no longer kills the backend outright. The app checks the library before picking a transcription engine and falls back to PyTorch Whisper, instead of handing off to a component that aborts the process with no error and restarts into the same crash. (#1371)
|
||||
- The dictation model picker now tells the truth about download size. Every one of the seven models was wrong: Parakeet TDT v3, the recommended default, said 180 MB and actually downloads 670 MB, while the small low-RAM fallbacks were advertised as three times bigger than they are. (#1398)
|
||||
- Dictation with the 0.6B Parakeet models is steadier under load — they now decode on more threads (still capped by your CPU, still overridable with `OMNIVOICE_SHERPA_ASR_THREADS`). The small models are unchanged. (#1398)
|
||||
- The dictation hotkey no longer leaves a blank dark square stuck on your desktop. A press that arrived while the pill was re-arming was dropped, and the window it had already opened had nothing in it and no way to close it. (#1398)
|
||||
- The blank dark square is gone for good: the dictation window could mistake itself for the main window when its shell wasn't ready yet, and once it did, nothing in the app could close it again. It now learns which window it is before any of its code runs. (#1398)
|
||||
- Dictation is more reliable to trigger: the hotkey listener no longer briefly detaches every time the pill changes state, so a press is never silently lost. (#1398)
|
||||
- An auto-captured crash report now keeps the error that actually caused the crash. Python prints a chained traceback oldest-first, so trimming the log to its newest end kept the generic wrapper and cut the real cause — the reports that needed the detail most were the ones that arrived without it. (#1376)
|
||||
- Text ending in punctuation no longer wastes a whole synthesis pass on it. A chunk boundary could leave a trailing fragment with nothing speakable in it, which the engine renders as nothing at all. (#1330)
|
||||
- A take that is missing part of your text now says so instead of coming back quietly short. When the engine renders a sentence to nothing, the app names the missing text and suggests re-generating — until now the only way to notice was to read along. (#1330)
|
||||
- A long render on modest hardware is no longer abandoned as "too heavy for the available compute" while it is visibly working. A generate that keeps finishing chunks now extends its own deadline (bounded), the way a model download already could; one that stops producing anything still fails on time. (#1338, #1348, #1391)
|
||||
- A backend that dies while loading its own Python dependencies is no longer reported as a memory problem. The crash notice now says the environment is incomplete and points at "Clean & Retry", instead of sending users to flush a model that had nothing to do with it. (#1282, #1376)
|
||||
- A UI whose API requests land on the wrong host — a rehosted frontend, or a reverse proxy with no API route — no longer echoes that host's raw 404 page as the error. It now says the responding server is not a VoiceStudio backend and points at the Backend URL setting and the proxy route. (#1385)
|
||||
- Building the GGUF engine from source produced a binary that died on its very first spawn ("libggml.so.0: cannot open shared object file") — the build script deleted the shared libraries it had just linked against. It now ships them next to the binary on every platform, and the backend puts that folder on the loader path — thanks @vanderlpp! (#1348)
|
||||
- The GGUF engine's hard 120-second per-render kill switch — which was reaping legitimate CPU-only renders mid-synthesis — is now 600s, tunable via `OMNIVOICE_GGUF_GENERATE_TIMEOUT_S`, and the timeout error names that setting — thanks @vanderlpp! (#1348)
|
||||
- Every subprocess TTS engine would have turned a stereo render into noise: the mono downmix always averaged axis 0, which is time rather than channels for channels-last audio. Unreachable today since every engine returns mono, fixed in all five before it isn't. (#1328)
|
||||
- First-run wizard: the Continue button and the Hugging Face token box were pushed below the window with no way to scroll to them — a layout container grew to the full model list's height, defeating every scroll clamp inside it. The pinned row now stays on screen at every UI scale, with the model list scrolling under it. (#1382, #1383)
|
||||
- Dubbing the same video twice no longer ties the second job's cloned voices to the first job's files — deleting the older dub from history was silently turning the newer one's single-segment regens into a default voice. (#1331)
|
||||
- ...and deleting a dub whose files an existing saved dub still renders from now keeps those files on disk (the history entry still disappears) — protecting dubs created before this fix, whose references already cross directories. (#1331)
|
||||
- An unclean previous shutdown is no longer announced as a crash: the notice says what it actually knows, names the benign causes (sleep, force-quit, a stopped VM), and the one-click bug report is only offered when there is evidence to put in it — an empty report helps nobody. (#1375)
|
||||
- A first-use generate no longer fails at 300s while its model is still downloading: the download's own progress heartbeats now extend the generation budget (bounded), so a slow connection isn't reported as too-slow hardware. A job that goes silent still dies at the original deadline. (#1367)
|
||||
- A generation that hits its time limit now says so, instead of "an error VoiceStudio doesn't recognize" followed by an empty `TimeoutError:`. It names the likely causes and the setting that raises the limit. (#1368)
|
||||
- The "transformers install is incomplete" advice now names torchvision — the package whose version mismatch actually produces that error — and points at the pinned reinstall that repairs it, instead of a reinstall that left the broken package untouched. (#1376, #1357)
|
||||
- A model download cut off mid-request is no longer reported as a broken transformers install — reinstalling could never have fixed a dropped connection. (#1347)
|
||||
- A TLS connection cut during generation is explained as the dropped download it is, instead of falling through as an unrecognized error carrying `_ssl.c:1016`. (#1335)
|
||||
- Windows "paging file is too small" no longer suggests the Flush button, which cannot help. It now names the virtual-memory setting to change, and says plainly that it is not a network problem. (#1334)
|
||||
- A port conflict that resolves itself while the backend is dying no longer reports a bare "Backend died (exit code 1)" — the conflict is named even when the other process has already let the port go. (#1364, #1223)
|
||||
- Fresh installs failing to import `transformers.HiggsAudioV2TokenizerModel` with "RuntimeError: operator torchvision::nms does not exist" are fixed by pinning `torchvision==0.23.0` to match `torch 2.8.0` — thanks @HanzlahCh! (#1358, #1357)
|
||||
- ...and that pin now actually reaches Colab and Docker: both install with `uv pip install`, which ignores the pyproject setting the pin lived in, so the torch trio could still drift apart. It is passed explicitly now. (#1357)
|
||||
- Every RTX 40-series card (4060–4090) was declared unsupported and silently run on the CPU. The compatibility gate demanded an exact `sm_89` match, but PyTorch ships `sm_86` kernels that already cover Ada. (#1285)
|
||||
- Under-provisioned hardware is now flagged **before** a synthesis starts instead of after the full compute budget expires. (#1240, #1246, #1248, #1277, #1283, #1284)
|
||||
- Long text on a CPU-only machine gets the same warning up front. (#1260, #1299)
|
||||
- A crash inside the compute stack no longer blames VRAM: a segfault or Windows access violation now points at the GPU driver or an incomplete model download. (#1275, #1293)
|
||||
- ffmpeg failures report the failure instead of ffmpeg's build configuration. (#1309)
|
||||
- A cut TLS connection is explained in words rather than as `_ssl.c:1016`. (#1301)
|
||||
- `torch.compile` is skipped when the torch library path contains a space, instead of failing in the linker on every load. (#1266)
|
||||
- macOS Preview updates work again — the updater bundle had been colliding with itself since early July. (#1281)
|
||||
- macOS Preview updates no longer fail signature verification — the preview manifest is rebuilt from the published assets and every signature in it is verified against the file it points at — thanks @Pinkers01! (#1327)
|
||||
- A dub whose transcription stream is cut by a reverse proxy now says so, instead of blaming the ASR model. (#1317)
|
||||
- The dev backend going quiet under `--reload` is named as auto-reload rather than reported as a crash. (#1261)
|
||||
- Building from source: `bun run desktop-prod:run`, documented as a re-launch, wiped the app's data every time — voice profiles, projects and outputs included. It now keeps them — thanks @Kakuzen93! (#1333)
|
||||
- Audiobook: a chapter that fails to render now shows the reason in the chapter list and in the final error, instead of a red row whose cause existed only in the backend log — thanks @Reaksa-Cambodia! (#1321)
|
||||
- Audiobook: an engine that stops without producing audio no longer stalls the render forever with no error and no timeout. (#1321)
|
||||
- Linux AppImage: a permanently blank window on Mesa 26.1+ hosts (Arch/CachyOS and other rolling distros) — the bundled WebKit ran against a newer system Mesa than it was built for, and no environment variable could help because the failure precedes every rendering flag; the launcher now lets a newer system WebKitGTK take precedence — thanks @rvasilev and @HannaLovvold! (#1258, #1244)
|
||||
- Linux AppImage: `OMNIVOICE_PREFER_SYSTEM_WEBKIT=1` forces your own WebKitGTK for hosts where its version can't be read automatically (no `pkg-config`), and `=0` forces the bundled one (#1258)
|
||||
- Dubbing: the transcription overlay said "Transcribing with Whisper…" whatever ASR engine was actually running — it now names the stage, in all 21 languages — thanks @paoloantinori! (#1352)
|
||||
- Error messages no longer arrive with terminal colour codes spliced into the sentence (`download: ^[[0;31mERROR:^[[0m …`) — every surfaced failure is cleaned now, whichever tool produced it. (#1344)
|
||||
- Linux AppImage: recording failed with "No microphone found" on hosts whose GStreamer is newer than the build runner's, even with a verified-healthy audio stack — your own GStreamer now takes precedence, and the plugin cache is app-private so it can neither be confused by nor corrupt the one other apps use — thanks @Kakuzen93! (#1333)
|
||||
- Linux AppImage: that GStreamer preference actually takes effect — the check guarding it could never pass, so it had been silently doing nothing. (#1333)
|
||||
- A TTS job abandoned for exceeding its compute budget now records where it was actually stuck, so a hang stops being reported as a machine that is merely too slow. (#1338, #1329, #1348)
|
||||
- Translation through LM Studio works. The built-in model name was the placeholder `local-model`, which LM Studio rejects because it serves whatever you have loaded — VoiceStudio now asks it, and a 404 from a local server names the models that ARE loaded instead of telling you to check a URL that was fine — thanks @biga73! (#1332)
|
||||
- Generation that silently dropped the end of the input now says so. When an engine returns no audio for part of the text the result sounds clean and is simply short, so the only way to notice was to read along; the backend log now names the sentences that produced nothing. (#1330)
|
||||
- Dubbing: a re-rendered line that quietly came back in a default voice instead of the cloned one now says why in the backend log — the clone clips are extracted per job and a saved dub outlives them, so regenerating after cleanup loses the reference with no error. (#1331)
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU. (#1289)
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing. (#1325)
|
||||
|
||||
### 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.
|
||||
|
||||
## [0.4.2] — 2026-07-28
|
||||
|
||||
**Highlights**
|
||||
|
||||
- The update prompt is a small toast with buttons, not a screenful of release notes
|
||||
- Installing an update no longer throws away work that is still running
|
||||
- Quitting the app mid-generate stops reporting itself as a crash
|
||||
- A half-downloaded model repairs itself instead of dead-ending
|
||||
- "Dismiss" no longer reads as "terminate an employee" in five languages
|
||||
|
||||
### Changed
|
||||
|
||||
- An available update now announces itself as a toast with **Install and restart**, **What's new** and **Later**, instead of only a dot beside the version number. The release notes stay in Settings → Updates, where there is room for them — a version's notes are the whole changelog section, and rendering them inline is what made the old prompt fill the screen (#1272)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Installing an update no longer relaunches the app while work is running. The check only knew about dub synthesis, so a restart could silently discard an upload, a transcription, a translation, an export or a standalone synth — and two overlapping synths used to cancel each other's protection. Install is now greyed out while anything is in flight (#1272)
|
||||
- A half-downloaded model now repairs itself instead of failing with a raw 500. The automatic repair recognised only one of the two ways the loader reports missing weights, so an interrupted download whose subfolder failed to load got neither the repair nor a hint about what to do (#1273)
|
||||
- Quitting the app with a generate queued reported "500 Internal Server Error: model load skipped: backend shutting down" and offered to file a bug for it. A shutdown is not a fault: the backend now answers 503 with what to do, and no bug report is offered for it (#1276)
|
||||
- Dub history: clearing a large history while a render was running could still resurrect the deleted job — which markers survived depended on the process hash seed, and an oversized purge could discard a live one (#1252)
|
||||
- German, Japanese, Russian and both Chinese locales rendered "Dismiss" as the employment sense — "terminate an employee" — on close buttons (#1272)
|
||||
- The "wait for the current job to finish" message named dubbing specifically, though it now covers uploads, transcription, translation, exports and synthesis; reworded across all 21 languages (#1272)
|
||||
|
||||
## [0.4.1] — 2026-07-27
|
||||
|
||||
**Highlights**
|
||||
|
||||
- AMD GPUs are used again — every ROCm host was silently running on the CPU
|
||||
- Two synth failures that used to say "an error VoiceStudio doesn't recognize" now say what actually went wrong
|
||||
- A dub URL ingest that fails on a disk problem now says which folder and why
|
||||
- A broken audio dependency no longer takes the whole backend down at startup
|
||||
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
|
||||
- A port conflict now says so, instead of "Backend died (exit code 1)"
|
||||
- A model download that dies at 90% now resumes instead of failing the install
|
||||
- First run: Continue and the Hugging Face token box no longer sit under the status bar
|
||||
- macOS 12 (Monterey): the app launches again instead of dying on startup
|
||||
- Exporting a voice or a dub no longer fails when the name isn't spelled in Latin letters
|
||||
- Two more failures that used to arrive as raw OS text now say what to do about them
|
||||
- Unload works on every model the panel offers it for, and a language the active engine can't speak says so
|
||||
- Deleting a dub no longer un-deletes itself when the job it belonged to finishes
|
||||
|
||||
### Changed
|
||||
|
||||
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
|
||||
|
||||
### Added
|
||||
|
||||
- `OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
|
||||
|
||||
### Docs
|
||||
|
||||
- Linux install: a new section for the Mesa 26.1+ blank window, stating plainly that no environment variable works and why (#1258)
|
||||
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deleting a dub while it was still importing crashed the import with the toast `ingest: 'mgw39lx3'` — a dict key and nothing else — and the delete could then be undone by the job's own pending write, in history or mid-render; both are fixed, and no failure can present itself as a bare value again — thanks @dustmaker124-ui! (#1252, #1253)
|
||||
- macOS 12 (Monterey): the app threw on startup and never started the backend — it called a Safari 16 method on the WebView that macOS ships. It launches and works now; some styling still needs a newer WebView (tracked in #1268) — thanks @singhrahat! (#1245)
|
||||
- Settings → Engines: Unload failed with `400 Unknown model id: engine:kittentts` on any in-process engine — the panel offered the button for ids the backend never accepted; the warm dictation model had the same gap — thanks @JavaxmI! (#1247)
|
||||
- Picking a language the active engine can't speak recited 23 codes without saying which engine refused or that switching engine was the fix — thanks @pulananave! (#1257)
|
||||
- A YouTube import that failed as "DRM protected" and then worked on a manual retry now escalates the player client automatically, and a genuinely undownloadable video says so — thanks @gysahlgreene! (#1254)
|
||||
- Exporting a voice profile, persona, dub, subtitle or stem whose name is Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew or emoji failed with a `'latin-1' codec` 500 — every download endpoint now sends the name correctly, and browsers get the real one back — thanks @zvxzdx! (#1262)
|
||||
- A synth that failed because ffmpeg/ffprobe wasn't on the system path said "an error VoiceStudio doesn't recognize"; it now names the media engine and points at Settings → Audio tools, and the app's own copy is published on PATH so dependencies find it in the first place — thanks @Heuvelsma! (#1256)
|
||||
- Windows "The paging file is too small" arrived as a bare 500; it now explains that this is a virtual-memory setting, not full RAM, and gives the steps to raise it — thanks @trankeny545-sudo! (#1251)
|
||||
- AMD/ROCm: every ROCm host was silently force-routed to the CPU — the compatibility gate compared a CUDA `sm_` tag against a ROCm build's `gfx` list, which can never match — thanks @simmessa! (#1228)
|
||||
- AMD/ROCm: `torch.compile` was disabled on all AMD hosts by the same mismatched comparison (#1228)
|
||||
- AMD/ROCm: `HSA_OVERRIDE_GFX_VERSION` is auto-set only when your card genuinely needs it and the remap target exists in your build; gfx1150/gfx1151 (Strix Point/Halo) added to the map (#1228)
|
||||
- Windows blocking an engine file (Smart App Control, WDAC, or AppLocker) is now named, with the fix for personal and managed PCs — thanks @AdityaHemantBhat! (#1227)
|
||||
- A failed audio write (`LibsndfileError: System error.`) now names the target file, its folder's writability and the drive's free space — thanks @morozov28061995-boop! (#1221)
|
||||
- Dub URL ingest: a disk error now names the job folder, its writability and the drive's free space, instead of pointing at the system TEMP folder it never used — thanks @dustmaker124-ui! (#1225)
|
||||
- Dub URL ingest fails immediately when the job folder is missing or unwritable, instead of starting a download that can only fail (#1225)
|
||||
- The backend no longer dies at startup when transformers can't resolve its audio tokenizer (a missing or mismatched torchaudio, common on Google Colab) — it starts, and the error arrives with a repair hint — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- Importing `omnivoice.utils.*` no longer drags in torch, torchaudio, transformers and the full model definition — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- Colab notebook: the install cell now catches a broken environment with the real error, instead of a 5-minute health timeout two cells later — thanks @Navdeep-Chauhan-777! (#1229)
|
||||
- A GPU with less VRAM than the chosen engine needs is flagged in Settings → Engines before you generate, instead of showing a clean green "accelerated" until the job times out — thanks @AdityaHemantBhat and @beingavais! (#1226, #1222)
|
||||
- A generation timeout now names your actual card and its VRAM and recommends a lighter engine (#1226, #1222)
|
||||
- First run: Continue and the Hugging Face token box rendered underneath the status bar, off the bottom of the window — the wizard laid itself out against the viewport instead of its own frame (#1241)
|
||||
- A busy port 3900 now reports a port conflict instead of "Backend died (exit code 1)", in every language — thanks @xipb14! (#1223)
|
||||
- The app verifies it actually freed the port before starting the backend, rather than assuming the kill worked (#1223)
|
||||
- A model download truncated near the end is now retried and resumed instead of aborting the whole install — thanks @Reaksa-Cambodia! (#1224)
|
||||
- Engine first-use downloads (VoxCPM2, MOSS-TTS-Nano) retry transient network failures instead of failing the load outright (#1224)
|
||||
- A backend killed by the OS mid-stream now leaves a low-memory trail in the crash report (#1224)
|
||||
|
||||
### CI
|
||||
|
||||
- The AppImage launcher's unit tests now run in CI — they existed but nothing executed them (#1258)
|
||||
|
||||
## [0.4.0] — 2026-07-21
|
||||
|
||||
**Highlights**
|
||||
|
||||
- Audiobooks, end to end — a real **Stop** with live per-chapter progress, a **multi-voice cast**, expressive controls, a markup toolbar, live stats, and a one-click sample
|
||||
- Pick a designed voice from the **Gallery** anywhere you choose a voice — audiobook, Stories, and Dubbing
|
||||
- Dub **Paste Translation** — drop in a translation or `.srt` and it maps straight onto your segments, timings intact
|
||||
- Downloading a finished audiobook no longer hijacks the app — it just saves
|
||||
- First run is ~2.4 GB, not ~5 GB — only the TTS model is required; ASR picks are curated per platform
|
||||
- Guided mic + Accessibility permissions with Open Settings deep-links; **Parakeet TDT v3** on Apple Silicon
|
||||
- Opens in your system language, with a one-tap switch back to English
|
||||
- Security: server-mode admin routes can't be reached by a trusted-network client without the API key
|
||||
- A render error shows a recoverable card instead of a blank window; queued and long generations stop failing with a bogus "too heavy for your hardware"
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings → Models: grouped catalog (TTS / ASR / Dictation / Diarisation), "recommended for this machine" chips, incompatible models collapsed behind a toggle
|
||||
- Only the TTS model (~2.4 GB) is required on first run; ASR picks are curated per platform via `curated_on` in `models.yaml` (MLX on Apple Silicon, CT2+Turbo on CUDA, PyTorch on ROCm, int8 on CPU)
|
||||
- Audiobook tab tidied up: the settings column is now grouped into compact collapsible sections (Output / Book details / Pronunciation / Markup), so script + voice + Create sit up top instead of a long scroll — same controls, denser layout (#1214)
|
||||
|
||||
### Removed
|
||||
|
||||
- The Dubbing per-segment picker's hardcoded design-presets group — superseded by the richer designed-voice Gallery; already-saved `preset:` picks still generate identically (#1220)
|
||||
|
||||
### Added
|
||||
|
||||
- Voice picker: the designed-voice **Gallery** is now selectable anywhere a voice is chosen — the audiobook default voice and each Cast row can pick a gallery archetype (searchable, favourites first), and it's materialised into a real profile on pick so it just works everywhere (#1219)
|
||||
- The Stories editor and the Dubbing per-segment voice pickers now use the same gallery-enabled picker, so designed-voice archetypes are selectable there too; the dub picker drops its redundant hardcoded presets group in favour of the richer Gallery (existing picks unchanged) (#1220)
|
||||
- Audiobook tab: a Cast panel maps each `[voice:NAME]` in the script to a profile so multi-voice renders correctly (it previously fell back to a single voice), plus a markup insert toolbar, live stats (chapters · words · est. runtime), and pre-flight validation for unknown voices and empty chapters (#1217)
|
||||
- Audiobook tab: a **Stop** button that truly cancels a running generation (not just the UI) and live per-chapter progress — a bar, elapsed + ETA, and each chapter's status (rendering / done / cached / failed); finished chapters stay cached so Create again resumes. `Cmd/Ctrl+Enter` starts a render (#1216)
|
||||
- Settings → Permissions + wizard System Check: live mic/Accessibility grant state, per-OS guidance, Open Settings deep-links; dictation pre-flights the mic grant (#1175)
|
||||
- `parakeet-mlx` engine: Parakeet TDT v3 on Apple Silicon — 25 EU languages, word timestamps, ~2 GB, opt-in from Settings → Models, never auto-downloads (#1175)
|
||||
- First-run downloads race the direct GitHub path against the mirror and use whichever answers fastest (#1179)
|
||||
- First-run consent question for the existing opt-in analytics (two equal buttons, skip = no)
|
||||
- First run: when the app auto-opens in a non-English system language, a one-time, dismissible banner offers to switch the UI to English — shown only until you pick a language, never for English systems (#1215)
|
||||
- Source builds carry the publishable analytics token and get the same first-run consent ask as installers; opt-in events now note the install channel (installer / docker / source) — thanks @agudmund! (#1193)
|
||||
- Official Google Colab notebook (`notebooks/VoiceStudio_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
|
||||
- ROCm Docker image `ghcr.io/debpalash/omnivoice-studio:rocm` (+ `:stable-rocm`, `:X.Y.Z-rocm`) (#1165)
|
||||
- `OMNIVOICE_TRUSTED_NETWORKS` — comma-separated CIDRs exempted from the consumption auth gates (share PIN / API key / dictation WS); admin routes stay loopback-only (#1170)
|
||||
- Info/warn system notifications are dismissible and stay dismissed across restarts; error-level notices can't be dismissed, and the unclean-shutdown notice is now acknowledged server-side — thanks @agudmund! (#1192)
|
||||
- `clone_voice` MCP tool — AI agents can clone a new voice from a base64 reference audio sample; returns a `profile_id` immediately usable with `generate_speech` — thanks @paoloantinori! (#1194)
|
||||
- Dub tab: **Paste Translation** — paste a translation made elsewhere (ChatGPT, DeepL, a human) as subtitles, numbered lines, or plain lines; it maps onto the existing segments with a before→after preview, keeping timings and the source transcript intact (#1203)
|
||||
- Audiobook tab: **Production Overrides** (position/class temperature, steps, guidance, postprocess, seed) for expressive narration, plus IndexTTS2 emotion controls and a "vary repeated lines" toggle — defaults reproduce today's renders exactly (#1208)
|
||||
- Audiobook tab: a **Load sample** button that fills the editor with a demo story — chapters, per-character `[voice:]`, `[pause]`, `[slow]`/`[fast]`/`[emphasis]`/`[spell]`, and reaction tags — so first-timers can hit Create and hear every capability before their real work (#1214)
|
||||
|
||||
### CI
|
||||
|
||||
- The quiet changelog style and 21-locale key/placeholder parity are now enforced by plain pytest checks; CodeRabbit/Greptile carry the house rules via `.coderabbit.yaml`/`greptile.json` (#1198)
|
||||
|
||||
### Docs
|
||||
|
||||
- `docs/expressive-speech.md`: per-engine breaths/laughter/emotion control, incl. the default engine's 13 native reaction tags
|
||||
- Flush caches / Unload documented in the performance guide, incl. `POST /system/flush-memory` for scripts
|
||||
- README FAQ: why a longer reference clip doesn't clone better (zero-shot 15 s cap; fine-tuning is the audiobook-grade path)
|
||||
- `docs/expressive-speech.md` corrected so every recipe it names (breaths, temperature) is reachable in the surface it points to, including the Audiobook tab (#1208)
|
||||
- New `docs/api-auth.md` — one place for authenticating the local API: share PIN, API key, dictation WebSocket, and trusted networks, with curl/SDK examples and what `401`/`403`/`429` mean (#1212)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downloading a finished audiobook (or story mix) no longer hijacks the app: in the desktop WebView a plain download link to the media file made WebKit navigate the whole window to it and play it fullscreen (then the blank-window guard misfired) — downloads now go through the native Save dialog + a server-side copy instead (#1218)
|
||||
- The blank-window guard's fallback page is shown by injection rather than a `data:` URL the desktop WebViews refuse to navigate to, and its Reload button now returns to the app even if the window had navigated away (#1218)
|
||||
- Security (server mode): the admin routes (`/system/*`, `/api/settings/*` — RCE-class) now require the API key or genuine loopback — with an API key set and `OMNIVOICE_TRUSTED_NETWORKS` configured, a trusted-network client could previously reach them with no credential; the short share PIN no longer gates admin either (#1213)
|
||||
- A render error no longer blanks the whole window — a recoverable error card (Reload / Report) appears instead, and CI now builds the real production bundle so a pre-mount crash can't ship (#1209)
|
||||
- Voice-clone trimmer: the preview now plays exactly the selected region on variable-bitrate clips (it had drifted off on VBR/mis-reported-duration files by playing the original file on a different timeline) (#1210)
|
||||
- Screen readers now announce the hidden file-picker buttons (batch add, gallery import, stories import) (#1211)
|
||||
- Audiobook language selection now reaches the backend — the client had dropped the `language` field, and the tab's Markup reference now lists the reaction tags (`[laughter]`, `[sigh]`, …) that already work there (#1208)
|
||||
- A backend that fails to start now says why — exit code and error output, with actionable hints and a one-click report — instead of the evidence-free "Can't reach the local VoiceStudio backend" (#1177)
|
||||
- Generation no longer crawls on CPU after a cancelled or failed dub: the TTS model is moved back to the GPU on every exit path, and each generation now verifies its own placement (#1191)
|
||||
- A generation queued behind a busy one no longer spends its timeout waiting: the budget starts when a GPU worker picks the job up, so a queued request can't be failed as "too heavy for the available compute" without having run (#1190)
|
||||
- One request's timeout no longer cancels unrelated jobs already waiting in the GPU queue (#1190)
|
||||
- Timeout messages stopped claiming capacity was restored automatically — the abandoned job keeps the device until it finishes, and the guidance now says to let it drain (#1190)
|
||||
- The length-scaled generate budget now covers every path — streaming previews, batch dubbing, `/v1/audio/speech`, dub and archetype previews — instead of only the two classic call sites, so long inputs stop failing at a flat 300s (#1190)
|
||||
- Provenance watermarking moved off the GPU worker pool: on 1-worker machines each embed was serializing ahead of the next generation (#1190)
|
||||
- A batch segment that times out fails the job with a reason instead of shipping a finished-looking dub with silent gaps (#1190)
|
||||
- `/v1/audio/speech` refuses work up front with 429 + `Retry-After` when the pool is saturated, and returns a retryable 503 rather than a 500 on timeout (#1190)
|
||||
- Subtitle parsing no longer stalls on a blank-line-heavy `.srt`: the timing-line regex backtracked across newlines, so a mis-saved export could pin an import for hours (#1203)
|
||||
- A broken ASR engine's fallback could silently auto-download multi-GB weights — every fallback now passes the same no-download preflight and shows the download CTA instead (#1189)
|
||||
- Dub transcription releases the ASR model from VRAM on every exit — crashes, early errors, and client disconnects included (#1175)
|
||||
- An invalid dictation model override could bypass the missing-model check for the Whisper fallback (#1175)
|
||||
- The OpenAI-compatible transcription route's 409 now carries the same typed download-CTA payload as every other route (#1175)
|
||||
- Cross-drive installs with a user-pinned `UV_CACHE_DIR` still keep uv's managed Python off the system drive (#1189)
|
||||
- MCP `clone_voice` accepts data-URI base64, stores the clip under its real container extension, and returns backend validation errors as structured JSON (#1195)
|
||||
- Sidecar launch errors no longer embed the user's home directory in logs or error messages (#1189)
|
||||
- KittenTTS degrades gracefully if a future upstream update moves its text chunker (#1189)
|
||||
- Restored 151 broken locale strings: mangled `{{placeholders}}` (vi/ar showed literal `_V_0__`) and gallery errors that dropped their detail in all 20 translations (#1198)
|
||||
- Voice cloning no longer fails on quiet recordings — silence removal retries with gentler thresholds (then skips), and a truly silent clip gets a localized, actionable message instead of a dead-end 400 (#1188)
|
||||
- Custom install folders on another drive are honored end-to-end — uv's wheel cache and Python download now follow the chosen environment folder instead of filling C: (#1186)
|
||||
- Dubbing no longer fails outright when an ASR engine's dependencies are broken ("No module named 'lightning_fabric'") — the engine is marked unavailable with a repair hint and the next one is used automatically (#1185)
|
||||
- `/v1/audio/speech` no longer 500s with a raw "Exec format error" when `bin/` holds a zero-byte GGUF placeholder — managed binaries are validated before exec and broken ones return an actionable 400/503 naming the repair (#1172)
|
||||
- KittenTTS no longer aborts with "invalid expand shape" on digit-heavy input and no longer 500s on empty input — chunks are split to the ONNX 512-token cap and unspeakable text returns a clear 400 (#1173)
|
||||
- Quitting the app while a model is still loading now shuts down clean — no "cannot schedule new futures" traceback, no crash-shaped exit or phantom crash record, and first-run/upgrade boots keep their backend log (#1174)
|
||||
- Dictation falls back to the main ASR engine when a model transcribes real speech to nothing (sherpa NeMo-TDT decoder defect, upstream k2-fsa/sherpa-onnx#3767), remembers the demotion, and names the model that failed (#1175)
|
||||
- A failed dictation session with no transcript clears the floating pill instead of parking it on screen forever (#1175)
|
||||
- A TTS-only first run gets a one-click ASR download prompt instead of a silent 1.6–3 GB Whisper pull (dub, batch, dictation, clone-ref, `/v1` STT, boot warm-up) (#1175)
|
||||
- App boot makes no Hugging Face calls and never silently downloads the TTS model — warm-up is local-cache-only (#1175)
|
||||
- Quitting with a batch dub in flight no longer hangs shutdown (#1175)
|
||||
- Dubbing's vocal-separation step no longer crashes on Windows dev runs (`SelectorEventLoop` sync-pipe fallback) (#1184)
|
||||
- Closing the app while the model is loading is logged as a shutdown, not a phantom "Model loading failed" crash (#1183)
|
||||
- The backend-crash banner sits below the navbar and stays clickable (#1182)
|
||||
- Windows source builds: `bun desktop` no longer dies on a `UnicodeEncodeError` from piped status glyphs (#1181)
|
||||
- The Windows app no longer boots to a black screen (production-minifier variable reorder in the splash; now gated by a real minified-bundle e2e) (#1178)
|
||||
- No more storms of black console windows on Windows — every subprocess, incl. third-party spawns, runs windowless (#1178)
|
||||
- `bun desktop` self-heals a stale terminal `PATH` without `~/.cargo/bin` and hints when Rust is genuinely missing (#1180)
|
||||
- FunASR/SenseVoice: no more crash or speaker-identity swaps across 30 s chunks with inline diarization (#182)
|
||||
- Provenance watermark now applied on every synthetic-audio route via one chokepoint (#1169)
|
||||
- "Can't reach the local backend" reports unclean backend deaths with evidence in dev/Docker/LAN too (#1164)
|
||||
- "Setup failed" screen renders instead of crashing — thanks @bultodepapas! (#1159)
|
||||
- Backend error logs keep stack traces (swept across 20 sites) — thanks @bultodepapas! (#1160)
|
||||
- Reference-clip uploads can't hang on a stuck audio probe (10 s timeout) — thanks @bultodepapas! (#1162)
|
||||
- Malformed EPUB chapters import partially instead of vanishing — thanks @bultodepapas! (#1161)
|
||||
- Failed sidebar fetches are logged instead of silently showing stale/empty lists — thanks @bultodepapas! (#1158)
|
||||
- A missing/broken `mcp` package degrades to "/mcp disabled" instead of killing the backend at startup (#1156)
|
||||
- "Setup failed" auto-dismisses when the backend recovers; relaunching retries instead of refocusing a dead window (#1156)
|
||||
- Ended the `forrtl: error (200)` mid-session Windows crashes (math-runtime console handler) (#1153)
|
||||
- Non-Latin text can't crash synthesis on Windows (backend forced to UTF-8) (#1155)
|
||||
- Video-export errors diagnose the real cause (Windows 32k command-line limit; big filter graphs go via a script file) with per-mode advice (#1152)
|
||||
- Remote backends with an API key get an API-key prompt (durable per-browser, one-shot `#api_key=` link) instead of an unpassable PIN form — thanks @paoloantinori! (#1154)
|
||||
|
||||
## [0.3.22] — 2026-07-14
|
||||
|
||||
The dubbing release. Dubbed videos stop sounding like a compromise: the music keeps its stereo width and full frequency range, short lines no longer leave dead air while the mouth keeps moving, one speaker stays one voice, and the language tabs finally switch the transcript with the audio. Underneath it, the memory fixes that ended the "can't reach the local backend" era on 16 GB machines ship at last — plus a sweep of never-again hardening drawn from an audit of every bug this project has ever closed.
|
||||
|
||||
### Added
|
||||
|
||||
- **A "Voice match" toggle for dubbing — keep one steady voice per speaker.** Each dubbed line clones from a snippet of its own original audio, which matches the delivery beautifully but can make the *voice itself* drift from line to line — most audibly on videos where speaker detection ran in fallback mode ("still 4 segments different in voice", as one report put it). A new control next to the Timing picker chooses: **Per line** (the default, unchanged) for the best per-line delivery match, or **Consistent** to clone every line of a speaker from one shared reference — the speaker's pooled sample, or the best single clip when none exists — for a steady identity across the whole dub. Flipping it honestly marks segments as needing regeneration, and the shared reference is encoded once and reused, not re-studied per line. (#1147)
|
||||
|
||||
- **A performance guide, at last.** [docs/performance.md](docs/performance.md) explains where generation and dubbing time actually goes, the three classic causes of "it got slow" (an empty Transcript field on a voice profile chief among them), every tuning knob the backend reads — none of which were documented anywhere — and which settings to leave alone (raising `OMNIVOICE_GPU_WORKERS` on a small GPU is how you get the crash the default exists to prevent). Includes how to run the built-in profiler so a slowness report can carry numbers instead of vibes.
|
||||
|
||||
- **In-app analytics is now wired end to end — and still off until you say yes.** The frontend analytics SDK is only ever started *after* you opt in (Settings → Privacy), never at app launch, so a default install still transmits nothing. Two of the SDK's defaults are explicitly disabled because they would be actively harmful here: **autocapture**, which sends the text content of whatever you click — in this app, the script you are about to synthesise, your voice names, your file names — and **session recording**, which records the screen. Events carry metadata only, filtered through the same allowlist as the backend, so no future change can leak your content by adding a field.
|
||||
|
||||
- **Opt-in analytics — off by default, and it can't lie to you.** VoiceStudio still sends **nothing** out of the box: no accounts, no telemetry, no phone-home, and your text, audio, voices, and projects never leave your machine regardless of what you choose. There is now one toggle in **Settings → Privacy → "Help improve VoiceStudio"**, **off unless you turn it on**. If you do, it sends anonymous usage stats — which engine and language you used, how long a generation took, how many *characters* the text had (a number, not the text), and the *type* of any error. It never sends the text you type, your audio, your file names, your voice names, or anything identifying you. That isn't a promise in a policy: an **allowlist in the code** drops any property that isn't on it, so a future change can't leak content by accident, and crash tracebacks are deliberately **not** auto-captured (they can carry file paths and tokens). Turning it off stops everything immediately. Builds from source have no analytics destination at all and don't even show the toggle.
|
||||
|
||||
- **Settings → Usage: see what you've made, counted entirely on your own machine.** Takes generated, audio produced, voices, days used, and a breakdown by mode and language — all computed from the history already in your own database. It collects nothing new, stores nothing new, and transmits nothing anywhere, no matter what you've chosen under Settings → Privacy: this panel is *yours*, it works with analytics switched off, and it never phones home. If you want to know what you've been making, the answer shouldn't require sending it to anyone.
|
||||
|
||||
- **The memory panel now tells the whole truth.** `Settings → Models` (and `GET /model/loaded`) used to report only the VoiceStudio core model — a resident second engine like MLX-Audio, or the warm dictation model, was invisible, so the memory picture looked ~2 GB lighter than reality. It now lists every resident model (in-process engines and the dictation ASR included) and adds a system block with free/total RAM (and free VRAM on a dedicated GPU) plus a low-memory warning. On top of that, a load that starts while memory is already low leaves a breadcrumb in the backend log, so a subsequent out-of-memory kill points at the load that tipped it instead of dying silently. Advisory only — nothing is blocked (the OS can reclaim memory, and refusing a load on an estimate would brick machines that would actually cope). Tune the threshold with `OMNIVOICE_LOW_MEMORY_HEADROOM_GB` (default 2).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Switching preview languages can't leave a mixed-language transcript.** Follow-up to the tab/transcript sync: if a track's translations were only partially stored in the browser (older projects, partial regenerations), switching tabs could show German audio with a few rows still in the previous language. Missing rows now hydrate from the app's own per-language store on the backend — and a picked regional dialect is automatically cleared when you switch to a language it doesn't belong to, wherever the switch comes from. (#1149)
|
||||
|
||||
- **The Export step's language tabs now switch the transcript too.** Clicking Bengali/German/Hindi… above the finished dub swapped the *video* but left the segment list showing whichever language you generated last — German audio over Bengali text. The tabs now also swap every segment's text to that language (through the same per-language store the language picker uses, so nothing is lost when you switch back); the Original tab keeps your editing language as-is, since each row already shows the original line beneath its translation. (#1148)
|
||||
|
||||
- **A "backend crashed" notice can no longer outlive the update that fixed the crash — and the desktop shell's self-repair paths are now pinned by tests that CI actually runs.** Crash notices now record which app version wrote them, and a notice left behind by an older version is ignored and cleaned up after you upgrade instead of resurfacing as if the new build had crashed. The Windows blank-window repair (the one-click WebView cache fix after a BSOD) also gets regression tests pinning its safety contract — one attempt per request, never touches anything unasked, never blocks startup on a locked cache — and CI now runs the desktop shell's entire Rust unit-test suite on macOS, Windows, and Linux, which it previously never executed at all. (#1145)
|
||||
|
||||
- **The MLX-Audio phonemizer's language model now ships with the app environment instead of being fetched mid-generation.** Follow-up to the pip fix: with the installer present, the first English MLX-Audio generation would auto-download a small model straight from GitHub — an outbound request that bypasses the app's mirror system (a problem on restricted networks) and fails offline. The model is now a pinned dependency of the managed environment: it arrives at install/update time through the normal dependency flow, and first generation works fully offline. (#1146)
|
||||
|
||||
- **The MLX-Audio engine's first English generation no longer trips over a missing installer.** Its phonemizer auto-downloads a small language model on first use by shelling out to `pip` — which the app's managed Python environment didn't include, so the download always failed (and before the recent containment fix, took the whole backend down with it, #1133). `pip` now ships as a real dependency of the managed environment, so it survives app updates too — anything installed ad-hoc would have been stripped by the updater's environment sync, quietly re-breaking this after every release. (#1144)
|
||||
|
||||
- **A voice engine's helper library can no longer shut down the whole backend.** One user's backend died 21 seconds after starting (#1133): the MLX-Audio engine's phonemizer tries to auto-download a language model on first use, the downloader is written as a command-line tool, and on failure it calls "exit the program" — which, running inside the backend, exited *the backend*. Any engine dependency written that way could do this. Exits are now contained at the engine-dispatch boundary and turned into a normal, explained error ("an engine dependency failed to auto-install something — see the log"), for TTS and transcription alike. The app keeps running; the failed request tells you what actually happened. (#1143)
|
||||
|
||||
- **Vietnamese years read like Vietnamese again.** A recent release started spelling out numbers before synthesis, and its Vietnamese number library turns out to be wrong for exactly the numbers people say most — years ("2024" became *"hai nghìn lẻ hai mươi bốn"*, which no Vietnamese speaker says). The voice model has always pronounced Vietnamese digits correctly on its own, so Vietnamese text now keeps its digits — the same conservative rule that already protected Vietnamese decimals. Also closes the loophole that made this depend on spelling: picking "Vietnamese" from the language list behaved differently from the code "vi". (#1139)
|
||||
|
||||
- **A voice profile's pinned seed now pins Audiobook renders too.** Locking a take (or a designed voice) stores a seed so the voice performs reproducibly — and the Voice page honors it, but Audiobook/Stories renders quietly ignored it and rolled fresh randomness for every segment. Book renders with a pinned-seed profile are now deterministic end to end, matching the Voice page. And the audiobook renderer's higher generation quality (32 decoding steps — the model's own quality preset, vs. the Voice page's fast default of 16) is now pinned explicitly in code rather than inherited by accident, so it can't silently change; that steps gap is also *why* Audiobook sounds steadier than Voice at default settings — move the Voice page's Steps slider to 32 for the same quality. (#1139)
|
||||
|
||||
- **A finished audiobook's Download button stops vanishing.** The player and Download link for a completed book lived only in the page's temporary state — switch tabs once and they were gone, which read as "no way to export at all" (the file was still on disk, and in Projects → Audiobooks). The last finished render now survives tab switches and reloads, right where the book was made. (#1139)
|
||||
|
||||
- **Six recurrence guards from a full audit of the project's issue history — aimed at "this bug can never come back, even after an update or reinstall."** (1) Before loading the voice model on a memory-tight machine, the app now *first releases* things it already reclaims on idle (the warm dictation model, allocator caches) — the missing half of the 16 GB OOM-kill fix; roomy machines pay nothing. (2) When the operating system force-kills the backend for running out of RAM, the crash notice now says exactly that instead of blaming "VRAM" on machines that have none. (3) Saving a *cloned* voice with free-form text in its delivery field can no longer persist a profile that errors on every future generation — the server now sanitizes all profile kinds, closing a hole that had been re-exploited three times through different clients. (4) A reinstall that inherits an old settings file pointing at an unplugged drive or deleted folder no longer sends downloads into the void — dead paths are ignored for the run with a clear log line. (5) Locally-saved UI state is now schema-checked as a whole on restore, so one corrupted field can't silently discard everything after it (the general form of the "app got empty" fix). (6) File moves across drives (Windows D:-drive installs) get a dedicated safe-move helper, so the next code path that renames across devices degrades gracefully instead of failing with `[Errno 18]`. Long texts also get a generation time budget that scales with their length instead of a fixed five minutes. (#1141)
|
||||
|
||||
- **Dubbed videos get their stereo back — and the music's full frequency range.** A/B-measuring a dub against its original showed the dubbed audio was **mono in a stereo container** (channel correlation 1.000 vs the original's 0.754) — the entire stereo image of the music, gone. Two causes, both fixed: the separation step was being fed the **16 kHz mono** file extracted for transcription — so the music bed inherited mono *and* an 8 kHz ceiling at the source — and the mixer then let the mono voice drag the whole mix down to mono. Ingest now makes a second, full-quality stereo extraction (44.1 kHz) just for separation, transcription keeps its mono file, and the mixer pins both sides to stereo with the voice dead-center where dubbed dialogue belongs. Loudness already matched the original (−17.2 vs −17.8 LUFS, measured); now the width and brightness do too. (#1138)
|
||||
|
||||
- **Dubbed lines that finish early no longer leave dead air — they now speak at the pace of the scene.** Translations routinely come out shorter than the original delivery, and the dub used to just stop early: measured on a real dub, **8.8 of 18.7 seconds of speech time had no voice at all** — the mouth kept moving on screen over the thin residue the vocal separation leaves behind, which reads as silence and as "the music got quiet". Short lines are now gently slowed toward their time slot (pitch preserved, never below 0.85× — comfortably natural), so speech covers the speaking time the way the original did. This also does most of the work people expect from "lip sync": the voice now starts *and ends* with the mouth. Near-full lines are left untouched, the per-segment badge shows the applied rate, and `OMNIVOICE_UNDERRUN_MIN_RATE=1.0` turns the fill off. (#1137)
|
||||
|
||||
- **The dub's background music no longer comes out quiet and muffled.** Every dub export mixes your synthesized voice over the video's separated music/ambience bed — and that mix had two fidelity bugs stacked on top of each other. The mixer *normalizes* its inputs, so the weights meant to gently favor dialogue actually played the music at **~57% of its original level** (measured); and because the voice track is synthesized at 24 kHz, the mixer silently pulled the 44.1 kHz music down to 24 kHz — deleting everything above 12 kHz: cymbals, brightness, air. The batch pipeline was harsher still, pinning the bed near 8%. All six mix sites now share one filter that resamples both sides up to 48 kHz, cancels the normalization so the music plays at **90% of its true level** (a hair of headroom keeps dialogue legible), and adds a transparent peak limiter. Measured on a real dub: bed level 57% → 90%, bandwidth 12 kHz → 24 kHz. (#1136)
|
||||
|
||||
- **A rate-limited translation polish pass no longer sabotages the dub — or lies about it.** The Cinematic quality mode runs an optional critique-and-rewrite pass after translating. When that pass hit a rate limit (free-tier LLM endpoints throttle hard), three bad things happened at once: the app reported **"N/N segment(s) failed"** in red over a translate that had actually succeeded; the affected segments were **silently skipped by the speech-rate fit pass and duration planner** — so overlong lines went to synthesis unfitted and came out audibly time-compressed; and the two-second "retry shortly" hint the provider sent was ignored. All three are fixed: a rate-limited call now waits out the provider's own `Retry-After` (bounded, once) and usually just succeeds; a segment that still misses the polish keeps its plain translation, **stays in every downstream fitting pass**, and is reported honestly — "translated, polish skipped" as a warning with the reason, not a failure. Rows that really failed still say so. (#1135)
|
||||
|
||||
- **Dubbing kept re-studying the same speaker's voice, hundreds of times per video.** Each dubbed line clones from a clip of its own source audio (that's what makes deliveries match), and lines too short to clone from fall back to a per-speaker sample. But the app's memory for already-studied voices only holds 8 — and a long dub streams *hundreds* of one-shot per-line clips through it, each pushing out the per-speaker samples that every other line needs. Result: the speaker sample was re-studied (~0.4 s, measured) over and over. One-shot clips are now studied without displacing anything, so the per-speaker samples stay warm for the whole dub. Nothing about the audio changes — same clips, same voices, less repeated work. (#1132)
|
||||
|
||||
- **Clicking "Install" on an engine right after opening Settings could silently do nothing.** When the Engines page opens, it quietly checks each installable engine for an in-flight install to re-attach to. If you clicked Install while that check was still running, your click's status update was thrown away to keep requests orderly — so no progress panel, no error, no retry, just nothing (the install itself *did* start in the background; the UI simply never showed it). Fast machines usually won the race, which is why this mostly showed up as a once-in-a-while CI test failure. The Install click's update can no longer be dropped — it politely waits out the startup check instead. (#1131)
|
||||
|
||||
- **Cloning re-listened to your reference clip for every chunk of text — now it listens once.** Before VoiceStudio can speak in a cloned voice it has to *encode* the reference clip you gave it. That encode was being redone on **every single piece of the job**: long text is split into chunks, and each chunk re-encoded the same reference from scratch; so did each `[pause]` span, and each chapter segment of an audiobook. A cache to prevent exactly this was written a while back — and then quietly bypassed on the path the Generate button actually takes, so for several releases it only ever helped the API. It's now wired into every path. Measured on an M2, one encode costs **0.4 seconds**, so this gives back roughly **3–4 seconds on a long paragraph** and **about a minute on a 166-segment audiobook** — the same voice, the same audio out, just without listening to your reference clip 166 times. As a bonus, `preprocess_prompt` on the OpenAI-compatible endpoint now actually does something; it was being accepted and silently discarded. (#1130)
|
||||
|
||||
- **Dubbing loaded the 3 GB voice model, threw it away, and loaded it again.** Before transcribing, a dub pulled the entire voice model into memory to read a single setting off it — one that is empty unless you've turned on an off-by-default flag. So it loaded ~3 GB, found nothing, released it a moment later (on Apple Silicon that's a *full* unload), and then had to load the very same model again from cold when it was time to actually speak. Every dub paid for that round trip — roughly **8 seconds**, plus the memory churn on exactly the 16 GB machines where memory pressure is the problem. It now only loads the model when there's genuinely something to read. (#1130)
|
||||
|
||||
- **The backend stopped holding the voice model hostage while it loads the transcription model — the 16 GB dub crash.** Before transcribing a dub, VoiceStudio makes room by setting the TTS model aside. On an NVIDIA GPU it did. On **Apple Silicon it did nothing at all** — the code bailed out with "unified memory doesn't benefit from offloading". That was half right and wholly wrong: on unified memory, *moving* a model to "CPU" frees nothing (it's the same RAM), but the answer is to **release** it, not to skip the step. So a 16 GB Mac went into a dub holding the ~3 GB voice model, then loaded a ~3 GB transcription model on top of it — measured here: 4.1 GB free before, and large-v3 needs 3 — and the operating system killed the backend mid-transcription. That's the dub that "dropped before emitting any segments". The voice model is now genuinely released when memory is tight (and left alone when it isn't, so a roomy machine pays nothing); it reloads by itself on your next generation. (#1119)
|
||||
|
||||
- **Dubbing on a Mac was transcribing on the CPU — with the GPU sitting idle.** VoiceStudio picked its transcription engine without ever looking at your hardware: WhisperX won every time, and WhisperX (like faster-whisper) is built on CTranslate2, which **has no Metal backend at all**. So on Apple Silicon it ran whisper-large-v3 on the *processor*. Measured on an M2, one 30-second chunk: **90 seconds on the CPU versus 20 on the GPU** — slower than realtime, which turned a 16-minute video into a ~48-minute transcribe that looked exactly like a hang. Worse, the slowest chunks blew past the 2-minute per-chunk timeout and were **abandoned entirely**, so the transcript came back with pieces missing and the app blamed a "VRAM-starved GPU" — on a machine that has no VRAM. Apple Silicon now uses MLX, which runs the **same** whisper-large-v3 on the GPU, roughly **4x faster**. Word timing is unchanged: the wav2vec2 forced alignment that lip-sync depends on (±10-30 ms, versus Whisper's own ±100-300 ms) is layered on top exactly as before. Same model, same alignment, four times the speed. Nothing changes on NVIDIA or Linux, where WhisperX already used the GPU. (#1127)
|
||||
|
||||
- **The transcribe screen invented its ETA, and the number was a fiction.** It assumed transcription runs at ~20x realtime — true on a fast GPU — and predicted from the video's length alone. For a 16-minute video it promised **56 seconds**. Once reality overran the guess it pinned itself at "~0s remaining" with the bar frozen at 95%, and sat there for the next three quarters of an hour. It now reports the *real* fraction of the audio transcribed and extrapolates the time left from the speed it can actually observe — so it is right on a fast machine and a slow one, and says nothing at all until it has something true to say. (#1127)
|
||||
|
||||
- **Analytics you switched on would have stayed half-dead.** The backend half of the new opt-in analytics read its destination from an environment variable that nothing on your machine ever set — so in a shipped build it could never send anything, silently, no matter what you chose. Only the frontend half worked. The destination is now baked into the desktop shell at build time and handed to the backend when it starts, so "on" means on. Nothing else changes: it stays off until you opt in, builds from source still have no destination at all, and the property allowlist still decides what may leave. (#1123)
|
||||
|
||||
- **A dub that dies mid-transcription still guessed at the cause.** v0.3.20 taught it to check the crash report before blaming the ASR model — but it checked *instantly*, the moment the stream dropped, and the desktop shell needs about two seconds to notice the backend died and write that report. So it kept looking too early, finding nothing, and falling back to the same old guess ("Likely ASR backend failed to load") even when the backend had in fact just crashed. It now waits for the shell to catch up, so you get the real cause — exit code and error output — instead of a guess. (#1119)
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
## [0.3.21] — 2026-07-12
|
||||
|
||||
@@ -750,59 +12,55 @@ The memory release. The reason the app kept saying "Can't reach the local backen
|
||||
|
||||
### Added
|
||||
|
||||
- **Factory reset grew up: Settings → Storage → "Reset & remove".** It used to do exactly one thing — clear your UI preferences — while the only other option was deleting everything and starting over. Between "forget my theme" and "wipe the machine" sat every reset people actually needed. Now there are four one-click tiers — **UI preferences**, **all settings**, **downloaded assets & models**, and **everything VoiceStudio did** — plus a per-item checklist if you want to drop just the model weights, just a wedged sidecar engine, or just the history. Every option shows its **real size on disk before you commit**, and the number on the button is exactly what gets freed. Deleting voices, projects or audio asks you to type `DELETE`; nothing irreversible happens on a single click. "Everything" deliberately stops short of the Python environment, so you land on a working first-run screen rather than a rebuild — the app stops its engine, deletes, and starts it again for you. On macOS and Linux the model cache is the **shared** Hugging Face cache, so it's its own checkbox and says so; on Windows and portable installs it's VoiceStudio's own, and the app doesn't pretend otherwise.
|
||||
- **Factory reset grew up: Settings → Storage → "Reset & remove".** It used to do exactly one thing — clear your UI preferences — while the only other option was deleting everything and starting over. Between "forget my theme" and "wipe the machine" sat every reset people actually needed. Now there are four one-click tiers — **UI preferences**, **all settings**, **downloaded assets & models**, and **everything OmniVoice did** — plus a per-item checklist if you want to drop just the model weights, just a wedged sidecar engine, or just the history. Every option shows its **real size on disk before you commit**, and the number on the button is exactly what gets freed. Deleting voices, projects or audio asks you to type `DELETE`; nothing irreversible happens on a single click. "Everything" deliberately stops short of the Python environment, so you land on a working first-run screen rather than a rebuild — the app stops its engine, deletes, and starts it again for you. On macOS and Linux the model cache is the **shared** Hugging Face cache, so it's its own checkbox and says so; on Windows and portable installs it's OmniVoice's own, and the app doesn't pretend otherwise.
|
||||
|
||||
- **The Storage panels got a design.** "Remove all data" and "Reset & remove" listed folders as a flat run of text, so a 7.5 GB model cache and a 391-byte config file carried exactly the same visual weight — the one thing you actually wanted to see (where the space went) was the one thing you couldn't. Every row now has an icon, a dimmed path, and a **proportional bar showing its share of what will be freed**, so the big one looks big. The shared Hugging Face cache is promoted out of the confirm dialog into its own "Optional" row with a checkbox, so ticking it moves the running total **in front of you** instead of springing a different number on you at the point of no return, and the dialog now lists exactly what is about to go.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Switching TTS engines no longer stacks their models in memory.** Using a second engine in a session (or a per-request engine override) loaded its model *on top of* the first one's, because the VoiceStudio core model and the other engines live in two separate caches that never coordinated — measured on a 16 GB M2, an `omnivoice` → `mlx-audio` switch left the machine holding both (footprint 3.9 GB → 4.3 GB, the ~2.8 GB core never freed). That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. Now only one TTS engine's model stays resident: resolving an engine hands back every *other* resident engine first (the same `omnivoice → mlx-audio` switch now drops to ~1.5 GB). Steady-state single-engine use is unaffected; an A/B switch pays a re-load on the way back (~8 s for the VoiceStudio core, ~1–2 s for the lighter engines). Opt out with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0` if you have RAM to keep several warm. Two underlying leaks are fixed as part of this: every in-process TTS engine's `unload()` now actually frees its model and empties the device cache (previously all but VoiceStudio were silent no-ops), and `faster-whisper`'s `unload()` cleared the wrong attribute so its model was never released.
|
||||
- **Switching TTS engines no longer stacks their models in memory.** Using a second engine in a session (or a per-request engine override) loaded its model *on top of* the first one's, because the OmniVoice core model and the other engines live in two separate caches that never coordinated — measured on a 16 GB M2, an `omnivoice` → `mlx-audio` switch left the machine holding both (footprint 3.9 GB → 4.3 GB, the ~2.8 GB core never freed). That accumulation is a direct contributor to the memory pressure behind the "Can't reach the local backend" OOM deaths. Now only one TTS engine's model stays resident: resolving an engine hands back every *other* resident engine first (the same `omnivoice → mlx-audio` switch now drops to ~1.5 GB). Steady-state single-engine use is unaffected; an A/B switch pays a re-load on the way back (~8 s for the OmniVoice core, ~1–2 s for the lighter engines). Opt out with `OMNIVOICE_SINGLE_ENGINE_RESIDENT=0` if you have RAM to keep several warm. Two underlying leaks are fixed as part of this: every in-process TTS engine's `unload()` now actually frees its model and empties the device cache (previously all but OmniVoice were silent no-ops), and `faster-whisper`'s `unload()` cleared the wrong attribute so its model was never released.
|
||||
|
||||
- **The backend no longer sits on ~2 GB of idle dictation model — the real reason it was being killed on 16 GB Macs.** Four reports of *"Can't reach the local VoiceStudio backend"* (#1076, #1092, #1093, #1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it showed the generate was never the problem — it costs about 116 MB. The problem was the **baseline**: the backend sat at **~6.2 GB even while idle**. The TTS model has always been unloaded after an idle timeout, but the speech-recognition model used for dictation never was — so once you dictated a single time, ~2 GB stayed resident for as long as the app ran. On a 16 GB Mac, that plus the app, macOS, and your other programs is enough for the system to run out of memory and kill the backend, which surfaced as the "can't reach the backend" error. Dictation's model now gets the same idle release the TTS model already had, handing that memory back. The only cost is a ~1.4-second re-warm on your next dictation after a long pause, and a live dictation session is pinned so nothing is ever unloaded mid-sentence.
|
||||
- **The backend no longer sits on ~2 GB of idle dictation model — the real reason it was being killed on 16 GB Macs.** Four reports of *"Can't reach the local OmniVoice backend"* (#1076, #1092, #1093, #1101) all died at the same moment: during a generate, on a 16 GB machine. Measuring it showed the generate was never the problem — it costs about 116 MB. The problem was the **baseline**: the backend sat at **~6.2 GB even while idle**. The TTS model has always been unloaded after an idle timeout, but the speech-recognition model used for dictation never was — so once you dictated a single time, ~2 GB stayed resident for as long as the app ran. On a 16 GB Mac, that plus the app, macOS, and your other programs is enough for the system to run out of memory and kill the backend, which surfaced as the "can't reach the backend" error. Dictation's model now gets the same idle release the TTS model already had, handing that memory back. The only cost is a ~1.4-second re-warm on your next dictation after a long pause, and a live dictation session is pinned so nothing is ever unloaded mid-sentence.
|
||||
|
||||
- **Folder sizes under 1 KB displayed as "0 KB".** The uninstall panel's `391 B` config folder rendered as `0 KB` — which reads as "nothing here" for a folder that very much exists. The Storage panels now share one byte formatter that can say `391 B`.
|
||||
|
||||
- **Some styling silently did nothing.** A handful of components referenced CSS custom properties that were never defined (`--chrome-fg-subtle`, `--chrome-bg-raised`, `--color-warning`). An undefined `var()` makes the whole declaration invalid, so the browser drops it and the element quietly inherits — the dimmed folder paths in the Storage panels weren't dimmed at all. Fixed in those panels, and a new guard (`frontend/src/test/cssTokens.test.js`) fails on any bare `var(--token)` in JSX that isn't defined in a stylesheet or documented as runtime-injected, so a typo can't ship as invisible styling again.
|
||||
|
||||
- **Uninstalling now removes the saved-environment file it used to leave behind.** VoiceStudio keeps a small `~/.config/omnivoice/env` file (the model-cache location you chose, and any saved Hugging Face token). Every uninstall path — the in-app "Remove all data", `scripts/uninstall.sh`, and `scripts/uninstall.ps1` — walked right past it, so a later reinstall silently picked the *old* file back up and redirected its downloads to a location you may have long since deleted. All three now list and remove it (it's the same `~/.config/omnivoice` path on every OS, Windows included), and the per-platform tables in `docs/install/uninstall.md` document it.
|
||||
|
||||
- **Disk usage now counts installed sidecar engines instead of hiding them.** Settings → Storage measured engine venvs in `backend/engines` — the built-in engine *code*, which has no venvs — so a multi-GB IndexTTS-2 install (which actually lives in `DATA_DIR/engines/<id>`) was invisible in the engine row and quietly rolled into the data dir's "other" subtotal. The report now points at the real install location and sizes the **whole** install (venv + checkout + weights), counted once, so "IndexTTS-2 — 6.2 GB" shows up where you'd look for it.
|
||||
|
||||
## [0.3.20] — 2026-07-12
|
||||
|
||||
The follow-through release. v0.3.19 promised that "Can't reach the local VoiceStudio backend" would stop firing while the backend was merely restarting — and then a user hit it anyway, on 0.3.19, because the fix had a race in it. That's closed properly here. Uninstalling also stopped being a thing only maintainers could do: it's now a button in the app, where the person who asked for it can actually reach it.
|
||||
The follow-through release. v0.3.19 promised that "Can't reach the local OmniVoice backend" would stop firing while the backend was merely restarting — and then a user hit it anyway, on 0.3.19, because the fix had a race in it. That's closed properly here. Uninstalling also stopped being a thing only maintainers could do: it's now a button in the app, where the person who asked for it can actually reach it.
|
||||
|
||||
### Added
|
||||
|
||||
- **Uninstall is now in the app: Settings → Storage → "Remove all data".** The v0.3.19 uninstaller was a *script* — which never reached the people who needed it, since anyone who installed the .dmg / .msi / AppImage has no repo to run it from (exactly the case in #1089). The app now lists every folder this install owns with its real size, deletes them behind a typed confirmation, and quits. The **downloaded model weights are a separate, opt-in checkbox**, because that's the standard Hugging Face cache shared with other AI tools on your machine — removing it can delete models VoiceStudio never downloaded. Custom and portable install locations are honored, and nothing outside VoiceStudio's own folders can be touched. The scripts now also ship as **release assets**, so you can clean up without launching the app at all. (#1089)
|
||||
- **Uninstall is now in the app: Settings → Storage → "Remove all data".** The v0.3.19 uninstaller was a *script* — which never reached the people who needed it, since anyone who installed the .dmg / .msi / AppImage has no repo to run it from (exactly the case in #1089). The app now lists every folder this install owns with its real size, deletes them behind a typed confirmation, and quits. The **downloaded model weights are a separate, opt-in checkbox**, because that's the standard Hugging Face cache shared with other AI tools on your machine — removing it can delete models OmniVoice never downloaded. Custom and portable install locations are honored, and nothing outside OmniVoice's own folders can be touched. The scripts now also ship as **release assets**, so you can clean up without launching the app at all. (#1089)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **"Can't reach the local VoiceStudio backend" could still fire on 0.3.19 — the fix had a hole.** The app asks the desktop shell whether a start/restart is in progress before showing that error, but the shell learns of a dead backend from a **2-second poll**: when the backend dies mid-generation, the supervisor needs a moment to notice it, record the crash, and flip its state to "restarting". The app was asking **once**, ~3 seconds in — often still hearing "everything's fine" — and dead-ending on the generic toast anyway. A failed connection *contradicts* "everything's fine", so that answer is now treated as stale rather than authoritative: the app keeps retrying briefly, letting the shell catch up, which turns the failure into the "backend is restarting — hang tight" banner (and gives the crash report time to be written, so you get the real cause instead of a guess). A shell that has genuinely given up, or no shell at all, still errors immediately. (#1101)
|
||||
- **"Can't reach the local OmniVoice backend" could still fire on 0.3.19 — the fix had a hole.** The app asks the desktop shell whether a start/restart is in progress before showing that error, but the shell learns of a dead backend from a **2-second poll**: when the backend dies mid-generation, the supervisor needs a moment to notice it, record the crash, and flip its state to "restarting". The app was asking **once**, ~3 seconds in — often still hearing "everything's fine" — and dead-ending on the generic toast anyway. A failed connection *contradicts* "everything's fine", so that answer is now treated as stale rather than authoritative: the app keeps retrying briefly, letting the shell catch up, which turns the failure into the "backend is restarting — hang tight" banner (and gives the crash report time to be written, so you get the real cause instead of a guess). A shell that has genuinely given up, or no shell at all, still errors immediately. (#1101)
|
||||
|
||||
- **The uninstaller was leaving the backend's log folder behind on Linux and Windows.** It cleaned the app-data, config, and Python-env folders but missed where the backend actually writes `backend.log` / `backend_err.log` — `~/.local/state/OmniVoice` on Linux and `%LOCALAPPDATA%\OmniVoice\Logs` on Windows. Both the scripts and the documented path lists now cover them. (#1089)
|
||||
|
||||
## [0.3.19] — 2026-07-12
|
||||
|
||||
The honesty release. Every error in here was already *technically* true and practically useless — so this round went after the lies the app tells when something goes wrong. "Can't reach the local VoiceStudio backend" no longer fires while the backend is simply still starting; a dead Hugging Face mirror no longer strands the setup wizard with advice it can't follow; and a dub that dies mid-transcription now names the actual cause instead of guessing at it. Alongside that: generated speech starts playing on the *first* chunk instead of the last, and there's finally a real uninstaller.
|
||||
The honesty release. Every error in here was already *technically* true and practically useless — so this round went after the lies the app tells when something goes wrong. "Can't reach the local OmniVoice backend" no longer fires while the backend is simply still starting; a dead Hugging Face mirror no longer strands the setup wizard with advice it can't follow; and a dub that dies mid-transcription now names the actual cause instead of guessing at it. Alongside that: generated speech starts playing on the *first* chunk instead of the last, and there's finally a real uninstaller.
|
||||
|
||||
### Added
|
||||
|
||||
- **Generated speech starts playing on the first chunk, instead of after the last one.** Long text is synthesized in chunks, but you used to sit through the entire render before hearing anything. The Studio now streams the preview: audio begins the moment the first chunk is ready and the rest arrives as it renders, so a long passage is audible in about the time the first sentence takes. The take saved to your history is **byte-identical** to the non-streaming render — streaming is a delivery channel, not a different synthesis path — and if a stream fails mid-flight the app falls back to the classic whole-file flow with nothing half-written to disk. (#1088)
|
||||
|
||||
- **A clean uninstaller + a straight answer to "where's my data?"** VoiceStudio is fully local, so removing it is just deleting the folders it wrote — but until now users had to guess which ones. New `scripts/uninstall.sh` (macOS/Linux) and `scripts/uninstall.ps1` (Windows) find every VoiceStudio folder — app data, the multi-GB managed Python env, config, logs, and (separately, because it's shared) the Hugging Face model cache — print each with its size as a **dry-run first**, and delete only on `--yes`. They honor your custom locations (`OMNIVOICE_DATA_DIR`, `HF_HOME`, portable mode) and never touch the app binary. The complete per-platform path list lives in the new `docs/install/uninstall.md`, linked from the README FAQ, SUPPORT, and troubleshooting. (#1089)
|
||||
- **A clean uninstaller + a straight answer to "where's my data?"** OmniVoice is fully local, so removing it is just deleting the folders it wrote — but until now users had to guess which ones. New `scripts/uninstall.sh` (macOS/Linux) and `scripts/uninstall.ps1` (Windows) find every OmniVoice folder — app data, the multi-GB managed Python env, config, logs, and (separately, because it's shared) the Hugging Face model cache — print each with its size as a **dry-run first**, and delete only on `--yes`. They honor your custom locations (`OMNIVOICE_DATA_DIR`, `HF_HOME`, portable mode) and never touch the app binary. The complete per-platform path list lives in the new `docs/install/uninstall.md`, linked from the README FAQ, SUPPORT, and troubleshooting. (#1089)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A dub that dies mid-transcription now says what actually happened instead of guessing.** "Transcribe stream dropped before emitting any segments. Likely ASR backend failed to load" was a *guess* — and usually the wrong one. The backend is contract-bound to emit a terminal event on every stream even when it fails, so a stream that simply goes silent means the backend **process died underneath it** — on smaller GPUs, almost always a native out-of-memory abort while loading the ASR model on top of a still-resident TTS model. The app now consults the desktop shell's crash forensics and tells you that: the exit code, when it happened, a one-click "View crash details" with the captured error output, and the actual next step (free VRAM / pick a smaller ASR model) rather than "check the backend log". With no crash recorded, the original message still stands. (#1062)
|
||||
|
||||
- **"Can't reach the local VoiceStudio backend" stopped crying wolf during startups and restarts.** A real backend start or auto-restart takes 10–20+ seconds (Python spawn plus the PyTorch import), but the app's transport retry only bridged ~3 seconds — every click inside that window dead-ended with the scary toast, over and over, even though the backend healed itself moments later. The app now asks the desktop shell whether a start/restart is actually in progress and simply waits for it (up to the shell's own 2-minute restart budget), and shows a single "backend is restarting — hang tight" banner with a "back — carrying on" confirmation — the reconnecting affordance the supervisor has promised since #567. A truly dead backend (or a non-desktop deployment) still errors promptly, and the crash notice keeps telling the honest story.
|
||||
- **"Can't reach the local OmniVoice backend" stopped crying wolf during startups and restarts.** A real backend start or auto-restart takes 10–20+ seconds (Python spawn plus the PyTorch import), but the app's transport retry only bridged ~3 seconds — every click inside that window dead-ended with the scary toast, over and over, even though the backend healed itself moments later. The app now asks the desktop shell whether a start/restart is actually in progress and simply waits for it (up to the shell's own 2-minute restart budget), and shows a single "backend is restarting — hang tight" banner with a "back — carrying on" confirmation — the reconnecting affordance the supervisor has promised since #567. A truly dead backend (or a non-desktop deployment) still errors promptly, and the crash notice keeps telling the honest story.
|
||||
|
||||
- **A dead Hugging Face mirror can no longer strand the first-run wizard.** When a model download failed because the *configured* mirror was unreachable, the error pointed at Settings — which first-run users can't open (the wizard gates the studio) — and falsely claimed the mirror setting only applies after a restart (downloads actually pick it up per call, immediately). Now the wizard shows the mirror quick-pick (including "Hugging Face (official)") right next to the failed download and retries it the moment you switch; the corrected hint says retry-first, restart only if it still fails. Two backend holes in the same flow are closed too: switching endpoints clears the "failed recently" retry cooldown (no more 429 on the immediate retry), and clearing to official also removes the legacy `hf_endpoint` pref, which used to silently keep the dead mirror in effect.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The first-run wizard shows the app version in its masthead**, next to the VoiceStudio title — so setup-time screenshots and bug reports identify the build at a glance (the install splash already did).
|
||||
- **The first-run wizard shows the app version in its masthead**, next to the OmniVoice Studio title — so setup-time screenshots and bug reports identify the build at a glance (the install splash already did).
|
||||
|
||||
- **Repo root decluttered.** Retired the finished planning archives (`.planning/`, `specs/`), the pre-React design mockups (`design/`), the legacy research dir (`research/`), and stale third-party agent rules (`.agents/`) — ~110 files of process noise gone; everything stays in git history, and the four load-bearing engine decision docs moved to `docs/adr/`. Contributor-facing only; the app is unchanged.
|
||||
|
||||
@@ -880,6 +138,7 @@ The quality release. Three long-standing frictions got structural fixes: **regen
|
||||
|
||||
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
|
||||
@@ -915,7 +174,7 @@ The community-fixes release. Two contributors didn't just report bugs — they d
|
||||
|
||||
### Added
|
||||
|
||||
- **A path to Qwen3-ASR today: generic OpenAI-compatible transcription.** The direct integration is still blocked on `transformers>=5.13` stabilizing upstream, but a community member proposed splitting the work — add a backend that talks to any OpenAI-compatible transcription server right now. Point VoiceStudio at a self-hosted Qwen3-ASR/FunASR/SenseVoice server, or OpenAI's own API, configured in Settings → Models. No install; audio does leave your machine to whichever server you configure, unlike every other ASR engine. (#877)
|
||||
- **A path to Qwen3-ASR today: generic OpenAI-compatible transcription.** The direct integration is still blocked on `transformers>=5.13` stabilizing upstream, but a community member proposed splitting the work — add a backend that talks to any OpenAI-compatible transcription server right now. Point OmniVoice at a self-hosted Qwen3-ASR/FunASR/SenseVoice server, or OpenAI's own API, configured in Settings → Models. No install; audio does leave your machine to whichever server you configure, unlike every other ASR engine. (#877)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -931,7 +190,7 @@ The community-fixes release. Two contributors didn't just report bugs — they d
|
||||
|
||||
### Changed
|
||||
|
||||
- **Removed the donate heart from the nav rail.** Support VoiceStudio is still one click away from Settings and the Contact page.
|
||||
- **Removed the donate heart from the nav rail.** Support OmniVoice is still one click away from Settings and the Contact page.
|
||||
|
||||
### CI
|
||||
|
||||
@@ -948,14 +207,14 @@ A community-issue sweep — nineteen open reports triaged in one pass, most fixe
|
||||
### Fixed
|
||||
|
||||
- **First-run no longer dead-ends behind restricted networks (e.g. China).** The system check probed hardcoded huggingface.co, and any failure locked the Continue button — users behind the Great Firewall were stuck on the very first screen, even when they had already configured a working mirror. The check now probes the Hugging Face endpoint actually in effect, an unreachable endpoint is a warning instead of a blocker (models already on disk keep working offline), and when huggingface.co is blocked but the hf-mirror.com community mirror answers, the wizard says so and offers a one-click mirror switch right on the check screen — no restart needed. (#984)
|
||||
- **Installs behind a corporate or antivirus TLS-inspecting proxy no longer fail with a raw SSL error.** `SSLV3_ALERT_HANDSHAKE_FAILURE` happens when a proxy re-signs HTTPS traffic with a root CA your OS trusts but Python's bundled certificate list doesn't — a different failure mode from the network-blocking case above. VoiceStudio now trusts your OS's certificate store directly, which should resolve the handshake outright rather than just explain it better. (#976)
|
||||
- **The loaded-models panel now says when a resident model is not your active engine.** Switching TTS engines keeps the previous model in VRAM (so switching back is instant) — but the panel showed it with no context, so "VoiceStudio TTS — 1.9 GB" after selecting VoxCPM2 looked like the selection was ignored. A field report confirmed the confusion. Resident-but-inactive models are now tagged "not active — safe to unload", and the API self-describes each entry's engine. (#985)
|
||||
- **Installs behind a corporate or antivirus TLS-inspecting proxy no longer fail with a raw SSL error.** `SSLV3_ALERT_HANDSHAKE_FAILURE` happens when a proxy re-signs HTTPS traffic with a root CA your OS trusts but Python's bundled certificate list doesn't — a different failure mode from the network-blocking case above. OmniVoice now trusts your OS's certificate store directly, which should resolve the handshake outright rather than just explain it better. (#976)
|
||||
- **The loaded-models panel now says when a resident model is not your active engine.** Switching TTS engines keeps the previous model in VRAM (so switching back is instant) — but the panel showed it with no context, so "OmniVoice TTS — 1.9 GB" after selecting VoxCPM2 looked like the selection was ignored. A field report confirmed the confusion. Resident-but-inactive models are now tagged "not active — safe to unload", and the API self-describes each entry's engine. (#985)
|
||||
- **Voices no longer ship with a hidden echo.** Every non-raw synthesis was getting a small room reverb baked in by the mastering pre-stage — on top of whatever effect preset you chose, so even "Podcast" (which promises *no reverb*) had some, and Cinematic/Warm got it twice. A field report ("a lot of echo/reverb on some of the voices") led straight to it. The mastering stage is now highpass + compressor only; reverb happens only when a preset explicitly declares it. Also documented: cloned voices reproduce the reference clip's room acoustics — dry, close-mic references clone cleanest. (#986)
|
||||
- **Your engine selection now actually applies to Dubbing and Batch TTS.** Both hardcoded VoiceStudio regardless of what was picked in Settings → Engines — pick VoxCPM2, dub anyway with VoiceStudio, no error. Both now resolve the active engine up front; an engine that can't clone from reference audio (KittenTTS, Sherpa-ONNX, Supertonic 3 — fixed preset voices only) fails the job immediately with a clear message naming which engines do support it, instead of silently substituting VoiceStudio or mis-cloning every speaker into one voice. Batch only requires cloning when a specific voice is pinned — an unpinned batch job runs on any engine. (#987)
|
||||
- **Your engine selection now actually applies to Dubbing and Batch TTS.** Both hardcoded OmniVoice regardless of what was picked in Settings → Engines — pick VoxCPM2, dub anyway with OmniVoice, no error. Both now resolve the active engine up front; an engine that can't clone from reference audio (KittenTTS, Sherpa-ONNX, Supertonic 3 — fixed preset voices only) fails the job immediately with a clear message naming which engines do support it, instead of silently substituting OmniVoice or mis-cloning every speaker into one voice. Batch only requires cloning when a specific voice is pinned — an unpinned batch job runs on any engine. (#987)
|
||||
- **AMD ROCm torch install no longer silently falls back to CPU.** A community member (Kaihui-AMD) diagnosed it precisely: the ROCm wheel index we pointed at tops out at PyTorch 2.5.1, but the app pins `torch==2.8.0` — the reinstall was unsatisfiable and silently kept the default CUDA build, which runs on CPU on an AMD GPU. Bumped the default index to one that actually carries the pinned version. (#972)
|
||||
- **mlx-audio no longer crashes on unsupported languages.** Selecting a language like Dutch, Spanish, or Portuguese with mlx-audio's Kokoro model crashed with a raw, unreadable internal-details dump instead of a real error — the code was guessing an ISO language code by truncating the language name, which only worked by coincidence for a few languages. Unsupported languages now fail cleanly with a message naming what's actually supported, and no engine can leak a raw crash-internals dump into an error message again. (#977)
|
||||
- **The voice-design panel no longer crashes on certain saved voice profiles.** A genuine regression: an earlier translation fix accidentally introduced a crash when a saved design profile's data was incomplete (possible from an older app version or a partial save). Fixed at every layer — the render no longer crashes, both places that restore saved data complete it first, and profiles can no longer be *saved* with incomplete data in the first place. (#983)
|
||||
- **Windows: the dictation pill no longer steals focus.** Pressing the dictation shortcut activated the pill window, which meant the auto-paste landed back in VoiceStudio instead of whatever app you were dictating into, and the pill would get stuck on screen. Precisely diagnosed by a community reporter; fixed to match how this already worked on macOS. (#982)
|
||||
- **Windows: the dictation pill no longer steals focus.** Pressing the dictation shortcut activated the pill window, which meant the auto-paste landed back in OmniVoice instead of whatever app you were dictating into, and the pill would get stuck on screen. Precisely diagnosed by a community reporter; fixed to match how this already worked on macOS. (#982)
|
||||
- **The nemo-parakeet ASR engine's install hint no longer breaks your backend.** Following the in-app "pip install nemo_toolkit[asr]" instruction silently downgraded core packages your backend needs to start — the install reported success, and the breakage only showed up on the next restart. The hint now says plainly that this isn't safe to install into the shared environment. (#974)
|
||||
- **A stuck generate now tells you the actual fix.** When a job times out from GPU/VRAM contention, the error explained why but never mentioned Flush/Unload — the one action that actually resolves it, and one the sibling ASR-timeout error already recommended. (#939)
|
||||
|
||||
@@ -1005,15 +264,15 @@ The dictation release — and a deep reliability pass driven by live-testing the
|
||||
|
||||
### Added
|
||||
|
||||
- **Sponsor VoiceStudio.** A new `SPONSORS.md` (tiers, logo guidelines, how to sponsor), a README Sponsors section, and an in-app Sponsors area (Support page + a footer link) let people back the project — with a one-click "Become a sponsor" that opens a structured GitHub issue form, no account or token needed. Sponsorship is a thank-you, not a paywall: VoiceStudio stays free and AGPL-3.0. (#923, #924)
|
||||
- **OpenAPI reference in Settings.** A new Settings → OpenAPI page embeds an interactive Scalar reference for VoiceStudio's local backend API, with a one-click footer button. Fully local — Scalar is bundled, not loaded from a CDN, and phones home to nothing. (#928)
|
||||
- **Sponsor OmniVoice.** A new `SPONSORS.md` (tiers, logo guidelines, how to sponsor), a README Sponsors section, and an in-app Sponsors area (Support page + a footer link) let people back the project — with a one-click "Become a sponsor" that opens a structured GitHub issue form, no account or token needed. Sponsorship is a thank-you, not a paywall: OmniVoice stays free and AGPL-3.0. (#923, #924)
|
||||
- **OpenAPI reference in Settings.** A new Settings → OpenAPI page embeds an interactive Scalar reference for OmniVoice's local backend API, with a one-click footer button. Fully local — Scalar is bundled, not loaded from a CDN, and phones home to nothing. (#928)
|
||||
- **Engine Self-test.** The Engines matrix gains a "Self-test" button for in-process TTS engines that runs a tiny real synthesis and reports duration + sample rate — proving an engine actually makes audio, not just imports — plus a copy-paste `export OMNIVOICE_*_DIR=…` setup line for opt-in engines right in the "Why unavailable?" panel. (#930)
|
||||
- **One canonical HuggingFace-token store + incomplete-download visibility.** The Model Store token field now saves to and is cleared from the same encrypted store as Settings → Credentials (no more two-stores split), and a truncated model cache shows an "incomplete · N MB" state with one-click Repair and Delete instead of masquerading as "not installed". (#927)
|
||||
- **Launchpad, reimagined as a deck of cards.** The seven feature cards now fan out with animated waveform faces in each card's accent color; hover or keyboard-focus any card and it comes forward while the rest tuck underneath, and the layout stays usable down to the minimum window size. (#904)
|
||||
- **See exactly what VoiceStudio keeps on disk — and get warned before space runs out.** Settings → Storage shows real usage for the model cache (with your largest models), app data, engine environments and temp files, plus a free-space gauge and low-disk / near-full-volume warnings with one-click paths to open folders or reclaim space. (#906)
|
||||
- **See exactly what OmniVoice keeps on disk — and get warned before space runs out.** Settings → Storage shows real usage for the model cache (with your largest models), app data, engine environments and temp files, plus a free-space gauge and low-disk / near-full-volume warnings with one-click paths to open folders or reclaim space. (#906)
|
||||
- **A "What's new" changelog reader in Settings → Updates.** The available update's real release notes now render in-app, alongside an offline changelog viewer and a one-time "what's new" note after each update. (#909)
|
||||
- **Route each AI feature to its own LLM — or switch it off.** A new Settings → LLM Skills panel lists every LLM-powered capability (Cinematic/Autofit translation, slot fitting, glossary auto-extract, direction parsing, dictation cleanup) with a per-skill toggle and provider picker, so sensitive work can stay on a local model while heavier jobs use a remote one. Disabled skills fall back to the exact non-LLM behavior. (#912)
|
||||
- **A small thank-you moment, done right.** After a successful export, dub, audiobook, or batch run, VoiceStudio may — rarely — show a friendly, dismissible note by the footer heart about supporting development: never more than once a session, at most every 7 days, never for brand-new users, with a permanent "don't ask again". The logs bar also gained an icon and the footer icons now share one size. (#898)
|
||||
- **A small thank-you moment, done right.** After a successful export, dub, audiobook, or batch run, OmniVoice may — rarely — show a friendly, dismissible note by the footer heart about supporting development: never more than once a session, at most every 7 days, never for brand-new users, with a permanent "don't ask again". The logs bar also gained an icon and the footer icons now share one size. (#898)
|
||||
|
||||
- **Dictation, rebuilt.** The dictation pill now shows a live waveform the moment the mic opens, streams words as you speak with real download/loading progress on first use, and finishes what you say in about half a second of silence instead of two-and-a-half. Transcripts come out properly capitalized and punctuated. Text insertion is now honest and safe: your clipboard is preserved and restored, failures show what to do (including a one-click jump to macOS Accessibility settings when permission is missing) instead of a false "Pasted", and Esc cancels cleanly at any point. The dictation model also pre-warms in the background after launch, so the first press of the hotkey no longer sits on a cold model load.
|
||||
|
||||
@@ -1022,7 +281,7 @@ The dictation release — and a deep reliability pass driven by live-testing the
|
||||
### Changed
|
||||
|
||||
- **A "Get in touch" page that actually guides you.** The Contact page is now clearly-labelled cards (report a bug, request a feature, get community help, support the project, report a security issue) with a sentence each on when to use them, instead of a flat link list. (#925)
|
||||
- **Release titles are version-first.** GitHub's release-list sidebar truncates the title, so "VoiceStudio v0.3.8" hid the version; releases are now named "vX.Y.Z — VoiceStudio" so the version is always visible. (#922)
|
||||
- **Release titles are version-first.** GitHub's release-list sidebar truncates the title, so "OmniVoice Studio v0.3.8" hid the version; releases are now named "vX.Y.Z — OmniVoice Studio" so the version is always visible. (#922)
|
||||
- **Launchpad feature cards now fill the window.** The seven cards (Voice Clone, Voice Design, Video Dubbing, Stories, Audiobook, Voice Gallery, Transcripts) span the full content width on a maximized display instead of a fixed ~780px fan, and reflow responsively (7→3→1 columns) down to the 900×600 minimum — driven by the shell's own width, keeping the animated card faces, hover/keyboard-focus raise, and reduced-motion fallback. (#915)
|
||||
- **LLM Providers settings, de-confused.** The old inline "LLM endpoint" box in Translation is gone — LLM Providers is now the one place that owns it. Fields pinned by an environment variable are shown disabled with an explainer instead of silently reverting, the make-active button explains when a provider is env-pinned, and the Cloudflare Account ID is remembered and editable. (#907)
|
||||
- **Intel Macs: honestly unsupported for the local backend.** PyTorch no longer ships Intel-Mac builds, so the backend cannot run there; instead of a cryptic dependency error, Intel users now get a clear explanation up front (with the remote-backend option), and the README/docs say so plainly. (#889, #891)
|
||||
@@ -1052,7 +311,7 @@ The dictation release — and a deep reliability pass driven by live-testing the
|
||||
|
||||
- **Parakeet TDT transcription now works without an NVIDIA GPU.** The `nemo-parakeet` ASR engine (parakeet-tdt-0.6b-v3, 25 languages, word timestamps) was hard-gated behind CUDA — but a live measurement on an Apple Silicon M2 shows it transcribing at ~10× realtime *on CPU*, roughly 20× faster than the default whisper-large-v3 on the same machine at equal accuracy. The false GPU gate is removed, so Mac and CPU-only users can now pick the dramatically faster engine in Settings → Engines.
|
||||
|
||||
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.** On cards where the TTS model already held most of the VRAM (e.g. RTX 4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip or dub transcription died as a *native* CUDA out-of-memory abort — the whole backend process vanished with no error logged, and the app showed "Can't reach the local VoiceStudio backend." A new VRAM preflight re-checks free GPU memory right before the ASR load and steps down float16 → int8 → CPU instead of attempting a load that can't fit (opt-out: `OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
|
||||
- **8 GB GPUs: voice-clone/dub transcription no longer kills the backend.** On cards where the TTS model already held most of the VRAM (e.g. RTX 4060 Ti 8 GB), loading whisper `large-v3` in float16 for a reference-clip or dub transcription died as a *native* CUDA out-of-memory abort — the whole backend process vanished with no error logged, and the app showed "Can't reach the local OmniVoice backend." A new VRAM preflight re-checks free GPU memory right before the ASR load and steps down float16 → int8 → CPU instead of attempting a load that can't fit (opt-out: `OMNIVOICE_ASR_VRAM_PREFLIGHT=0`). (#723)
|
||||
|
||||
### CI
|
||||
|
||||
@@ -1228,7 +487,6 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
|
||||
above Continue, framed around what it actually buys you — authenticated, faster,
|
||||
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
|
||||
"get a free token" link. (#657, #669)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug reports redact more secrets and every Windows username casing.** The
|
||||
@@ -1275,9 +533,9 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
|
||||
|
||||
- **Dubbing a video URL no longer fails with "ffmpeg is not installed."** yt-dlp
|
||||
downloads video and audio as separate streams and muxes them with ffmpeg, but
|
||||
it only looked on PATH — so on Windows (where VoiceStudio's ffmpeg is a bundled
|
||||
it only looked on PATH — so on Windows (where OmniVoice's ffmpeg is a bundled
|
||||
sidecar / `imageio-ffmpeg` binary off PATH) the merge aborted before the dub
|
||||
could start. yt-dlp is now pointed at the same ffmpeg VoiceStudio resolves. (#712)
|
||||
could start. yt-dlp is now pointed at the same ffmpeg OmniVoice resolves. (#712)
|
||||
- **A synth that succeeded no longer 500s because of a history-logging hiccup.**
|
||||
If the local database somehow missed schema init, recording the clip to
|
||||
generation history failed with *"no such table: generation_history"* and
|
||||
@@ -1375,7 +633,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
|
||||
- **Dubbing a URL no longer fails with `[Errno 22] Invalid argument` on Windows.**
|
||||
yt-dlp stamps the downloaded file's modified-time with the video's upload
|
||||
date; an out-of-range/invalid timestamp makes the `os.utime` call raise
|
||||
`[Errno 22]` and aborts the whole URL ingest. VoiceStudio downloads to a throwaway
|
||||
`[Errno 22]` and aborts the whole URL ingest. OmniVoice downloads to a throwaway
|
||||
file and never uses its mtime, so it now skips the stamp entirely
|
||||
(`updatetime=False`). (#642)
|
||||
|
||||
@@ -1472,7 +730,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
|
||||
field now owns its own height (starts taller, and the corner grip grows it
|
||||
reliably on every platform). (#595)
|
||||
- **An interrupted model download now self-repairs instead of dead-ending.**
|
||||
When the VoiceStudio TTS cache was missing weight shards (the usual aftermath of
|
||||
When the OmniVoice TTS cache was missing weight shards (the usual aftermath of
|
||||
an interrupted first download), the next synthesize failed with a 500 and a
|
||||
"delete the model and install it again" instruction — a manual dead-end. The
|
||||
backend now detects the truncated-cache error on load, re-fetches just the
|
||||
@@ -1750,7 +1008,7 @@ first-run, and install reliability all get a pass too.
|
||||
- **Portable personas (`.ovsvoice`).** Export any voice as a self-contained,
|
||||
fully-local persona bundle — identity, optional reference clip, consent
|
||||
attestation, SPDX license, and a watermarked preview — and import it back into
|
||||
another VoiceStudio install. A privacy toggle ships a **preview-only** bundle so
|
||||
another OmniVoice install. A privacy toggle ships a **preview-only** bundle so
|
||||
no raw recording of your voice has to travel. Verified-own-voice status can't
|
||||
be forged by hand-editing a bundle (real recording + consent text + attestation
|
||||
required). Legacy `.omnivoice` files still import. See
|
||||
@@ -1762,7 +1020,7 @@ first-run, and install reliability all get a pass too.
|
||||
active engine's GPU verdict (accelerated / caveat / CPU-fallback /
|
||||
unavailable). At synth time every TTS entry point (`/generate`,
|
||||
`/v1/audio/speech`) enforces the same routing — an engine that can't use this
|
||||
host's GPU returns an explicit error or an `X-VoiceStudio-Routing` header instead
|
||||
host's GPU returns an explicit error or an `X-OmniVoice-Routing` header instead
|
||||
of silently dropping to CPU or dying mid-synth. (#21)
|
||||
- **Diagnostics suite.** New self-check tooling for when something's wrong: a
|
||||
`/system/diagnose` report (and matching backend `--diagnose`), a persistent
|
||||
@@ -1804,7 +1062,7 @@ first-run, and install reliability all get a pass too.
|
||||
crossfade removes the per-generation length cap, and a new sentence-by-sentence
|
||||
`/ws/tts` streams audio as it's produced. An inline `[pause Nms]` marker
|
||||
inserts measured silence in generated speech. (#276, #357, #358)
|
||||
- **MCP server v1.** VoiceStudio mounts an MCP server on `/mcp` (with a stdio shim
|
||||
- **MCP server v1.** OmniVoice mounts an MCP server on `/mcp` (with a stdio shim
|
||||
and per-agent voice binding) so it can act as a local TTS/STT provider for
|
||||
agentic pipelines. (#368)
|
||||
- **Remote-backend access.** Point the desktop UI at a remote backend URL with a
|
||||
@@ -1820,7 +1078,7 @@ first-run, and install reliability all get a pass too.
|
||||
### Fixed
|
||||
|
||||
- **Transcription/dubbing failed when ffmpeg wasn't on `PATH`** (notably on
|
||||
Windows). WhisperX now decodes audio through VoiceStudio's own validated ffmpeg
|
||||
Windows). WhisperX now decodes audio through OmniVoice's own validated ffmpeg
|
||||
binary instead of a bare `PATH` lookup, so ASR works without a system ffmpeg
|
||||
install. (#479)
|
||||
- **Translation defaulted the source language to English.** Dubbing/translation
|
||||
@@ -2069,4 +1327,4 @@ Region selector, realtime download speed, retry buttons, recheck top-right, HF m
|
||||
|
||||
## Earlier releases
|
||||
|
||||
See [GitHub Releases](https://github.com/debpalash/VoiceStudio/releases) for prior versions.
|
||||
See [GitHub Releases](https://github.com/debpalash/OmniVoice-Studio/releases) for prior versions.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**VoiceStudio**
|
||||
**OmniVoice Studio**
|
||||
|
||||
VoiceStudio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/VoiceStudio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
|
||||
OmniVoice Studio is an open-source, fully-local ElevenLabs alternative — a desktop app for voice cloning, voice design, video dubbing, and real-time dictation across 646 languages. It runs entirely on the user's machine (CUDA/MPS/ROCm/CPU auto-detect), with no API keys, no accounts, and no cloud dependencies. It's an active beta with a growing user base who hit it with real workloads (50-video batches, multi-engine setups, edge-OS platforms) and report friction in GitHub Issues and Discord. The current version lives in `frontend/package.json` (the single source of truth — see Versioning); the latest stable tag is on the [Releases page](https://github.com/debpalash/OmniVoice-Studio/releases/latest). With `AUTO_VERSION_BUMP` off (the current owner setting), `main` holds at the released version between releases.
|
||||
|
||||
**Core Value:** **A first-run that actually works.** A user who downloads the installer (or clones the repo) should reach a working voice-cloning or dubbing output without hitting a wall — and when something does go wrong, the error or docs should tell them exactly what to do.
|
||||
|
||||
@@ -13,9 +13,9 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
|
||||
|
||||
- **Existing engine compatibility**: Users with already-installed engines (IndexTTS, CosyVoice, etc.) must not have to reinstall. Fixes touching engine code must be backward-compatible with on-disk model state.
|
||||
- **Cross-platform parity**: Every fix must work on macOS (Apple Silicon + Intel), Windows (x64), and Linux (AppImage + deb). No platform-only regressions; the cross-platform bug bash (PR #51) is the baseline.
|
||||
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option. **This rule governs BEHAVIOUR, not PERFORMANCE** (clarified 2026-07-30, council): hardware acceleration is expected to vary by host — CUDA, MPS, DirectML, Triton availability and `torch.compile` are all host-dependent by design, and reading the rule to forbid that would forbid GPU support itself. An optimization that is skipped where it cannot work (missing Triton, an arch the wheel lacks, a path its toolchain cannot link) is NOT a parity violation; a *feature* the user can see and use on one OS but not another is.
|
||||
- **Default features must work on every platform (strict rule, 2026-05-20):** A feature that ships in default mode — out-of-the-box, no user customization, no opt-in toggle — must behave identically on macOS, Windows, and Linux. Platform-specific *implementation code* is allowed for OS APIs / shells / packaging, but the user-visible *default behavior* cannot diverge. Platform-only features (e.g., a macOS-only global shortcut, a Windows-only path picker) must go behind explicit user opt-in: Settings toggle, env var, or CLI flag. When a default doesn't work on a platform, that's a P0 bug — either fix it on the missing platform or move it behind opt-in. No third option.
|
||||
- **Backward-compatible project data**: Existing `omnivoice_data/` (user voices, projects, settings) must keep working without manual migration. Any DB schema change goes through alembic with a tested upgrade path.
|
||||
- **Local-first guarantee preserved**: nothing leaves the machine without the user's **explicit yes**, and the app must remain fully functional with everything declined. Auto bug reporting is opt-in and submits only to GitHub Issues (prefilled-URL, from the user's own browser). Product analytics (owner-sanctioned 2026-07-16) is opt-in PostHog EU with a **first-run consent prompt** — two equal-weight Yes/No buttons, never default-on, skipping = off; consent-gated, allowlisted content-free metadata only (`backend/core/analytics.py`); every build — installer, Docker, and source alike (owner reversal 2026-07-20, #1193) — carries the in-repo publishable write-only token and shows the same consent ask, with env/baked token overriding it. No required cloud calls, accounts, or API keys.
|
||||
- **Local-first guarantee preserved**: Auto bug reporting (new addition) must be **opt-in**, must submit only to GitHub Issues (no third-party telemetry endpoint), and the app must remain fully functional with reporting disabled. No required cloud calls, accounts, or API keys.
|
||||
- **Beta release cadence (no RC, no ceremony — strict rule, 2026-05-20):** the v0.3.x line has **no release candidates, no 48h soak, no formal release ceremony**. Every fix goes continuous-to-main; the owner tags a patch (`v0.3.Z`) from main whenever the current state is worth cutting. No `-rc` tags. No phased release. No `v0.4` deferrals while the v0.3.x line is open — every open issue and every open community PR gets absorbed into the v0.3.x line or explicitly declined. Users follow `main` for previews; users wanting stable stay on the latest tagged release. ROADMAP.md's Phase 6 "Release/Verify/Retro" entries are obsolete unless the user revives them.
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
@@ -24,7 +24,7 @@ Everything else (new engines, fancy features) is downstream of "the thing instal
|
||||
|
||||
The May-2026 stack research that used to live here served five capabilities that have all since shipped (HF-token Settings panel, prefilled-URL bug reporting, uv mirror fallback for restricted networks, the Supertonic-3 engine, in-repo Markdown docs). Follow the patterns in the code itself; the durable *don'ts* that research established:
|
||||
|
||||
- **No third-party endpoints for bug reporting or crash dumps** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs, submitted from the user's own browser. The one sanctioned third-party endpoint is the opt-in PostHog EU product analytics (owner-set 2026-07-16), which is consent-gated behind the first-run prompt, ships allowlisted content-free metadata only, and must never grow exception/DOM autocapture. Its publishable write-only project token is committed in-repo (owner reversal 2026-07-20, #1193 — source builds get the same consent-gated analytics as installers; env/baked token overrides), allowed by `tests/test_no_committed_analytics_token.py` in exactly `backend/core/analytics.py` + `frontend/src/utils/analytics.ts`.
|
||||
- **No third-party telemetry endpoints, ever** (`sentry-tauri` was evaluated and rejected) — bug reporting stays opt-in via prefilled GitHub-issue URLs.
|
||||
- **No PAT/token-based GitHub posting from the app** — the user submits from their own browser.
|
||||
- **Don't recommend `setx` for env vars on Windows** (silent truncation, no current-shell propagation) — use the in-app Settings panel or PowerShell `[Environment]::SetEnvironmentVariable`.
|
||||
- **Don't adopt Material for MkDocs** for any future docs site (maintenance mode since Nov 2025) — Astro Starlight is the precedent if docs ever outgrow the repo.
|
||||
@@ -42,14 +42,12 @@ For anything new: prefer what's already pinned in `pyproject.toml` / `frontend/p
|
||||
- Docker: `ghcr.io/debpalash/omnivoice-studio:latest` = **main** (rolling preview); `:X.Y.Z` + `:X.Y` + `:stable` = tagged releases. `:latest` is the preview channel by design — stable users pin `:stable` or a version tag.
|
||||
- Do not bump minor/major or invent RCs/codenames without the owner asking. No "defer to next version" labels — scope is absorbed or declined, never re-versioned.
|
||||
|
||||
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, `.github/CONTRIBUTING.md`, `.github/SECURITY.md`, `.github/SUPPORT.md`, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
|
||||
**Docs-sync (hard rule, owner-set 2026-06-11):** any change that alters something these docs describe — README.md, CONTRIBUTING.md, SECURITY.md, SUPPORT.md, LICENSE, or `docs/**` (install flows, Docker tag semantics, platform support, versioning/release behavior, review process, supported versions) — must update those docs **in the same PR** as the change. If a doc impact is discovered after merge, the docs fix is the immediate next commit, not backlog. Stale docs are treated as bugs.
|
||||
|
||||
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar (owner-restyled 2026-07-17, replaces the old bold-lead paragraphs): **quiet and scannable** — a short `**Highlights**` bullet list first (plain words, one line each), then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### License` / `### CI` subsections where each entry is a **single one-liner** with the `(#NNN)` issue/PR ref and contributor credit (`— thanks @user!`) where applicable. Written for users, grouped by theme, no multi-line paragraphs, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md` **and** `gh release edit <tag>` the live body — not backlog.
|
||||
**Release notes / changelog (hard rule, owner-set 2026-06-16):** every tagged release gets a **high-quality, user-facing `## [X.Y.Z] — DATE` section in `CHANGELOG.md`** before (or in the same hour as) the tag — never the "Auto-generated release for vX.Y.Z…" fallback. `release.yml` extracts that section verbatim as the GitHub Release body (the `Extract CHANGELOG section for tag` step), so a missing/empty section ships a bare release. Quality bar = the existing house style: a one-paragraph headline, then `### Added` / `### Fixed` / `### Changed` / `### License` / `### CI` subsections; each entry is a **bold one-line lead** (what the user gets), 1–3 lines of plain-English why, and the `(#NNN)` issue/PR ref — grouped by theme, written for users, **not** raw commit dumps. This applies to **preview builds too**: preview release notes summarize what's new on `main` since the last stable, in the same style. Workflow: as features merge, keep `## [Unreleased]` current; at release time rename it to the version + date. If a release was already cut with the fallback body, the next action is to backfill `CHANGELOG.md` **and** `gh release edit <tag>` the live body — not backlog.
|
||||
|
||||
**Localization (hard rule):** No hardcoded non-English (CJK) **user-facing text** anywhere in the codebase except the translation layer (`frontend/src/i18n/`). All UI strings go through i18n (`t('...')` keys in `locales/*.json`); native language names live in `i18n/index.ts` (`LANGUAGES`). Functional CJK is allowed and tracked via the allowlist in `tests/test_no_hardcoded_cjk.py` — text-processing regexes, model/engine vocabulary & identifiers (e.g. CosyVoice speaker IDs), localized error matching, demo/eval data, and test fixtures. CI fails on any hardcoded CJK outside the allowlist; to add legitimate functional CJK, extend `_ALLOWED_FILES` there with a justification.
|
||||
|
||||
**Release deployment channels (hard rule, owner-set 2026-07-16):** a version bump is not "released" until **every** deployment channel ships it — the full checklist (sources, producing workflows, per-channel verification) lives in `docs/RELEASING.md` §5b. The channels: GitHub Release with 4-platform installers + signed `latest.json` (Stable updater channel, body from CHANGELOG); the Preview updater channel; GHCR **and** Docker Hub images in **both** flavors (CUDA `:X.Y.Z`/`:X.Y`/`:stable`, ROCm `-rocm` suffixes); the Docker Hub overview page synced from `deploy/dockerhub-overview.md` (its sync step is `continue-on-error` and 403s silently if `DOCKERHUB_TOKEN` lacks description-edit scope — verify the **step log**, never just job-green). Verify all channels after tagging; a missing channel is a release bug to fix immediately, not backlog. **Preview/RC always sources from `main`:** there are no RC tags — the rolling preview channel (preview `latest.json`, Docker `:latest`/`:main`/`:rocm`) *is* the RC, it always builds from `main` (release.yml's preview-gate refuses other branches), and previewing a fix means merging it to `main` first. Never cut a side-branch build.
|
||||
|
||||
**Fix quality (hard rule, owner-set 2026-06-16):** Fix issues *properly* and future-maintenance-proof — don't stop at the symptom. Root-cause fully, fix the whole **class** of the bug (not just the one reported instance), add a fail-before/pass-after regression test, and harden against recurrence (e.g. if a lockfile drift only fails in Docker, also make CI catch it). Go the extra mile where it durably pays off. Be token-efficient about it — extra **effort**, not extra **verbosity**: no padding, no redundant re-checks, the smallest correct change that is also recurrence-proof. Don't be shy to spend the effort a proper fix needs; do be shy about wasting tokens.
|
||||
|
||||
**Keep main green (hard rule, owner-set 2026-06-16):** A merge must **never break `main`'s CI**. Before a change lands, verify the *full* CI matrix would pass — every workflow in `.github/workflows/` **and** `deploy/Dockerfile`, not only the checks you happened to run. Dependency / lockfile / config changes must be validated against **all** consumers. Specifically: `frontend/` is a bun **workspace monorepo** — the lockfile is the repo-root `bun.lock`, and `deploy/Dockerfile` runs `bun install --frozen-lockfile`, so any `frontend/package.json` change requires regenerating root `bun.lock` and confirming `bun install --frozen-lockfile` passes (plain `bun install` in `ci.yml` silently tolerates drift, so CI-green ≠ Docker-green). Likewise re-check CodeQL/Security on code changes and the Tauri `cargo` build on Rust/dep changes.
|
||||
@@ -66,22 +64,13 @@ Architecture not yet mapped. Follow existing patterns found in the codebase.
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
|
||||
- `vite` — Vite configuration, assets, HMR, builds, and Vitest guidance.
|
||||
- `fastapi-python` — FastAPI and Pydantic implementation patterns.
|
||||
|
||||
Canonical copies live under `.agents/skills/`; `skills-lock.json` pins their sources and hashes. Claude should follow these paths directly, avoiding cross-platform symlinks.
|
||||
No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.
|
||||
<!-- GSD:skills-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## Workflow
|
||||
|
||||
Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command gate that used to live here referenced `/gsd-quick` / `/gsd-debug` / `/gsd-execute-phase` skills that are not installed in this environment; the owner chose to keep working directly rather than restore them. The working conventions that matter are in **Conventions** above — versioning, docs-sync, changelog, localization, fix quality, keep-main-green — plus: gate every merge on the "Tests (backend + frontend)" check passing and the PR being MERGEABLE, and check the open-PR queue before implementing any community-reported fix (contributors may have already submitted one).
|
||||
|
||||
**Harvest bot reviews before merging (rule, 2026-07-20):** CodeRabbit and Greptile auto-review every PR (tuned via `.coderabbit.yaml` / `greptile.json`, both fed CLAUDE.md as context). Before merging ANY PR — including your own — read their inline comments (`gh api repos/<owner>/<repo>/pulls/<N>/comments` filtered by bot login) and triage: fix real findings, ignore noise, never merge with an unread Critical/P1. They are the free first review pass; reserve deep agent-driven review for what they can't judge (architecture, cross-file semantics, product intent). Mechanical rules belong in deterministic CI tests, not in any AI reviewer.
|
||||
|
||||
**Token economy (owner directive, 2026-07-20; tightened 2026-07-28):** default to the shortest response that fully answers — outlines and tables over prose, no preamble, no recap of work just done, no re-explaining what the diff shows; applies to every response, not just status updates. Lead with the outcome; one-line statuses; no narration, filler, or diff-restating. Read what CI/linters/review bots already computed instead of re-deriving it. Mechanical rules belong in deterministic tests (changelog style, locale parity, version lockstep, CJK — all in `tests/`), never in agent effort. Targeted tests while iterating; full suites only before landing. `AGENTS.md` carries this contract for all agents — keep the two in sync.
|
||||
|
||||
**Never accept a PR as-is (owner directive, 2026-07-20):** review findings — bot, agent, or human — get FIXED on the PR branch before merge (maintainer commits are fine and credit the contributor in the changelog); do not merge with known issues, do not merge-then-fix, do not leave findings as comments for someone else. Also merge current `main` into stale community branches before judging their CI, so the PR runs today's workflow gates (PR-green under an old workflow ≠ main-green).
|
||||
<!-- GSD:workflow-end -->
|
||||
|
||||
|
||||
@@ -92,17 +81,3 @@ Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command g
|
||||
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-claude-profile` -- do not edit manually.
|
||||
<!-- GSD:profile-end -->
|
||||
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
GitHub Issues on `debpalash/VoiceStudio`, via the `gh` CLI. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
The five canonical roles, each label string equal to its name. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
|
||||
|
||||
@@ -46,7 +46,7 @@ an individual is officially representing the community in public spaces.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
**VoiceStudio@palash.dev**.
|
||||
**OmniVoice@palash.dev**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
# Contributing to VoiceStudio
|
||||
# Contributing to OmniVoice Studio
|
||||
|
||||
Thanks for your interest in improving VoiceStudio! This guide covers everything you need to get started.
|
||||
Thanks for your interest in improving OmniVoice Studio! This guide covers everything you need to get started.
|
||||
|
||||
## Quick Links
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 💬 **Chat** | [Discord](https://discord.gg/bzQavDfVV9) |
|
||||
| 🐛 **Bugs** | [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) |
|
||||
| 🏷️ **Good First Issues** | [Filtered list](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) |
|
||||
| 🐛 **Bugs** | [GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues) |
|
||||
| 🏷️ **Good First Issues** | [Filtered list](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue) |
|
||||
| 📋 **Roadmap** | [README → Roadmap](README.md#roadmap) |
|
||||
|
||||
---
|
||||
|
||||
## Adding a TTS or ASR engine
|
||||
|
||||
New engines are hired for a **named job**, not added to a list — the bar, the current job map,
|
||||
and the out-of-tree path are in [docs/engine-acceptance.md](../docs/engine-acceptance.md).
|
||||
Read it before opening a proposal; the licence check in particular ends most of them.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
@@ -28,30 +22,13 @@ Read it before opening a proposal; the licence check in particular ends most of
|
||||
- [Bun](https://bun.sh/) (frontend package manager)
|
||||
- [uv](https://docs.astral.sh/uv/) (Python environment manager)
|
||||
- [ffmpeg](https://ffmpeg.org/) (audio/video processing)
|
||||
- [Rust / Cargo](https://rustup.rs/) (desktop shell only)
|
||||
- Python 3.10+ (managed automatically by `uv`)
|
||||
|
||||
Linux desktop development also needs WebKitGTK/GTK development libraries. On
|
||||
Debian or Ubuntu, install the same packages used by CI:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev libgtk-3-dev libpango1.0-dev libcairo2-dev \
|
||||
libsoup-3.0-dev libgdk-pixbuf-2.0-dev \
|
||||
libayatana-appindicator3-dev librsvg2-dev libssl-dev libxdo-dev \
|
||||
gstreamer1.0-plugins-good \
|
||||
libasound2-dev build-essential curl wget file
|
||||
```
|
||||
|
||||
See the [Linux source-build guide](../docs/install/linux.md#building-from-source)
|
||||
for Fedora and Arch packages.
|
||||
|
||||
### Clone & Run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
@@ -63,14 +40,6 @@ This starts both services:
|
||||
| **Backend** | `localhost:3900` | FastAPI server — TTS, ASR, diarization, dubbing pipeline |
|
||||
| **Frontend** | `localhost:3901` | React + Vite UI |
|
||||
|
||||
The backend runs through `scripts/dev-backend.mjs` (the `dev:api` script): the
|
||||
uvicorn command is unchanged, but if the backend **dies** (OOM kill, hard
|
||||
crash), the wrapper prints a boxed exit banner with the exit code/signal and
|
||||
the last 20 lines of `omnivoice.log` before the dev stack shuts down — so the
|
||||
cause doesn't scroll away with the terminal. The same death is also reported
|
||||
as a crash notice in the UI the next time the backend starts (see
|
||||
[docs/install/troubleshooting.md §14c](docs/install/troubleshooting.md)).
|
||||
|
||||
### Desktop App (Tauri)
|
||||
|
||||
```bash
|
||||
@@ -85,18 +54,6 @@ names: there is no `desktop=prod` (note the **hyphen** in `desktop-prod`).
|
||||
|
||||
Requires [Rust](https://rustup.rs/) and platform-specific Tauri dependencies — see the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
After installing Rust with rustup on macOS/Linux, either open a new terminal or
|
||||
load Cargo into the current one before starting the desktop app:
|
||||
|
||||
```bash
|
||||
source "$HOME/.cargo/env"
|
||||
bun desktop
|
||||
```
|
||||
|
||||
On Linux, errors such as `Package gdk-3.0 was not found`, `pango.pc` missing,
|
||||
or `javascriptcoregtk-4.1` missing mean the native packages above were not
|
||||
installed; changing `PKG_CONFIG_PATH` does not fix libraries that are absent.
|
||||
|
||||
If the app opens but stays on the **setup splash with no buttons**, the Python
|
||||
backend didn't finish starting — the splash surfaces the stall reason, a log
|
||||
panel, and a **Retry** button (and Settings → Logs → Backend has the full trace).
|
||||
@@ -107,7 +64,7 @@ The most common from-source cause is `uv` or Python not being on your PATH.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
VoiceStudio/
|
||||
OmniVoice-Studio/
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── api/ # Route handlers
|
||||
│ ├── core/ # Config, prefs, constants
|
||||
@@ -131,7 +88,7 @@ VoiceStudio/
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Open an [issue](https://github.com/debpalash/VoiceStudio/issues/new) with:
|
||||
Open an [issue](https://github.com/debpalash/OmniVoice-Studio/issues/new) with:
|
||||
|
||||
1. **What happened** vs **what you expected**
|
||||
2. **Steps to reproduce**
|
||||
@@ -155,7 +112,7 @@ Open an [issue](https://github.com/debpalash/VoiceStudio/issues/new) with:
|
||||
|
||||
### Adding a New TTS Engine
|
||||
|
||||
VoiceStudio's TTS backend is a plugin registry. Adding a new engine takes ~50 lines:
|
||||
OmniVoice's TTS backend is a plugin registry. Adding a new engine takes ~50 lines:
|
||||
|
||||
1. Open `backend/services/tts_backend.py`
|
||||
2. Create a class extending `TTSBackend`:
|
||||
@@ -203,8 +160,7 @@ class MyEngineBackend(TTSBackend):
|
||||
|
||||
- **Components**: Functional components with hooks
|
||||
- **State**: Zustand stores in `src/stores/`, organized by slice
|
||||
- **Brand assets**: Reuse the canonical mark, palette, naming, and compatibility rules in [`docs/branding.md`](../docs/branding.md); do not redraw or rename runtime identifiers ad hoc
|
||||
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the VoiceStudio palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
|
||||
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the OmniVoice palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
|
||||
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
|
||||
|
||||
### Rust (Tauri)
|
||||
@@ -292,21 +248,6 @@ what's right, push back (in a reply) on what's wrong.
|
||||
(`fix(dub): …`, `feat(setup): …`) and link the issue (`Closes #N` / `Refs #N`)
|
||||
in the title or body.
|
||||
|
||||
### Contributing with AI agents
|
||||
|
||||
Plenty of contributions here are built with Claude Code, Cursor, and similar
|
||||
agents — welcome, with the same quality bar as hand-written PRs (real bug,
|
||||
correct fix, regression test; see the quality gates below).
|
||||
|
||||
One practical tip: this codebase is large, and re-explaining it to your agent
|
||||
every session burns context and tokens fast. A persistent memory layer fixes
|
||||
that — the agent recalls the architecture, conventions, and your past findings
|
||||
instead of re-reading the tree each time. [**memxt**](https://github.com/debpalash/memxt)
|
||||
(100% local, MCP-based, built by this project's maintainer) exists for exactly
|
||||
this; any MCP memory server works. Pair it with the repo's agent skill —
|
||||
`npx skills add debpalash/omnivoice-studio` — so your agent knows the project's
|
||||
hard rules from the first prompt.
|
||||
|
||||
## Quality gates your PR must pass
|
||||
|
||||
- **Cross-platform parity (hard rule):** anything that ships in default mode
|
||||
@@ -333,7 +274,7 @@ hard rules from the first prompt.
|
||||
|
||||
## Contribution licensing
|
||||
|
||||
VoiceStudio is **AGPL-3.0-only**, and the maintainer also offers a
|
||||
OmniVoice Studio is **AGPL-3.0-only**, and the maintainer also offers a
|
||||
**commercial license** (see [LICENSE](LICENSE)). By submitting a contribution
|
||||
you agree that:
|
||||
|
||||
@@ -353,7 +294,7 @@ appreciated but not required.
|
||||
## Need Help?
|
||||
|
||||
- **Stuck on setup?** Ask in [Discord #help](https://discord.gg/bzQavDfVV9)
|
||||
- **Not sure where to start?** Check [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- **Want to discuss a big change?** Open a [discussion](https://github.com/debpalash/VoiceStudio/discussions) or Discord thread before coding
|
||||
- **Not sure where to start?** Check [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
|
||||
- **Want to discuss a big change?** Open a [discussion](https://github.com/debpalash/OmniVoice-Studio/discussions) or Discord thread before coding
|
||||
|
||||
Thank you for contributing! 🎙️
|
||||
@@ -1,3 +1,57 @@
|
||||
# OmniVoice Studio — License
|
||||
|
||||
## Abbreviation
|
||||
|
||||
AGPL-3.0-only
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2024-present Palash Debnath and OmniVoice Studio contributors.
|
||||
|
||||
OmniVoice Studio is **free and open-source software, licensed under the GNU
|
||||
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
|
||||
copy, modify, and redistribute it — and that **includes commercial and internal
|
||||
business use**: run the app, use its outputs commercially, sell the audio you
|
||||
produce with it, provide professional/client services with it, and deploy it
|
||||
within your organization.
|
||||
|
||||
Because this is the **Affero** GPL, one additional obligation applies: if you
|
||||
modify OmniVoice Studio and make that modified version available to others over
|
||||
a network, you must also offer those users the complete corresponding source
|
||||
code of your modified version under these same AGPL-3.0 terms. See the full
|
||||
text below.
|
||||
|
||||
A **commercial license is available** for organizations that want to embed
|
||||
OmniVoice Studio in a closed-source or proprietary product or service without
|
||||
the AGPL-3.0 copyleft obligations. Pricing tiers are coming soon; for inquiries
|
||||
contact `OmniVoice@palash.dev`.
|
||||
|
||||
(This Notice is a plain-language summary; the binding terms are the full GNU
|
||||
AGPL-3.0 text reproduced below.)
|
||||
|
||||
### Scope
|
||||
|
||||
These terms cover the OmniVoice Studio application — the Tauri desktop shell
|
||||
(`frontend/src-tauri/`), the React frontend (`frontend/src/`), the FastAPI
|
||||
backend (`backend/`), and supporting build / packaging scripts (`scripts/`,
|
||||
`Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by Han Zhu —
|
||||
is **separately licensed under Apache License 2.0** by its upstream authors and
|
||||
is not relicensed here. Apache License 2.0 is compatible with, and may be
|
||||
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
|
||||
|
||||
Third-party dependencies retain their own licenses. See `Cargo.lock`,
|
||||
`bun.lock`, and `uv.lock` for the resolved set.
|
||||
|
||||
### Reference
|
||||
|
||||
The full canonical text of the GNU Affero General Public License, Version 3
|
||||
follows verbatim. The authoritative copy lives at
|
||||
<https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||
|
||||
---
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# VoiceStudio — License Notice
|
||||
|
||||
## Abbreviation
|
||||
|
||||
AGPL-3.0-only
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2024-present Palash Debnath and VoiceStudio contributors.
|
||||
|
||||
VoiceStudio is **free and open-source software, licensed under the GNU
|
||||
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
|
||||
copy, modify, and redistribute it. That **includes commercial and internal
|
||||
business use** of the application itself. Model weights, tokenizers, and other
|
||||
third-party assets retain their own terms; this application license does not
|
||||
grant or summarize rights under those separate terms.
|
||||
|
||||
Because this is the **Affero** GPL, one additional obligation applies: if you
|
||||
modify VoiceStudio and make that modified version available to others over
|
||||
a network, you must also offer those users the complete corresponding source
|
||||
code of your modified version under these same AGPL-3.0 terms. See the full
|
||||
text in [`LICENSE`](LICENSE).
|
||||
|
||||
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 are coming soon; for inquiries
|
||||
contact `VoiceStudio@palash.dev`.
|
||||
|
||||
(This Notice is a plain-language summary; the binding terms are the full GNU
|
||||
AGPL-3.0 text in [`LICENSE`](LICENSE).)
|
||||
|
||||
### Scope
|
||||
|
||||
These terms cover the VoiceStudio application — the Tauri desktop shell
|
||||
(`frontend/src-tauri/`), the React frontend (`frontend/src/`), the FastAPI
|
||||
backend (`backend/`), and supporting build / packaging scripts (`scripts/`,
|
||||
`Dockerfile`, `docker-compose.yml`, `.github/`).
|
||||
|
||||
The bundled `omnivoice/` Python package — the underlying TTS model by Han Zhu —
|
||||
is **separately licensed under Apache License 2.0** by its upstream authors and
|
||||
is not relicensed here. Apache License 2.0 is compatible with, and may be
|
||||
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
|
||||
|
||||
Downloaded model weights are not relicensed by VoiceStudio. The default
|
||||
`k2-fsa/OmniVoice` model card identifies its code as Apache-2.0 and pretrained
|
||||
weights as CC-BY-NC. Its `audio_tokenizer/LICENSE` contains separate Boson
|
||||
Higgs Audio 2 and Meta Llama community terms. A commercial license for
|
||||
VoiceStudio-owned code does not replace any of those terms.
|
||||
|
||||
Third-party dependencies retain their own licenses. See `Cargo.lock`,
|
||||
`bun.lock`, and `uv.lock` for the resolved set.
|
||||
|
||||
### Reference
|
||||
|
||||
The full canonical text of the GNU Affero General Public License, Version 3 is
|
||||
reproduced verbatim in [`LICENSE`](LICENSE). The authoritative copy lives at
|
||||
<https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||
|
||||
> **Why this notice is a separate file:** `LICENSE` must contain the verbatim
|
||||
> AGPL-3.0 text and nothing else, so GitHub's license detection (and the
|
||||
> corporate license scanners that gate adoption) can identify it as
|
||||
> `AGPL-3.0-only` rather than falling back to "Other" / `NOASSERTION`.
|
||||
+363
-531
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,13 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 0.5.x (latest release + `main` previews) | ✅ Current — all fixes land here |
|
||||
| 0.3.x (latest release + `main` previews) | ✅ Current — all fixes land here |
|
||||
| 0.2.7 | ⚠️ Legacy stable — security fixes only, upgrade recommended |
|
||||
| < 0.2.7 | ❌ No longer supported |
|
||||
|
||||
## Model supply chain
|
||||
|
||||
VoiceStudio supports models from **public, verifiable sources only** (Hugging
|
||||
OmniVoice supports models from **public, verifiable sources only** (Hugging
|
||||
Face repos, official project releases). Privately sold or gated model files
|
||||
are not supported: an archive from a private source can carry anything
|
||||
(bundled executables, modified configs), and nobody else can verify or
|
||||
@@ -23,7 +23,7 @@ download, and never run executables bundled with model archives.
|
||||
|
||||
Instead, report them privately via one of these channels:
|
||||
|
||||
1. **GitHub Security Advisories** (preferred) — [Report a vulnerability](https://github.com/debpalash/VoiceStudio/security/advisories/new)
|
||||
1. **GitHub Security Advisories** (preferred) — [Report a vulnerability](https://github.com/debpalash/OmniVoice-Studio/security/advisories/new)
|
||||
2. **Email** — Send details to **security@palash.dev**
|
||||
|
||||
### What to include
|
||||
@@ -44,7 +44,7 @@ Instead, report them privately via one of these channels:
|
||||
|
||||
### Scope
|
||||
|
||||
VoiceStudio runs **100% locally** by default. The primary attack surface is:
|
||||
OmniVoice Studio runs **100% locally** by default. The primary attack surface is:
|
||||
|
||||
- **Network exposure** — if the user binds to `0.0.0.0` without a reverse proxy
|
||||
- **Model downloads** — fetched from Hugging Face Hub over HTTPS
|
||||
@@ -71,6 +71,6 @@ GitHub Apps on creation.
|
||||
|
||||
## Security Best Practices for Users
|
||||
|
||||
- **Do not expose VoiceStudio to the internet without authentication.** The API has no built-in auth. Use a reverse proxy (Caddy, nginx, Tailscale) if you need remote access.
|
||||
- **Do not expose OmniVoice to the internet without authentication.** The API has no built-in auth. Use a reverse proxy (Caddy, nginx, Tailscale) if you need remote access.
|
||||
- **Keep your installation updated.** The desktop app auto-checks for updates via the built-in updater.
|
||||
- **Review model sources.** Only download models from trusted Hugging Face repositories.
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="96" height="96" />
|
||||
<h1>Sponsor VoiceStudio</h1>
|
||||
<img src="docs/logo.png" alt="OmniVoice Logo" width="96" />
|
||||
<h1>Sponsor OmniVoice Studio</h1>
|
||||
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
## Why sponsor?
|
||||
|
||||
VoiceStudio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
|
||||
OmniVoice Studio is built by one developer, in the open, using Claude Code and AI agents — and the agent bills are real. Over the last few months I've spent thousands of dollars on Claude subscriptions to keep features shipping, bugs fixed, and your issues answered.
|
||||
|
||||
VoiceStudio is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
|
||||
OmniVoice is **free**, **fully local**, and **AGPL-3.0**. There's no paid tier, no accounts, no cloud, and no SaaS revenue — nothing runs on a server we bill you for, because nothing runs on a server at all. That's the whole point, and it's also why there's no recurring revenue to fund development. Sponsorship is what makes continued full-time work possible.
|
||||
|
||||
If VoiceStudio has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
|
||||
If OmniVoice has created value for you or your company, sponsoring means the next release keeps coming — and you get a thank-you (and, at most tiers, a logo slot) in return.
|
||||
|
||||
### Where your money goes
|
||||
|
||||
Every dollar goes to the cost of building VoiceStudio — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
|
||||
Every dollar goes to the cost of building OmniVoice — chiefly the **AI agent bills that keep it shipping** (Claude subscriptions and API usage), plus the occasional signing certificate, test hardware, and model-hosting costs. It is not a salary top-up; it's what keeps the lights on for continuous development.
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +41,7 @@ Placements marked "as that page ships" (the in-app Sponsors page and the project
|
||||
|
||||
**1. Open a sponsorship inquiry (recommended).** This opens a short GitHub form (name/org, logo, tier, contact) so we can get you set up:
|
||||
|
||||
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/VoiceStudio/issues/new?template=sponsor.yml)**
|
||||
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml)**
|
||||
|
||||
**2. Or start recurring support directly:**
|
||||
|
||||
@@ -70,16 +70,16 @@ To make your logo look sharp everywhere (README on GitHub, the in-app page, the
|
||||
|
||||
**How your logo gets added:**
|
||||
|
||||
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/VoiceStudio/issues/new?template=sponsor.yml) — the maintainer places it.
|
||||
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml) — the maintainer places it.
|
||||
- **Or open a PR:** add your asset under `docs/sponsors/` and an entry to the tables in this file. Silver/Gold logos are also wired into the app's in-app Sponsors page (via the `sponsors.js` manifest) and the project website as those surfaces ship.
|
||||
|
||||
By sponsoring you confirm you have the right to use the submitted logo and grant VoiceStudio permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
|
||||
By sponsoring you confirm you have the right to use the submitted logo and grant OmniVoice permission to display it in the contexts above. We won't alter your logo beyond scaling, and we'll remove it promptly on request.
|
||||
|
||||
---
|
||||
|
||||
## Current sponsors
|
||||
|
||||
VoiceStudio doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
|
||||
OmniVoice doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
|
||||
|
||||
### 🥇 Gold
|
||||
|
||||
@@ -107,13 +107,13 @@ _Open — [become a Backer](#how-to-become-a-sponsor)._
|
||||
|
||||
Sponsorship is a **thank-you, never a paywall.**
|
||||
|
||||
Every feature of VoiceStudio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
|
||||
Every feature of OmniVoice Studio is and will remain **free** and **open-source under [AGPL-3.0](LICENSE)**. Sponsors do **not** get private builds, gated features, license exceptions, or anything that degrades the experience for people who don't (or can't) pay. What sponsors get is **visibility and our gratitude** — and the knowledge that they're directly funding the next release.
|
||||
|
||||
VoiceStudio stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
|
||||
OmniVoice stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>Thank you for keeping local-first voice AI alive and free. ❤️</sub><br/>
|
||||
<sub>Questions? <a href="https://github.com/debpalash/VoiceStudio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
|
||||
<sub>Questions? <a href="https://github.com/debpalash/OmniVoice-Studio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
|
||||
</div>
|
||||
|
||||
@@ -5,21 +5,21 @@
|
||||
| Channel | Best for |
|
||||
|---|---|
|
||||
| [Discord](https://discord.gg/bzQavDfVV9) — `#help` | Setup problems, quick questions, sharing results |
|
||||
| [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) | Bugs and feature requests — use the templates; attach the diagnostic bundle (Settings → About → "Save diagnostic bundle") |
|
||||
| [GitHub Discussions](https://github.com/debpalash/VoiceStudio/discussions) | Design questions, ideas, show & tell |
|
||||
| [GitHub Issues](https://github.com/debpalash/OmniVoice-Studio/issues) | Bugs and feature requests — use the templates; attach the diagnostic bundle (Settings → About → "Save diagnostic bundle") |
|
||||
| [GitHub Discussions](https://github.com/debpalash/OmniVoice-Studio/discussions) | Design questions, ideas, show & tell |
|
||||
| Security issues | **Never a public issue** — see [SECURITY.md](SECURITY.md) for private reporting |
|
||||
|
||||
## Uninstalling / removing all data
|
||||
|
||||
VoiceStudio is fully local — there's nothing to deactivate, just folders to
|
||||
OmniVoice is fully local — there's nothing to deactivate, just folders to
|
||||
delete. `scripts/uninstall.sh` (macOS/Linux) or `scripts\uninstall.ps1`
|
||||
(Windows) lists every VoiceStudio folder with its size (dry-run first) and
|
||||
(Windows) lists every OmniVoice folder with its size (dry-run first) and
|
||||
removes them on `--yes`. The complete per-platform path list is in
|
||||
[docs/install/uninstall.md](docs/install/uninstall.md).
|
||||
|
||||
## Model sources we support
|
||||
|
||||
VoiceStudio is built on the idea that everything it runs is **open and available
|
||||
OmniVoice is built on the idea that everything it runs is **open and available
|
||||
to everyone**: free, public models with verifiable sources and licenses
|
||||
(Hugging Face repos, official project releases), so the whole community can
|
||||
use, test, and debug the same thing.
|
||||
+4
-12
@@ -1,5 +1,5 @@
|
||||
# Alembic configuration for VoiceStudio.
|
||||
# Run from anywhere: alembic -c <repo>/alembic.ini <command>
|
||||
# Alembic configuration for OmniVoice Studio.
|
||||
# Run from the repo root: alembic -c alembic.ini <command>
|
||||
# Default commands:
|
||||
# alembic upgrade head — apply all pending migrations
|
||||
# alembic revision -m "…" — create a new migration
|
||||
@@ -9,16 +9,8 @@
|
||||
# See backend/migrations/env.py.
|
||||
|
||||
[alembic]
|
||||
# %(here)s = this file's directory. Alembic resolves bare relative paths
|
||||
# against the process CWD, not the ini — and the app doesn't always start
|
||||
# from the repo root (`tauri dev` runs the backend with
|
||||
# cwd=frontend/src-tauri), which made startup migrations die with
|
||||
# "Path doesn't exist: backend/migrations" the first time one was pending.
|
||||
script_location = %(here)s/backend/migrations
|
||||
prepend_sys_path = %(here)s/backend
|
||||
# Split multi-path options on os.pathsep, not the legacy space/comma/colon
|
||||
# set — a colon-split would shred "C:\..." absolute paths on Windows.
|
||||
path_separator = os
|
||||
script_location = backend/migrations
|
||||
prepend_sys_path = backend
|
||||
# sqlalchemy.url is set programmatically in env.py — do NOT set it here.
|
||||
sqlalchemy.url =
|
||||
|
||||
|
||||
+1
-26
@@ -1,5 +1,5 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# PyInstaller spec for VoiceStudio backend.
|
||||
# PyInstaller spec for OmniVoice Studio backend.
|
||||
#
|
||||
# Produces a one-folder bundle at dist/omnivoice-backend/ that Tauri launches
|
||||
# as a sidecar binary. Kept intentionally permissive with collect_all(...)
|
||||
@@ -41,16 +41,6 @@ hiddenimports = [
|
||||
# even though pyproject.toml ships the package. Guarded by
|
||||
# tests/test_socks_proxy.py.
|
||||
'socksio',
|
||||
# Remote GPU workers (backend/worker/). The feature is opt-in, so every
|
||||
# import of it is deliberately deferred to the moment it is switched on —
|
||||
# inside `lifespan` and inside `ControlPlane.start()`. That keeps the cost
|
||||
# off users who never enable it, but it also means a frozen build has no
|
||||
# static import chain to follow, so the modules must be named here or the
|
||||
# feature raises ModuleNotFoundError only in the installers.
|
||||
'grpc', 'grpc.aio',
|
||||
'worker.service', 'worker.agent',
|
||||
'worker.transport.server', 'worker.transport.client',
|
||||
'worker.protocol.gen.worker_v1_pb2', 'worker.protocol.gen.worker_v1_pb2_grpc',
|
||||
|
||||
# Core
|
||||
'uuid', 'asyncio',
|
||||
@@ -95,11 +85,6 @@ if IS_MAC_ARM:
|
||||
# do NOT collect_all() mlx because that double-registers mlx.core with
|
||||
# nanobind and the binary aborts on the first mlx.core touch.
|
||||
hiddenimports.append('mlx_whisper')
|
||||
# Parakeet TDT v3 ASR (services.asr_backend.ParakeetMLXBackend) — imported
|
||||
# lazily at is_available()/transcribe time, so the tracer misses it. Same
|
||||
# rule as mlx_whisper: list the package, never collect_all() anything that
|
||||
# touches nanobind-registered mlx.core.
|
||||
hiddenimports.append('parakeet_mlx')
|
||||
# mlx-audio engine multiplexer — Kokoro / CSM / Dia / Qwen3-TTS /
|
||||
# Chatterbox / MeloTTS / OuteTTS / … — gives mac-ARM users a rich
|
||||
# engine picker. Like mlx_whisper it's mac-ARM-only; also like
|
||||
@@ -110,16 +95,6 @@ if IS_MAC_ARM:
|
||||
'mlx_audio.tts.models', 'mlx_audio.tts.generate',
|
||||
'mlx_audio.stt', 'mlx_audio.codec',
|
||||
]
|
||||
# Kokoro's phonemizer (misaki) loads the spaCy model en_core_web_sm
|
||||
# DYNAMICALLY (spacy.load by name), so PyInstaller never sees the import —
|
||||
# a frozen build without it would hit misaki's in-process downloader at
|
||||
# first English generation (#1133 class; contained since #1143, but the
|
||||
# generation still degrades). It's a plain data-heavy package with no
|
||||
# nanobind involvement, so collect_all is safe here (unlike mlx itself).
|
||||
_sm_datas, _sm_bins, _sm_hidden = collect_all('en_core_web_sm')
|
||||
datas += _sm_datas
|
||||
binaries += _sm_bins
|
||||
hiddenimports += _sm_hidden
|
||||
|
||||
# Note: we deliberately DON'T enumerate mlx submodules here. Any variant of
|
||||
# `collect_submodules('mlx')` or `collect_all('mlx')` — even filtered to
|
||||
|
||||
+37
-218
@@ -6,30 +6,25 @@ composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(read-only bootstrap is allowed in explicit server mode; mutations still
|
||||
require the admin API key — see `_server_mode`).
|
||||
- `require_admin`: method-aware admin gate for privileged routers.
|
||||
- `require_admin_action`: strict admin gate for side-effectful GET actions.
|
||||
- `require_native_access`: true-loopback-only access to the host filesystem;
|
||||
unlike `require_loopback`, it is never bypassed by server mode.
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
- `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback
|
||||
client carries the remote API key (Wave 2.3) — used by WS endpoints that
|
||||
keep their own inline loopback guards.
|
||||
"""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
is_local_host,
|
||||
is_loopback,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import SAFE_HTTP_METHODS, cookie_csrf_allowed
|
||||
|
||||
# IPv4 + IPv6 loopback literals + the conventional `localhost` hostname.
|
||||
# `request.client.host` carries an address, not a hostname, so the literal
|
||||
# "localhost" entry is defensive — some upstream wrappers (TestClient with
|
||||
# a custom client tuple, certain reverse-proxy headers) may pass strings
|
||||
# rather than parsed addresses. We accept the broader set without weakening
|
||||
# the guard: nothing here matches a non-loopback origin.
|
||||
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
@@ -54,67 +49,6 @@ def _server_mode() -> bool:
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def validate_server_admin_key() -> None:
|
||||
"""Reject an explicitly blank key before a server-mode app starts."""
|
||||
raw_key = os.environ.get("OMNIVOICE_API_KEY")
|
||||
if _server_mode() and raw_key is not None and not raw_key.strip():
|
||||
raise RuntimeError(
|
||||
"OMNIVOICE_API_KEY is blank; configure a non-whitespace administrator key"
|
||||
)
|
||||
|
||||
|
||||
def _configured_pin(request) -> str | None:
|
||||
"""The active share PIN (``app.state.network_share.pin``) or None. Read via
|
||||
getattr so a bare Request stub (or a request that hit before lifespan set
|
||||
the state) never raises — a missing PIN just means 'no PIN gate'."""
|
||||
app = getattr(request, "app", None)
|
||||
state = getattr(app, "state", None) if app is not None else None
|
||||
ns = getattr(state, "network_share", None) if state is not None else None
|
||||
return getattr(ns, "pin", None) if ns is not None else None
|
||||
|
||||
|
||||
def _admin_credential_configured(request) -> bool:
|
||||
"""Whether an API key or share PIN is configured.
|
||||
|
||||
The PIN cannot authorize admin access, but its presence means the operator
|
||||
opted out of bare-server discovery. Remote admin then remains closed until
|
||||
they configure and present the long API key.
|
||||
"""
|
||||
if remote_api_key():
|
||||
return True
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
|
||||
def _request_presents_admin_credential(
|
||||
request,
|
||||
*,
|
||||
side_effectful_get: bool = False,
|
||||
) -> bool:
|
||||
"""Whether the canonical principal carries remote admin capability.
|
||||
|
||||
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
|
||||
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:
|
||||
"""Reject any request whose `client.host` is not a loopback address.
|
||||
|
||||
@@ -131,156 +65,41 @@ def require_loopback(request: Request) -> None:
|
||||
on rejection — the response body is `{"detail": "loopback origin required"}`
|
||||
so existing tests for `/system/set-env` keep passing without modification.
|
||||
|
||||
In server mode (Docker, see `_server_mode`) the loopback origin is
|
||||
unenforceable, so the gate can't require true loopback. It then applies the
|
||||
admin-credential rule instead:
|
||||
|
||||
- No credential configured (no API key, no PIN) → read-only requests are
|
||||
open, matching the #261 Docker bootstrap flow. State-changing requests
|
||||
fail closed even if a route accidentally kept this legacy dependency.
|
||||
- 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
|
||||
(``is_local_host``) that bypasses the PIN / API-key middleware, and it must
|
||||
NEVER by itself unlock the admin surface (``/system/set-env`` — RCE-class —
|
||||
and ``/api/settings/*``). The 6-digit share PIN is a consumption credential
|
||||
too and does not gate admin, so a PIN-only deployment keeps admin
|
||||
loopback-only; remote admin requires the long API key. See
|
||||
docs/api-auth.md (#1213).
|
||||
In server mode (Docker, see `_server_mode`) the gate is a no-op: the
|
||||
loopback origin is unenforceable there and exposure is governed by the
|
||||
deployment's port mapping + the optional share PIN instead.
|
||||
"""
|
||||
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):
|
||||
return
|
||||
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.
|
||||
|
||||
Desktop callers keep the loopback-only contract. Docker cannot reliably
|
||||
observe the host operator as loopback, so authenticated remote admin stays
|
||||
available there, but every state-changing request must present the long API
|
||||
key. An unconfigured server must never expose executable-path or filesystem
|
||||
settings to every client that can reach its published port.
|
||||
|
||||
Read-only requests retain the bare-Docker bootstrap behaviour until an API
|
||||
key is configured. Share PINs and trusted CIDRs are consumption credentials;
|
||||
neither authorizes this gate.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
read_only = method in SAFE_HTTP_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
return
|
||||
_admin_gate_403()
|
||||
|
||||
|
||||
def require_admin_action(request: Request) -> None:
|
||||
"""Gate an administrative action even when its HTTP method is read-only.
|
||||
|
||||
A small number of legacy GET endpoints have real side effects. For example,
|
||||
an engine health check may spawn a sidecar process. Such routes cannot use
|
||||
:func:`require_admin`'s bare-server discovery exception.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode() and _request_presents_admin_credential(
|
||||
request,
|
||||
side_effectful_get=True,
|
||||
):
|
||||
return
|
||||
_admin_gate_403()
|
||||
|
||||
|
||||
def require_desktop(request: Request) -> None:
|
||||
"""Gate capabilities that may select or execute host filesystem paths.
|
||||
|
||||
An API key authorizes remote administration, not access to the desktop
|
||||
shell's native file-picker boundary. These capabilities therefore remain
|
||||
strictly loopback-only even when server mode is enabled.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="desktop origin required")
|
||||
|
||||
|
||||
def require_local(request: Request) -> None:
|
||||
"""Reject any request whose client.host is not loopback OR on a configured
|
||||
trusted network. The consumption-tier companion to :func:`require_loopback`:
|
||||
use on routes a trusted-network client (LAN/proxy) should reach without a PIN
|
||||
or API key — e.g. the dictation model/prefs endpoints that pair with the
|
||||
dictation WebSocket. Admin routes stay on :func:`require_admin`.
|
||||
|
||||
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):
|
||||
if host in _LOOPBACK_HOSTS:
|
||||
return
|
||||
if _server_mode():
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
|
||||
|
||||
def require_native_access(request: Request) -> None:
|
||||
"""Protect capabilities that read or write operator-chosen host paths.
|
||||
|
||||
Docker server mode deliberately relaxes the ordinary admin gate because a
|
||||
bridge makes even local traffic appear remote. That exception is unsafe for
|
||||
native file pickers: a remote API caller must never probe or overwrite an
|
||||
arbitrary path on the backend host, even with the server API key.
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if not is_loopback(host):
|
||||
raise HTTPException(status_code=403, detail="native filesystem access requires loopback origin")
|
||||
def remote_api_key() -> str | None:
|
||||
"""The remote-backend bearer key (Wave 2.3), or None when remote mode is
|
||||
off. Read at call time so tests can monkeypatch the env."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY") or None
|
||||
|
||||
|
||||
def ws_remote_authorized(websocket) -> bool:
|
||||
"""Whether the canonical WS principal has a remote admin credential."""
|
||||
return principal_for(websocket).kind in {
|
||||
PrincipalKind.API_KEY,
|
||||
PrincipalKind.ADMIN_SESSION,
|
||||
}
|
||||
"""Whether a WebSocket handshake presents the remote API key.
|
||||
|
||||
Browser WebSockets cannot set an Authorization header, so the key may
|
||||
arrive as ``?api_key=`` or via the ``ov_key`` cookie that the bearer
|
||||
middleware sets on the first authenticated HTTP request. Returns False
|
||||
when remote mode is off — callers keep their loopback-only behavior.
|
||||
"""
|
||||
key = remote_api_key()
|
||||
if not key:
|
||||
return False
|
||||
auth = websocket.headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if not supplied:
|
||||
supplied = (
|
||||
websocket.query_params.get("api_key")
|
||||
or websocket.cookies.get("ov_key")
|
||||
or ""
|
||||
)
|
||||
return secrets.compare_digest(supplied, key)
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Stable, non-diagnostic metadata for engine-discovery responses."""
|
||||
from __future__ import annotations
|
||||
|
||||
from core.device_caps import KERNEL_RISK_MARKER
|
||||
|
||||
_UNAVAILABLE = "Engine unavailable. Check installation and configuration."
|
||||
_PREVIOUS_FAILURE = "A previous engine check failed."
|
||||
_ROUTING_BY_STATUS = {
|
||||
"cpu_fallback": "GPU acceleration is unavailable; this engine will use CPU.",
|
||||
"cpu_only": "This engine runs on CPU on this host.",
|
||||
"unavailable": "This engine has no compatible compute device on this host.",
|
||||
}
|
||||
_ROUTING_UNAVAILABLE = "Engine routing details are unavailable."
|
||||
_ACCELERATOR_KERNEL_RISK = (
|
||||
"The selected accelerator may not be supported by this PyTorch build."
|
||||
)
|
||||
_ACCELERATOR_LOW_VRAM = (
|
||||
"The accelerator may not meet this engine's recommended VRAM."
|
||||
)
|
||||
_ACCELERATOR_ADVISORY = "The selected accelerator has a compatibility advisory."
|
||||
|
||||
|
||||
def _public_routing_reason(status: object, diagnostic: object) -> str:
|
||||
"""Map a private routing diagnostic to an accurate stable category."""
|
||||
if status == "accelerated":
|
||||
private = diagnostic if isinstance(diagnostic, str) else ""
|
||||
if KERNEL_RISK_MARKER in private:
|
||||
return _ACCELERATOR_KERNEL_RISK
|
||||
if " GB VRAM; this engine wants about " in private:
|
||||
return _ACCELERATOR_LOW_VRAM
|
||||
return _ACCELERATOR_ADVISORY
|
||||
return _ROUTING_BY_STATUS.get(status, _ROUTING_UNAVAILABLE)
|
||||
|
||||
|
||||
def public_backends(entries: list[dict]) -> list[dict]:
|
||||
"""Copy registry entries while replacing service diagnostics.
|
||||
|
||||
Availability probes may contain exception text, local paths, tracebacks, or
|
||||
credentials. Installation hints are registry-authored and remain intact.
|
||||
"""
|
||||
safe: list[dict] = []
|
||||
for entry in entries:
|
||||
item = dict(entry)
|
||||
if item.get("reason") is not None:
|
||||
item["reason"] = _UNAVAILABLE
|
||||
if item.get("last_error") is not None:
|
||||
item["last_error"] = _PREVIOUS_FAILURE
|
||||
if item.get("routing_reason") is not None:
|
||||
item["routing_reason"] = _public_routing_reason(
|
||||
item.get("routing_status"), item["routing_reason"]
|
||||
)
|
||||
evidence = item.get("execution_evidence")
|
||||
if isinstance(evidence, dict) and evidence.get("cpu_fallback_reason") is not None:
|
||||
evidence = dict(evidence)
|
||||
evidence["cpu_fallback_reason"] = _public_routing_reason(
|
||||
"cpu_fallback", evidence["cpu_fallback_reason"]
|
||||
)
|
||||
item["execution_evidence"] = evidence
|
||||
safe.append(item)
|
||||
return safe
|
||||
|
||||
|
||||
def public_unavailability(detail: object) -> str | None:
|
||||
return None if detail is None else _UNAVAILABLE
|
||||
@@ -15,33 +15,22 @@ Design notes
|
||||
* Previews are cached on disk keyed by a hash of (instruct, language), so two
|
||||
archetypes that resolve to the same voice share a cache file and the cold
|
||||
render only happens once per distinct voice.
|
||||
* That same key names the pre-rendered clips in the opt-in voice gallery
|
||||
(``services.gallery``), which is consulted BEFORE the engine so a fresh
|
||||
install can hear voices before the 2.4 GB checkpoint finishes downloading.
|
||||
Gallery files win over a local render of the same key — but only for
|
||||
``/preview``. ``/use`` always renders locally: the WAV it keeps in
|
||||
``VOICES_DIR`` is the reference audio a cloned voice is built from, and a
|
||||
downloaded MP3 must never become that.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services import gallery
|
||||
|
||||
logger = logging.getLogger("omnivoice.archetypes")
|
||||
|
||||
@@ -57,12 +46,11 @@ _PREVIEW_SEED = 42
|
||||
# 32 reliably converges to speech across the gallery's instruct/script space
|
||||
# at a one-time (cached) render cost.
|
||||
_PREVIEW_NUM_STEP = 32
|
||||
# Reject near-pure tonal artifacts using mean framed spectral flatness.
|
||||
# Calibrated against the tracked speech demos exercised by
|
||||
# test_archetype_preview_quality.py: the quietest (Mandarin dubbing, 44.1 kHz)
|
||||
# measures ~7.7e-6, while the worst tested tonal buzz measures ~3.3e-9.
|
||||
# 1e-7 leaves >10x margin on both sides without rejecting low-flatness speech.
|
||||
_DEGENERATE_FLATNESS = 1e-7
|
||||
# Spectral-flatness floor below which a render is a degenerate tonal artifact
|
||||
# rather than speech. Real, mastered speech sits ~0.04–0.07; a tonal buzz
|
||||
# collapses to <0.005. 0.015 separates the two with wide margin and sits well
|
||||
# below even breathy/whisper voices (which are broadband → high flatness).
|
||||
_DEGENERATE_FLATNESS = 0.015
|
||||
|
||||
|
||||
def _preview_key(a: dict) -> str:
|
||||
@@ -73,153 +61,6 @@ def _preview_key(a: dict) -> str:
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _design_profile_values(a: dict) -> tuple[str, str]:
|
||||
"""Canonical instruct + complete picker state for a designed archetype."""
|
||||
return a["instruct"], json.dumps(a["attrs"], sort_keys=True)
|
||||
|
||||
|
||||
def _profile_audio_path(ref_audio_path: object) -> Optional[Path]:
|
||||
"""Resolve only a regular, non-symlinked file inside ``VOICES_DIR``."""
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _materialized_audio_is_current(row, a: dict) -> bool:
|
||||
"""Whether an existing row still has the sample described by its metadata."""
|
||||
expected_filename = _profile_audio_filename(row["id"])
|
||||
path = _profile_audio_path(row["ref_audio_path"])
|
||||
return bool(
|
||||
row["ref_audio_path"] == expected_filename
|
||||
and is_playable_wav(path)
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
)
|
||||
|
||||
|
||||
def _profile_audio_filename(profile_id: str) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _archetype_personality(a: dict) -> str:
|
||||
return f"archetype:{a['id']}"
|
||||
|
||||
|
||||
def _legacy_archetype_profile(conn, a: dict):
|
||||
"""Adopt only a row that an older archetype materializer could have made."""
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(a["id"],),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
expected_audio = _profile_audio_filename(row["id"])
|
||||
try:
|
||||
states_match = (
|
||||
not row["vd_states"] or json.loads(row["vd_states"]) == a["attrs"]
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
states_match = False
|
||||
if (
|
||||
row["ref_audio_path"] == expected_audio
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["kind"] in (None, "", "clone", "design")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and states_match
|
||||
):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _is_materialized_archetype_row(row, a: dict) -> bool:
|
||||
"""Recognize rows owned by this materializer without trusting identity text alone."""
|
||||
try:
|
||||
states_match = json.loads(row["vd_states"]) == a["attrs"]
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["personality"] == _archetype_personality(a)
|
||||
and row["kind"] == "design"
|
||||
and row["seed"] == _PREVIEW_SEED
|
||||
and row["ref_audio_path"] == _profile_audio_filename(row["id"])
|
||||
and row["instruct"] == a["instruct"]
|
||||
and row["language"] == a["language"]
|
||||
and row["ref_text"] == a["sample_script"]
|
||||
and states_match
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_archetype_profile(conn, a: dict):
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(_archetype_personality(a),),
|
||||
).fetchall()
|
||||
owned = next((row for row in rows if _is_materialized_archetype_row(row, a)), None)
|
||||
return owned if owned is not None else _legacy_archetype_profile(conn, a)
|
||||
|
||||
|
||||
async def _render_profile_audio(
|
||||
a: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Render one validated sample, optionally staging it for a later CAS."""
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
safe_id = Path(audio_filename).stem
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
if publish:
|
||||
await _render_wav_atomic(a, audio_path, prefix=f".{safe_id}-")
|
||||
else:
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = audio_path.parent / f".{safe_id}-{uuid.uuid4().hex}.staged.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
if not is_playable_wav(audio_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
except BaseException:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
audio_path.unlink()
|
||||
raise
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
async def _render_wav_atomic(a: dict, out_path: Path, *, prefix: str = ".render-") -> Path:
|
||||
"""Render and validate a WAV before atomically replacing *out_path*."""
|
||||
audio_path = Path(out_path)
|
||||
audio_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = audio_path.parent / f"{prefix}{uuid.uuid4().hex}.wav"
|
||||
try:
|
||||
await _render_archetype_wav(a, tmp_path)
|
||||
if not is_playable_wav(tmp_path):
|
||||
raise RuntimeError("the voice engine produced an invalid WAV")
|
||||
os.replace(tmp_path, audio_path)
|
||||
finally:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
tmp_path.unlink()
|
||||
return audio_path
|
||||
|
||||
|
||||
def _heal_materialized_profile(conn, row, a: dict, audio_filename: str) -> None:
|
||||
"""Repair profiles created before archetype `/use` persisted design kind."""
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind='design', instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, ref_audio_path=?, personality=? WHERE id=?",
|
||||
(
|
||||
instruct, vd_states, a["language"], a["sample_script"], _PREVIEW_SEED,
|
||||
audio_filename, _archetype_personality(a), row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# A non-empty script is always required — synthesizing empty text yields
|
||||
# silence. Every archetype carries a use-case script, but guard the render path
|
||||
# too so a malformed archetype can never drive a blank render.
|
||||
@@ -249,46 +90,24 @@ def _is_blank_audio(audio_tensor) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
_FLATNESS_FRAME = 1024
|
||||
_FLATNESS_HOP = 512
|
||||
#: Frames quieter than this fraction of the loudest frame's energy are the gaps
|
||||
#: between words, not speech; their spectrum is the noise floor and averaging it
|
||||
#: in drags the measurement toward the value of whatever silence sounds like.
|
||||
_FLATNESS_FRAME_FLOOR = 1e-4
|
||||
|
||||
|
||||
def _spectral_flatness(audio_tensor) -> Optional[float]:
|
||||
"""Mean per-frame geometric-mean / arithmetic-mean of the power spectrum.
|
||||
"""Geometric-mean / arithmetic-mean of the power spectrum.
|
||||
|
||||
~1.0 for broadband noise, →0 for a pure tone. The degenerate diffusion
|
||||
renders this guards against are near-pure tonal buzzes, distinct from both
|
||||
silence (caught by ``_is_blank_audio``) and real speech. Returns ``None``
|
||||
if it can't be computed so callers don't act on a bad measurement.
|
||||
|
||||
Measured over short frames and averaged — the standard definition. A single
|
||||
FFT of the whole clip (what this used to do) is not the same quantity: its
|
||||
frequency resolution grows with clip length, so speech harmonics carve
|
||||
ever-deeper nulls into the spectrum and the geometric mean collapses. That
|
||||
made the result depend on how long the clip was rather than on what it
|
||||
sounded like, and put real speech below the rejection threshold.
|
||||
renders this guards against are near-pure tonal buzzes (flatness <0.005),
|
||||
distinct from both silence (caught by ``_is_blank_audio``) and real speech
|
||||
(~0.04+). Returns ``None`` if it can't be computed so callers don't act on
|
||||
a bad measurement.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
|
||||
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
|
||||
t = t.detach().to("cpu", dtype=torch.float32)
|
||||
if t.ndim > 1:
|
||||
t = t.mean(dim=0)
|
||||
t = t.flatten()
|
||||
if t.numel() < _FLATNESS_FRAME or not torch.isfinite(t).all():
|
||||
t = t.detach().to("cpu", dtype=torch.float32).flatten()
|
||||
if t.numel() < 1024 or not torch.isfinite(t).all():
|
||||
return None
|
||||
frames = t.unfold(0, _FLATNESS_FRAME, _FLATNESS_HOP)
|
||||
spec = torch.fft.rfft(frames * torch.hann_window(_FLATNESS_FRAME)).abs().pow(2) + 1e-12
|
||||
energy = spec.sum(dim=1)
|
||||
spec = spec[energy > energy.max() * _FLATNESS_FRAME_FLOOR]
|
||||
if spec.shape[0] == 0:
|
||||
return None
|
||||
return float((torch.exp(spec.log().mean(dim=1)) / spec.mean(dim=1)).mean())
|
||||
spec = torch.fft.rfft(t * torch.hann_window(t.numel())).abs().pow(2) + 1e-12
|
||||
return float(torch.exp(torch.mean(torch.log(spec))) / torch.mean(spec))
|
||||
except Exception: # never let the checker itself block a render
|
||||
return None
|
||||
|
||||
@@ -350,13 +169,9 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
)
|
||||
|
||||
# Bounded + pool-reset on hang so a wedged preview render can't starve the
|
||||
# GPU pool and brick the backend (#730 class). Budget comes from the shared
|
||||
# length-scaled helper (#1190) instead of the flat 300s default.
|
||||
from services.model_manager import generate_timeout_s
|
||||
_budget = generate_timeout_s(text, engine=model)
|
||||
# GPU pool and brick the backend (#730 class).
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _infer(_PREVIEW_SEED), what="Archetype preview generate",
|
||||
timeout=_budget)
|
||||
lambda: _infer(_PREVIEW_SEED), what="Archetype preview generate")
|
||||
if _is_unusable_audio(audio_tensor):
|
||||
# Blank OR a degenerate tonal buzz — retry once on a different seed to
|
||||
# step off the bad diffusion trajectory. Static message only: the
|
||||
@@ -364,76 +179,14 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
# module constant, safe to log.
|
||||
logger.warning("Archetype rendered unusable at seed %d — retrying once", _PREVIEW_SEED)
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _infer(_PREVIEW_SEED + 1), what="Archetype preview generate",
|
||||
timeout=_budget)
|
||||
lambda: _infer(_PREVIEW_SEED + 1), what="Archetype preview generate")
|
||||
if _is_unusable_audio(audio_tensor):
|
||||
raise RuntimeError("the voice engine returned no audible audio for this archetype")
|
||||
|
||||
# Invisible provenance mark (#1169), tensor stage, before the WAV is
|
||||
# persisted: this one site covers BOTH archetype outputs — the served
|
||||
# preview clip (GET /archetypes/{id}/preview) and the synthetic reference
|
||||
# WAV a materialized profile keeps in VOICES_DIR (played back via the
|
||||
# profile preview route). Runs in the GPU pool like generate's finalize;
|
||||
# never raises (degrades to unmarked on failure). User-uploaded/recorded
|
||||
# reference audio is human speech and is never marked — this only touches
|
||||
# audio the engine synthesized.
|
||||
# Runs on the dedicated watermark pool (#1190): AudioSeal embedding is CPU
|
||||
# work that holds no VRAM, so it must not occupy a GPU worker ahead of the
|
||||
# next generate on 1-worker hosts.
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, model.sampling_rate,
|
||||
context="archetypes.render",
|
||||
timeout=generate_timeout_s(""),
|
||||
)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_safe_torchaudio_save(str(out_path), audio_tensor, model.sampling_rate)
|
||||
|
||||
|
||||
def _no_voice_model_downloaded() -> bool:
|
||||
"""True only on a *positive* "no TTS weights on this machine" answer.
|
||||
|
||||
Fails open on purpose: the cache probes are best-effort (a user-managed
|
||||
clone outside the HF layout is invisible to them), and telling someone with
|
||||
a working engine to go download a model is worse than saying nothing. Only
|
||||
a catalog we could read, with not one TTS repo cached, earns the offline
|
||||
message.
|
||||
"""
|
||||
try:
|
||||
from api.routers.setup.models import get_model_catalog, is_cached
|
||||
|
||||
tts = [m for m in get_model_catalog().all if m.get("role") == "TTS"]
|
||||
return bool(tts) and not any(is_cached(m["repo_id"]) for m in tts)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _preview_source(a: dict) -> tuple[str, str]:
|
||||
"""Which path ``/preview`` will take for *a*, and what to tell the user.
|
||||
|
||||
Replaces the old "see Settings → Logs → Backend" advice, which asked a user
|
||||
who wanted to hear a voice to go read a log file. The three states that
|
||||
actually differ are: we already have the audio (gallery), we can make it
|
||||
(render — say so, it takes a moment), and we can neither fetch nor make it
|
||||
(no model — the one state with an action attached).
|
||||
"""
|
||||
key = _preview_key(a)
|
||||
if gallery.cached_preview(key) is not None:
|
||||
return "gallery", (
|
||||
"Pre-rendered preview from the voice gallery — a fixed reference "
|
||||
"rendering, not a render from your current engine."
|
||||
)
|
||||
if is_playable_wav(_PREVIEW_DIR / f"{key}.wav"):
|
||||
return "cached", ""
|
||||
if _no_voice_model_downloaded():
|
||||
return "no_model", (
|
||||
"You're offline and no voice model is downloaded yet — "
|
||||
"Model Catalogue → Models → Download."
|
||||
)
|
||||
return "rendering", "Rendering this preview on your machine — it may take a moment."
|
||||
|
||||
|
||||
# ── Read endpoints (no model) ─────────────────────────────────────────────────
|
||||
# NOTE: declare the literal `/archetypes/categories` before `/archetypes/{id}`
|
||||
# so it isn't swallowed by the path-parameter route.
|
||||
@@ -443,40 +196,8 @@ def list_categories():
|
||||
return archetypes.categories()
|
||||
|
||||
|
||||
# ── Voice-gallery (pre-rendered previews) ─────────────────────────────────────
|
||||
# Declared above `/archetypes/{archetype_id}` for the same reason as
|
||||
# `/categories`: keep literal paths out of the path-parameter route's reach.
|
||||
@router.get("/archetypes/previews/status")
|
||||
def preview_gallery_status():
|
||||
"""Consent state, coverage and freshness for the Settings line."""
|
||||
return gallery.status()
|
||||
|
||||
|
||||
@router.put("/archetypes/previews")
|
||||
async def set_preview_gallery(enabled: bool = Body(..., embed=True)):
|
||||
"""Turn pre-rendered previews on or off.
|
||||
|
||||
Turning it ON is the user's explicit yes to an outbound call, and is the
|
||||
only thing that ever starts one — there is no on-install background fetch.
|
||||
The featured set is pulled right here so the yes has a visible effect;
|
||||
failures are silent by design (``fetch_featured`` swallows them) and leave
|
||||
previews rendering locally.
|
||||
"""
|
||||
state = gallery.set_enabled(enabled)
|
||||
if enabled:
|
||||
state = await gallery.fetch_featured()
|
||||
return state
|
||||
|
||||
|
||||
@router.post("/archetypes/previews/check")
|
||||
async def check_preview_gallery():
|
||||
"""Manual "check now" — bypasses the 24 h throttle, never the signature."""
|
||||
return await gallery.check_for_updates(force=True)
|
||||
|
||||
|
||||
@router.get("/archetypes")
|
||||
def list_archetypes_endpoint(
|
||||
q: Optional[str] = None,
|
||||
use_case: Optional[str] = None,
|
||||
gender: Optional[str] = None,
|
||||
age: Optional[str] = None,
|
||||
@@ -488,15 +209,9 @@ def list_archetypes_endpoint(
|
||||
limit: int = Query(60, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""Filtered, paginated view over the archetype catalog.
|
||||
|
||||
``q`` is a free-text substring match over the archetype name/instruct so a
|
||||
voice picker can search the *entire* several-hundred-voice catalog by typing
|
||||
(the facet filters alone can't reach a specific voice by name). Content-free
|
||||
and local — it just narrows the in-memory catalog.
|
||||
"""
|
||||
"""Filtered, paginated view over the archetype catalog."""
|
||||
items = archetypes.list_archetypes(
|
||||
q=q, use_case=use_case, gender=gender, age=age, pitch=pitch,
|
||||
use_case=use_case, gender=gender, age=age, pitch=pitch,
|
||||
accent=accent, whisper=whisper, lang=lang, featured=featured,
|
||||
)
|
||||
total = len(items)
|
||||
@@ -513,77 +228,33 @@ def get_archetype_endpoint(archetype_id: str):
|
||||
|
||||
|
||||
# ── Render endpoints (model-gated) ────────────────────────────────────────────
|
||||
@router.get("/archetypes/{archetype_id}/preview/state")
|
||||
def preview_archetype_state(archetype_id: str):
|
||||
"""Where the next ``/preview`` for this archetype would come from.
|
||||
|
||||
Touches neither the model nor the network, so a picker can label a voice
|
||||
("may take a moment", "download a model first") *before* it commits to a
|
||||
request that may take 40 seconds or fail.
|
||||
"""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
source, message = _preview_source(a)
|
||||
return {"source": source, "message": message}
|
||||
|
||||
|
||||
@router.get("/archetypes/{archetype_id}/preview")
|
||||
async def preview_archetype(
|
||||
archetype_id: str,
|
||||
local: bool = Query(False, description="Bypass gallery audio after a client decode failure"),
|
||||
):
|
||||
"""Serve a short preview clip — from the gallery, the cache, or the engine."""
|
||||
async def preview_archetype(archetype_id: str):
|
||||
"""Serve a short preview clip — pre-rendered if cached, else render once."""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
|
||||
key = _preview_key(a)
|
||||
# Gallery first, and only for /preview: these bytes are audio we can prove
|
||||
# the provenance of, so they beat a local render of the same key. A miss
|
||||
# (offline, disabled, key not published) is silent — we just render.
|
||||
gallery_path = None if local else gallery.cached_preview(key)
|
||||
if gallery_path is None and not local:
|
||||
gallery_path = await gallery.fetch_preview(key)
|
||||
if gallery_path is not None:
|
||||
# Nothing else in the app polls, so the daily refresh hangs off the
|
||||
# request that proves previews are being used. Fire-and-forget.
|
||||
gallery.maybe_refresh_in_background()
|
||||
return FileResponse(
|
||||
str(gallery_path),
|
||||
media_type="audio/mpeg",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "gallery"},
|
||||
)
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{key}.wav"
|
||||
if not is_playable_wav(cache_path):
|
||||
cache_path = _PREVIEW_DIR / f"{_preview_key(a)}.wav"
|
||||
if not cache_path.exists():
|
||||
try:
|
||||
await _render_wav_atomic(a, cache_path, prefix=".preview-")
|
||||
await _render_archetype_wav(a, cache_path)
|
||||
except Exception as e: # model missing / OOM / inference failure
|
||||
logger.error("Archetype preview render failed", exc_info=True)
|
||||
# Two different failures, two different answers. Without a model
|
||||
# there is nothing to read in a log — there is something to do.
|
||||
if _no_voice_model_downloaded():
|
||||
detail = (
|
||||
"You're offline and no voice model is downloaded yet — "
|
||||
"Model Catalogue → Models → Download. (Or turn on pre-rendered "
|
||||
"voice previews in Model Catalogue → Models.)"
|
||||
)
|
||||
else:
|
||||
detail = (
|
||||
"Couldn't render a preview right now — the voice engine "
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Couldn't render a preview right now — the voice engine is "
|
||||
f"unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
# no-cache (not no-store): the URL is stable but its bytes change when an
|
||||
# archetype's preview is re-rendered, so force the client to revalidate
|
||||
# against the ETag instead of serving a stale cached clip indefinitely.
|
||||
return FileResponse(
|
||||
str(cache_path),
|
||||
media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "local"},
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
|
||||
@@ -595,11 +266,6 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
preview) and inserts a ``voice_profiles`` row carrying the archetype's
|
||||
instruct + language. The profile then shows up everywhere voices are
|
||||
picked (Dub / Generate / Clone).
|
||||
|
||||
Never sourced from the voice gallery, no matter how cheap that would be:
|
||||
this WAV lands in ``VOICES_DIR`` as the profile's reference audio, so a
|
||||
downloaded, lossily-encoded MP3 would silently become the sample every
|
||||
future clone of this voice is built from. It renders locally or it fails.
|
||||
"""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
@@ -608,125 +274,38 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
|
||||
# Idempotent (dedup): an archetype materializes to exactly ONE voice profile.
|
||||
# Picking the same gallery voice again — from any picker (Gallery grid,
|
||||
# VoiceSelector, …) — must reuse that one row instead of rendering + inserting
|
||||
# a fresh duplicate every time. Use a namespaced personality identity so an
|
||||
# imported persona cannot collide with and be rewritten by an archetype id.
|
||||
with db_conn() as conn:
|
||||
existing = _existing_archetype_profile(conn, a)
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
|
||||
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:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_archetype_profile(conn, a)
|
||||
still_owned = current is not None and (
|
||||
owned is not None and owned["id"] == current["id"]
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, current, a, audio_filename)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
# The row was edited/deleted while rendering. Preserve it and use the
|
||||
# validated staged sample for a fresh canonical materialization.
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _profile_audio_filename(profile_id)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
try:
|
||||
audio_filename, audio_path = await _render_profile_audio(a, profile_id)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't create a voice from this archetype.",
|
||||
) from e
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
|
||||
if audio_path is None: # defensive: a new profile always rendered above
|
||||
raise RuntimeError("new archetype profile has no rendered audio")
|
||||
try:
|
||||
await _render_archetype_wav(a, audio_path)
|
||||
except Exception as e:
|
||||
logger.error("Archetype 'use' render failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"Couldn't create a voice from this archetype — the voice engine "
|
||||
f"is unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
|
||||
profile_name = (name or a["name"]).strip() or a["name"]
|
||||
try:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
# Re-check under the write connection right before inserting: a
|
||||
# concurrent /use for the same archetype may have inserted while we
|
||||
# were rendering (the pre-render SELECT above raced). Reuse that row
|
||||
# and drop our just-rendered sample instead of creating a duplicate.
|
||||
# `personality` is not globally UNIQUE, so serialize and re-check.
|
||||
dup = _existing_archetype_profile(conn, a)
|
||||
if dup is not None:
|
||||
duplicate_audio = dup["ref_audio_path"]
|
||||
if not _materialized_audio_is_current(dup, a):
|
||||
duplicate_audio = _profile_audio_filename(dup["id"])
|
||||
_duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_duplicate_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(audio_path, _duplicate_path)
|
||||
audio_path = None
|
||||
_heal_materialized_profile(conn, dup, a, duplicate_audio)
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
duplicate_result = {"profile_id": dup["id"], "name": dup["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
instruct, vd_states = _design_profile_values(a)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'design', ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
instruct, a["language"], _PREVIEW_SEED,
|
||||
_archetype_personality(a), time.time(), vd_states,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
profile_id, profile_name, audio_filename, a["sample_script"],
|
||||
a["instruct"], a["language"], _PREVIEW_SEED, a["id"], time.time(),
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
if audio_path is not None:
|
||||
os.remove(audio_path)
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
@@ -25,17 +25,13 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.audiobook import (
|
||||
ExpressiveOptions,
|
||||
parse_audiobook_script,
|
||||
synthesize_chapter,
|
||||
)
|
||||
@@ -83,55 +79,6 @@ def _safe_cover_path(cover_path: str | None) -> str | None:
|
||||
return real if os.path.isfile(real) else None
|
||||
|
||||
|
||||
class ExpressiveMixin(BaseModel):
|
||||
"""Optional expressive/quality knobs shared by every longform front door
|
||||
(#1208). All optional — an omitted field reproduces today's exact render.
|
||||
|
||||
* Sampling: ``num_step`` / ``guidance_scale`` / ``position_temperature`` /
|
||||
``class_temperature`` / ``postprocess_output`` — the same surface the
|
||||
Voice page's Production Overrides expose. Unset → the documented longform
|
||||
preset (num_step 32, guidance 2.0, model-default temps, postprocess on).
|
||||
* ``seed`` — a book-level determinism override (else the profile's pinned
|
||||
seed, else fresh-render variety).
|
||||
* Emotion (IndexTTS2 only): ``emo_vector`` (8 floats) / ``emo_text`` /
|
||||
``emo_alpha`` — reach engines that understand them via the generic synth
|
||||
closure; other engines ignore them.
|
||||
* ``vary_repeats`` — cache opt-out: give identical repeated lines distinct
|
||||
takes instead of replaying one recording (default off = today).
|
||||
"""
|
||||
|
||||
# Bounds so a loopback POST (reachable by a browser-tab CSRF) can't pin a
|
||||
# GPU-pool worker with an absurd step count or otherwise feed the sampler
|
||||
# nonsense. Ranges are generous supersets of the Voice-page controls; unset
|
||||
# (None) still means "use the longform default", unchanged. (#1208)
|
||||
num_step: int | None = Field(default=None, ge=1, le=512)
|
||||
guidance_scale: float | None = Field(default=None, ge=0.0, le=20.0)
|
||||
position_temperature: float | None = Field(default=None, ge=0.0, le=100.0)
|
||||
class_temperature: float | None = Field(default=None, ge=0.0, le=100.0)
|
||||
postprocess_output: bool | None = None
|
||||
seed: int | None = Field(default=None, ge=0, le=2**32 - 1)
|
||||
emo_vector: list[float] | None = Field(default=None, min_length=8, max_length=8)
|
||||
emo_text: str | None = Field(default=None, max_length=500)
|
||||
emo_alpha: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
vary_repeats: bool = False
|
||||
|
||||
|
||||
def _expressive_opts(req: "ExpressiveMixin") -> ExpressiveOptions:
|
||||
"""Lower a request's expressive fields into the typed engine-options object."""
|
||||
return ExpressiveOptions(
|
||||
num_step=req.num_step,
|
||||
guidance_scale=req.guidance_scale,
|
||||
position_temperature=req.position_temperature,
|
||||
class_temperature=req.class_temperature,
|
||||
postprocess_output=req.postprocess_output,
|
||||
seed=req.seed,
|
||||
emo_vector=tuple(req.emo_vector) if req.emo_vector else None,
|
||||
emo_text=(req.emo_text or None),
|
||||
emo_alpha=req.emo_alpha,
|
||||
vary_repeats=bool(req.vary_repeats),
|
||||
)
|
||||
|
||||
|
||||
class AudiobookPlanRequest(BaseModel):
|
||||
text: str
|
||||
default_voice: str | None = None
|
||||
@@ -214,7 +161,7 @@ async def audiobook_cover(cover: UploadFile = File(...)) -> dict:
|
||||
return {"path": path}
|
||||
|
||||
|
||||
class AudiobookRequest(ExpressiveMixin):
|
||||
class AudiobookRequest(BaseModel):
|
||||
text: str
|
||||
default_voice: str | None = None # voice profile id; None = engine default
|
||||
language: str | None = None # None/"Auto" → profile language, else autodetect (#505)
|
||||
@@ -227,9 +174,6 @@ class AudiobookRequest(ExpressiveMixin):
|
||||
metadata: dict | None = None
|
||||
# Optional pronunciation lexicon {word: respelling} applied before synthesis.
|
||||
lexicon: dict | None = None
|
||||
# Optional cast map {[voice:NAME] → profile id} for multi-voice books (#1217).
|
||||
# Absent/empty reproduces today's exact render + cache keys.
|
||||
voice_map: dict[str, str] | None = None
|
||||
|
||||
|
||||
def _resolve_voice(profile_id: str | None) -> dict:
|
||||
@@ -272,55 +216,6 @@ def _resolve_voice(profile_id: str | None) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _voice_profile_exists(profile_id: str | None) -> bool:
|
||||
"""True iff ``profile_id`` names a real voice profile (#1217).
|
||||
|
||||
Used to distinguish an exact profile id (a UUID someone passed as a span
|
||||
voice) from a bare ``[voice:NAME]`` name that has no cast mapping — the
|
||||
former resolves as-is, the latter falls back to the book default instead of
|
||||
silently missing and dropping to the engine default."""
|
||||
if not profile_id:
|
||||
return False
|
||||
from core.db import db_conn
|
||||
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM voice_profiles WHERE id=? LIMIT 1", (profile_id,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _map_span_voice(
|
||||
voice_id: str | None, default_voice: str | None, voice_map: dict | None
|
||||
) -> str | None:
|
||||
"""Translate a span's voice token to the profile id to synthesize with (#1217).
|
||||
|
||||
A span's ``voice_id`` is whatever the longform parser captured from
|
||||
``[voice:NAME]`` — the raw human NAME, never a profile id. Resolve it:
|
||||
|
||||
* ``None``/empty (a run with no ``[voice:]``) → ``default_voice``.
|
||||
* a NAME present in ``voice_map`` → its mapped profile id. THIS is the
|
||||
multi-voice cast fix: before it, a NAME was handed straight to
|
||||
``_resolve_voice`` as if it were a profile id, always missed (profile
|
||||
ids are UUIDs), and every ``[voice:…]`` silently rendered in the engine
|
||||
default — so ``[voice:Mara]``/``[voice:Cole]`` sounded identical.
|
||||
* an unmapped token that IS a real profile id (someone passed an exact id)
|
||||
→ itself, unchanged (exact-id back-compat, e.g. Stories spans).
|
||||
* an unmapped token that is NOT a real profile id (a NAME with no cast
|
||||
entry) → ``default_voice`` (fixes the silent-default bug for unmapped
|
||||
names: no longer treated as a literal id).
|
||||
"""
|
||||
if not voice_id:
|
||||
return default_voice
|
||||
if voice_map:
|
||||
mapped = voice_map.get(voice_id)
|
||||
if mapped:
|
||||
return mapped
|
||||
if _voice_profile_exists(voice_id):
|
||||
return voice_id
|
||||
return default_voice
|
||||
|
||||
|
||||
def _resolve_default_language(language: str | None, default_voice: str | None) -> str | None:
|
||||
"""Pick the language to thread into the longform synth callable.
|
||||
|
||||
@@ -349,129 +244,11 @@ def _resolve_default_language(language: str | None, default_voice: str | None) -
|
||||
return None
|
||||
|
||||
|
||||
#: Longform renders run at the model's documented quality preset (#1139).
|
||||
#: This used to be an accident of omission — the synth wrappers below passed
|
||||
#: no num_step/guidance_scale, silently inheriting OmniVoiceGenerationConfig's
|
||||
#: defaults (32 / 2.0) while interactive /generate defaults to num_step=16 —
|
||||
#: and users correctly heard audiobooks as more stable than the Voice page.
|
||||
#: Named constants make the divergence a documented decision (a book is a
|
||||
#: cached batch job: quality beats latency) and pin book quality against any
|
||||
#: upstream config-default drift.
|
||||
LONGFORM_NUM_STEP = 32
|
||||
LONGFORM_GUIDANCE_SCALE = 2.0
|
||||
|
||||
|
||||
def _seed_segment_rng(base_seed, text: str, nonce: int = 0) -> int | None:
|
||||
"""Apply a profile's pinned seed to this synth call (#1139).
|
||||
|
||||
``_resolve_voice`` has always fetched the profile ``seed`` — but only the
|
||||
cache signature ever used it; generation itself ran unseeded, so a locked
|
||||
take's pinned seed silently did nothing here while /generate honored it.
|
||||
No pinned seed → no-op (fresh-render variety unchanged).
|
||||
|
||||
Concurrency contract: this seeds the process-global torch RNG, exactly
|
||||
like /generate's #526 seeding (generation.py's ``torch.manual_seed`` in
|
||||
``_run_inference``/``_run_backend_inference``, same GPU pool). Both are
|
||||
strictly deterministic wherever the pool has one worker — the default on
|
||||
MPS/CPU and small-VRAM CUDA (model_manager._pick_gpu_workers) — and
|
||||
best-effort when a >1-worker CUDA pool runs another seeded job in the
|
||||
same window. Making that window race-free requires threading a per-call
|
||||
torch.Generator through the model's samplers app-wide; if that lands, it
|
||||
must cover /generate and here together, not one path.
|
||||
"""
|
||||
if base_seed is None:
|
||||
return None
|
||||
import torch
|
||||
|
||||
from services.audiobook import segment_seed
|
||||
seed = segment_seed(base_seed, text, nonce)
|
||||
torch.manual_seed(seed)
|
||||
return seed
|
||||
|
||||
|
||||
def _base_seed(opts: ExpressiveOptions, voice: dict):
|
||||
"""The seed that drives this render's determinism: an explicit book-level
|
||||
``seed`` override wins, else the selected profile's pinned seed, else None
|
||||
(fresh-render variety, unchanged)."""
|
||||
return opts.seed if opts.seed is not None else voice.get("seed")
|
||||
|
||||
|
||||
def _make_occ_counter(opts: ExpressiveOptions):
|
||||
"""Per-closure occurrence counter for the cache opt-out (#1208).
|
||||
|
||||
When ``vary_repeats`` is on, every synth call gets a monotonically rising
|
||||
nonce so a pinned-seed line that repeats is seeded distinctly per take (the
|
||||
segment cache is defeated per-occurrence in parallel). Off → always 0, so
|
||||
the seed derivation is byte-identical to pre-#1208."""
|
||||
state = {"n": 0}
|
||||
|
||||
def next_nonce() -> int:
|
||||
if not opts.vary_repeats:
|
||||
return 0
|
||||
n = state["n"]
|
||||
state["n"] = n + 1
|
||||
return n
|
||||
|
||||
return next_nonce
|
||||
|
||||
|
||||
def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
"""VoiceStudio-model generate kwargs for the sampling knobs. UNSET reproduces
|
||||
today exactly: num_step 32, guidance 2.0, and NO temperature/postprocess
|
||||
kwargs (the model keeps its own defaults). Emotion is never forwarded —
|
||||
the VoiceStudio config rejects unknown kwargs."""
|
||||
kw = {
|
||||
"num_step": opts.num_step if opts.num_step is not None else LONGFORM_NUM_STEP,
|
||||
"guidance_scale": (
|
||||
opts.guidance_scale if opts.guidance_scale is not None else LONGFORM_GUIDANCE_SCALE
|
||||
),
|
||||
}
|
||||
if opts.position_temperature is not None:
|
||||
kw["position_temperature"] = opts.position_temperature
|
||||
if opts.class_temperature is not None:
|
||||
kw["class_temperature"] = opts.class_temperature
|
||||
if opts.postprocess_output is not None:
|
||||
kw["postprocess_output"] = opts.postprocess_output
|
||||
return kw
|
||||
|
||||
|
||||
def _generic_extra_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
"""Extra generate kwargs for a non-VoiceStudio engine. UNSET → empty dict →
|
||||
byte-identical to the pre-#1208 generic call. Only present knobs are added,
|
||||
and every shipped backend's ``generate(self, text, **kw)`` ignores the ones
|
||||
it doesn't understand (never TypeError) — the engine-options contract. The
|
||||
emotion trio reaches IndexTTS2's arbitration; other engines drop it."""
|
||||
kw: dict = {}
|
||||
if opts.num_step is not None:
|
||||
kw["num_step"] = opts.num_step
|
||||
if opts.guidance_scale is not None:
|
||||
kw["guidance_scale"] = opts.guidance_scale
|
||||
if opts.position_temperature is not None:
|
||||
kw["position_temperature"] = opts.position_temperature
|
||||
if opts.class_temperature is not None:
|
||||
kw["class_temperature"] = opts.class_temperature
|
||||
if opts.postprocess_output is not None:
|
||||
kw["postprocess_output"] = opts.postprocess_output
|
||||
if opts.emo_vector:
|
||||
kw["emo_vector"] = list(opts.emo_vector)
|
||||
if opts.emo_text:
|
||||
kw["emo_text"] = opts.emo_text
|
||||
kw["use_emo_text"] = True
|
||||
if opts.emo_alpha is not None:
|
||||
kw["emo_alpha"] = opts.emo_alpha
|
||||
return kw
|
||||
|
||||
|
||||
def _build_synth(
|
||||
default_voice: str | None,
|
||||
language: str | None = None,
|
||||
opts: ExpressiveOptions | None = None,
|
||||
voice_map: dict | None = None,
|
||||
) -> dict:
|
||||
def _build_synth(default_voice: str | None, language: str | None = None) -> dict:
|
||||
"""Describe how to synthesize for the active TTS engine.
|
||||
|
||||
Returns a dict with ``mode``, ``resolve`` (voice-id → resolved refs, cached
|
||||
per id) and ``engine_id``. For VoiceStudio it also carries the async
|
||||
per id) and ``engine_id``. For OmniVoice it also carries the async
|
||||
``get_model``; other engines carry a ready ``synth`` + ``sample_rate``.
|
||||
:func:`_prepare_synth` turns this into a uniform ``(synth, sr, resolve,
|
||||
engine_id)`` once the (async) model is in hand.
|
||||
@@ -480,24 +257,13 @@ def _build_synth(
|
||||
threaded into every chunk's ``generate`` so a non-English clone stays in its
|
||||
language instead of re-autodetecting per chunk (#505 B2). ``None`` keeps the
|
||||
engine's autodetect behavior unchanged.
|
||||
|
||||
``opts`` (#1208) carries the expressive/quality knobs + cache opt-out. A
|
||||
default instance reproduces today's exact synth call and caching.
|
||||
"""
|
||||
from services.tts_backend import OmniVoiceBackend, active_backend_id, get_backend_class
|
||||
|
||||
opts = opts or ExpressiveOptions()
|
||||
cache: dict = {}
|
||||
token_cache: dict = {}
|
||||
|
||||
def resolve(voice_id):
|
||||
# Translate the span token ([voice:NAME] / exact id / None) to a profile
|
||||
# id first (#1217) — the cast fix lives here, not in the parser, so the
|
||||
# parser stays a pure text→plan and exact ids keep working. Cache the
|
||||
# translation so a book of hundreds of same-name spans does one DB check.
|
||||
if voice_id not in token_cache:
|
||||
token_cache[voice_id] = _map_span_voice(voice_id, default_voice, voice_map)
|
||||
key = token_cache[voice_id]
|
||||
key = voice_id or default_voice
|
||||
if key not in cache:
|
||||
cache[key] = _resolve_voice(key)
|
||||
return cache[key]
|
||||
@@ -506,71 +272,47 @@ def _build_synth(
|
||||
cls = get_backend_class(engine_id)
|
||||
if cls is OmniVoiceBackend:
|
||||
from services.model_manager import get_model
|
||||
return {"mode": "omnivoice", "resolve": resolve, "engine_id": engine_id,
|
||||
"get_model": get_model, "language": language, "opts": opts}
|
||||
return {"mode": "omnivoice", "resolve": resolve,
|
||||
"engine_id": engine_id, "get_model": get_model, "language": language}
|
||||
|
||||
backend = cls()
|
||||
native_proxy = bool(getattr(cls, "supports_native_omnivoice_controls", False))
|
||||
extra = (_omnivoice_sampling_kwargs(opts) if native_proxy
|
||||
else _generic_extra_kwargs(opts))
|
||||
next_nonce = _make_occ_counter(opts)
|
||||
|
||||
def synth(text, voice_id, speed=None):
|
||||
v = resolve(voice_id)
|
||||
seed = _seed_segment_rng(_base_seed(opts, v), text, next_nonce())
|
||||
call_extra = dict(extra)
|
||||
if native_proxy and seed is not None:
|
||||
call_extra["seed"] = seed
|
||||
return backend.generate(
|
||||
text, language=language, ref_audio=v["ref_audio"],
|
||||
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
|
||||
speed=float(speed) if speed else 1.0, **call_extra,
|
||||
speed=float(speed) if speed else 1.0,
|
||||
)
|
||||
return {"mode": "generic", "resolve": resolve, "engine_id": engine_id,
|
||||
"synth": synth, "sample_rate": backend.sample_rate}
|
||||
|
||||
|
||||
async def _prepare_synth(
|
||||
default_voice: str | None,
|
||||
language: str | None = None,
|
||||
opts: ExpressiveOptions | None = None,
|
||||
voice_map: dict | None = None,
|
||||
):
|
||||
async def _prepare_synth(default_voice: str | None, language: str | None = None):
|
||||
"""Resolve :func:`_build_synth` into ``(synth, sample_rate, resolve,
|
||||
engine_id)`` — awaiting the VoiceStudio model load when needed. Shared by the
|
||||
engine_id)`` — awaiting the OmniVoice model load when needed. Shared by the
|
||||
full job and the per-chapter preview. ``language`` is threaded into every
|
||||
chunk so a non-English clone holds its language (#505 B2). ``opts`` (#1208)
|
||||
carries the expressive knobs; a default instance reproduces today exactly."""
|
||||
opts = opts or ExpressiveOptions()
|
||||
info = _build_synth(default_voice, language=language, opts=opts, voice_map=voice_map)
|
||||
chunk so a non-English clone holds its language (#505 B2)."""
|
||||
info = _build_synth(default_voice, language=language)
|
||||
resolve, engine_id = info["resolve"], info["engine_id"]
|
||||
if info["mode"] == "omnivoice":
|
||||
lang = info["language"]
|
||||
model = await info["get_model"]()
|
||||
sr = getattr(model, "sampling_rate", 24000)
|
||||
|
||||
from services.tts_backend import generate_with_cached_ref
|
||||
|
||||
sampling = _omnivoice_sampling_kwargs(opts)
|
||||
next_nonce = _make_occ_counter(opts)
|
||||
|
||||
def synth(text, voice_id, speed=None):
|
||||
v = resolve(voice_id)
|
||||
_seed_segment_rng(_base_seed(opts, v), text, next_nonce())
|
||||
# A book is the worst case for the re-encode this avoids: hundreds of
|
||||
# segments, one voice. The reference is encoded on the first segment
|
||||
# and reused for every one after it.
|
||||
return generate_with_cached_ref(
|
||||
model, ref_audio=v["ref_audio"], ref_text=v["ref_text"],
|
||||
text=text, language=lang, instruct=v["instruct"], duration=None,
|
||||
speed=float(speed) if speed else 1.0, **sampling,
|
||||
return model.generate(
|
||||
text=text, language=lang, ref_audio=v["ref_audio"],
|
||||
ref_text=v["ref_text"], instruct=v["instruct"], duration=None,
|
||||
speed=float(speed) if speed else 1.0,
|
||||
)[0]
|
||||
return synth, sr, resolve, engine_id
|
||||
return info["synth"], info["sample_rate"], resolve, engine_id
|
||||
|
||||
|
||||
def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, lexicon=None,
|
||||
language=None, opts=None, voice_map=None):
|
||||
language=None):
|
||||
"""Render one chapter, content-addressed so a re-run reuses it (resume).
|
||||
|
||||
Returns ``(wav_path, duration_s, was_cached, seg_stats)``. Two cache
|
||||
@@ -580,11 +322,8 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
:func:`chapter_cache_key` over the chapter's spans + sample rate +
|
||||
engine + each voice's resolved signature (+ the lexicon, so a lexicon
|
||||
edit re-renders). A fully-unchanged chapter hits here and never touches
|
||||
segment files. With invisible watermarking active the key also carries a
|
||||
watermark tag (#1169) — pre-#1169 chapter caches (unmarked audio)
|
||||
deliberately miss once and re-render marked; with watermarking off the
|
||||
derivation is unchanged and released-version caches keep hitting.
|
||||
``seg_stats`` is ``None``.
|
||||
segment files; the key derivation is unchanged, so chapter caches
|
||||
written by released versions keep hitting. ``seg_stats`` is ``None``.
|
||||
* Inner — on a chapter miss, each spoken span goes through the
|
||||
:class:`services.longform_render.SegmentCache` under
|
||||
``cache_dir/segments``: cached segments load from disk, only the
|
||||
@@ -603,13 +342,10 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
import wave
|
||||
|
||||
from services.audio_io import atomic_save_wav
|
||||
from services.audiobook import ExpressiveOptions, Span, voice_map_signature
|
||||
from services.audiobook import Span
|
||||
from services.longform_render import SegmentCache, chapter_cache_key
|
||||
from services.pronunciation import normalize_lexicon
|
||||
from services.text_normalization import normalize_for_tts
|
||||
from services.watermark import mark_synthetic, will_mark
|
||||
|
||||
opts = opts or ExpressiveOptions()
|
||||
|
||||
spans = [Span(voice_id=s.voice_id, text=normalize_for_tts(s.text, language),
|
||||
pause_ms_after=s.pause_ms_after, speed=getattr(s, "speed", None))
|
||||
@@ -629,35 +365,6 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
# invalidates cached chapters (reserved key can't collide with a voice id).
|
||||
lex_sig = json.dumps(normalize_lexicon(lexicon), sort_keys=True)
|
||||
sig["\x00lexicon"] = lex_sig
|
||||
# Fold the #1208 expressive signature into BOTH cache layers so changing any
|
||||
# new knob (sampling, emotion, seed, cache opt-out) re-renders instead of
|
||||
# replaying stale audio (the CRITICAL TRAP). Empty for a default render, so
|
||||
# the derivation stays byte-identical to pre-#1208 and released caches hit.
|
||||
expr_sig = opts.cache_signature()
|
||||
if expr_sig:
|
||||
sig["\x00expressive"] = expr_sig
|
||||
# Fold the #1217 voice map into BOTH cache layers, exactly like the
|
||||
# expressive signature: remapping a [voice:NAME] must re-render, while an
|
||||
# empty/absent map keeps the key byte-identical to pre-#1217 (existing books
|
||||
# never re-render). The resolved voice_sigs above already reflect a mapping
|
||||
# when synthesis actually resolves it, but folding the raw map in makes the
|
||||
# invalidation robust even where resolution is short-circuited/stubbed.
|
||||
vmap_sig = voice_map_signature(voice_map)
|
||||
if vmap_sig:
|
||||
sig["\x00voicemap"] = vmap_sig
|
||||
seg_extra_sig = f"{lex_sig}\x00{expr_sig}" if expr_sig else lex_sig
|
||||
if vmap_sig:
|
||||
seg_extra_sig = f"{seg_extra_sig}\x00{vmap_sig}"
|
||||
if will_mark():
|
||||
# Provenance-marked chapters cache under their own key (#1169): a
|
||||
# chapter WAV rendered while watermarking was off/unavailable —
|
||||
# including every cache entry written before marking existed — must
|
||||
# never satisfy a request made while it's on. Deliberately one-time
|
||||
# invalidates pre-#1169 chapter caches (the SEGMENT cache underneath
|
||||
# is untouched, so re-rendering is assembly + one embed, not re-TTS);
|
||||
# with marking off the key is byte-identical to the released
|
||||
# derivation, so those caches keep hitting.
|
||||
sig["\x00watermark"] = "1"
|
||||
key = chapter_cache_key(spans_tuples, sample_rate=sr, engine_id=engine_id, voice_sig=sig)
|
||||
wav_path = os.path.join(cache_dir, f"{key}.wav")
|
||||
|
||||
@@ -670,115 +377,20 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
pass # corrupt cache entry — fall through and re-render
|
||||
|
||||
seg_cache = SegmentCache(cache_dir, sample_rate=sr, engine_id=engine_id,
|
||||
voice_sig=voice_sigs, extra_sig=seg_extra_sig,
|
||||
vary_repeats=opts.vary_repeats)
|
||||
voice_sig=voice_sigs, extra_sig=lex_sig)
|
||||
audio, dur = synthesize_chapter(spans, synth, sr, lexicon=lexicon,
|
||||
segment_cache=seg_cache)
|
||||
# Invisible provenance mark on the assembled chapter (#1169), tensor stage,
|
||||
# before the WAV lands in the cache — this single site covers every
|
||||
# longform front door (/audiobook, /longform/render [Stories],
|
||||
# /audiobook/preview, /audiobook/resume/{id}): the m4b/mp3 mux only
|
||||
# concatenates these WAVs, and AudioSeal survives the lossy encode.
|
||||
# Segments in the segment cache stay unmarked by design — they're
|
||||
# intermediate assembly inputs, re-marked here on every chapter render.
|
||||
# Already runs in the GPU-pool executor; never raises (degrades to
|
||||
# unmarked on failure).
|
||||
audio = mark_synthetic(audio, sr, context="longform.chapter")
|
||||
atomic_save_wav(wav_path, audio, sr)
|
||||
return wav_path, dur, False, {"total": seg_cache.hits + seg_cache.misses,
|
||||
"cached": seg_cache.hits}
|
||||
|
||||
|
||||
def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
|
||||
language, lexicon, opts, cache_dir):
|
||||
"""Build one opaque remote chapter task without loading a local TTS model."""
|
||||
import hashlib
|
||||
|
||||
from services import gpu_gateway
|
||||
from services.text_normalization import normalize_for_tts
|
||||
from services.watermark import is_enabled as watermark_enabled
|
||||
|
||||
rows, voices, refs = [], [], []
|
||||
for span in chapter.spans:
|
||||
profile_id = _map_span_voice(span.voice_id, default_voice, voice_map)
|
||||
voice = _resolve_voice(profile_id)
|
||||
rows.append({
|
||||
"text": normalize_for_tts(span.text, language),
|
||||
"pause_ms_after": span.pause_ms_after,
|
||||
"speed": getattr(span, "speed", None),
|
||||
})
|
||||
refs.append(voice.get("ref_audio"))
|
||||
voices.append({
|
||||
"ref_text": voice.get("ref_text"), "instruct": voice.get("instruct"),
|
||||
"seed": voice.get("seed"),
|
||||
})
|
||||
params = {
|
||||
"spans": rows, "voices": voices, "ref_audio": refs,
|
||||
"language": language, "lexicon": lexicon,
|
||||
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
|
||||
}
|
||||
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
||||
wav_path = os.path.join(cache_dir, f"remote-{signature}.wav")
|
||||
|
||||
def decode(result):
|
||||
import soundfile as sf
|
||||
if not os.path.exists(wav_path):
|
||||
partial = f"{wav_path}.part"
|
||||
shutil.copyfile(result.path, partial)
|
||||
os.replace(partial, wav_path)
|
||||
info = sf.info(wav_path)
|
||||
return wav_path, float(info.duration), False, None
|
||||
|
||||
return gpu_gateway.RemoteCall(
|
||||
engine=engine_id, operation="audiobook", params=params,
|
||||
idempotency_key=f"audiobook:{signature}", decode=decode,
|
||||
), wav_path
|
||||
|
||||
|
||||
async def _run_chapter(chapter, *, operation="audiobook", decision, job, default_voice, language, opts,
|
||||
voice_map, lexicon, cache_dir):
|
||||
"""Run one chapter through the gateway; local preparation stays lazy."""
|
||||
from services import gpu_gateway
|
||||
from services.tts_backend import active_backend_id
|
||||
|
||||
engine_id = active_backend_id()
|
||||
remote, remote_cache = _remote_chapter_call(
|
||||
chapter, engine_id=engine_id, default_voice=default_voice,
|
||||
voice_map=voice_map, language=language, lexicon=lexicon,
|
||||
opts=opts, cache_dir=cache_dir,
|
||||
)
|
||||
if decision.remote and os.path.exists(remote_cache):
|
||||
import soundfile as sf
|
||||
info = sf.info(remote_cache)
|
||||
return remote_cache, float(info.duration), True, None
|
||||
|
||||
async def prepare_local():
|
||||
synth, sr, resolve, local_engine = await _prepare_synth(
|
||||
default_voice, language=language, opts=opts, voice_map=voice_map
|
||||
)
|
||||
return gpu_gateway.LocalCall(
|
||||
fn=lambda: _render_chapter_cached(
|
||||
chapter, synth, sr, local_engine, resolve, cache_dir, lexicon,
|
||||
language, opts, voice_map,
|
||||
),
|
||||
what="Audiobook chapter",
|
||||
)
|
||||
|
||||
return await gpu_gateway.run(
|
||||
operation, local=gpu_gateway.LocalCall(prepare=prepare_local),
|
||||
remote=remote, decision=decision, job=job,
|
||||
)
|
||||
|
||||
|
||||
class AudiobookPreviewRequest(ExpressiveMixin):
|
||||
class AudiobookPreviewRequest(BaseModel):
|
||||
text: str
|
||||
chapter_index: int = 0
|
||||
default_voice: str | None = None
|
||||
language: str | None = None # None/"Auto" → profile language, else autodetect
|
||||
lexicon: dict | None = None
|
||||
# Cast map {[voice:NAME] → profile id} — MUST match the full render's so a
|
||||
# preview warms exactly the cache slot the render reuses (#1217).
|
||||
voice_map: dict[str, str] | None = None
|
||||
|
||||
|
||||
@router.post("/audiobook/preview")
|
||||
@@ -789,7 +401,7 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
cache (the later full render reuses it) and a re-preview is instant.
|
||||
"""
|
||||
from core.config import OUTPUTS_DIR
|
||||
from services import gpu_gateway
|
||||
from services.model_manager import _gpu_pool
|
||||
|
||||
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
|
||||
if not plan.chapters:
|
||||
@@ -802,12 +414,14 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache") # shared with _render_longform_sse
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
resolved_lang = _resolve_default_language(req.language, req.default_voice)
|
||||
opts = _expressive_opts(req)
|
||||
decision = gpu_gateway.decide("audiobook")
|
||||
wav_path, dur, was_cached, _seg_stats = await _run_chapter(
|
||||
chapter, decision=decision, job=None, default_voice=req.default_voice,
|
||||
language=resolved_lang, opts=opts, voice_map=req.voice_map,
|
||||
lexicon=req.lexicon, cache_dir=cache_dir,
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
req.default_voice,
|
||||
language=resolved_lang,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
|
||||
req.lexicon, resolved_lang,
|
||||
)
|
||||
return {
|
||||
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
|
||||
@@ -828,12 +442,9 @@ async def _render_longform_sse(
|
||||
cover_path: str | None = None,
|
||||
metadata: dict | None = None,
|
||||
lexicon: dict | None = None,
|
||||
opts: ExpressiveOptions | None = None,
|
||||
voice_map: dict | None = None,
|
||||
job_type: str = "audiobook",
|
||||
job_id: str | None = None,
|
||||
resume: bool = False,
|
||||
is_disconnected: Callable[[], Awaitable[bool]] | None = None,
|
||||
):
|
||||
"""Shared chapterized-render SSE generator for Audiobook *and* Stories.
|
||||
|
||||
@@ -844,11 +455,8 @@ async def _render_longform_sse(
|
||||
convergence point: one renderer, two front doors.
|
||||
"""
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core.failure import build_failure, build_failure_event
|
||||
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
|
||||
from services import gpu_gateway
|
||||
|
||||
opts = opts or ExpressiveOptions()
|
||||
from services.model_manager import _gpu_pool
|
||||
|
||||
# Resume reuses the original job_id (continuing the same job row + cached
|
||||
# chapters); a fresh render generates a new one. The id may arrive from the
|
||||
@@ -880,12 +488,6 @@ async def _render_longform_sse(
|
||||
"fmt": fmt, "bitrate": bitrate,
|
||||
"loudness": loudness, "cover_path": cover_path,
|
||||
"metadata": metadata, "lexicon": lexicon,
|
||||
# #1208: persist the expressive knobs so a resumed render is
|
||||
# byte-consistent with the interrupted one (same cache keys).
|
||||
"expressive": opts.to_manifest(),
|
||||
# #1217: persist the cast map so a resumed render resolves and
|
||||
# caches every [voice:NAME] identically to the interrupted one.
|
||||
"voice_map": voice_map,
|
||||
},
|
||||
))
|
||||
except Exception: # resume durability is an enhancement; never block the render
|
||||
@@ -921,72 +523,34 @@ async def _render_longform_sse(
|
||||
cache_dir = os.path.join(OUTPUTS_DIR, "longform_cache")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
prune_cache_dir(cache_dir) # bound disk before this job adds its chapters
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
resolved_lang = _resolve_default_language(language, default_voice)
|
||||
operation = "audiobook" if job_type == "audiobook" else "longform"
|
||||
decision = gpu_gateway.decide(operation)
|
||||
chapter_run = gpu_gateway.JobRun(operation)
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
default_voice, language=resolved_lang
|
||||
)
|
||||
|
||||
total = len(plan.chapters)
|
||||
chapter_files: list[str] = []
|
||||
chapters_meta: list[tuple[str, int]] = []
|
||||
cached_n = 0
|
||||
failed: list[int] = []
|
||||
# Kept so the terminal "all chapters failed" event can name the cause
|
||||
# instead of restating the symptom (#1321).
|
||||
last_chapter_exc: Exception | None = None
|
||||
interrupted = False
|
||||
yield _emit({"type": "started", "job_id": job_id, "chapters": total})
|
||||
|
||||
for i, chapter in enumerate(plan.chapters):
|
||||
# Client-disconnect cancellation (#1216): if the browser aborted the
|
||||
# request (the user hit Stop), stop scheduling further chapters
|
||||
# instead of rendering the whole book into a stream nobody reads.
|
||||
# Checked at the chapter boundary so a stop is clean and the finished
|
||||
# chapters — content-addressed in the shared cache — plus the resume
|
||||
# manifest are left in place, so a later Create/resume finishes the
|
||||
# rest cheaply. (Starlette also cancels this task on disconnect; the
|
||||
# explicit poll makes the stop deterministic and lets us emit a clean
|
||||
# terminal `stopped` event. This render parks no model on CPU the way
|
||||
# the dub transcribe does — #1191 — so there is no restore debt to
|
||||
# pay on exit; stopping is simply "schedule no more chapters".)
|
||||
if is_disconnected is not None:
|
||||
try:
|
||||
gone = await is_disconnected()
|
||||
except Exception:
|
||||
gone = False
|
||||
if gone:
|
||||
interrupted = True
|
||||
break
|
||||
try:
|
||||
wav_path, dur, was_cached, seg_stats = await _run_chapter(
|
||||
chapter, operation=operation, decision=decision, job=chapter_run,
|
||||
default_voice=default_voice, language=resolved_lang,
|
||||
opts=opts, voice_map=voice_map, lexicon=lexicon,
|
||||
cache_dir=cache_dir,
|
||||
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached,
|
||||
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
|
||||
resolved_lang,
|
||||
)
|
||||
except Exception as e: # isolate a bad chapter — keep going
|
||||
except Exception: # isolate a bad chapter — keep going
|
||||
logger.warning("[%s] chapter %d (%s) failed to render",
|
||||
job_id, i, chapter.title, exc_info=True)
|
||||
failed.append(i)
|
||||
# Carry the real reason (#1321). The old event said only
|
||||
# "chapter failed to render", so a failed chapter was a red row
|
||||
# and nothing else — the cause existed solely in the backend log,
|
||||
# which is why the report for this arrived as a bare traceback.
|
||||
# build_failure guarantees a non-empty reason even for exceptions
|
||||
# whose str() is empty (a generator-based engine that yields
|
||||
# nothing raises a bare StopIteration), sanitizes paths/tokens,
|
||||
# and adds the docs deeplink + hint. `error` stays populated —
|
||||
# build_failure mirrors reason into it — so older frontends and
|
||||
# the Stories exporter keep working.
|
||||
last_chapter_exc = e
|
||||
yield _emit({"type": "chapter_error", "index": i, "total": total,
|
||||
"title": chapter.title,
|
||||
# No env diagnostic per chapter: a book can fail
|
||||
# hundreds of times and it is identical every time.
|
||||
# The terminal error below carries one.
|
||||
**build_failure(e, stage="audiobook_chapter",
|
||||
include_diagnostic=False)})
|
||||
"title": chapter.title, "error": "chapter failed to render"})
|
||||
continue
|
||||
chapter_files.append(wav_path)
|
||||
chapters_meta.append((chapter.title, int(round(dur * 1000))))
|
||||
@@ -1001,59 +565,8 @@ async def _render_longform_sse(
|
||||
ev["cached_segments"] = seg_stats["cached"]
|
||||
yield _emit(ev)
|
||||
|
||||
route_notice = chapter_run.notice()
|
||||
if route_notice is not None:
|
||||
yield _emit({"type": "routing_notice", "status": route_notice[0],
|
||||
"reason": route_notice[1]})
|
||||
|
||||
if interrupted:
|
||||
logger.info("[%s] client disconnected — stopped after %d/%d chapters",
|
||||
job_id, len(chapter_files), total)
|
||||
if job_store is not None:
|
||||
try:
|
||||
# A client disconnect here is a user-initiated Stop, not a
|
||||
# failure — record it as cancelled so job history reads right
|
||||
# and the resumable state isn't mistaken for a broken render.
|
||||
job_store.mark_cancelled(job_id)
|
||||
except Exception:
|
||||
pass # best-effort job history
|
||||
# Deliberately DO NOT clear the resume manifest: the rendered chapters
|
||||
# are cached, so Create-again / resume picks up where this left off.
|
||||
# Emit a terminal `stopped` event (a fully-disconnected client won't
|
||||
# receive it, but a same-origin proxy or a partial read still gets a
|
||||
# clean close instead of a dangling stream).
|
||||
yield _emit({"type": "stopped", "rendered": len(chapter_files),
|
||||
"total": total, "cached_chapters": cached_n,
|
||||
"failed_chapters": failed})
|
||||
return
|
||||
|
||||
if not chapter_files:
|
||||
# Every chapter failed, so the render is over — this is the event the
|
||||
# UI turns into a toast, and it used to carry only the symptom
|
||||
# (#1321). Lead with the summary, then the cause; docs_topic/hint are
|
||||
# classified from the raw exception text, so prefixing the reason
|
||||
# afterwards cannot mis-route the deeplink.
|
||||
if last_chapter_exc is not None:
|
||||
ev = build_failure_event(last_chapter_exc, stage="audiobook_render")
|
||||
ev["reason"] = f"all {total} chapters failed to render — {ev['reason']}"
|
||||
ev["error"] = ev["reason"]
|
||||
else:
|
||||
ev = {"type": "error", "error": "all chapters failed to render",
|
||||
"reason": "all chapters failed to render"}
|
||||
# Terminal failure — record it. This branch used to return without
|
||||
# touching job history, so the row stayed `running` forever: the next
|
||||
# startup read it as an interrupted job, and the retained manifest
|
||||
# offered a render that had already failed every chapter as
|
||||
# resumable (Greptile P1 on #1321). The manifest IS kept on purpose —
|
||||
# a failure whose cause the user can now see (a missing voice, an
|
||||
# engine that can't read the script) is worth retrying once fixed,
|
||||
# and the chapter cache is empty here so a retry costs nothing extra.
|
||||
if job_store is not None:
|
||||
try:
|
||||
job_store.mark_failed(job_id, ev["reason"])
|
||||
except Exception:
|
||||
pass # best-effort job history; never block the stream
|
||||
yield _emit(ev)
|
||||
yield _emit({"type": "error", "error": "all chapters failed to render"})
|
||||
return
|
||||
|
||||
yield _emit({"type": "assembling"})
|
||||
@@ -1121,40 +634,16 @@ async def _render_longform_sse(
|
||||
yield _emit({"type": "error", "error": "render failed (see backend log)"})
|
||||
|
||||
|
||||
async def _public_longform_stream(plan, **render_kwargs):
|
||||
"""Keep generator diagnostics local if setup fails before its own guard."""
|
||||
try:
|
||||
async for event in _render_longform_sse(plan, **render_kwargs):
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
from core.public_errors import public_failure
|
||||
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Longform response stream failed",
|
||||
exc,
|
||||
response="Render failed; check the backend log for details.",
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': error})}\n\n"
|
||||
|
||||
|
||||
@router.post("/audiobook")
|
||||
async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
async def audiobook_synthesize(req: AudiobookRequest):
|
||||
"""Synthesize a chapterized audiobook from a script, streaming SSE progress."""
|
||||
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
|
||||
# `request` is injected by FastAPI on the HTTP path (the default only applies
|
||||
# to a direct in-process call, e.g. a unit test); its disconnect poll is what
|
||||
# lets Stop cancel the render mid-book (#1216).
|
||||
return StreamingResponse(
|
||||
_public_longform_stream(
|
||||
_render_longform_sse(
|
||||
plan, default_voice=req.default_voice, language=req.language,
|
||||
fmt=req.format, bitrate=req.bitrate,
|
||||
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
|
||||
lexicon=req.lexicon, opts=_expressive_opts(req), voice_map=req.voice_map,
|
||||
job_type="audiobook",
|
||||
is_disconnected=request.is_disconnected if request is not None else None,
|
||||
lexicon=req.lexicon, job_type="audiobook",
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
@@ -1174,7 +663,7 @@ class LongformChapter(BaseModel):
|
||||
spans: list[LongformSpan] = []
|
||||
|
||||
|
||||
class LongformRenderRequest(ExpressiveMixin):
|
||||
class LongformRenderRequest(BaseModel):
|
||||
chapters: list[LongformChapter] = []
|
||||
default_voice: str | None = None
|
||||
language: str | None = None # None/"Auto" → profile language, else autodetect (#505)
|
||||
@@ -1184,12 +673,10 @@ class LongformRenderRequest(ExpressiveMixin):
|
||||
cover_path: str | None = None
|
||||
metadata: dict | None = None
|
||||
lexicon: dict | None = None
|
||||
# Cast map {[voice:NAME] → profile id} (#1217); absent/empty = today's render.
|
||||
voice_map: dict[str, str] | None = None
|
||||
|
||||
|
||||
@router.post("/longform/render")
|
||||
async def longform_render(req: LongformRenderRequest, request: Request = None):
|
||||
async def longform_render(req: LongformRenderRequest):
|
||||
"""Render a pre-built chapter/span plan (the Stories Editor's compiled
|
||||
cast+lines) through the shared chapterized renderer — same resume, loudness,
|
||||
cover, metadata, and output formats as the Audiobook job."""
|
||||
@@ -1209,13 +696,11 @@ async def longform_render(req: LongformRenderRequest, request: Request = None):
|
||||
chapters.append(Chapter(title=c.title or f"Chapter {i + 1}", spans=spans))
|
||||
plan = AudiobookPlan(chapters=chapters)
|
||||
return StreamingResponse(
|
||||
_public_longform_stream(
|
||||
_render_longform_sse(
|
||||
plan, default_voice=req.default_voice, language=req.language,
|
||||
fmt=req.format, bitrate=req.bitrate,
|
||||
loudness=req.loudness, cover_path=req.cover_path, metadata=req.metadata,
|
||||
lexicon=req.lexicon, opts=_expressive_opts(req), voice_map=req.voice_map,
|
||||
job_type="story",
|
||||
is_disconnected=request.is_disconnected if request is not None else None,
|
||||
lexicon=req.lexicon, job_type="story",
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
@@ -1266,7 +751,7 @@ def list_resumable_jobs() -> dict:
|
||||
|
||||
|
||||
@router.post("/audiobook/resume/{job_id}")
|
||||
async def resume_longform(job_id: str, request: Request = None):
|
||||
async def resume_longform(job_id: str):
|
||||
"""Resume an interrupted longform render from its persisted manifest. The
|
||||
already-rendered chapters are content-addressed in the shared cache, so they
|
||||
return instantly — only the unrendered chapters synthesize again. Streams the
|
||||
@@ -1302,15 +787,12 @@ async def resume_longform(job_id: str, request: Request = None):
|
||||
# unrendered ones synthesize. Using a fresh id means the request's job_id
|
||||
# never names a work dir / output file (defence-in-depth path-injection).
|
||||
return StreamingResponse(
|
||||
_public_longform_stream(
|
||||
_render_longform_sse(
|
||||
plan, default_voice=p.get("default_voice"), language=p.get("language"),
|
||||
fmt=p.get("fmt", "m4b"), bitrate=p.get("bitrate", "128k"),
|
||||
loudness=p.get("loudness"), cover_path=p.get("cover_path"),
|
||||
metadata=p.get("metadata"), lexicon=p.get("lexicon"),
|
||||
opts=ExpressiveOptions.from_manifest(p.get("expressive")),
|
||||
voice_map=p.get("voice_map"),
|
||||
job_type=entry["job_type"],
|
||||
is_disconnected=request.is_disconnected if request is not None else None,
|
||||
),
|
||||
media_type="text/event-stream",
|
||||
)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"""Short-lived credentials for the first-party remote administration UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict, deque
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.auth import (
|
||||
CredentialTransport,
|
||||
PrincipalKind,
|
||||
authorization_credential_present,
|
||||
legacy_master_cookie_valid,
|
||||
master_header_valid,
|
||||
principal_for,
|
||||
remote_api_key,
|
||||
)
|
||||
from core.csrf import cookie_csrf_allowed, effective_scheme
|
||||
from services.admin_sessions import (
|
||||
SESSION_TTL_SECONDS,
|
||||
WS_TICKET_TTL_SECONDS,
|
||||
admin_session_store,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
_FAILED_EXCHANGE_LIMIT = 10
|
||||
_FAILED_EXCHANGE_WINDOW_SECONDS = 60
|
||||
_MAX_TRACKED_CLIENTS = 1024
|
||||
|
||||
|
||||
class _ExchangeAttemptLimiter:
|
||||
"""Bounded per-client sliding window for failed pre-auth exchanges."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
limit: int = _FAILED_EXCHANGE_LIMIT,
|
||||
window_seconds: int = _FAILED_EXCHANGE_WINDOW_SECONDS,
|
||||
max_clients: int = _MAX_TRACKED_CLIENTS,
|
||||
) -> None:
|
||||
if limit <= 0 or window_seconds <= 0 or max_clients <= 0:
|
||||
raise ValueError("rate-limit bounds must be positive")
|
||||
self._monotonic = monotonic
|
||||
self._limit = limit
|
||||
self._window_seconds = window_seconds
|
||||
self._max_clients = max_clients
|
||||
self._attempts: OrderedDict[str, deque[float]] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def register_failure(self, client_id: str) -> int | None:
|
||||
now = self._monotonic()
|
||||
cutoff = now - self._window_seconds
|
||||
with self._lock:
|
||||
failures = self._attempts.setdefault(client_id, deque())
|
||||
while failures and failures[0] <= cutoff:
|
||||
failures.popleft()
|
||||
self._attempts.move_to_end(client_id)
|
||||
while len(self._attempts) > self._max_clients:
|
||||
self._attempts.popitem(last=False)
|
||||
if len(failures) >= self._limit:
|
||||
return max(
|
||||
1,
|
||||
math.ceil(self._window_seconds - (now - failures[0])),
|
||||
)
|
||||
failures.append(now)
|
||||
return None
|
||||
|
||||
def clear(self, client_id: str) -> None:
|
||||
with self._lock:
|
||||
self._attempts.pop(client_id, None)
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._attempts.clear()
|
||||
|
||||
|
||||
_exchange_attempt_limiter = _ExchangeAttemptLimiter()
|
||||
|
||||
|
||||
class SessionRequest(BaseModel):
|
||||
transport: Literal["cookie", "bearer"]
|
||||
|
||||
|
||||
class WebSocketTicketRequest(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
def _secure_cookie(request: Request) -> bool:
|
||||
# Same effective-scheme logic as the exact-origin CSRF check: the resolved
|
||||
# scope first (uvicorn's trusted-proxy rewrite), upgraded — never
|
||||
# downgraded — by X-Forwarded-Proto for TLS-terminating proxies uvicorn
|
||||
# doesn't trust (Tailscale Serve into Docker, etc.). Spoofing the header on
|
||||
# a plain-http hop can only ADD the Secure flag, which fails safe: the
|
||||
# browser drops such a cookie, so the spoofer only breaks their own
|
||||
# session. See core.csrf.effective_scheme for the full analysis.
|
||||
return effective_scheme(request) == "https"
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, request: Request, token: str, expires_at: float) -> None:
|
||||
response.set_cookie(
|
||||
"ov_session",
|
||||
token,
|
||||
max_age=SESSION_TTL_SECONDS,
|
||||
expires=datetime.fromtimestamp(expires_at, tz=UTC),
|
||||
path="/",
|
||||
secure=_secure_cookie(request),
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _expire_cookie(response: Response, request: Request, name: str) -> None:
|
||||
response.delete_cookie(
|
||||
name,
|
||||
path="/",
|
||||
secure=_secure_cookie(request),
|
||||
httponly=name == "ov_session",
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def _client_id(request: Request) -> str:
|
||||
host = request.client.host if request.client else "unknown"
|
||||
return str(host).strip().lower()[:255] or "unknown"
|
||||
|
||||
|
||||
def _reject_master_exchange(request: Request) -> None:
|
||||
retry_after = _exchange_attempt_limiter.register_failure(_client_id(request))
|
||||
if retry_after is not None:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many authentication attempts",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
|
||||
@router.post("/session")
|
||||
def create_session(payload: SessionRequest, request: Request) -> Response:
|
||||
configured = remote_api_key()
|
||||
if not configured:
|
||||
raise HTTPException(status_code=401, detail="API key required")
|
||||
|
||||
authorization_present = authorization_credential_present(request)
|
||||
header_authorized = master_header_valid(request)
|
||||
legacy_authorized = legacy_master_cookie_valid(request)
|
||||
migrating_legacy = False
|
||||
|
||||
if authorization_present:
|
||||
if not header_authorized:
|
||||
_reject_master_exchange(request)
|
||||
elif legacy_authorized:
|
||||
if payload.transport != "cookie" or not cookie_csrf_allowed(request):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
migrating_legacy = True
|
||||
else:
|
||||
_reject_master_exchange(request)
|
||||
|
||||
_exchange_attempt_limiter.clear(_client_id(request))
|
||||
issued = admin_session_store.issue(configured)
|
||||
if payload.transport == "bearer":
|
||||
return JSONResponse(
|
||||
{
|
||||
"token": issued.token,
|
||||
"expires_at": issued.expires_at,
|
||||
"expires_in": SESSION_TTL_SECONDS,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
response = Response(status_code=204)
|
||||
_set_session_cookie(response, request, issued.token, issued.expires_at)
|
||||
if migrating_legacy or request.cookies.get("ov_key"):
|
||||
_expire_cookie(response, request, "ov_key")
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/session", status_code=204)
|
||||
def delete_session(request: Request) -> Response:
|
||||
principal = principal_for(request)
|
||||
if principal.kind is PrincipalKind.ADMIN_SESSION:
|
||||
if (
|
||||
principal.transport is CredentialTransport.COOKIE
|
||||
and not cookie_csrf_allowed(request)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
admin_session_store.revoke_by_credential(principal.credential_id)
|
||||
response = Response(status_code=204)
|
||||
_expire_cookie(response, request, "ov_session")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/ws-ticket")
|
||||
def create_ws_ticket(payload: WebSocketTicketRequest, request: Request) -> JSONResponse:
|
||||
principal = principal_for(request)
|
||||
if principal.kind is not PrincipalKind.ADMIN_SESSION:
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
if (
|
||||
principal.transport is CredentialTransport.COOKIE
|
||||
and not cookie_csrf_allowed(request)
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="browser origin rejected")
|
||||
try:
|
||||
ticket = admin_session_store.issue_ws_ticket_for_credential(
|
||||
principal.credential_id,
|
||||
payload.path,
|
||||
remote_api_key(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from None
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=401, detail="admin session required") from None
|
||||
return JSONResponse(
|
||||
{
|
||||
"ticket": ticket.token,
|
||||
"expires_at": ticket.expires_at,
|
||||
"expires_in": WS_TICKET_TTL_SECONDS,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
+22
-290
@@ -20,8 +20,6 @@ from pydantic import BaseModel
|
||||
|
||||
from core.config import DATA_DIR
|
||||
from core import failure
|
||||
from core.logging_utils import log_safe
|
||||
from core.file_cleanup import FileCleanupError, unlink_if_present
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.batch")
|
||||
@@ -78,16 +76,8 @@ async def _worker():
|
||||
job_id, job["finished_at"] - job["started_at"],
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Task cancellation always means SHUTDOWN: the job-level cancel
|
||||
# endpoint only flips job["status"] — nothing ever cancels this
|
||||
# task to abort a single job. Swallowing the CancelledError here
|
||||
# made the worker unkillable (the while-loop re-entered
|
||||
# _queue.get() and event-loop teardown hung forever in
|
||||
# _cancel_all_tasks waiting on a task that never finishes). Mark
|
||||
# the in-flight job, then let the cancellation propagate.
|
||||
job["status"] = "cancelled"
|
||||
job["finished_at"] = time.time()
|
||||
raise
|
||||
except Exception as e:
|
||||
job["status"] = "failed"
|
||||
# plan-04 (#131): guaranteed non-empty, structured reason.
|
||||
@@ -103,93 +93,6 @@ def _set_progress(job, stage, percent=0, **extra):
|
||||
job["progress"] = {"stage": stage, "percent": percent, **extra}
|
||||
|
||||
|
||||
#: Override for the native dub batch width. Set to 1 to disable batching.
|
||||
BATCH_WIDTH_ENV = "OMNIVOICE_DUB_BATCH_WIDTH"
|
||||
|
||||
#: Hard ceiling on the override — a batch this wide is already amortizing
|
||||
#: almost all of the per-call setup, and beyond it the failure mode is an OOM
|
||||
#: that costs more than the saving.
|
||||
_MAX_BATCH_WIDTH = 16
|
||||
|
||||
# Bound each allocation while persisting multipart uploads. Video inputs can
|
||||
# be many gigabytes; `await UploadFile.read()` with no size used to mirror the
|
||||
# entire file in process memory before writing it back out.
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _save_upload(upload: UploadFile, destination: str) -> None:
|
||||
try:
|
||||
with open(destination, "wb") as output:
|
||||
while chunk := await upload.read(_UPLOAD_CHUNK_BYTES):
|
||||
output.write(chunk)
|
||||
except BaseException:
|
||||
try:
|
||||
unlink_if_present(destination)
|
||||
except FileCleanupError:
|
||||
logger.warning("Could not remove incomplete batch upload", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _native_batch_width(backend) -> int:
|
||||
"""How many segments to render in one native batch on THIS host.
|
||||
|
||||
A native batch widens the forward pass, so the width cannot be a constant.
|
||||
The default engine declares ``min_vram_gb = 6.0`` for a SINGLE job; an
|
||||
unconditional 8-wide batch would OOM the 4-8 GB CUDA cards and the MPS
|
||||
Macs where the per-segment path succeeds today — turning a throughput
|
||||
optimization into a regression on exactly the hardware that already
|
||||
struggles (#1616 is a 4 GB card reporting capacity failures). Default
|
||||
behaviour must not get riskier on a host, so the width is derived from
|
||||
measured headroom and falls back to 1 (no batching) when unknown.
|
||||
|
||||
CPU hosts get 1: batching there buys no kernel amortization and only
|
||||
multiplies peak RAM.
|
||||
"""
|
||||
override = os.environ.get(BATCH_WIDTH_ENV, "").strip()
|
||||
if override:
|
||||
try:
|
||||
return max(1, min(_MAX_BATCH_WIDTH, int(override)))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"%s=%r is not an integer — deriving the batch width from the host instead.",
|
||||
BATCH_WIDTH_ENV, override,
|
||||
)
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
caps = detect_host_caps()
|
||||
except Exception: # noqa: BLE001 — an unprobeable host takes the safe path
|
||||
return 1
|
||||
if caps.family == "cpu" or not caps.vram_gb:
|
||||
return 1
|
||||
headroom = caps.vram_gb - float(getattr(backend, "min_vram_gb", 0.0) or 0.0)
|
||||
if headroom < 2.0:
|
||||
return 1
|
||||
if headroom < 6.0:
|
||||
return 2
|
||||
if headroom < 12.0:
|
||||
return 4
|
||||
return 8
|
||||
|
||||
|
||||
def _batch_timeout_s(texts: list[str], backend) -> float:
|
||||
"""Execution budget for one native batch.
|
||||
|
||||
Not the sum of the per-item budgets: ``generate_timeout_s`` returns a
|
||||
floor (300s GPU / 600s CPU) plus per-length overage, so summing it across
|
||||
eight items yields a ~2400s budget — and a wedged batch would hold a
|
||||
GPU-pool worker for forty minutes before the reset this file depends on
|
||||
(#730). One floor covers wedge detection for the whole call; only the
|
||||
length-driven overage is genuinely additive.
|
||||
"""
|
||||
from services.model_manager import generate_timeout_s
|
||||
|
||||
floor = generate_timeout_s("", engine=backend)
|
||||
overage = sum(
|
||||
max(0.0, generate_timeout_s(text, engine=backend) - floor) for text in texts
|
||||
)
|
||||
return floor + overage
|
||||
|
||||
|
||||
async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
"""Full batch dub pipeline: extract → transcribe → translate → generate → mix → export."""
|
||||
import subprocess
|
||||
@@ -204,7 +107,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
_set_progress(job, "extract", 0)
|
||||
audio_path = os.path.join(batch_dir, "audio.wav")
|
||||
|
||||
from services.ffmpeg_utils import bed_mix_filter, find_ffmpeg
|
||||
from services.ffmpeg_utils import find_ffmpeg
|
||||
ffmpeg = find_ffmpeg()
|
||||
|
||||
def _extract():
|
||||
@@ -238,17 +141,14 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# ── 2. Transcribe ─────────────────────────────────────────────────
|
||||
_set_progress(job, "transcribe", 0)
|
||||
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
from services.model_manager import _gpu_pool, _cpu_pool, run_on_gpu_pool_guarded
|
||||
from services.segmentation import (
|
||||
segment_transcript, assign_speakers_heuristic,
|
||||
)
|
||||
|
||||
def _transcribe():
|
||||
# `load_*`, not `get_*`: the plain selector returns engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, failing
|
||||
# the whole batch job at `.transcribe()` instead of degrading (#1185).
|
||||
backend = load_active_asr_backend()
|
||||
backend = get_active_asr_backend()
|
||||
result = backend.transcribe(audio_path, word_timestamps=True)
|
||||
detected_lang = result.get("language", "en")
|
||||
segments = segment_transcript(result, duration=duration)
|
||||
@@ -280,8 +180,8 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
return
|
||||
|
||||
# ── Engine resolution (issue #312 class) ────────────────────────────
|
||||
# Batch used to hardcode VoiceStudio via get_model() regardless of the
|
||||
# engine selected in Model Catalogue → Engines. require_cloning only when a
|
||||
# Batch used to hardcode OmniVoice via get_model() regardless of the
|
||||
# engine selected in Settings → Engines. require_cloning only when a
|
||||
# specific voice is pinned (job["voice_id"]) — an unpinned job is fine on
|
||||
# any active engine. Resolved ONCE for the whole job (every language
|
||||
# below shares the same active engine); an uncaught ValueError here
|
||||
@@ -366,111 +266,6 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
full_audio = torch.zeros(1, total_samples)
|
||||
total_segs = len(translated_segments)
|
||||
|
||||
# Native engines can amortize encoder/decoder setup across a small
|
||||
# batch. Keep the adapter seam optional: engines without a real batch
|
||||
# implementation inherit TTSBackend.generate_batch(), which preserves
|
||||
# the established one-segment behavior below.
|
||||
from services.tts_backend import TTSBackend
|
||||
batched_audio: dict[int, torch.Tensor] = {}
|
||||
has_native_batch = type(backend).generate_batch is not TTSBackend.generate_batch
|
||||
if has_native_batch:
|
||||
from services.text_normalization import normalize_for_tts
|
||||
|
||||
batch_ref_audio = None
|
||||
batch_ref_text = None
|
||||
if job.get("voice_id"):
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR as _VD
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?",
|
||||
(job["voice_id"],),
|
||||
).fetchone()
|
||||
if row:
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
batch_ref_audio = os.path.join(_VD, row["locked_audio_path"])
|
||||
elif row["ref_audio_path"]:
|
||||
batch_ref_audio = os.path.join(_VD, row["ref_audio_path"])
|
||||
batch_ref_text = row["ref_text"]
|
||||
|
||||
batch_width = _native_batch_width(backend)
|
||||
|
||||
async def _prefetch_batch(first_index: int) -> None:
|
||||
"""Render the batch beginning at ``first_index`` into
|
||||
``batched_audio``.
|
||||
|
||||
Rendered on demand rather than prerendering the whole track:
|
||||
the tensors are popped as they are placed, so peak host memory
|
||||
is one batch instead of every segment of the language — and
|
||||
the progress bar tracks placement instead of running to the
|
||||
end and restarting at segment 1.
|
||||
"""
|
||||
if job["status"] == "cancelled":
|
||||
return
|
||||
batch_rows = []
|
||||
index = first_index
|
||||
while index < total_segs and len(batch_rows) < batch_width:
|
||||
seg = translated_segments[index]
|
||||
if (seg.get("end", 0) - seg.get("start", 0) > 0.05
|
||||
and seg.get("text", "").strip()):
|
||||
batch_rows.append((index, seg))
|
||||
index += 1
|
||||
if len(batch_rows) < 2:
|
||||
return # nothing to amortize — the per-segment path is equal
|
||||
batch_indices = [index for index, _ in batch_rows]
|
||||
batch_texts = [
|
||||
normalize_for_tts(row.get("text", "").strip(), target_lang)
|
||||
for _, row in batch_rows
|
||||
]
|
||||
batch_durations = [
|
||||
row.get("end", 0) - row.get("start", 0)
|
||||
for _, row in batch_rows
|
||||
]
|
||||
|
||||
def _render_native_batch():
|
||||
generated = backend.generate_batch(
|
||||
batch_texts,
|
||||
language=target_lang,
|
||||
ref_audio=batch_ref_audio,
|
||||
ref_text=batch_ref_text,
|
||||
duration=batch_durations,
|
||||
num_step=16,
|
||||
guidance_scale=2.0,
|
||||
speed=1.0,
|
||||
denoise=True,
|
||||
postprocess_output=True,
|
||||
)
|
||||
if len(generated) != len(batch_indices):
|
||||
raise RuntimeError(
|
||||
f"native batch returned {len(generated)} outputs for "
|
||||
f"{len(batch_indices)} segments"
|
||||
)
|
||||
rendered = []
|
||||
for audio_out in generated:
|
||||
if not getattr(backend, "applies_own_mastering", False):
|
||||
audio_out = apply_mastering(audio_out, sample_rate=sr)
|
||||
rendered.append(normalize_audio(audio_out, target_dBFS=-2.0))
|
||||
return rendered
|
||||
|
||||
try:
|
||||
rendered = await run_on_gpu_pool_guarded(
|
||||
_render_native_batch,
|
||||
what="Batch generate",
|
||||
timeout=_batch_timeout_s(batch_texts, backend),
|
||||
)
|
||||
batched_audio.update(zip(batch_indices, rendered))
|
||||
except TimeoutError:
|
||||
# Do not immediately queue the same expensive work again:
|
||||
# the timed-out pool task may still be holding the device.
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Native TTS batch failed for segments %s-%s; falling back per segment: %s",
|
||||
batch_indices[0] + 1,
|
||||
batch_indices[-1] + 1,
|
||||
e,
|
||||
)
|
||||
|
||||
for i, seg in enumerate(translated_segments):
|
||||
if job["status"] == "cancelled":
|
||||
return
|
||||
@@ -531,32 +326,12 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
return normalize_audio(audio_out, target_dBFS=-2.0)
|
||||
except Exception as e:
|
||||
logger.warning("TTS failed for seg %d (lang=%s): %s", i, lang, e)
|
||||
# #1190: the silence still stands in for the segment (one
|
||||
# bad line shouldn't bin an otherwise good dub), but it is
|
||||
# no longer INVISIBLE — the job carries a warning the UI /
|
||||
# API consumer can see instead of shipping a
|
||||
# finished-looking track with unexplained silence.
|
||||
job.setdefault("warnings", []).append(
|
||||
f"Segment {i + 1} of the {lang} track failed to "
|
||||
f"synthesize and was left silent: {e}"
|
||||
)
|
||||
return torch.zeros(1, int(dur * sr))
|
||||
|
||||
try:
|
||||
# Bounded + pool-reset on hang so a wedged batch segment can't
|
||||
# starve the GPU pool and brick the backend (#730 class).
|
||||
# Budget is the shared length-scaled one (#1190): a long segment
|
||||
# on CPU-class hardware no longer dies on the flat 300s.
|
||||
from services.model_manager import generate_timeout_s
|
||||
if has_native_batch and i not in batched_audio:
|
||||
await _prefetch_batch(i)
|
||||
if i in batched_audio:
|
||||
audio_tensor = batched_audio.pop(i)
|
||||
else:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_gen, what="Batch generate",
|
||||
timeout=generate_timeout_s(seg_text, engine=backend),
|
||||
)
|
||||
audio_tensor = await run_on_gpu_pool_guarded(_gen, what="Batch generate")
|
||||
|
||||
# Fit to slot
|
||||
target_samples_seg = int(seg_duration * sr)
|
||||
@@ -581,46 +356,10 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
e_idx = min(s_idx + wl, total_samples)
|
||||
full_audio[:, s_idx:e_idx] += audio_tensor[:, :e_idx - s_idx]
|
||||
|
||||
except TimeoutError as e:
|
||||
# #1190/#1202: a GPU timeout (or a saturated pool) used to be
|
||||
# swallowed into a silent gap in the dubbed track — the user got
|
||||
# a finished-looking video with missing speech and no warning,
|
||||
# and on a 1-worker host the abandoned job made every later
|
||||
# segment likelier to time out too (the "22-chunk batch dies at
|
||||
# chunk 3" cascade). Fail the job loudly instead: _worker()'s
|
||||
# except-Exception handler records a structured failure the UI
|
||||
# surfaces. Non-timeout per-segment errors keep the old
|
||||
# degrade-to-gap behaviour, but are now recorded on the job.
|
||||
logger.error("Batch TTS seg %d timed out — failing the job: %s", i, e)
|
||||
raise RuntimeError(
|
||||
f"Segment {i + 1} of the {target_lang} track did not "
|
||||
f"render, so the dubbed track would have shipped with a "
|
||||
f"silent gap. {e}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
logger.warning("Batch TTS seg %d failed: %s", i, e)
|
||||
job.setdefault("warnings", []).append(
|
||||
f"Segment {i + 1} of the {target_lang} track failed and was "
|
||||
f"left silent: {e}"
|
||||
)
|
||||
|
||||
# ── 3c. Save dubbed audio track ───────────────────────────────
|
||||
# Invisible provenance mark on the assembled track (#1169), tensor
|
||||
# stage, before the WAV write / aac mux — batch dubs used to ship
|
||||
# unmarked while the interactive dub pipeline marked every segment.
|
||||
# One whole-track embed (chunked internally, #1045) is equivalent to
|
||||
# dub_generate's per-segment marks: the 16-bit message repeats
|
||||
# throughout. Never raises (degrades to unmarked on failure, same as
|
||||
# every producer).
|
||||
# Dispatched to the dedicated watermark pool, not the GPU pool (#1190):
|
||||
# AudioSeal embedding is CPU work that holds no VRAM, and a whole-track
|
||||
# embed is long enough that occupying a GPU worker with it stalled the
|
||||
# next language's segments on 1-worker hosts.
|
||||
from services.watermark import mark_synthetic_async
|
||||
full_audio = await mark_synthetic_async(
|
||||
full_audio, sr, context="batch.dub_track",
|
||||
)
|
||||
|
||||
# Same assembly pattern as dub_generate.py:390 — `full_audio` is a
|
||||
# zero-init tensor that gets +='d from torch.cat-style slices, so
|
||||
# it can land non-contiguous + out-of-range. Go through the
|
||||
@@ -646,7 +385,7 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
"-i", video_path,
|
||||
"-i", track_path,
|
||||
"-filter_complex",
|
||||
bed_mix_filter("0:a", "1:a", out="out", duration="first"),
|
||||
"[0:a]volume=0.15[bg];[1:a]volume=1.0[dub];[bg][dub]amix=inputs=2:duration=first[out]",
|
||||
"-map", "0:v", "-map", "[out]",
|
||||
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest", output_path],
|
||||
@@ -694,21 +433,15 @@ async def enqueue_batch_job(
|
||||
if not lang_list:
|
||||
raise HTTPException(400, "At least one target language is required")
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download CTA
|
||||
# now, instead of accepting the job and having the transcribe stage
|
||||
# silently auto-download multi-GB whisper weights (or fail) in the worker.
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(409, {**missing, "message": asr_model_missing_detail(missing)})
|
||||
|
||||
# Save the uploaded video
|
||||
batch_dir = os.path.join(DATA_DIR, "batch")
|
||||
os.makedirs(batch_dir, exist_ok=True)
|
||||
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
|
||||
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
|
||||
|
||||
await _save_upload(video, video_path)
|
||||
with open(video_path, "wb") as f:
|
||||
content = await video.read()
|
||||
f.write(content)
|
||||
|
||||
job = {
|
||||
"id": job_id,
|
||||
@@ -727,10 +460,13 @@ async def enqueue_batch_job(
|
||||
_jobs[job_id] = job
|
||||
await _queue.put(job_id)
|
||||
|
||||
logger.info(
|
||||
"Batch job %s enqueued (%d target languages)",
|
||||
log_safe(job_id), len(lang_list),
|
||||
)
|
||||
logger.info("Batch job %s enqueued: %s → %s", job_id, video.filename, lang_list)
|
||||
from core.analytics import capture as _ph_capture
|
||||
_ph_capture("batch_job_submitted", {
|
||||
"target_language_count": len(lang_list),
|
||||
"has_voice_id": bool(voice_id),
|
||||
"preserve_bg": preserve_bg,
|
||||
})
|
||||
return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()}
|
||||
|
||||
|
||||
@@ -772,18 +508,14 @@ def cancel_batch_job(job_id: str):
|
||||
@router.delete("/batch/jobs/{job_id}")
|
||||
def delete_batch_job(job_id: str):
|
||||
"""Delete a batch job record and its video file."""
|
||||
job = _jobs.get(job_id)
|
||||
job = _jobs.pop(job_id, None)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job.get("video_path"):
|
||||
if job.get("video_path") and os.path.exists(job["video_path"]):
|
||||
try:
|
||||
unlink_if_present(job["video_path"])
|
||||
except FileCleanupError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Could not delete the batch video file. Close any app using it and retry.",
|
||||
) from exc
|
||||
_jobs.pop(job_id, None)
|
||||
os.remove(job["video_path"])
|
||||
except Exception:
|
||||
pass
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
|
||||
@@ -8,11 +8,8 @@ raw audio bytes and get back transcribed text immediately. Used by:
|
||||
• The MCP server's future `transcribe_audio` tool
|
||||
• CLI consumers that just want speech-to-text
|
||||
|
||||
The ASR engine is whatever `load_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured. The *loader*,
|
||||
not the bare selector: it also runs `ensure_loaded()` and falls through to
|
||||
the next healthy engine when the selected one has a broken deep import chain
|
||||
(#1185), which the shallow `is_available()` probe cannot see.
|
||||
The ASR engine is whatever `get_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -82,30 +79,12 @@ async def transcribe_audio(
|
||||
|
||||
use_accurate = (mode or "").strip().lower() == "accurate"
|
||||
|
||||
# TTS-only install: no ASR model on disk → typed 409 with a download
|
||||
# CTA, BEFORE any backend is constructed (the whisper backends
|
||||
# auto-download multi-GB weights from HF on first load).
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="transcribe" if use_accurate else "dictation",
|
||||
)
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
|
||||
def _run():
|
||||
if use_accurate:
|
||||
# Accurate mode: full WhisperX with forced alignment —
|
||||
# for when the user explicitly wants word-level timing.
|
||||
# `load_*`, not `get_*`: the selector alone hands back an
|
||||
# engine whose shallow probe passed but whose deep import
|
||||
# chain is broken, which then 500s at `.transcribe()`. The
|
||||
# loader degrades to the next healthy engine (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
result = backend.transcribe(tmp.name, word_timestamps=True)
|
||||
else:
|
||||
# Fast mode (default): use the fastest available engine
|
||||
@@ -117,11 +96,7 @@ async def transcribe_audio(
|
||||
return result, backend.id
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result, engine_id = await run_transcribe_guarded(
|
||||
@@ -132,14 +107,6 @@ async def transcribe_audio(
|
||||
# silent hang the UI reads as "can't reach the local backend".
|
||||
logger.warning("Capture transcription timed out: %s", e)
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
# Degraded past the broken engine onto one with no weights on
|
||||
# disk — same typed 409 (+ download CTA) as the preflight above,
|
||||
# never a 500 and never a silent multi-GB auto-download.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
# Normalize result shape
|
||||
|
||||
@@ -9,9 +9,7 @@ Protocol:
|
||||
→ Client sends binary audio frames (16-bit PCM or WebM/Opus blobs)
|
||||
← Server sends JSON messages:
|
||||
|
||||
Raw PCM mode (``?pcm=1&sr=16000``) is the container-free fallback for
|
||||
WebViews without MediaRecorder. Opt-in AEC mode
|
||||
(``?aec=1[&sr=16000]``, parity Action 8b): for dictating
|
||||
Opt-in AEC mode (``?aec=1[&sr=16000]``, parity Action 8b): for dictating
|
||||
while the app plays audio. Frames must be raw int16 mono PCM, each tagged
|
||||
with a 1-byte prefix — 0x00 = microphone, 0x01 = playback reference. The
|
||||
server runs an NLMS echo canceller, cleaning the mic against the reference
|
||||
@@ -27,10 +25,6 @@ Protocol:
|
||||
"detail": "..."} — error ("detail"
|
||||
kept for legacy)
|
||||
|
||||
Sherpa ``final`` frames additionally carry
|
||||
``"final_kind": "utterance"|"summary"``. Utterances are mid-session
|
||||
commits; the summary is the authoritative whole-session result at EOF.
|
||||
|
||||
Every ``final`` text is normalised by services.text_polish (leading
|
||||
capital for Latin scripts, terminal punctuation, single-spaced) so the
|
||||
pasted result reads like typed text. Partials are raw.
|
||||
@@ -38,26 +32,19 @@ Protocol:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from api.dependencies import is_local_host, ws_remote_authorized
|
||||
from api.dependencies import _LOOPBACK_HOSTS, ws_remote_authorized
|
||||
from services.text_polish import polish_text
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.capture_ws")
|
||||
|
||||
SPEECH_PROTOCOL = "voicestudio.speech.v1"
|
||||
PLATFORM_STREAM_PATH = "/v1/audio/transcriptions/stream"
|
||||
|
||||
# How often (seconds) to run transcription on the accumulated buffer.
|
||||
# Shorter = more responsive but more GPU load.
|
||||
PARTIAL_INTERVAL_S = float(os.environ.get("OMNIVOICE_STREAM_INTERVAL", "2.0"))
|
||||
@@ -81,81 +68,6 @@ _AEC_NEAR = 0x00 # microphone frame (clean it, then buffer for ASR)
|
||||
_AEC_FAR = 0x01 # playback reference frame (feed the echo model only)
|
||||
|
||||
|
||||
# Client-supplied ``?sr=`` values outside the range real capture devices use
|
||||
# are replaced with 16 kHz. The rate sizes server-side state — RecoveryTail
|
||||
# multiplies it by RECOVERY_TAIL_SECONDS to compute its byte ceiling — so an
|
||||
# absurd rate must never be believed: it would re-open the unbounded-memory
|
||||
# path the recovery-tail cap closed.
|
||||
SR_MIN, SR_MAX = 8000, 96000
|
||||
|
||||
|
||||
def _is_end_control(text: str | None) -> bool:
|
||||
"""Accept the versioned JSON control frame and the legacy ``EOF`` frame."""
|
||||
if text == "EOF":
|
||||
return True
|
||||
if not text:
|
||||
return False
|
||||
try:
|
||||
message = json.loads(text)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return False
|
||||
return isinstance(message, dict) and message.get("type") == "input_audio.end"
|
||||
|
||||
|
||||
class _PlatformWebSocket:
|
||||
"""Add v1 session metadata without changing the legacy WebSocket contract."""
|
||||
|
||||
def __init__(self, websocket: WebSocket):
|
||||
self._websocket = websocket
|
||||
self.session_id = uuid.uuid4().hex
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._websocket, name)
|
||||
|
||||
async def send_json(self, data: Any, mode: str = "text") -> None:
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
data.setdefault("protocol", SPEECH_PROTOCOL)
|
||||
data.setdefault("session_id", self.session_id)
|
||||
if data.get("type") == "final":
|
||||
data.setdefault("final_kind", "summary")
|
||||
await self._websocket.send_json(data, mode=mode)
|
||||
|
||||
|
||||
def _bounded_sample_rate(query_params) -> int:
|
||||
try:
|
||||
sample_rate = int(query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
return 16000
|
||||
return sample_rate if SR_MIN <= sample_rate <= SR_MAX else 16000
|
||||
|
||||
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return the bounded rate when the client transport is raw PCM.
|
||||
|
||||
Sherpa clients omit ``pcm=1`` because the selected model already defines
|
||||
that transport. If the model is demoted or its runtime is unavailable, the
|
||||
legacy recognizer fallback must still decode those same bytes as PCM.
|
||||
"""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
sherpa_pcm = False
|
||||
requested_model = query_params.get("model")
|
||||
if requested_model:
|
||||
try:
|
||||
from services.sherpa_dictation import is_sherpa_model
|
||||
sherpa_pcm = is_sherpa_model(requested_model)
|
||||
except Exception: # noqa: BLE001
|
||||
# A broken sherpa install must not decide the framing question —
|
||||
# sherpa_pcm stays False and the session negotiates the
|
||||
# MediaRecorder path; availability is re-probed (and reported)
|
||||
# when the model is actually selected.
|
||||
sherpa_pcm = False
|
||||
if not raw_pcm and not aec and not sherpa_pcm:
|
||||
return None
|
||||
return _bounded_sample_rate(query_params)
|
||||
|
||||
|
||||
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
|
||||
"""Split a prefixed AEC binary frame into ``(kind, pcm)``.
|
||||
|
||||
@@ -210,106 +122,41 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
from services import sherpa_dictation as sd
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _usable_spec(model_id):
|
||||
spec = sd.get_spec(model_id)
|
||||
if spec is not None and sd.is_demoted(spec.id):
|
||||
logger.warning(
|
||||
"dictation model %s is demoted — using the capture ASR fallback",
|
||||
spec.id,
|
||||
)
|
||||
return None
|
||||
return spec
|
||||
|
||||
requested = websocket.query_params.get("model")
|
||||
if requested:
|
||||
return _usable_spec(requested) # explicit selection (may be unavailable)
|
||||
return sd.get_spec(requested) # explicit selection (may be None if bad)
|
||||
# Fall back to the persisted dictation pref.
|
||||
try:
|
||||
from services.asr_backend import dictation_model_id
|
||||
mid = dictation_model_id()
|
||||
except Exception:
|
||||
mid = None
|
||||
return _usable_spec(mid) if mid else None
|
||||
return sd.get_spec(mid) if mid else None
|
||||
|
||||
|
||||
@router.websocket(PLATFORM_STREAM_PATH)
|
||||
@router.websocket("/ws/transcribe")
|
||||
async def ws_transcribe(websocket: WebSocket):
|
||||
"""Stream audio in, get partial + final transcription out."""
|
||||
is_platform_stream = websocket.url.path == PLATFORM_STREAM_PATH
|
||||
if is_platform_stream:
|
||||
websocket = _PlatformWebSocket(websocket)
|
||||
# A browser can reach localhost regardless of the page's own origin.
|
||||
# Reject ambient cross-site WebSocket handshakes before the loopback-host
|
||||
# shortcut or accept(), while keeping native clients (no Origin header)
|
||||
# and configured/same-origin browser UIs working (#1646 review).
|
||||
origin = websocket.headers.get("origin")
|
||||
if origin:
|
||||
from core.csrf import origin_allowed
|
||||
|
||||
if not origin_allowed(websocket):
|
||||
await websocket.close(code=1008, reason="browser origin not allowed")
|
||||
return
|
||||
# Loopback origin guard — refuse anything not from 127.0.0.1, ::1, or
|
||||
# localhost. Privileged HTTP routers use Depends(require_admin) at router
|
||||
# level; WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# localhost. HTTP routers use Depends(require_loopback) at router level;
|
||||
# WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# inline the check before accept(). Without it, any local process could
|
||||
# stream the user's microphone over this endpoint.
|
||||
# Wave 2.3 (remote backend): a non-loopback client that presents the
|
||||
# OMNIVOICE_API_KEY bearer is the thin-client dictation case — the mic
|
||||
# lives on the user's machine, the GPU here — and is allowed through.
|
||||
host = websocket.client.host if websocket.client else None
|
||||
if not is_local_host(host) and not ws_remote_authorized(websocket):
|
||||
if host not in _LOOPBACK_HOSTS and not ws_remote_authorized(websocket):
|
||||
await websocket.close(code=1008, reason="loopback origin required")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
if is_platform_stream:
|
||||
await websocket.send_json({
|
||||
"type": "session.started",
|
||||
"input_format": (
|
||||
"audio/pcm;encoding=s16le;channels=1"
|
||||
if _requested_pcm_sample_rate(websocket.query_params) is not None
|
||||
else "audio/webm;codecs=opus"
|
||||
),
|
||||
"sample_rate": _bounded_sample_rate(websocket.query_params),
|
||||
})
|
||||
|
||||
# Live-dictation engine selection. When a sherpa-onnx model is selected
|
||||
# (via ?model= or the dictation.model_id pref) AND sherpa is installed,
|
||||
# run the dedicated low-latency handler. Otherwise fall through to the
|
||||
# legacy Whisper/WebM path, byte-for-byte unchanged.
|
||||
spec = _select_sherpa_spec(websocket)
|
||||
|
||||
# TTS-only install: no ASR model on disk for this session's selection →
|
||||
# typed error frame + close, BEFORE any recognizer is built (both the
|
||||
# sherpa loader and the whisper backends auto-download weights on first
|
||||
# load). The client renders a one-click download CTA from the payload.
|
||||
# Pass the RAW ?model= override, not just the resolved spec: an invalid
|
||||
# override resolves spec to None, and a bare None would make the preflight
|
||||
# consult the persisted sherpa pref (possibly installed → preflight
|
||||
# passes) while execution falls through to the Whisper path (weights
|
||||
# possibly missing → silent auto-download). The raw string keeps the
|
||||
# preflight on the same selection execution will use.
|
||||
from services.asr_backend import asr_model_missing_detail, asr_model_missing_error
|
||||
_requested_model = websocket.query_params.get("model")
|
||||
missing = await asyncio.to_thread(
|
||||
asr_model_missing_error, purpose="dictation",
|
||||
sherpa_model_id=(
|
||||
spec.id if spec is not None else _requested_model
|
||||
),
|
||||
)
|
||||
if missing is not None:
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error", "kind": "asr_model_missing",
|
||||
"message": asr_model_missing_detail(missing), **missing,
|
||||
})
|
||||
await websocket.close()
|
||||
except Exception: # noqa: BLE001 — client may already be gone
|
||||
pass
|
||||
return
|
||||
if spec is not None:
|
||||
from services.asr_backend import SherpaDictationBackend, capture_lease
|
||||
ok, _reason = SherpaDictationBackend.is_available()
|
||||
@@ -334,11 +181,12 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
# identical legacy behaviour. When on, frames are 1-byte-tagged raw PCM
|
||||
# and the cleaned mic stream is muxed via stdlib wave (not ffmpeg).
|
||||
aec = None
|
||||
pcm_sr = _requested_pcm_sample_rate(websocket.query_params)
|
||||
pcm_sr: int | None = None
|
||||
if websocket.query_params.get("aec") in ("1", "true", "on"):
|
||||
try:
|
||||
pcm_sr = int(websocket.query_params.get("sr", "16000"))
|
||||
from services.aec import NlmsEchoCanceller
|
||||
aec = NlmsEchoCanceller(sample_rate=pcm_sr or 16000)
|
||||
aec = NlmsEchoCanceller(sample_rate=pcm_sr)
|
||||
logger.info("AEC enabled for dictation session (sr=%d)", pcm_sr)
|
||||
except Exception as e:
|
||||
# Bad sr or import failure → fall back to plain dictation.
|
||||
@@ -397,7 +245,7 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
total_bytes += len(data)
|
||||
last_audio_time = time.monotonic()
|
||||
continue
|
||||
if _is_end_control(msg.get("text")):
|
||||
if msg.get("text") == "EOF":
|
||||
# Client signals end-of-audio but stays connected for `final`.
|
||||
running = False
|
||||
break
|
||||
@@ -490,7 +338,7 @@ async def ws_transcribe(websocket: WebSocket):
|
||||
if not await _safe_send({"type": "final", **result}):
|
||||
logger.debug("Skipped final send — client already disconnected")
|
||||
except Exception as e:
|
||||
logger.exception("Final transcription failed")
|
||||
logger.error("Final transcription failed: %s", e)
|
||||
await _safe_send({"type": "error", "message": str(e),
|
||||
"kind": "transcribe", "detail": str(e)})
|
||||
else:
|
||||
@@ -531,79 +379,6 @@ SHERPA_OFFLINE_SILENCE_S = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_SILENC
|
||||
SHERPA_OFFLINE_RMS_FLOOR = float(os.environ.get("OMNIVOICE_SHERPA_OFFLINE_RMS", "0.01"))
|
||||
|
||||
|
||||
#: Seconds of audio retained for silent-model recovery. Recovery only needs
|
||||
#: enough speech to prove the model is broken and to re-transcribe what was
|
||||
#: said; retaining the whole session grew ~115 MB/hour at 16 kHz on an open
|
||||
#: mic, unbounded, and only ever got read when the fallback fired.
|
||||
RECOVERY_TAIL_DEFAULT_SECONDS = 120.0
|
||||
RECOVERY_TAIL_MAX_SECONDS = 300.0
|
||||
|
||||
|
||||
def _bounded_recovery_tail_seconds(value: str | None) -> float:
|
||||
"""Parse the recovery tail override without allowing unbounded buffers."""
|
||||
try:
|
||||
seconds = float(value) if value is not None else RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
except (TypeError, ValueError):
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
if not math.isfinite(seconds) or seconds <= 0:
|
||||
return RECOVERY_TAIL_DEFAULT_SECONDS
|
||||
return min(seconds, RECOVERY_TAIL_MAX_SECONDS)
|
||||
|
||||
|
||||
RECOVERY_TAIL_SECONDS = _bounded_recovery_tail_seconds(
|
||||
os.environ.get("OMNIVOICE_DICTATION_RECOVERY_TAIL_S")
|
||||
)
|
||||
|
||||
|
||||
class RecoveryTail:
|
||||
"""The most recent ``RECOVERY_TAIL_SECONDS`` of session audio.
|
||||
|
||||
Keeps the *tail* rather than the head: a long dictation's useful speech is
|
||||
what the user just said, and the silent-model check cares about how much
|
||||
audio the session carried overall — which ``total_bytes`` still reports
|
||||
truthfully after trimming.
|
||||
"""
|
||||
|
||||
__slots__ = ("_buf", "_max", "total_bytes")
|
||||
|
||||
def __init__(self, sample_rate: int, seconds: float = RECOVERY_TAIL_SECONDS):
|
||||
# int16 mono → 2 bytes/sample. Floor of one frame so a nonsense rate
|
||||
# or seconds value can't produce a zero-length buffer.
|
||||
self._max = max(2, int(seconds * max(1, sample_rate)) * 2)
|
||||
self._buf = bytearray()
|
||||
self.total_bytes = 0
|
||||
|
||||
def extend(self, pcm: bytes) -> None:
|
||||
self._buf.extend(pcm)
|
||||
self.total_bytes += len(pcm)
|
||||
excess = len(self._buf) - self._max
|
||||
if excess > 0:
|
||||
# int16 mono: trim whole samples only. A split frame can carry an
|
||||
# odd byte count, and an odd trim would leave the tail starting
|
||||
# mid-sample — every later sample byte-shifted, and the recovery
|
||||
# transcription fed noise.
|
||||
excess += excess % 2
|
||||
del self._buf[:excess]
|
||||
|
||||
def tail(self) -> bytes:
|
||||
return bytes(self._buf)
|
||||
|
||||
|
||||
def is_model_silent(text: str, heard_speech: bool, pcm_bytes: int) -> bool:
|
||||
"""True when the dictation model produced NO text despite real speech.
|
||||
|
||||
Distinguishes "the user said nothing" (fine — stay quiet) from "the model
|
||||
is broken" (fall back + warn). A sherpa model can load cleanly and still
|
||||
decode nothing: the NeMo-TDT path does exactly this on some builds, where
|
||||
parakeet-tdt v2/v3 return an empty token list for clear speech while
|
||||
whisper/zipformer transcribe the same bytes. Without this, dictation just
|
||||
silently produces nothing and looks dead.
|
||||
"""
|
||||
return bool(not (text or "").strip()
|
||||
and heard_speech
|
||||
and pcm_bytes > MIN_FINAL_BUFFER_BYTES)
|
||||
|
||||
|
||||
def _pcm16_to_f32(pcm: bytes):
|
||||
"""int16 little-endian mono PCM bytes → float32 numpy in [-1, 1]."""
|
||||
import numpy as np
|
||||
@@ -615,74 +390,19 @@ def _pcm16_to_f32(pcm: bytes):
|
||||
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def _pcm16_rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
|
||||
async def _recover_silent_sherpa(
|
||||
spec, pcm: bytes, pcm_sr: int,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Retry a token-silent Sherpa session through an installed local ASR."""
|
||||
logger.warning(
|
||||
"dictation model %s decoded NOTHING from %.1fs of speech-level audio "
|
||||
"— falling back to the capture ASR engine for this session",
|
||||
spec.id, len(pcm) / float(max(1, pcm_sr) * 2),
|
||||
)
|
||||
try:
|
||||
from services.asr_backend import asr_model_missing_error
|
||||
fallback_missing = await asyncio.to_thread(
|
||||
asr_model_missing_error,
|
||||
purpose="dictation",
|
||||
skip_sherpa=True,
|
||||
require_installed=True,
|
||||
)
|
||||
if fallback_missing is not None:
|
||||
logger.warning(
|
||||
"dictation silent-model fallback is not installed (%s); "
|
||||
"skipping recovery to avoid an automatic download",
|
||||
fallback_missing.get("missing_repo_id", "unknown"),
|
||||
)
|
||||
return "", []
|
||||
|
||||
result = await _transcribe_buffer_full(
|
||||
[pcm], pcm_sr=pcm_sr, skip_sherpa=True,
|
||||
)
|
||||
text = polish_text(_result_text(result))
|
||||
if not text:
|
||||
return "", []
|
||||
# The RMS gate can fire on fan/keyboard noise. Only another recognizer
|
||||
# producing words proves the audio held speech and makes persistent
|
||||
# demotion safe.
|
||||
try:
|
||||
from services.sherpa_dictation import demote_model
|
||||
if await asyncio.to_thread(demote_model, spec.id):
|
||||
logger.error(
|
||||
"dictation model %s demoted on this machine — it will no longer be "
|
||||
"auto-selected. Pick it again in Settings to give it another chance.",
|
||||
spec.id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("silent-model demotion failed")
|
||||
segments = (result or {}).get("segments") or [
|
||||
{"start": 0.0, "end": None, "text": text}
|
||||
]
|
||||
return text, segments
|
||||
except Exception:
|
||||
logger.exception("dictation silent-model fallback failed")
|
||||
return "", []
|
||||
|
||||
|
||||
async def _sherpa_session(websocket: WebSocket):
|
||||
"""Shared WS setup for the sherpa handlers.
|
||||
"""Shared WS receive setup for the sherpa handlers.
|
||||
|
||||
Returns ``(pcm_sr, aec)``: the bounded PCM sample rate for the session
|
||||
and the echo canceller when ``?aec=1`` requested one (``None`` otherwise
|
||||
or when AEC setup fails).
|
||||
Returns ``(get_frame, state)`` where ``get_frame`` is an async callable
|
||||
that yields the next near-end (mic) PCM bytes, ``b""`` for a keepalive/ref
|
||||
frame, or ``None`` on EOF/disconnect. ``state`` carries sample rate, AEC,
|
||||
and the disconnect flag for the caller's finaliser.
|
||||
"""
|
||||
pcm_sr = _bounded_sample_rate(websocket.query_params)
|
||||
pcm_sr = 16000
|
||||
try:
|
||||
pcm_sr = int(websocket.query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
pcm_sr = 16000
|
||||
aec = None
|
||||
if websocket.query_params.get("aec") in ("1", "true", "on"):
|
||||
try:
|
||||
@@ -720,7 +440,7 @@ async def _recv_pcm_frame(websocket: WebSocket, aec):
|
||||
return "skip", b""
|
||||
return "near", aec.process_near_end(payload)
|
||||
return "near", data
|
||||
if _is_end_control(msg.get("text")):
|
||||
if msg.get("text") == "EOF":
|
||||
return "eof", b""
|
||||
return "skip", b""
|
||||
|
||||
@@ -743,12 +463,11 @@ async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "stage": stage})
|
||||
except Exception:
|
||||
logger.warning("Sherpa load status could not be delivered; stopping stream setup")
|
||||
return False
|
||||
pass
|
||||
try:
|
||||
await asyncio.to_thread(backend.ensure_loaded)
|
||||
except Exception as e:
|
||||
logger.exception("sherpa dictation load failed (%s)", spec.id)
|
||||
logger.error("sherpa dictation load failed (%s): %s", spec.id, e)
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(e),
|
||||
"kind": "load", "detail": str(e)})
|
||||
@@ -759,8 +478,7 @@ async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "stage": "ready"})
|
||||
except Exception:
|
||||
logger.warning("Sherpa ready status could not be delivered; stopping stream setup")
|
||||
return False
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
@@ -791,8 +509,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
|
||||
last_partial = ""
|
||||
committed: list[str] = [] # finalized utterances this session
|
||||
session_pcm = RecoveryTail(pcm_sr) # bounded audio for silent-model recovery
|
||||
heard_speech = False
|
||||
client_disconnected = False
|
||||
|
||||
async def _send(payload) -> bool:
|
||||
@@ -834,9 +550,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
break
|
||||
if kind == "skip":
|
||||
continue
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
text, endpoint = await asyncio.to_thread(_decode_after_feed, pcm)
|
||||
if endpoint:
|
||||
# Commit this utterance (polished — it gets pasted); reset
|
||||
@@ -845,7 +558,6 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
rec.reset(stream)
|
||||
@@ -872,28 +584,7 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(t for t in committed if t).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed if t]
|
||||
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
payload["engine"] = "capture-asr-fallback" if full else backend.id
|
||||
payload["model_silent"] = spec.id
|
||||
payload["warning"] = (
|
||||
f"The selected dictation model ({spec.id}) produced no text from your "
|
||||
"speech. Switched to the fallback engine for this session — pick a "
|
||||
"different model in Settings → Dictation."
|
||||
)
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s): never delays this summary `final`
|
||||
# beyond OMNIVOICE_REFINE_TIMEOUT_S even with a dead LLM endpoint.
|
||||
@@ -902,9 +593,14 @@ async def _run_sherpa_streaming(websocket: WebSocket, spec):
|
||||
refined = await maybe_refine_async(full)
|
||||
except Exception:
|
||||
refined = None
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if refined and refined != full:
|
||||
payload["refined_text"] = refined
|
||||
await _send(payload)
|
||||
await _send(payload)
|
||||
else:
|
||||
await _send({"type": "final", "text": "", "segments": [],
|
||||
"language": "auto", "engine": backend.id})
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
@@ -935,14 +631,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
buf = bytearray() # live (uncommitted) PCM only
|
||||
committed: list[str] = [] # polished utterances already flushed
|
||||
last_partial = ""
|
||||
# Silent-model guard (#1175 follow-up): a sherpa model can load cleanly and
|
||||
# still decode NOTHING — the NeMo-TDT path does exactly this on some builds
|
||||
# (parakeet-tdt v2/v3 return an empty token list for clear speech, while
|
||||
# whisper/zipformer transcribe the same bytes). Keep the whole session's
|
||||
# audio and whether any of it was speech-level, so the finaliser can tell
|
||||
# "user said nothing" (fine) from "model produced nothing" (broken).
|
||||
session_pcm = RecoveryTail(pcm_sr)
|
||||
heard_speech = False
|
||||
running = True
|
||||
client_disconnected = False
|
||||
last_audio = time.monotonic()
|
||||
@@ -960,6 +648,12 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
client_disconnected = True
|
||||
return False
|
||||
|
||||
def _rms(pcm: bytes) -> float:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
return 0.0
|
||||
return float((samples * samples).mean() ** 0.5)
|
||||
|
||||
def _decode_window(pcm: bytes) -> str:
|
||||
samples = _pcm16_to_f32(pcm)
|
||||
if not len(samples):
|
||||
@@ -967,7 +661,7 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
return backend._decode_offline(samples, pcm_sr)
|
||||
|
||||
async def receive():
|
||||
nonlocal running, client_disconnected, last_audio, heard_speech
|
||||
nonlocal running, client_disconnected, last_audio
|
||||
try:
|
||||
while running:
|
||||
kind, pcm = await _recv_pcm_frame(websocket, aec)
|
||||
@@ -977,9 +671,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
if kind == "skip":
|
||||
continue
|
||||
buf.extend(pcm)
|
||||
session_pcm.extend(pcm)
|
||||
if not heard_speech and _pcm16_rms(pcm) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
heard_speech = True
|
||||
last_audio = time.monotonic()
|
||||
except WebSocketDisconnect:
|
||||
client_disconnected = True
|
||||
@@ -1004,7 +695,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
if text:
|
||||
committed.append(text)
|
||||
await _send({"type": "final", "text": text,
|
||||
"final_kind": "utterance",
|
||||
"segments": [{"start": 0.0, "end": None, "text": text}],
|
||||
"language": "auto", "engine": backend.id})
|
||||
|
||||
@@ -1016,8 +706,8 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
continue
|
||||
snapshot = bytes(buf)
|
||||
if len(snapshot) > sil_bytes and \
|
||||
_pcm16_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _pcm16_rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
_rms(snapshot[-sil_bytes:]) < SHERPA_OFFLINE_RMS_FLOOR:
|
||||
if _rms(snapshot[:-sil_bytes]) >= SHERPA_OFFLINE_RMS_FLOOR:
|
||||
await _commit(snapshot)
|
||||
else:
|
||||
# Pure silence — drop it (keep the gate window for
|
||||
@@ -1048,8 +738,8 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# Drain the trailing (un-committed) utterance on EOF.
|
||||
try:
|
||||
tail = await asyncio.to_thread(_decode_window, bytes(buf))
|
||||
except Exception:
|
||||
logger.exception("sherpa offline final failed")
|
||||
except Exception as e:
|
||||
logger.error("sherpa offline final failed: %s", e)
|
||||
tail = ""
|
||||
tail = polish_text(tail)
|
||||
if tail:
|
||||
@@ -1057,35 +747,9 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
# Pieces are already polished; the join is too (polish is idempotent).
|
||||
full = " ".join(committed).strip()
|
||||
segments = [{"start": 0.0, "end": None, "text": t} for t in committed]
|
||||
|
||||
# Silent-model fallback: we heard speech-level audio but the selected
|
||||
# sherpa model returned nothing at all. That is a broken engine, not a
|
||||
# quiet user — hand the session to the capture ASR backend so the user
|
||||
# still gets their words, and say which model let them down. Bounded to
|
||||
# this session; the pref is left alone so the user stays in control.
|
||||
model_silent = is_model_silent(full, heard_speech, session_pcm.total_bytes)
|
||||
if model_silent:
|
||||
recovered, recovered_segments = await _recover_silent_sherpa(
|
||||
spec, session_pcm.tail(), pcm_sr,
|
||||
)
|
||||
if recovered:
|
||||
full = recovered
|
||||
segments = recovered_segments
|
||||
|
||||
if not client_disconnected:
|
||||
payload = {"type": "final", "text": full, "final_kind": "summary",
|
||||
"segments": segments,
|
||||
payload = {"type": "final", "text": full, "segments": segments,
|
||||
"language": "auto", "engine": backend.id}
|
||||
if model_silent:
|
||||
# The client surfaces this so a silently-broken model can't look
|
||||
# like "dictation is just broken" ever again.
|
||||
payload["engine"] = "capture-asr-fallback" if full else backend.id
|
||||
payload["model_silent"] = spec.id
|
||||
payload["warning"] = (
|
||||
f"The selected dictation model ({spec.id}) produced no text from your "
|
||||
"speech. Switched to the fallback engine for this session — pick a "
|
||||
"different model in Settings → Dictation."
|
||||
)
|
||||
if full:
|
||||
# Hard-bounded refinement (~4s) — never delays the `final`.
|
||||
try:
|
||||
@@ -1102,35 +766,6 @@ async def _run_sherpa_offline(websocket: WebSocket, spec):
|
||||
pass
|
||||
|
||||
|
||||
def _result_text(result: dict | None) -> str:
|
||||
"""Normalize text from every ASR backend result shape.
|
||||
|
||||
Some backends return a top-level ``text`` value, while WhisperX, Faster
|
||||
Whisper, Moonshine, and OpenAI-compatible ASR expose only ``segments`` and
|
||||
``chunks``. Dictation partials and finals must interpret both contracts the
|
||||
same way.
|
||||
"""
|
||||
if not isinstance(result, dict):
|
||||
return ""
|
||||
|
||||
text = result.get("text")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return text.strip()
|
||||
|
||||
for key in ("segments", "chunks"):
|
||||
items = result.get(key)
|
||||
if not isinstance(items, (list, tuple)):
|
||||
continue
|
||||
text = " ".join(
|
||||
str(item.get("text", "")).strip()
|
||||
for item in items
|
||||
if isinstance(item, dict) and item.get("text")
|
||||
).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None) -> str:
|
||||
"""Quick partial transcription of the current audio buffer."""
|
||||
|
||||
@@ -1145,7 +780,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
def _run():
|
||||
backend = get_capture_asr_backend()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
return _result_text(result)
|
||||
return result.get("text", "")
|
||||
|
||||
# Bound dictation transcribes (#730): a wedged whisperx/CTranslate2 call
|
||||
# must not hold its GPU-pool worker forever and starve TTS / other ASR
|
||||
@@ -1159,9 +794,7 @@ async def _transcribe_buffer(chunks: list[bytes], *, pcm_sr: int | None = None)
|
||||
pass
|
||||
|
||||
|
||||
async def _transcribe_buffer_full(
|
||||
chunks: list[bytes], *, pcm_sr: int | None = None, skip_sherpa: bool = False,
|
||||
) -> dict:
|
||||
async def _transcribe_buffer_full(chunks: list[bytes], *, pcm_sr: int | None = None) -> dict:
|
||||
"""Full transcription with timing info for the final result."""
|
||||
tmp = _pcm16_to_wav(b"".join(chunks), pcm_sr) if pcm_sr else _chunks_to_wav(chunks)
|
||||
if tmp is None:
|
||||
@@ -1173,13 +806,15 @@ async def _transcribe_buffer_full(
|
||||
from services.asr_backend import get_capture_asr_backend, run_transcribe_guarded
|
||||
|
||||
def _run():
|
||||
backend = get_capture_asr_backend(skip_sherpa=skip_sherpa)
|
||||
backend = get_capture_asr_backend()
|
||||
t0 = time.perf_counter()
|
||||
result = backend.transcribe(tmp, word_timestamps=False)
|
||||
elapsed = round(time.perf_counter() - t0, 2)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
full_text = _result_text(result)
|
||||
full_text = result.get("text", "")
|
||||
if not full_text and segments:
|
||||
full_text = " ".join(s.get("text", "") for s in segments).strip()
|
||||
|
||||
# Wave 1.1: strip Whisper hallucination loops from the final
|
||||
# text (the string that gets auto-pasted). Segments keep the
|
||||
@@ -1264,3 +899,4 @@ def _chunks_to_wav(chunks: list[bytes]) -> str | None:
|
||||
# WhisperX) can decode WebM/Opus containers natively.
|
||||
logger.debug("Falling back to raw WebM input for ASR")
|
||||
return tmp_in.name
|
||||
|
||||
|
||||
@@ -20,26 +20,18 @@ Design / safety
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.audio_validation import is_playable_wav, resolve_regular_file
|
||||
from core.config import DATA_DIR, VOICES_DIR
|
||||
from core.config import DATA_DIR
|
||||
|
||||
logger = logging.getLogger("omnivoice.community")
|
||||
router = APIRouter()
|
||||
@@ -50,32 +42,9 @@ _ALLOWED_AUDIO_HOSTS = {
|
||||
"cdn.jsdelivr.net", "github.com", "raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com", "release-assets.githubusercontent.com",
|
||||
}
|
||||
_ALLOWED_MANIFEST_HOSTS = {"cdn.jsdelivr.net"}
|
||||
_VALID_TOKENS = set(archetypes._VD._INSTRUCT_ALL_VALID)
|
||||
_USE_CASE_IDS = {c["id"] for c in archetypes.USE_CASES}
|
||||
_SOURCE_RE = re.compile(
|
||||
r"^[A-Za-z0-9._-]{1,100}/[A-Za-z0-9._-]{1,100}$",
|
||||
) # owner/repo only
|
||||
_ITEM_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
# A gallery open may touch this loader several times (grid, preview, use). Keep
|
||||
# a successful response for six hours, then revalidate it once. On a network
|
||||
# failure the readable stale copy remains usable and its check time advances,
|
||||
# preventing every offline gallery open from waiting through the same timeout.
|
||||
_MANIFEST_MAX_AGE_S = 6 * 60 * 60
|
||||
_MAX_MANIFEST_BYTES = 4 << 20
|
||||
_MAX_SAMPLE_SCRIPT_CHARS = 2_000
|
||||
_MAX_REF_TEXT_CHARS = 4_000
|
||||
|
||||
# Community voice submissions are documented as short clean WAV clips. The cap
|
||||
# comfortably covers 15 s of uncompressed 96 kHz stereo PCM while preventing a
|
||||
# remote manifest from turning Preview into an unbounded disk/memory download.
|
||||
_MAX_VOICE_AUDIO_BYTES = 32 << 20
|
||||
|
||||
_ATTR_NAMES = (
|
||||
"Gender", "Age", "Pitch", "Style", "EnglishAccent", "ChineseDialect",
|
||||
)
|
||||
_SOURCE_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") # owner/repo only
|
||||
|
||||
|
||||
# ── Config: which content repos to load ───────────────────────────────────────
|
||||
@@ -83,18 +52,14 @@ def configured_sources() -> list[str]:
|
||||
"""Gallery sources, in priority order. Env var > config file > default."""
|
||||
env = os.environ.get("OMNIVOICE_GALLERY_SOURCES")
|
||||
if env:
|
||||
sources = [s.strip() for s in env.split(",")]
|
||||
valid = [s for s in sources if _SOURCE_RE.fullmatch(s)]
|
||||
return valid or list(_DEFAULT_SOURCES)
|
||||
return [s.strip() for s in env.split(",") if s.strip()]
|
||||
cfg = Path(DATA_DIR) / "gallery_sources.json"
|
||||
if cfg.exists():
|
||||
try:
|
||||
data = json.loads(cfg.read_text(encoding="utf-8"))
|
||||
srcs = data.get("sources")
|
||||
if isinstance(srcs, list) and srcs:
|
||||
valid = [s for s in srcs if isinstance(s, str) and _SOURCE_RE.fullmatch(s)]
|
||||
if valid:
|
||||
return valid
|
||||
return [str(s) for s in srcs]
|
||||
except Exception:
|
||||
logger.warning("gallery_sources.json unreadable; using default")
|
||||
return list(_DEFAULT_SOURCES)
|
||||
@@ -116,51 +81,9 @@ def _safe_audio_url(url: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _safe_manifest_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url or "")
|
||||
return parsed.scheme == "https" and parsed.hostname in _ALLOWED_MANIFEST_HOSTS
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_preset_instruct(instruct: str) -> Optional[tuple[str, dict]]:
|
||||
"""Normalize one validator-safe tag per design category.
|
||||
|
||||
Membership in the vocabulary is not enough: ``male, female`` contains two
|
||||
individually valid tokens but the engine rejects the pair as conflicting.
|
||||
Build the frontend's full ``vd_states`` shape at this trust boundary too,
|
||||
so Magic Wand never inherits stale sliders from the previous voice.
|
||||
"""
|
||||
attrs = {name: "Auto" for name in _ATTR_NAMES}
|
||||
normalized: list[str] = []
|
||||
seen_categories: set[int] = set()
|
||||
for raw in re.split("[," + chr(0xFF0C) + "]", str(instruct or "")):
|
||||
token = raw.strip().lower()
|
||||
if not token or token not in _VALID_TOKENS:
|
||||
return None
|
||||
category = archetypes._VD._instruct_category_index(token)
|
||||
if category < 0 or category in seen_categories:
|
||||
return None
|
||||
seen_categories.add(category)
|
||||
|
||||
# The picker represents the universal gender/age/pitch/style axes in
|
||||
# English even for Chinese speech; dialect remains Chinese-only.
|
||||
canonical = archetypes._VD._INSTRUCT_ZH_TO_EN.get(token, token)
|
||||
attrs[_ATTR_NAMES[category]] = canonical
|
||||
normalized.append(canonical)
|
||||
|
||||
if not normalized:
|
||||
return None
|
||||
# Accent and Chinese dialect are separate taxonomy buckets but the engine
|
||||
# deliberately forbids mixing them in a single design.
|
||||
if 4 in seen_categories and 5 in seen_categories:
|
||||
return None
|
||||
return ", ".join(normalized), attrs
|
||||
|
||||
|
||||
def is_valid_instruct(instruct: str) -> bool:
|
||||
return normalize_preset_instruct(instruct) is not None
|
||||
toks = [t.strip() for t in (instruct or "").split(",") if t.strip()]
|
||||
return bool(toks) and all(t in _VALID_TOKENS for t in toks)
|
||||
|
||||
|
||||
def validate_item(raw: dict) -> Optional[dict]:
|
||||
@@ -170,203 +93,62 @@ def validate_item(raw: dict) -> Optional[dict]:
|
||||
it = dict(raw)
|
||||
if it.get("type") not in ("preset", "voice"):
|
||||
return None
|
||||
if not isinstance(it.get("id"), str) or not _ITEM_ID_RE.fullmatch(it["id"]):
|
||||
if not it.get("id") or not it.get("name"):
|
||||
return None
|
||||
if not isinstance(it.get("name"), str) or not it["name"].strip():
|
||||
return None
|
||||
it["name"] = it["name"].strip()[:80]
|
||||
if it.get("use_case") not in _USE_CASE_IDS:
|
||||
return None
|
||||
raw_facets = it.get("facets")
|
||||
if not isinstance(raw_facets, dict):
|
||||
raw_facets = {}
|
||||
language = it.get("language")
|
||||
if not isinstance(language, str) or not language.strip():
|
||||
language = raw_facets.get("lang", "English")
|
||||
it["language"] = language.strip() if isinstance(language, str) and language.strip() else "English"
|
||||
|
||||
facets = dict(raw_facets)
|
||||
if it["type"] == "preset":
|
||||
normalized = normalize_preset_instruct(it.get("instruct", ""))
|
||||
if normalized is None:
|
||||
return None # unknown/conflicting tokens would crash synthesis
|
||||
it["instruct"], it["attrs"] = normalized
|
||||
attrs = it["attrs"]
|
||||
facets.update({
|
||||
"gender": None if attrs["Gender"] == "Auto" else attrs["Gender"],
|
||||
"age": None if attrs["Age"] == "Auto" else attrs["Age"],
|
||||
"pitch": None if attrs["Pitch"] == "Auto" else attrs["Pitch"],
|
||||
"accent": None if attrs["EnglishAccent"] == "Auto" else attrs["EnglishAccent"],
|
||||
"whisper": attrs["Style"] == "whisper",
|
||||
"lang": it["language"],
|
||||
})
|
||||
sample_script = it.get("sample_script")
|
||||
it["sample_script"] = (
|
||||
sample_script.strip()[:_MAX_SAMPLE_SCRIPT_CHARS]
|
||||
if isinstance(sample_script, str) else ""
|
||||
)
|
||||
else:
|
||||
audio = it.get("audio")
|
||||
if not isinstance(audio, dict) or not _safe_audio_url(audio.get("url", "")):
|
||||
return None
|
||||
expected = audio.get("sha256")
|
||||
if expected is not None:
|
||||
expected = str(expected).lower()
|
||||
if not _SHA256_RE.fullmatch(expected):
|
||||
return None
|
||||
audio = {**audio, "sha256": expected}
|
||||
ref_text = audio.get("ref_text")
|
||||
audio = {
|
||||
**audio,
|
||||
"ref_text": (
|
||||
ref_text.strip()[:_MAX_REF_TEXT_CHARS]
|
||||
if isinstance(ref_text, str) else ""
|
||||
),
|
||||
}
|
||||
it["audio"] = audio
|
||||
facets.setdefault("gender", None)
|
||||
facets.setdefault("age", None)
|
||||
facets.setdefault("pitch", None)
|
||||
facets.setdefault("accent", None)
|
||||
facets.setdefault("whisper", False)
|
||||
facets.setdefault("lang", it["language"])
|
||||
it["facets"] = facets
|
||||
if it["type"] == "preset" and not is_valid_instruct(it.get("instruct", "")):
|
||||
return None # would crash synthesis — drop it
|
||||
if it["type"] == "voice" and not _safe_audio_url((it.get("audio") or {}).get("url", "")):
|
||||
return None
|
||||
it.setdefault("facets", {})
|
||||
it.setdefault("icon", archetypes._USE_ICON.get(it["use_case"], "Sparkles"))
|
||||
it.setdefault("language", it.get("facets", {}).get("lang", "English"))
|
||||
it["is_community"] = it.get("source") != "starter"
|
||||
it["preview_url"] = f"/community/items/{it['id']}/preview"
|
||||
return it
|
||||
|
||||
|
||||
def _merge(manifests: list[tuple[str, Optional[dict]]]) -> tuple[list, list]:
|
||||
items, packs, seen = [], [], set()
|
||||
for src, m in manifests:
|
||||
if not isinstance(m, dict):
|
||||
if not m:
|
||||
continue
|
||||
raw_items = m.get("items")
|
||||
for raw in raw_items if isinstance(raw_items, list) else []:
|
||||
for raw in (m.get("items") or []):
|
||||
v = validate_item(raw)
|
||||
if v and v["id"] not in seen:
|
||||
v["_source_repo"] = src
|
||||
seen.add(v["id"])
|
||||
items.append(v)
|
||||
raw_packs = m.get("packs")
|
||||
for p in raw_packs if isinstance(raw_packs, list) else []:
|
||||
for p in (m.get("packs") or []):
|
||||
if isinstance(p, dict):
|
||||
packs.append({**p, "_source_repo": src})
|
||||
return items, packs
|
||||
|
||||
|
||||
def _read_manifest_cache(cache: Path) -> Optional[dict]:
|
||||
try:
|
||||
if cache.stat().st_size > _MAX_MANIFEST_BYTES:
|
||||
return None
|
||||
data = json.loads(cache.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_bytes_atomic(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}-", suffix=".part")
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(data)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_remote_manifest(source: str, *, client=None) -> dict:
|
||||
"""Fetch one bounded manifest, validating every redirect before request."""
|
||||
import httpx
|
||||
|
||||
if not _SOURCE_RE.fullmatch(source or ""):
|
||||
raise ValueError("invalid gallery source")
|
||||
owned_client = client is None
|
||||
http = client or httpx.Client(timeout=15.0, follow_redirects=False)
|
||||
current_url = _manifest_url(source)
|
||||
payload = bytearray()
|
||||
try:
|
||||
fetched = False
|
||||
for _redirect in range(6):
|
||||
if not _safe_manifest_url(current_url):
|
||||
raise ValueError("gallery manifest URL is not from an allowed host")
|
||||
with http.stream("GET", current_url, follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
next_url = urljoin(current_url, location or "")
|
||||
if not location or not _safe_manifest_url(next_url):
|
||||
raise ValueError("gallery manifest redirected to a disallowed host")
|
||||
current_url = next_url
|
||||
continue
|
||||
response.raise_for_status()
|
||||
length = response.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
declared_length = int(length)
|
||||
except ValueError:
|
||||
declared_length = None
|
||||
if declared_length is not None and declared_length > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the size limit")
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
if len(payload) + len(chunk) > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the size limit")
|
||||
payload.extend(chunk)
|
||||
fetched = True
|
||||
break
|
||||
if not fetched:
|
||||
raise ValueError("gallery manifest followed too many redirects")
|
||||
finally:
|
||||
if owned_client:
|
||||
http.close()
|
||||
if not payload:
|
||||
raise ValueError("gallery manifest was empty")
|
||||
data = json.loads(payload)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("gallery manifest is not a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def _fetch_manifest(
|
||||
source: str, refresh: bool, *, now: Optional[float] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Return a fresh manifest, with a throttled stale-cache offline fallback."""
|
||||
def _fetch_manifest(source: str, refresh: bool) -> Optional[dict]:
|
||||
"""Return a source's manifest from cache, or fetch + cache it. None if both fail."""
|
||||
cache = _cache_path(source)
|
||||
cached = _read_manifest_cache(cache)
|
||||
checked_at = time.time() if now is None else float(now)
|
||||
if not refresh and cached is not None:
|
||||
if not refresh and cache.exists():
|
||||
try:
|
||||
if checked_at - cache.stat().st_mtime < _MANIFEST_MAX_AGE_S:
|
||||
return cached
|
||||
except OSError:
|
||||
pass # treat a stat race as stale and try the source once
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data = _fetch_remote_manifest(source)
|
||||
encoded = json.dumps(
|
||||
data, ensure_ascii=False, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
if len(encoded) > _MAX_MANIFEST_BYTES:
|
||||
raise ValueError("gallery manifest exceeded the cache size limit")
|
||||
_write_bytes_atomic(cache, encoded)
|
||||
# Tests inject their own clock; production's value equals wall time.
|
||||
os.utime(cache, (checked_at, checked_at))
|
||||
import httpx
|
||||
with httpx.Client(timeout=15.0, follow_redirects=True) as client:
|
||||
resp = client.get(_manifest_url(source))
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
cache.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache.write_text(json.dumps(data), encoding="utf-8")
|
||||
return data
|
||||
except Exception as e: # offline / 404 / bad json
|
||||
logger.warning("manifest fetch failed for %s: %s", source, e)
|
||||
if cached is not None:
|
||||
# This mtime is a last-*check* marker. Advancing it on failure keeps
|
||||
# an offline app responsive while guaranteeing another check after
|
||||
# the bounded freshness interval.
|
||||
with contextlib.suppress(OSError):
|
||||
os.utime(cache, (checked_at, checked_at))
|
||||
return cached
|
||||
if cache.exists():
|
||||
try:
|
||||
return json.loads(cache.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@@ -432,385 +214,6 @@ def community_submit_url(item_type: str = Query("preset", alias="type"), source:
|
||||
return {"url": f"https://github.com/{src}/issues/new?template={template}"}
|
||||
|
||||
|
||||
def _find_item(items: list[dict], item_id: str) -> dict:
|
||||
if not _ITEM_ID_RE.fullmatch(item_id or ""):
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
return item
|
||||
|
||||
|
||||
def _canonical_archetype(item: dict) -> Optional[dict]:
|
||||
"""The built-in archetype represented exactly by a marketplace preset."""
|
||||
if item.get("type") != "preset":
|
||||
return None
|
||||
canonical = archetypes.get_archetype(item["id"])
|
||||
if canonical is None:
|
||||
return None
|
||||
if (canonical.get("instruct") != item.get("instruct")
|
||||
or canonical.get("language") != item.get("language")):
|
||||
return None
|
||||
remote_script = (item.get("sample_script") or "").strip()
|
||||
if remote_script and remote_script != (canonical.get("sample_script") or "").strip():
|
||||
return None
|
||||
return canonical
|
||||
|
||||
|
||||
def _preset_preview_path(item: dict) -> Path:
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps({
|
||||
"instruct": item.get("instruct"),
|
||||
"language": item.get("language"),
|
||||
"sample_script": item.get("sample_script"),
|
||||
}, sort_keys=True).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
return _CACHE_DIR / "previews" / f"{item['id']}-{fingerprint}.wav"
|
||||
|
||||
|
||||
def _voice_audio_fingerprint(item: dict) -> str:
|
||||
audio = item.get("audio") or {}
|
||||
return hashlib.sha256(
|
||||
f"{audio.get('url', '')}|{audio.get('sha256', '')}".encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
|
||||
|
||||
def _voice_audio_path(item: dict) -> Path:
|
||||
return _CACHE_DIR / "audio" / f"{item['id']}-{_voice_audio_fingerprint(item)}.wav"
|
||||
|
||||
|
||||
async def _render_preset_atomic(item: dict, out_path: Path) -> Path:
|
||||
if is_playable_wav(out_path):
|
||||
return out_path
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".preview-", suffix=".wav")
|
||||
os.close(fd)
|
||||
tmp = Path(tmp_name)
|
||||
try:
|
||||
await _render_archetype_wav({
|
||||
"instruct": item["instruct"],
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": (
|
||||
(item.get("sample_script") or "").strip()
|
||||
or "Hello — this is a preview of this voice."
|
||||
),
|
||||
}, tmp)
|
||||
if not is_playable_wav(tmp):
|
||||
raise RuntimeError("the voice engine produced an invalid preview WAV")
|
||||
os.replace(tmp, out_path)
|
||||
return out_path
|
||||
finally:
|
||||
with contextlib.suppress(OSError):
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path, *, client=None) -> None:
|
||||
"""Stream one allow-listed voice clip into an atomic, size-bounded file."""
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
|
||||
import httpx
|
||||
|
||||
owned_client = client is None
|
||||
http = client or httpx.Client(timeout=30.0, follow_redirects=False)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(out_path.parent), prefix=".voice-", suffix=".part")
|
||||
total = 0
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
current_url = url
|
||||
downloaded = False
|
||||
for _redirect in range(6):
|
||||
with http.stream("GET", current_url, follow_redirects=False) as response:
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = response.headers.get("location")
|
||||
next_url = urljoin(current_url, location or "")
|
||||
if not location or not _safe_audio_url(next_url):
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio redirected to a disallowed host.",
|
||||
)
|
||||
current_url = next_url
|
||||
continue
|
||||
response.raise_for_status()
|
||||
length = response.headers.get("content-length")
|
||||
if length:
|
||||
try:
|
||||
if int(length) > _MAX_VOICE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio exceeded the download size limit.",
|
||||
)
|
||||
except ValueError:
|
||||
# A non-numeric Content-Length header is the
|
||||
# server's problem, not a reason to refuse the
|
||||
# download — the streamed byte counter below
|
||||
# still enforces the same cap on what actually
|
||||
# arrives.
|
||||
pass
|
||||
for chunk in response.iter_bytes():
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > _MAX_VOICE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio exceeded the download size limit.",
|
||||
)
|
||||
digest.update(chunk)
|
||||
handle.write(chunk)
|
||||
downloaded = True
|
||||
break
|
||||
if not downloaded:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Community voice audio followed too many redirects.",
|
||||
)
|
||||
if total == 0:
|
||||
raise HTTPException(status_code=502, detail="Community voice audio was empty.")
|
||||
expected = audio.get("sha256")
|
||||
if expected and digest.hexdigest() != expected:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Downloaded voice failed its integrity check.",
|
||||
)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if not is_playable_wav(Path(tmp_name)):
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Community voice audio was not a valid WAV.",
|
||||
)
|
||||
os.replace(tmp_name, out_path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
finally:
|
||||
if owned_client:
|
||||
http.close()
|
||||
|
||||
|
||||
def _cached_voice_audio(item: dict) -> Path:
|
||||
path = _voice_audio_path(item)
|
||||
if is_playable_wav(path):
|
||||
return path
|
||||
with contextlib.suppress(OSError):
|
||||
path.unlink()
|
||||
_download_voice_audio(item, path)
|
||||
return path
|
||||
|
||||
|
||||
def _copy_atomic(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(destination.parent), prefix=f".{destination.name}-", suffix=".part",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, source.open("rb") as src:
|
||||
shutil.copyfileobj(src, out)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(tmp_name, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/community/items/{item_id}/preview")
|
||||
async def community_preview(
|
||||
item_id: str,
|
||||
local: bool = Query(False, description="Bypass canonical gallery audio after decode failure"),
|
||||
):
|
||||
"""Serve every community preview through the authenticated same-origin API."""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = _find_item(items, item_id)
|
||||
|
||||
canonical = _canonical_archetype(item)
|
||||
if canonical is not None:
|
||||
# Reuse the signed-gallery/local-render fallback and cache owned by the
|
||||
# canonical endpoint rather than synthesizing the same preset twice.
|
||||
# Delegate in-process: a root-relative HTTP redirect drops supported
|
||||
# reverse-proxy path prefixes such as ``https://host/api``.
|
||||
from api.routers.archetypes import preview_archetype
|
||||
return await preview_archetype(canonical["id"], local=local)
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
path = await _render_preset_atomic(item, _preset_preview_path(item))
|
||||
else:
|
||||
path = await asyncio.to_thread(_cached_voice_audio, item)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning("Community preview unavailable (%s)", type(exc).__name__)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="This community voice preview is unavailable right now.",
|
||||
) from exc
|
||||
return FileResponse(
|
||||
path, media_type="audio/wav",
|
||||
headers={"Cache-Control": "no-cache", "X-OmniVoice-Preview-Source": "community"},
|
||||
)
|
||||
|
||||
|
||||
def _profile_fields(item: dict) -> tuple[str, str, Optional[str], Optional[int]]:
|
||||
if item["type"] == "preset":
|
||||
return "design", item["instruct"], json.dumps(item["attrs"]), 42
|
||||
return "clone", "", None, None
|
||||
|
||||
|
||||
def _community_profile_audio_filename(profile_id: str, item: dict) -> str:
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else hashlib.sha256(str(profile_id).encode("utf-8")).hexdigest()[:16]
|
||||
)
|
||||
if item["type"] == "voice":
|
||||
# The manifest URL/checksum fingerprint makes a changed submission
|
||||
# invalidate its already-materialized clone without a schema change.
|
||||
return f"{safe_id}-community-{_voice_audio_fingerprint(item)}.wav"
|
||||
return f"{safe_id}.wav"
|
||||
|
||||
|
||||
def _stored_profile_audio(ref_audio_path: object) -> Optional[Path]:
|
||||
return resolve_regular_file(VOICES_DIR, ref_audio_path)
|
||||
|
||||
|
||||
def _community_audio_is_current(row, item: dict, ref_text: str) -> bool:
|
||||
path = _stored_profile_audio(row["ref_audio_path"])
|
||||
expected_filename = _community_profile_audio_filename(row["id"], item)
|
||||
if row["ref_audio_path"] != expected_filename or not is_playable_wav(path):
|
||||
return False
|
||||
kind, instruct, _vd_states, seed = _profile_fields(item)
|
||||
inputs_match = (
|
||||
row["instruct"] == instruct
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == ref_text
|
||||
and row["seed"] == seed
|
||||
)
|
||||
if not inputs_match:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _materialize_item_audio(
|
||||
item: dict, profile_id: str, *, publish: bool = True,
|
||||
) -> tuple[str, Path]:
|
||||
"""Copy the current manifest audio, optionally staging it for a later CAS."""
|
||||
audio_filename = _community_profile_audio_filename(profile_id, item)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
audio_path = destination
|
||||
if not publish:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
audio_path = destination.parent / f".{Path(audio_filename).stem}-{uuid.uuid4().hex}.staged.wav"
|
||||
if item["type"] == "preset":
|
||||
cached = await _render_preset_atomic(item, _preset_preview_path(item))
|
||||
else:
|
||||
cached = await asyncio.to_thread(_cached_voice_audio, item)
|
||||
await asyncio.to_thread(_copy_atomic, cached, audio_path)
|
||||
return audio_filename, audio_path
|
||||
|
||||
|
||||
def _community_personality(item: dict) -> str:
|
||||
source = item.get("_source_repo")
|
||||
if not isinstance(source, str) or not _SOURCE_RE.fullmatch(source):
|
||||
source = _DEFAULT_SOURCES[0]
|
||||
return f"community:{source}:{item['id']}"
|
||||
|
||||
|
||||
def _is_materialized_community_row(row, item: dict) -> bool:
|
||||
if (
|
||||
row["personality"] != _community_personality(item)
|
||||
or row["is_locked"] or row["verified_own_voice"]
|
||||
):
|
||||
return False
|
||||
if item["type"] == "voice":
|
||||
safe_id = Path(_community_profile_audio_filename(row["id"], item)).name.split(
|
||||
"-community-", 1,
|
||||
)[0]
|
||||
return bool(
|
||||
row["kind"] == "clone"
|
||||
and row["seed"] is None
|
||||
and not row["vd_states"]
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == (item.get("audio") or {}).get("ref_text", "")
|
||||
and re.fullmatch(
|
||||
rf"{re.escape(safe_id)}-community-[0-9a-f]{{16}}\.wav",
|
||||
row["ref_audio_path"] or "",
|
||||
)
|
||||
)
|
||||
try:
|
||||
states = json.loads(row["vd_states"])
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(
|
||||
row["kind"] == "design"
|
||||
and row["seed"] == 42
|
||||
and row["ref_audio_path"] == _community_profile_audio_filename(row["id"], item)
|
||||
and row["instruct"] == item["instruct"]
|
||||
and row["language"] == item.get("language", "Auto")
|
||||
and row["ref_text"] == (item.get("sample_script") or "")
|
||||
and states == item["attrs"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_community_profile(conn, item: dict, personality: str):
|
||||
candidates = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
existing = next(
|
||||
(row for row in candidates if _is_materialized_community_row(row, item)), None,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
# Old builds stored the bare item id. Import formats preserve arbitrary
|
||||
# personality text too, so adopt only the exact shape the old materializer
|
||||
# wrote; otherwise a remote item id could rewrite a user's imported voice.
|
||||
if archetypes.get_archetype(item["id"]) is None:
|
||||
legacy = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? LIMIT 1",
|
||||
(item["id"],),
|
||||
).fetchone()
|
||||
if legacy is not None:
|
||||
kind, instruct, _vd_states, _seed = _profile_fields(item)
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get(
|
||||
"ref_text", "",
|
||||
)
|
||||
if (
|
||||
legacy["ref_audio_path"] == f"{legacy['id']}.wav"
|
||||
and legacy["kind"] == kind
|
||||
and legacy["instruct"] == instruct
|
||||
and legacy["language"] == item.get("language", "Auto")
|
||||
and legacy["ref_text"] == ref_text
|
||||
and legacy["seed"] is None
|
||||
and not legacy["vd_states"]
|
||||
and not legacy["is_locked"]
|
||||
and not legacy["verified_own_voice"]
|
||||
):
|
||||
return legacy
|
||||
return None
|
||||
|
||||
|
||||
def _heal_existing_profile(
|
||||
conn, row, item: dict, ref_text: str, personality: str, audio_filename: str,
|
||||
) -> None:
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET kind=?, instruct=?, vd_states=?, language=?, "
|
||||
"ref_text=?, seed=?, personality=?, ref_audio_path=? WHERE id=?",
|
||||
(
|
||||
kind, instruct, vd_states, item.get("language", "Auto"), ref_text,
|
||||
seed, personality, audio_filename, row["id"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/community/items/{item_id}/use")
|
||||
async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
"""Materialize a community item into a reusable voice profile.
|
||||
@@ -820,108 +223,76 @@ async def community_use(item_id: str, name: Optional[str] = Query(None)):
|
||||
``voice_profiles`` row usable everywhere voices are picked.
|
||||
"""
|
||||
_, items, _, _ = await asyncio.to_thread(_load, False)
|
||||
item = _find_item(items, item_id)
|
||||
|
||||
canonical = _canonical_archetype(item)
|
||||
if canonical is not None:
|
||||
from api.routers.archetypes import use_archetype
|
||||
return await use_archetype(canonical["id"], name)
|
||||
item = next((it for it in items if it["id"] == item_id), None)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found in the gallery.")
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from core import event_bus
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR
|
||||
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
personality = _community_personality(item)
|
||||
with db_conn() as conn:
|
||||
existing = _existing_community_profile(conn, item, personality)
|
||||
|
||||
profile_id = existing["id"] if existing is not None else str(uuid.uuid4())[:8]
|
||||
audio_path: Optional[Path] = None
|
||||
if existing is not None and _community_audio_is_current(existing, item, ref_text):
|
||||
audio_filename = existing["ref_audio_path"]
|
||||
else:
|
||||
try:
|
||||
audio_filename, audio_path = await _materialize_item_audio(
|
||||
item, profile_id, publish=existing is None,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Couldn't add this voice right now.",
|
||||
) from e
|
||||
|
||||
if existing is not None:
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
current = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (existing["id"],),
|
||||
).fetchone()
|
||||
owned = _existing_community_profile(conn, item, personality)
|
||||
still_owned = current is not None and (
|
||||
_is_materialized_community_row(current, item)
|
||||
or (owned is not None and owned["id"] == current["id"])
|
||||
)
|
||||
if still_owned:
|
||||
if audio_path is not None:
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = None
|
||||
_heal_existing_profile(
|
||||
conn, current, item, ref_text, personality, audio_filename,
|
||||
)
|
||||
existing_result = {"profile_id": current["id"], "name": current["name"]}
|
||||
else:
|
||||
existing_result = None
|
||||
if existing_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": existing_result["profile_id"]})
|
||||
return existing_result
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = _community_profile_audio_filename(profile_id, item)
|
||||
destination = Path(VOICES_DIR) / audio_filename
|
||||
if audio_path is None:
|
||||
audio_filename, audio_path = await _materialize_item_audio(item, profile_id)
|
||||
else:
|
||||
os.replace(audio_path, destination)
|
||||
audio_path = destination
|
||||
|
||||
if audio_path is None: # defensive: a new profile always materialized above
|
||||
raise RuntimeError("new community profile has no materialized audio")
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
audio_filename = f"{profile_id}.wav"
|
||||
audio_path = Path(VOICES_DIR) / audio_filename
|
||||
profile_name = (name or item["name"]).strip() or item["name"]
|
||||
kind, instruct, vd_states, seed = _profile_fields(item)
|
||||
instruct = item.get("instruct", "") if item["type"] == "preset" else ""
|
||||
ref_text = item.get("sample_script") or (item.get("audio") or {}).get("ref_text", "")
|
||||
|
||||
try:
|
||||
if item["type"] == "preset":
|
||||
from api.routers.archetypes import _render_archetype_wav
|
||||
pseudo = {
|
||||
"instruct": instruct,
|
||||
"language": item.get("language", "English"),
|
||||
"sample_script": ref_text or "Hello — this is a preview of this voice.",
|
||||
}
|
||||
await _render_archetype_wav(pseudo, audio_path)
|
||||
else: # voice — download the reference clip (off the event loop)
|
||||
await asyncio.to_thread(_download_voice_audio, item, audio_path)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Community 'use' failed", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail=f"Couldn't add this voice right now. Error: {e}")
|
||||
|
||||
try:
|
||||
# A community "preset" is a synthetic designed voice (rendered from an
|
||||
# instruct string) → kind='design'; a "voice" carries a real reference
|
||||
# clip → kind='clone'. Setting kind makes the persona-gallery
|
||||
# synthetic-only gating work (§R3) instead of defaulting all imports to
|
||||
# 'clone'.
|
||||
kind = "design" if item["type"] == "preset" else "clone"
|
||||
with db_conn() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
duplicate = _existing_community_profile(conn, item, personality)
|
||||
if duplicate is not None:
|
||||
duplicate_audio = duplicate["ref_audio_path"]
|
||||
if not _community_audio_is_current(duplicate, item, ref_text):
|
||||
duplicate_audio = _community_profile_audio_filename(duplicate["id"], item)
|
||||
duplicate_path = Path(VOICES_DIR) / duplicate_audio
|
||||
_copy_atomic(audio_path, duplicate_path)
|
||||
_heal_existing_profile(
|
||||
conn, duplicate, item, ref_text, personality, duplicate_audio,
|
||||
)
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
duplicate_result = {"profile_id": duplicate["id"], "name": duplicate["name"]}
|
||||
else:
|
||||
duplicate_result = None
|
||||
if duplicate_result is None:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
|
||||
"created_at, kind, vd_states) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), seed, personality, time.time(), kind, vd_states),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles "
|
||||
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, created_at, kind) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(profile_id, profile_name, audio_filename, ref_text, instruct,
|
||||
item.get("language", "Auto"), None, item["id"], time.time(), kind),
|
||||
)
|
||||
except Exception:
|
||||
with contextlib.suppress(OSError):
|
||||
audio_path.unlink()
|
||||
with __import__("contextlib").suppress(OSError):
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
if duplicate_result is not None:
|
||||
event_bus.emit("profiles", {"action": "updated", "id": duplicate_result["profile_id"]})
|
||||
return duplicate_result
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
def _download_voice_audio(item: dict, out_path: Path) -> None:
|
||||
import hashlib
|
||||
audio = item.get("audio") or {}
|
||||
url = audio.get("url", "")
|
||||
if not _safe_audio_url(url):
|
||||
raise HTTPException(status_code=400, detail="Voice audio URL is not from an allowed host.")
|
||||
import httpx
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
data = resp.content
|
||||
expected = audio.get("sha256")
|
||||
if expected and hashlib.sha256(data).hexdigest() != expected:
|
||||
raise HTTPException(status_code=502, detail="Downloaded voice failed its integrity check.")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(data)
|
||||
|
||||
@@ -23,8 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from api.dependencies import require_local
|
||||
from api.public_engine_metadata import public_unavailability
|
||||
from api.dependencies import require_loopback
|
||||
from core import prefs
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
@@ -55,7 +54,7 @@ def _read_prefs() -> dict:
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dictation/models", dependencies=[Depends(require_local)])
|
||||
@router.get("/dictation/models", dependencies=[Depends(require_loopback)])
|
||||
def list_dictation_models():
|
||||
"""The seven sherpa-onnx dictation models + install state.
|
||||
|
||||
@@ -81,12 +80,12 @@ def list_dictation_models():
|
||||
return {
|
||||
"models": out,
|
||||
"engine_available": available,
|
||||
"engine_reason": None if available else public_unavailability(reason),
|
||||
"engine_reason": None if available else reason,
|
||||
"default_model_id": sd.DEFAULT_MODEL_ID,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dictation/prefs", dependencies=[Depends(require_local)])
|
||||
@router.get("/dictation/prefs", dependencies=[Depends(require_loopback)])
|
||||
def get_dictation_prefs():
|
||||
return _read_prefs()
|
||||
|
||||
@@ -97,17 +96,17 @@ class DictationPrefsUpdate(BaseModel):
|
||||
model_id: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/dictation/prefs", dependencies=[Depends(require_local)])
|
||||
@router.post("/dictation/prefs", dependencies=[Depends(require_loopback)])
|
||||
def set_dictation_prefs(req: DictationPrefsUpdate):
|
||||
"""Persist any subset of the dictation prefs. Validates ``mode`` and
|
||||
``model_id`` so a bad value can't wedge the capture engine."""
|
||||
canonical = None
|
||||
if req.mode is not None:
|
||||
if req.mode not in _VALID_MODES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"mode must be one of {_VALID_MODES}",
|
||||
)
|
||||
prefs.set_(PREF_MODE, req.mode)
|
||||
if req.model_id is not None:
|
||||
if not sd.is_sherpa_model(req.model_id):
|
||||
raise HTTPException(
|
||||
@@ -115,33 +114,14 @@ def set_dictation_prefs(req: DictationPrefsUpdate):
|
||||
detail=f"unknown dictation model_id {req.model_id!r}",
|
||||
)
|
||||
# Normalise to the canonical dictation id (accept repo_id too).
|
||||
canonical = sd.get_spec(req.model_id).id
|
||||
|
||||
# Reset before persisting: if the capture service is unavailable, the
|
||||
# request fails without claiming that settings which are not active were
|
||||
# saved. A reset is safe even when a later preference write fails; the old
|
||||
# persisted selection is simply loaded again on next capture.
|
||||
try:
|
||||
from services import asr_backend
|
||||
|
||||
asr_backend._capture_backend = None
|
||||
asr_backend._capture_backend_key = None
|
||||
except Exception as exc:
|
||||
logger.warning("Dictation capture backend could not be reset")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Dictation settings could not be applied. Retry after the capture service is ready.",
|
||||
) from exc
|
||||
|
||||
if req.mode is not None:
|
||||
prefs.set_(PREF_MODE, req.mode)
|
||||
if canonical is not None:
|
||||
prefs.set_(PREF_MODEL_ID, canonical)
|
||||
# Explicitly choosing a model clears any auto-demotion: the user is in
|
||||
# charge, and a sherpa upgrade may well have fixed the decoder that
|
||||
# produced no text last time. Without this, a demoted model could never
|
||||
# be re-selected from the UI.
|
||||
sd.clear_demotion(canonical)
|
||||
prefs.set_(PREF_MODEL_ID, sd.get_spec(req.model_id).id)
|
||||
if req.enabled is not None:
|
||||
prefs.set_(PREF_ENABLED, bool(req.enabled))
|
||||
# Rebuild the cached capture singleton so the change takes effect at once.
|
||||
try:
|
||||
from services import asr_backend
|
||||
asr_backend._capture_backend = None
|
||||
asr_backend._capture_backend_key = None
|
||||
except Exception:
|
||||
pass
|
||||
return _read_prefs()
|
||||
|
||||
+177
-989
File diff suppressed because it is too large
Load Diff
+160
-518
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,23 +8,12 @@ from fastapi.responses import JSONResponse
|
||||
|
||||
from schemas.requests import TranslateRequest
|
||||
from services.model_manager import _cpu_pool, _gpu_pool
|
||||
from services.hf_revisions import revision_for
|
||||
from services.translator import cinematic_available, cinematic_refine_many, _cinematic_budget
|
||||
from api.routers.dub_core import _get_job, _save_job
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
_NLLB_REPO_ID = "facebook/nllb-200-distilled-600M"
|
||||
|
||||
|
||||
def _load_nllb_component(factory):
|
||||
"""Load a curated NLLB component from its reviewed immutable revision."""
|
||||
return factory.from_pretrained(
|
||||
_NLLB_REPO_ID,
|
||||
revision=revision_for(_NLLB_REPO_ID),
|
||||
)
|
||||
|
||||
TRANSLATE_CODES = {
|
||||
"en": "en", "es": "es", "fr": "fr", "de": "de", "it": "it", "pt": "pt",
|
||||
"ru": "ru", "ja": "ja", "ko": "ko", "zh": "zh-CN", "cmn-Hans": "zh-CN",
|
||||
@@ -298,9 +287,9 @@ async def dub_translate(req: TranslateRequest):
|
||||
|
||||
try:
|
||||
if _nllb_tokenizer is None:
|
||||
_nllb_tokenizer = _load_nllb_component(AutoTokenizer)
|
||||
_nllb_tokenizer = AutoTokenizer.from_pretrained("facebook/nllb-200-distilled-600M")
|
||||
if _nllb_model is None:
|
||||
_nllb_model = _load_nllb_component(AutoModelForSeq2SeqLM)
|
||||
_nllb_model = AutoModelForSeq2SeqLM.from_pretrained("facebook/nllb-200-distilled-600M")
|
||||
if target_device != "cpu":
|
||||
try:
|
||||
_nllb_model = _nllb_model.to(target_device)
|
||||
@@ -725,16 +714,8 @@ async def dub_translate(req: TranslateRequest):
|
||||
translated, req, src_lang, loop,
|
||||
)
|
||||
except Exception as e:
|
||||
from core.public_errors import public_failure
|
||||
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Translation request failed",
|
||||
e,
|
||||
response="Translation failed; check the backend log for details.",
|
||||
traceback=True,
|
||||
)
|
||||
return JSONResponse(status_code=500, content={"error": error})
|
||||
import traceback; traceback.print_exc()
|
||||
return JSONResponse(status_code=500, content={"error": str(e)})
|
||||
|
||||
|
||||
def _stamp_duration_plan(rows, req) -> None:
|
||||
@@ -1050,15 +1031,6 @@ async def _maybe_cinematic(translated, req, src_lang, loop, *, already_llm=False
|
||||
"literal": r["literal"],
|
||||
"critique": r.get("critique", ""),
|
||||
}
|
||||
# `degraded` ≠ `error`: a degraded row fell back to its literal text
|
||||
# (reflect/adapt skipped — rate limit, budget, divergence) but is fully
|
||||
# usable, so the fit pass, condense pass, and duration planning below
|
||||
# must still run on it. Marking these `error` used to (a) skip all
|
||||
# three passes — overlong lines then hit heavy time-compression at mix,
|
||||
# audibly degrading the dub — and (b) make the UI report "N/N segments
|
||||
# failed" for a translate that succeeded.
|
||||
if r.get("degraded"):
|
||||
out["degraded"] = r["degraded"]
|
||||
if r.get("error"):
|
||||
out["error"] = r["error"]
|
||||
merged.append(out)
|
||||
|
||||
+69
-100
@@ -15,25 +15,21 @@ Environment variables (`OMNIVOICE_TTS_BACKEND`, `OMNIVOICE_ASR_BACKEND`,
|
||||
`OMNIVOICE_LLM_BACKEND`) still win over the UI choice so power-users can pin
|
||||
a backend without Settings silently undoing it.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from time import perf_counter
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from huggingface_hub import utils as hf_utils
|
||||
from huggingface_hub.errors import HFValidationError
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_admin, require_admin_action, require_desktop
|
||||
from api.dependencies import require_loopback
|
||||
from core import prefs
|
||||
from services import tts_backend, asr_backend, llm_backend, translation_engines
|
||||
from services.audio_dsp import list_effect_presets
|
||||
from api.schemas import EffectPresetsResponse
|
||||
from api.public_engine_metadata import public_backends, public_unavailability
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.engines_api")
|
||||
|
||||
_FAMILIES = {
|
||||
"tts": (tts_backend, "tts_backend"),
|
||||
@@ -42,62 +38,37 @@ _FAMILIES = {
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
return False
|
||||
try:
|
||||
hf_utils.validate_repo_id(value)
|
||||
except (HFValidationError, TypeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@router.get("/engines")
|
||||
def list_all_engines():
|
||||
return {
|
||||
"tts": _family_payload("tts", tts_backend),
|
||||
"asr": _family_payload("asr", asr_backend),
|
||||
"llm": _family_payload("llm", llm_backend),
|
||||
"tts": {
|
||||
"active": tts_backend.active_backend_id(),
|
||||
"backends": tts_backend.list_backends(),
|
||||
},
|
||||
"asr": {
|
||||
"active": asr_backend.active_backend_id(),
|
||||
"backends": asr_backend.list_backends(),
|
||||
},
|
||||
"llm": {
|
||||
"active": llm_backend.active_backend_id(),
|
||||
"backends": llm_backend.list_backends(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return _family_payload("tts", tts_backend)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/disk-usage",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def engine_disk_usage(engine_id: str):
|
||||
"""Measure owned engine bytes only when a catalogue row is opened."""
|
||||
try:
|
||||
tts_backend.get_backend_class(engine_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Unknown TTS engine")
|
||||
from services.engine_disk_usage import disk_usage_for
|
||||
|
||||
return disk_usage_for(engine_id)
|
||||
return {"active": tts_backend.active_backend_id(), "backends": tts_backend.list_backends()}
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return _family_payload("asr", asr_backend)
|
||||
return {"active": asr_backend.active_backend_id(), "backends": asr_backend.list_backends()}
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return _family_payload("llm", llm_backend)
|
||||
return {"active": llm_backend.active_backend_id(), "backends": llm_backend.list_backends()}
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
@@ -120,18 +91,12 @@ def list_translation_engines():
|
||||
an engine whose Python dependency isn't importable yet.
|
||||
"""
|
||||
return {
|
||||
"engines": [
|
||||
{**entry, "availability_reason": public_unavailability(entry.get("availability_reason"))}
|
||||
for entry in translation_engines.list_engines()
|
||||
],
|
||||
"engines": translation_engines.list_engines(),
|
||||
"sandboxed": translation_engines.is_frozen(),
|
||||
}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/translation/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
@router.post("/engines/translation/{engine_id}/install")
|
||||
async def install_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -167,10 +132,7 @@ async def install_translation_engine(engine_id: str):
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/engines/translation/{engine_id}",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
@router.delete("/engines/translation/{engine_id}")
|
||||
async def uninstall_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -199,7 +161,7 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
# Sidecar engines (dedicated venv + source checkout + weights, isolated from
|
||||
# the parent's transformers>=5.3) used to require four manual terminal steps.
|
||||
# These routes drive services.sidecar_install: POST starts a resumable
|
||||
# background job, GET polls its step-by-step status (the Model Catalogue → Engines
|
||||
# background job, GET polls its step-by-step status (the Settings → Engines
|
||||
# Install button polls this), DELETE removes an app-managed install.
|
||||
#
|
||||
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
|
||||
@@ -209,16 +171,15 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
# POST /engines/sonitranslate/install). Mirrors the
|
||||
# /engines/translation/{engine_id}/install namespace pattern.
|
||||
#
|
||||
# Desktop-only: installing spawns git/uv against mutable source and writes an
|
||||
# editable environment. An API key does not make that supply-chain path safe to
|
||||
# trigger remotely. The job runs fine in packaged builds: the venv lives under
|
||||
# the user data dir, not inside the signed app bundle, and uv resolves via
|
||||
# OMNIVOICE_BUNDLED_UV/PATH.
|
||||
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
|
||||
# data directory — only the local desktop frontend may trigger it. The job
|
||||
# runs fine in packaged builds: the venv lives under the user data dir, not
|
||||
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin), Depends(require_desktop)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def install_sidecar_engine(engine_id: str):
|
||||
"""Start (or report) the one-click install for a sidecar engine.
|
||||
@@ -244,7 +205,7 @@ def install_sidecar_engine(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/sidecar/{engine_id}/install/status",
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def sidecar_install_status(engine_id: str):
|
||||
"""Step-by-step status of the sidecar install job (poll while running).
|
||||
@@ -265,7 +226,7 @@ def sidecar_install_status(engine_id: str):
|
||||
|
||||
@router.delete(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def uninstall_sidecar_engine(engine_id: str):
|
||||
"""Remove an app-managed sidecar install (checkout + venv + weights) and
|
||||
@@ -296,22 +257,29 @@ def uninstall_sidecar_engine(engine_id: str):
|
||||
# frame. Result includes wall-clock latency so the UI can render
|
||||
# "1234 ms — pong" inline next to the button.
|
||||
#
|
||||
# Admin-gated (T-02-13): only the local desktop frontend or an authenticated
|
||||
# server-mode administrator may trigger a sidecar spawn through this endpoint.
|
||||
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
|
||||
# a sidecar spawn through this endpoint.
|
||||
|
||||
# Engine instances cached for the lifetime of the FastAPI process so that
|
||||
# repeated health checks don't spawn a new SubprocessBackend (each spawn
|
||||
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
|
||||
# class to survive registry-sandbox tests that rebind ids transiently.
|
||||
#
|
||||
# It now lives in services.tts_backend — the worker executor needs the same
|
||||
# warm instances and cannot import an API router without inverting the
|
||||
# layering. This name is the SAME dict object, kept so the existing consumers
|
||||
# (engine_memory eviction, model_lifecycle inventory/unload) go on working
|
||||
# unchanged; rebinding it here would fork the cache in two.
|
||||
_ENGINE_INSTANCES: dict[type, object] = tts_backend._ENGINE_INSTANCES
|
||||
_ENGINE_INSTANCES: dict[type, object] = {}
|
||||
|
||||
_get_engine_instance = tts_backend.get_engine_instance
|
||||
|
||||
def _get_engine_instance(cls):
|
||||
"""Return a cached singleton instance of ``cls``.
|
||||
|
||||
SubprocessBackend's ``__init__`` registers an atexit shutdown hook,
|
||||
so re-instantiating per request would leak handler entries (and on
|
||||
real engines, additional sidecar processes the first time the lock
|
||||
is acquired). One instance per process is the right move.
|
||||
"""
|
||||
inst = _ENGINE_INSTANCES.get(cls)
|
||||
if inst is None:
|
||||
inst = cls()
|
||||
_ENGINE_INSTANCES[cls] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def _resolve_engine_class(engine_id: str):
|
||||
@@ -333,7 +301,7 @@ def _resolve_engine_class(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/health",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def engine_health(engine_id: str):
|
||||
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
|
||||
@@ -341,10 +309,10 @@ def engine_health(engine_id: str):
|
||||
Returns:
|
||||
{ id, ok, message, latency_ms }
|
||||
|
||||
Never raises through to a 500: backend diagnostics stay in the local
|
||||
log and the response carries a fixed failure message, so the UI can
|
||||
render a per-row failure without exposing private data. Unknown engine
|
||||
ids return 404.
|
||||
Never raises through to a 500: if the backend's check throws, the
|
||||
exception is captured into the response body as ``ok=False`` /
|
||||
``message="ExcType: ..."`` so the UI can render a per-row failure
|
||||
without crashing the panel. Unknown engine ids return 404.
|
||||
"""
|
||||
cls = _resolve_engine_class(engine_id)
|
||||
if cls is None:
|
||||
@@ -373,17 +341,16 @@ def engine_health(engine_id: str):
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
# Engine-owned output can contain much more than shaped HF tokens: local
|
||||
# paths, arbitrary credentials, source lines, or a nested traceback.
|
||||
from core.public_errors import public_engine_health
|
||||
# Mask any HF token the engine accidentally leaked into the message
|
||||
# so the response body matches the same redaction guarantee as
|
||||
# ``list_backends()``.
|
||||
from services.tts_backend import _mask_hf_tokens
|
||||
|
||||
latency_ms = (perf_counter() - t0) * 1000.0
|
||||
if not ok:
|
||||
logger.warning("Engine health check failed; details withheld")
|
||||
return {
|
||||
"id": engine_id,
|
||||
"ok": bool(ok),
|
||||
"message": public_engine_health(bool(ok), msg),
|
||||
"message": _mask_hf_tokens(msg) if isinstance(msg, str) else str(msg),
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
@@ -407,11 +374,11 @@ def engine_health(engine_id: str):
|
||||
# hanging the Settings panel. The orphaned worker is best-effort daemon.
|
||||
# * A process-wide lock serialises self-tests so a click-storm can't stack
|
||||
# concurrent model loads.
|
||||
# * Only ever on user click (POST) — never on Settings load. Admin-gated.
|
||||
# * Only ever on user click (POST) — never on Settings load. Loopback-gated.
|
||||
|
||||
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
|
||||
# trips the no-hardcoded-CJK guard.
|
||||
_SELFTEST_PHRASE = "VoiceStudio engine self test."
|
||||
_SELFTEST_PHRASE = "OmniVoice engine self test."
|
||||
_SELFTEST_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -474,7 +441,7 @@ class SelfTestResponse(BaseModel):
|
||||
@router.post(
|
||||
"/engines/{engine_id}/selftest",
|
||||
response_model=SelfTestResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
def engine_selftest(engine_id: str):
|
||||
"""Run a bounded, real synthesis on an available in-process TTS engine.
|
||||
@@ -573,11 +540,7 @@ class SelectEngineResponse(BaseModel):
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/select",
|
||||
response_model=SelectEngineResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
@router.post("/engines/select", response_model=SelectEngineResponse)
|
||||
def select_engine(req: SelectEngineRequest):
|
||||
"""Persist a family's engine pick to prefs.json. Refuses unknown backends,
|
||||
backends whose deps aren't installed, AND backends that cannot run on THIS
|
||||
@@ -608,7 +571,7 @@ def select_engine(req: SelectEngineRequest):
|
||||
# #981: mlx-audio multiplexes 7+ curated models behind one backend id —
|
||||
# persist the model pick alongside the backend id so the UI can actually
|
||||
# select which curated model gets loaded (previously it always defaulted
|
||||
# to Kokoro no matter what the user downloaded in Model Catalogue → Models).
|
||||
# to Kokoro no matter what the user downloaded in Settings → Models).
|
||||
if req.family == "tts" and req.backend_id == "mlx-audio" and req.model_id is not None:
|
||||
known_keys = tts_backend.MLXAudioBackend.CURATED_MODELS
|
||||
# Accept a curated key OR a raw HF repo id ("owner/name") — the same
|
||||
@@ -616,14 +579,20 @@ def select_engine(req: SelectEngineRequest):
|
||||
# Anything else (typo'd key, malformed id) is rejected outright
|
||||
# rather than silently persisted as a "custom repo" that then fails
|
||||
# to resolve at load time.
|
||||
if req.model_id not in known_keys and not _is_hf_repo_id(req.model_id):
|
||||
if req.model_id not in known_keys and not re.fullmatch(r"[\w.-]+/[\w.-]+", req.model_id):
|
||||
raise HTTPException(
|
||||
400,
|
||||
"Unknown mlx-audio model. Expected a curated model key or a "
|
||||
"Hugging Face repo ID like 'owner/name'.",
|
||||
f"Unknown mlx-audio model: {req.model_id!r}. Expected one of "
|
||||
f"{sorted(known_keys)} or a HF repo id like 'owner/name'.",
|
||||
)
|
||||
prefs.set_("mlx_audio_model_id", req.model_id)
|
||||
prefs.set_(pref_key, req.backend_id)
|
||||
from core.analytics import capture as _ph_capture
|
||||
_ph_capture("engine_selected", {
|
||||
"family": req.family,
|
||||
"backend_id": req.backend_id,
|
||||
"routing_status": entry.get("routing_status", "cpu_only"),
|
||||
})
|
||||
return {
|
||||
"family": req.family,
|
||||
"active": module.active_backend_id(),
|
||||
|
||||
@@ -4,28 +4,34 @@ import time
|
||||
import shutil
|
||||
import subprocess
|
||||
import platform
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from api.dependencies import require_native_access
|
||||
from core.db import db_conn
|
||||
from core.config import DATA_DIR, OUTPUTS_DIR
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.path_authorization import PathAuthorizationError, consume
|
||||
from core.path_security import UnsafePath, resolve_within, safe_filename
|
||||
from schemas.requests import ExportRequest, ExportRecordRequest, RevealRequest
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _authorized_destination(token: str) -> str:
|
||||
"""Consume a native save-dialog capability and validate its destination."""
|
||||
try:
|
||||
raw = consume(token, "dub_export")
|
||||
except PathAuthorizationError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if not raw or not raw.strip() or not os.path.isabs(os.path.expanduser(raw)):
|
||||
raise HTTPException(status_code=400, detail="The selected destination is invalid.")
|
||||
dest = os.path.realpath(os.path.expanduser(raw))
|
||||
def _safe_destination(raw: str) -> str:
|
||||
"""Resolve + validate an export destination. Rejects relative/empty paths."""
|
||||
if not raw or not raw.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Export needs a destination folder. Pick where the file should go and try again.",
|
||||
)
|
||||
expanded = os.path.expanduser(raw)
|
||||
# Check BEFORE realpath(): realpath absolutizes a relative path against
|
||||
# the server's cwd, which made this check dead code — a relative
|
||||
# destination silently exported to a cwd-dependent location instead of
|
||||
# the documented 400 (regression-tested in tests/test_exports_api.py).
|
||||
if not os.path.isabs(expanded):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The destination needs to be a full path (e.g. /Users/you/Movies/OmniVoice) — not relative.",
|
||||
)
|
||||
dest = os.path.realpath(expanded)
|
||||
parent = os.path.dirname(dest)
|
||||
if not parent or not os.path.isdir(parent):
|
||||
raise HTTPException(
|
||||
@@ -37,32 +43,32 @@ def _authorized_destination(token: str) -> str:
|
||||
|
||||
def _safe_source(filename: str) -> str:
|
||||
"""Resolve a source filename against OUTPUTS_DIR / dub outputs, blocking traversal."""
|
||||
try:
|
||||
base = safe_filename(filename)
|
||||
except UnsafePath as exc:
|
||||
base = os.path.basename(filename or "")
|
||||
# "." and ".." are their own basename, so they'd slip past the
|
||||
# base != filename check and only die later on realpath containment —
|
||||
# reject them up front with the same 400 as any other malformed name.
|
||||
if not base or base != filename or base in (".", ".."):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="The file to export has an unexpected name. Try re-generating the audio and exporting again.",
|
||||
) from exc
|
||||
)
|
||||
for root in (OUTPUTS_DIR, os.path.join("dub", "outputs")):
|
||||
try:
|
||||
candidate = resolve_within(root, base)
|
||||
except UnsafePath:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
candidate = os.path.realpath(os.path.join(root, base))
|
||||
root_real = os.path.realpath(root)
|
||||
if candidate.startswith(root_real + os.sep) and os.path.exists(candidate):
|
||||
return candidate
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="That file isn't on disk anymore — it may have been cleaned up. Regenerate and try again.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export", dependencies=[Depends(require_native_access)])
|
||||
@router.post("/export")
|
||||
def export_file(req: ExportRequest):
|
||||
src = _safe_source(req.source_filename)
|
||||
dest = _authorized_destination(req.authorization)
|
||||
dest = _safe_destination(req.destination_path)
|
||||
try:
|
||||
# Video exports: overlay VoiceStudio logo if visible watermark is enabled
|
||||
# Video exports: overlay OmniVoice logo if visible watermark is enabled
|
||||
if src.lower().endswith(".mp4"):
|
||||
from services.watermark import is_visible_video_enabled, get_ffmpeg_overlay_args
|
||||
logo_path = os.path.join(os.path.dirname(__file__), "..", "..", "..", "docs", "logo.png")
|
||||
@@ -120,38 +126,31 @@ def get_export_history():
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/export/reveal", dependencies=[Depends(require_native_access)])
|
||||
@router.post("/export/reveal")
|
||||
def reveal_in_folder(req: RevealRequest):
|
||||
# Desktop clients reveal arbitrary user-selected export destinations in
|
||||
# the native Tauri process. This HTTP fallback is deliberately limited to
|
||||
# server-owned data so a remote/browser caller cannot make the host open
|
||||
# an attacker-chosen path.
|
||||
# Tauri/native dialog-provided path; subprocess uses list args (no shell interpolation).
|
||||
if not req.path or not req.path.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No path was provided — nothing to reveal.",
|
||||
)
|
||||
try:
|
||||
target_path = resolve_within(DATA_DIR, req.path)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=403, detail="That path cannot be opened remotely.") from exc
|
||||
if not target_path.exists():
|
||||
target = os.path.realpath(os.path.expanduser(req.path))
|
||||
if not os.path.exists(target):
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="That file or folder is no longer on disk. It may have been moved or deleted.",
|
||||
)
|
||||
|
||||
target = str(target_path)
|
||||
folder = target if target_path.is_dir() else str(target_path.parent)
|
||||
folder = target if os.path.isdir(target) else os.path.dirname(target)
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Darwin":
|
||||
if target_path.is_file():
|
||||
if os.path.isfile(target):
|
||||
subprocess.Popen(["open", "-R", target])
|
||||
else:
|
||||
subprocess.Popen(["open", folder])
|
||||
elif system == "Windows":
|
||||
if target_path.is_file():
|
||||
if os.path.isfile(target):
|
||||
subprocess.Popen(["explorer", "/select,", target.replace("/", "\\")])
|
||||
else:
|
||||
subprocess.Popen(["explorer", folder.replace("/", "\\")])
|
||||
|
||||
+98
-247
@@ -1,25 +1,18 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.db import db_conn
|
||||
from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.audio_validation import resolve_regular_file
|
||||
from core.file_cleanup import FileCleanupError, unlink_if_present
|
||||
from services.ffmpeg_utils import spawn_subprocess
|
||||
|
||||
logger = logging.getLogger("omnivoice.gallery")
|
||||
@@ -146,14 +139,11 @@ def delete_voice(voice_id: str):
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
if audio_path:
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
unlink_if_present(audio_path)
|
||||
except FileCleanupError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Could not delete the voice audio file. Close any app using it and retry.",
|
||||
) from exc
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (voice_id,))
|
||||
return {"success": True}
|
||||
@@ -214,7 +204,7 @@ async def search_youtube(
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="yt-dlp not installed")
|
||||
except Exception as e:
|
||||
logger.exception("YouTube search error")
|
||||
logger.error(f"YouTube search error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -308,7 +298,7 @@ async def download_youtube_clip(
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=500, detail="yt-dlp not installed")
|
||||
except Exception as e:
|
||||
logger.exception("Download error")
|
||||
logger.error(f"Download error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@@ -366,223 +356,46 @@ async def upload_voice_clip(
|
||||
}
|
||||
|
||||
|
||||
def _stage_profile_audio(source: Path, directory: Path) -> Path:
|
||||
"""Copy an imported clip to a hidden temp file inside ``directory``.
|
||||
|
||||
The temp lives in the destination directory itself so a later
|
||||
``os.replace`` to the final name is an atomic same-filesystem rename —
|
||||
cheap enough to run while holding a DB write lock, unlike the copy.
|
||||
Callers own cleanup of the returned path if they never publish it.
|
||||
"""
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(directory), prefix=".gallery-import-", suffix=".part",
|
||||
)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(source, tmp_name)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
return Path(tmp_name)
|
||||
|
||||
|
||||
def _copy_profile_audio(source: Path, destination: Path) -> None:
|
||||
"""Copy an imported clip without exposing a partial profile audio file."""
|
||||
staged = _stage_profile_audio(source, destination.parent)
|
||||
try:
|
||||
os.replace(staged, destination)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged)
|
||||
raise
|
||||
|
||||
|
||||
def _gallery_profile_audio_filename(profile_id: str, source: Path) -> str:
|
||||
"""Return the canonical, portable filename for a My Imports profile."""
|
||||
safe_id = (
|
||||
profile_id if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", profile_id or "")
|
||||
else uuid.uuid5(uuid.NAMESPACE_URL, str(profile_id)).hex[:16]
|
||||
)
|
||||
suffix = source.suffix.lower()
|
||||
if not re.fullmatch(r"\.[a-z0-9]{1,8}", suffix):
|
||||
suffix = ".wav"
|
||||
return f"{safe_id}_gallery{suffix}"
|
||||
|
||||
|
||||
def _is_materialized_gallery_profile(row, voice: dict, audio_filename: str) -> bool:
|
||||
"""Recognize only rows created by this materializer, not identity collisions."""
|
||||
return bool(
|
||||
row["personality"] == f"gallery:{voice['id']}"
|
||||
and row["ref_audio_path"] == audio_filename
|
||||
and row["ref_text"] == ""
|
||||
and row["instruct"] == ""
|
||||
and row["language"] == "Auto"
|
||||
and row["seed"] is None
|
||||
and row["kind"] == "clone"
|
||||
and not row["vd_states"]
|
||||
and row["description"] == (voice.get("description") or "")
|
||||
and not row["is_locked"]
|
||||
and not row["verified_own_voice"]
|
||||
and not row["locked_audio_path"]
|
||||
)
|
||||
|
||||
|
||||
def _existing_gallery_profile(conn, voice: dict, source: Path):
|
||||
personality = f"gallery:{voice['id']}"
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE personality=? ORDER BY created_at, id",
|
||||
(personality,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
expected = _gallery_profile_audio_filename(row["id"], source)
|
||||
if _is_materialized_gallery_profile(row, voice, expected):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _gallery_profile_audio_is_current(row, source: Path) -> bool:
|
||||
"""Detect missing/replaced copies without re-hashing unchanged imports."""
|
||||
destination = resolve_regular_file(VOICES_DIR, row["ref_audio_path"])
|
||||
if destination is None:
|
||||
return False
|
||||
try:
|
||||
source_stat = source.stat()
|
||||
destination_stat = destination.stat()
|
||||
# copy2 preserves mtime; size + nanosecond mtime catches ordinary edits
|
||||
# and partial writes while keeping repeated Use clicks inexpensive.
|
||||
return (
|
||||
source_stat.st_size == destination_stat.st_size
|
||||
and source_stat.st_mtime_ns == destination_stat.st_mtime_ns
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _materialize_gallery_profile(
|
||||
voice_id: str, requested_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Idempotently materialize/heal one My Imports clip as a clone profile."""
|
||||
personality = f"gallery:{voice_id}"
|
||||
copied_path: Optional[Path] = None
|
||||
created = False
|
||||
staged_path: Optional[Path] = None
|
||||
staged_source: Optional[Path] = None
|
||||
try:
|
||||
# Stage the (potentially large) audio copy BEFORE taking SQLite's
|
||||
# write lock: copying inside BEGIN IMMEDIATE would stall every other
|
||||
# backend writer for the whole copy. The staged temp lives in
|
||||
# VOICES_DIR itself, so publishing it inside the transaction is an
|
||||
# atomic same-filesystem os.replace. This pre-read is advisory only —
|
||||
# the locked transaction below re-reads and re-decides everything.
|
||||
copy_needed = False
|
||||
with db_conn() as conn:
|
||||
pre_row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if pre_row is not None:
|
||||
pre_source = Path(pre_row["audio_path"])
|
||||
if pre_source.is_file():
|
||||
pre_existing = _existing_gallery_profile(conn, dict(pre_row), pre_source)
|
||||
copy_needed = pre_existing is None or not _gallery_profile_audio_is_current(
|
||||
pre_existing, pre_source,
|
||||
)
|
||||
if copy_needed:
|
||||
staged_path = _stage_profile_audio(pre_source, Path(VOICES_DIR))
|
||||
staged_source = pre_source
|
||||
|
||||
with db_conn() as conn:
|
||||
# The identity is not globally UNIQUE because personality is shared
|
||||
# with other import mechanisms. Serialize this check+insert in
|
||||
# SQLite so simultaneous Use clicks cannot both create a row.
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
source = Path(voice["audio_path"])
|
||||
if not source.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
def _install_audio(destination: Path) -> None:
|
||||
"""Publish the staged copy under the lock via atomic rename."""
|
||||
nonlocal staged_path
|
||||
if staged_path is not None and staged_source == source:
|
||||
os.replace(staged_path, destination)
|
||||
staged_path = None
|
||||
else:
|
||||
# Rare race: the gallery row changed between the advisory
|
||||
# pre-read and taking the lock, so any staged bytes may be
|
||||
# from the wrong source. Fall back to the blocking copy
|
||||
# rather than publish stale audio.
|
||||
_copy_profile_audio(source, destination)
|
||||
|
||||
existing = _existing_gallery_profile(conn, voice, source)
|
||||
if existing is not None:
|
||||
ref_filename = _gallery_profile_audio_filename(existing["id"], source)
|
||||
if not _gallery_profile_audio_is_current(existing, source):
|
||||
ref_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(ref_path)
|
||||
copied_path = ref_path
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET ref_audio_path=?, ref_text='', instruct='', "
|
||||
"language='Auto', seed=NULL, description=?, kind='clone', vd_states=NULL, "
|
||||
"personality=? WHERE id=?",
|
||||
(
|
||||
ref_filename, voice["description"] or "", personality,
|
||||
existing["id"],
|
||||
),
|
||||
)
|
||||
result = {"profile_id": existing["id"], "name": existing["name"]}
|
||||
else:
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
profile_name = (requested_name or voice["name"]).strip() or voice["name"]
|
||||
ref_filename = _gallery_profile_audio_filename(profile_id, source)
|
||||
copied_path = Path(VOICES_DIR) / ref_filename
|
||||
_install_audio(copied_path)
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, language, seed,
|
||||
personality, is_locked, locked_audio_path, description, kind,
|
||||
vd_states, created_at)
|
||||
VALUES (?, ?, ?, '', '', 'Auto', NULL, ?, 0, '', ?, 'clone', NULL, ?)""",
|
||||
(
|
||||
profile_id, profile_name, ref_filename, personality,
|
||||
voice["description"] or "", time.time(),
|
||||
),
|
||||
)
|
||||
created = True
|
||||
result = {"profile_id": profile_id, "name": profile_name}
|
||||
except BaseException:
|
||||
if copied_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
copied_path.unlink()
|
||||
raise
|
||||
finally:
|
||||
# Staged but never published (failure, or a concurrent request healed
|
||||
# the profile first) — never leave .part droppings in VOICES_DIR.
|
||||
if staged_path is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(staged_path)
|
||||
|
||||
event_bus.emit(
|
||||
"profiles", {"action": "created" if created else "updated", "id": result["profile_id"]},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/save-as-profile")
|
||||
async def save_voice_as_profile(
|
||||
voice_id: str,
|
||||
profile_name: str = Query(..., description="Name for the voice profile"),
|
||||
):
|
||||
"""Save a gallery voice as a voice profile for cloning."""
|
||||
result = await asyncio.to_thread(_materialize_gallery_profile, voice_id, profile_name)
|
||||
return {"profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
import shutil
|
||||
|
||||
ext = os.path.splitext(row["audio_path"])[1]
|
||||
new_audio_path = os.path.join(VOICES_DIR, f"{profile_id}{ext}")
|
||||
shutil.copy(row["audio_path"], new_audio_path)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO voice_profiles (id, name, ref_audio_path, ref_text, instruct, language, seed, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
profile_id,
|
||||
profile_name,
|
||||
f"{profile_id}{ext}",
|
||||
row["description"] or "",
|
||||
row["character"] or "",
|
||||
"Auto",
|
||||
None,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"profile_id": profile_id, "name": profile_name}
|
||||
|
||||
|
||||
@router.get("/gallery/voices/{voice_id}/preview")
|
||||
@@ -598,10 +411,22 @@ def preview_voice(voice_id: str):
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
|
||||
if os.path.isabs(audio_path) and os.path.exists(audio_path):
|
||||
# Serve the file from this API route so deployments mounted below a
|
||||
# path prefix do not lose that prefix while following a redirect.
|
||||
return FileResponse(audio_path)
|
||||
# Debug logging
|
||||
is_absolute = os.path.isabs(audio_path)
|
||||
path_exists = os.path.exists(audio_path) if audio_path else False
|
||||
|
||||
# If absolute path, serve directly or redirect
|
||||
if is_absolute and path_exists:
|
||||
# Get just the relative path from outputs dir
|
||||
outputs_path = str(OUTPUTS_DIR)
|
||||
if audio_path.startswith(outputs_path):
|
||||
# Remove outputs_dir prefix to get relative path within outputs
|
||||
rel_path = os.path.relpath(audio_path, outputs_path)
|
||||
# The audio_path is like: /Users/user4/.../outputs/voice_gallery/file.wav
|
||||
# rel_path becomes: voice_gallery/file.wav
|
||||
# We want to serve from /audio/ so: /audio/voice_gallery/file.wav
|
||||
return RedirectResponse(f"/audio/{rel_path}")
|
||||
return FileResponse(audio_path, media_type="audio/wav")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
@@ -653,26 +478,52 @@ def batch_delete_voices(body: dict):
|
||||
return {"deleted": 0}
|
||||
|
||||
deleted = 0
|
||||
failed = 0
|
||||
with db_conn() as conn:
|
||||
for vid in ids:
|
||||
row = conn.execute("SELECT audio_path FROM voice_gallery WHERE id = ?", (vid,)).fetchone()
|
||||
if row:
|
||||
audio_path = row["audio_path"]
|
||||
if audio_path:
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
try:
|
||||
unlink_if_present(audio_path)
|
||||
except FileCleanupError:
|
||||
logger.warning("Voice audio cleanup failed for a gallery item")
|
||||
failed += 1
|
||||
continue
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (vid,))
|
||||
deleted += 1
|
||||
return {"deleted": deleted, "failed": failed}
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
def voice_to_profile(voice_id: str):
|
||||
"""Create a voice profile from a gallery clip."""
|
||||
result = _materialize_gallery_profile(voice_id)
|
||||
return {"success": True, "profile_id": result["profile_id"], "name": result["name"]}
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_gallery WHERE id = ?", (voice_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
voice = dict(row)
|
||||
audio_path = voice["audio_path"]
|
||||
if not os.path.exists(audio_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
# Copy audio to voices dir
|
||||
dest_filename = f"{profile_id}_gallery.wav"
|
||||
dest_path = os.path.join(VOICES_DIR, dest_filename)
|
||||
shutil.copy2(audio_path, dest_path)
|
||||
|
||||
import time
|
||||
now = time.time()
|
||||
conn.execute(
|
||||
"""INSERT INTO voice_profiles
|
||||
(id, name, ref_audio_path, ref_text, instruct, seed, is_locked, locked_audio_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(profile_id, voice["name"], dest_filename, "", None, None, 0, None, now, now),
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
|
||||
+268
-1312
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,6 @@ from typing import Callable, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from core import job_store
|
||||
|
||||
logger = logging.getLogger("omnivoice.longform_jobs")
|
||||
router = APIRouter()
|
||||
|
||||
@@ -90,13 +88,7 @@ def build_longform_library(
|
||||
# so ask for more rows than the caller's limit to still fill the page.
|
||||
rows = list_jobs(status="done", limit=limit * 4)
|
||||
except Exception:
|
||||
# The route deliberately never 500s, but an unreadable job store is a
|
||||
# real failure, not an empty library — log it loudly (error + stack),
|
||||
# never silently.
|
||||
logger.exception(
|
||||
"longform library: list_jobs failed — returning an empty library "
|
||||
"even though finished renders may exist"
|
||||
)
|
||||
logger.warning("longform library: list_jobs failed", exc_info=True)
|
||||
return []
|
||||
|
||||
out: list[dict] = []
|
||||
@@ -157,22 +149,9 @@ def longform_jobs(limit: int = Query(50, ge=1, le=500)) -> dict:
|
||||
|
||||
Each item's ``output`` is served at ``/audio/<output>``. Never 500s — on any
|
||||
backend hiccup it returns an empty list rather than an error.
|
||||
|
||||
``job_store`` is bound at module import (top of file), NOT re-imported
|
||||
here at call time. A call-time ``from core import job_store`` re-resolves
|
||||
through ``sys.modules`` on every request — and several test suites purge
|
||||
and re-import the whole ``core``/``services`` namespace under a
|
||||
temporary OMNIVOICE_DATA_DIR (the ``isolated_db`` pattern,
|
||||
tests/smoke/test_boot_smoke.py, …) without restoring the old module
|
||||
tree. After one of those ran, the call-time import resolved a stale
|
||||
module world whose DB_PATH pointed at a different SQLite file than the
|
||||
one the test seeded through its collection-time bindings, so the library
|
||||
came back without the seeded jobs (the order-dependent
|
||||
test_route_handler_returns_jobs_envelope full-suite flake). The
|
||||
module-level binding keeps this route in the same world as whoever
|
||||
imported this module. Regression:
|
||||
tests/test_longform_jobs.py::test_route_survives_leaked_module_world_purge.
|
||||
"""
|
||||
from core import job_store
|
||||
|
||||
jobs = build_longform_library(
|
||||
job_store.list_jobs, job_store.events_since, limit=limit,
|
||||
)
|
||||
|
||||
@@ -39,9 +39,6 @@ from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from core.db import db_conn
|
||||
from core import event_bus
|
||||
from core.version import APP_VERSION
|
||||
from core.http_headers import content_disposition
|
||||
from core.logging_utils import log_safe
|
||||
from core.path_security import UnsafePath, resolve_within, safe_filename
|
||||
|
||||
logger = logging.getLogger("omnivoice.marketplace")
|
||||
|
||||
@@ -58,26 +55,6 @@ BUNDLE_VERSION = 1
|
||||
MAX_BUNDLE_BYTES = 100 * 1024 * 1024
|
||||
|
||||
|
||||
def _contained_path(root, value, *, detail="Invalid file path") -> Path:
|
||||
try:
|
||||
return resolve_within(root, value)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail=detail) from exc
|
||||
|
||||
|
||||
def _voice_asset(value) -> Path | None:
|
||||
"""Resolve a DB-stored voice asset without trusting the database value."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
resolved = resolve_within(VOICES_DIR, value)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Voice profile contains an invalid asset path") from exc
|
||||
if not resolved.is_file():
|
||||
raise HTTPException(status_code=400, detail="Voice profile reference audio is missing")
|
||||
return resolved
|
||||
|
||||
|
||||
# ── Export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -131,18 +108,18 @@ def export_profile(profile_id: str):
|
||||
# Reference audio
|
||||
ref_path = profile.get("ref_audio_path")
|
||||
if ref_path:
|
||||
full_ref = _voice_asset(ref_path)
|
||||
if full_ref and full_ref.is_file():
|
||||
full_ref = os.path.join(VOICES_DIR, ref_path)
|
||||
if os.path.isfile(full_ref):
|
||||
ext = os.path.splitext(ref_path)[1] or ".wav"
|
||||
zf.write(str(full_ref), f"ref_audio{ext}")
|
||||
zf.write(full_ref, f"ref_audio{ext}")
|
||||
|
||||
# Locked audio (if profile is locked)
|
||||
locked_path = profile.get("locked_audio_path")
|
||||
if locked_path:
|
||||
full_locked = _voice_asset(locked_path)
|
||||
if full_locked and full_locked.is_file():
|
||||
full_locked = os.path.join(VOICES_DIR, locked_path)
|
||||
if os.path.isfile(full_locked):
|
||||
ext = os.path.splitext(locked_path)[1] or ".wav"
|
||||
zf.write(str(full_locked), f"locked_audio{ext}")
|
||||
zf.write(full_locked, f"locked_audio{ext}")
|
||||
|
||||
buf.seek(0)
|
||||
safe_name = "".join(
|
||||
@@ -154,7 +131,7 @@ def export_profile(profile_id: str):
|
||||
buf,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": content_disposition(filename),
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(buf.getbuffer().nbytes),
|
||||
},
|
||||
)
|
||||
@@ -277,7 +254,7 @@ def publish_to_marketplace(
|
||||
"""Publish a voice profile to the local marketplace directory.
|
||||
|
||||
This saves a .omnivoice bundle to the marketplace folder so other
|
||||
VoiceStudio instances on the same machine (or shared network drive)
|
||||
OmniVoice instances on the same machine (or shared network drive)
|
||||
can discover and import it.
|
||||
"""
|
||||
with db_conn() as conn:
|
||||
@@ -292,11 +269,7 @@ def publish_to_marketplace(
|
||||
safe_name = "".join(
|
||||
c if c.isalnum() or c in "-_ " else "" for c in profile.get("name", "voice")
|
||||
).strip().replace(" ", "_")[:40]
|
||||
bundle_path = _contained_path(
|
||||
MARKETPLACE_DIR,
|
||||
f"{safe_name}_{profile_id}.omnivoice",
|
||||
detail="Invalid profile id",
|
||||
)
|
||||
bundle_path = MARKETPLACE_DIR / f"{safe_name}_{profile_id}.omnivoice"
|
||||
|
||||
# Build the bundle
|
||||
with zipfile.ZipFile(str(bundle_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
@@ -309,19 +282,19 @@ def publish_to_marketplace(
|
||||
|
||||
ref_path = profile.get("ref_audio_path")
|
||||
if ref_path:
|
||||
full_ref = _voice_asset(ref_path)
|
||||
if full_ref and full_ref.is_file():
|
||||
full_ref = os.path.join(VOICES_DIR, ref_path)
|
||||
if os.path.isfile(full_ref):
|
||||
ext = os.path.splitext(ref_path)[1] or ".wav"
|
||||
zf.write(str(full_ref), f"ref_audio{ext}")
|
||||
zf.write(full_ref, f"ref_audio{ext}")
|
||||
|
||||
locked_path = profile.get("locked_audio_path")
|
||||
if locked_path:
|
||||
full_locked = _voice_asset(locked_path)
|
||||
if full_locked and full_locked.is_file():
|
||||
full_locked = os.path.join(VOICES_DIR, locked_path)
|
||||
if os.path.isfile(full_locked):
|
||||
ext = os.path.splitext(locked_path)[1] or ".wav"
|
||||
zf.write(str(full_locked), f"locked_audio{ext}")
|
||||
zf.write(full_locked, f"locked_audio{ext}")
|
||||
|
||||
logger.info("Voice published to marketplace")
|
||||
logger.info("Published voice %r to marketplace: %s", profile.get("name"), bundle_path)
|
||||
return {
|
||||
"success": True,
|
||||
"profile_id": profile_id,
|
||||
@@ -371,7 +344,7 @@ def browse_marketplace(
|
||||
),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("Skipping invalid bundle %s: %s", log_safe(path.name), log_safe(e))
|
||||
logger.warning("Skipping invalid bundle %s: %s", path.name, e)
|
||||
|
||||
return {"bundles": bundles, "total": len(bundles), "directory": str(MARKETPLACE_DIR)}
|
||||
|
||||
@@ -379,13 +352,7 @@ def browse_marketplace(
|
||||
@router.post("/install/{filename}")
|
||||
async def install_from_marketplace(filename: str):
|
||||
"""Import a voice profile from a bundle in the local marketplace directory."""
|
||||
try:
|
||||
filename = safe_filename(filename)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid bundle filename") from exc
|
||||
if not filename.endswith(".omnivoice"):
|
||||
raise HTTPException(status_code=400, detail="Invalid bundle filename")
|
||||
bundle_path = _contained_path(MARKETPLACE_DIR, filename, detail="Invalid bundle filename")
|
||||
bundle_path = MARKETPLACE_DIR / filename
|
||||
if not bundle_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Bundle not found: {filename}")
|
||||
|
||||
@@ -458,13 +425,7 @@ async def install_from_marketplace(filename: str):
|
||||
@router.delete("/{filename}")
|
||||
def remove_from_marketplace(filename: str):
|
||||
"""Remove a bundle from the local marketplace directory."""
|
||||
try:
|
||||
filename = safe_filename(filename)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid bundle filename") from exc
|
||||
if not filename.endswith(".omnivoice"):
|
||||
raise HTTPException(status_code=400, detail="Invalid bundle filename")
|
||||
bundle_path = _contained_path(MARKETPLACE_DIR, filename, detail="Invalid bundle filename")
|
||||
bundle_path = MARKETPLACE_DIR / filename
|
||||
if not bundle_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Bundle not found: {filename}")
|
||||
try:
|
||||
|
||||
@@ -9,13 +9,13 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_loopback
|
||||
from services import mcp_bindings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/mcp",
|
||||
tags=["mcp"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_loopback
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
|
||||
|
||||
class CustomPathRequest(BaseModel):
|
||||
authorization: str
|
||||
path: str
|
||||
|
||||
|
||||
def _svc():
|
||||
@@ -61,13 +61,8 @@ def media_tools_ytdlp_restore():
|
||||
|
||||
@router.post("/media-tools/{tool}/custom-path")
|
||||
def media_tools_custom_path(tool: str, body: CustomPathRequest):
|
||||
from core.path_authorization import PathAuthorizationError, consume
|
||||
|
||||
try:
|
||||
path = consume(body.authorization, tool)
|
||||
return _svc().set_custom_path(tool, path)
|
||||
except PathAuthorizationError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
return _svc().set_custom_path(tool, body.path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -2,25 +2,23 @@
|
||||
OpenAI-compatible TTS & STT API — Phase 3.2 (ROADMAP.md P0).
|
||||
|
||||
Drop-in replacement for OpenAI's audio endpoints so that any tool speaking the
|
||||
OpenAI protocol (Claude, Cursor, LangChain, litellm, etc.) can use VoiceStudio
|
||||
OpenAI protocol (Claude, Cursor, LangChain, litellm, etc.) can use OmniVoice
|
||||
as a local backend with zero code changes.
|
||||
|
||||
Endpoints
|
||||
─────────
|
||||
POST /v1/audio/speech → TTS (text → wav/mp3/opus/flac)
|
||||
POST /v1/audio/transcriptions → STT (audio file → text/json)
|
||||
GET /v1/audio/voices → list available voices (VoiceStudio extension)
|
||||
GET /v1/audio/voices → list available voices (OmniVoice extension)
|
||||
|
||||
The router delegates to the active TTS/ASR backends via the same adapter
|
||||
protocol used by the rest of VoiceStudio, so engine selection, GPU offloading,
|
||||
model loading, and invisible provenance watermarking (services.watermark,
|
||||
#1169) all work identically.
|
||||
protocol used by the rest of OmniVoice, so engine selection, GPU offloading,
|
||||
and model loading all work identically.
|
||||
|
||||
Reference: https://platform.openai.com/docs/api-reference/audio
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
@@ -32,7 +30,6 @@ from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
|
||||
from core.http_headers import content_disposition
|
||||
|
||||
logger = logging.getLogger("omnivoice.openai_compat")
|
||||
|
||||
@@ -48,7 +45,7 @@ class SpeechRequest(BaseModel):
|
||||
model: str = Field(
|
||||
default="omnivoice",
|
||||
description=(
|
||||
"TTS model to use. Maps to VoiceStudio engine IDs: "
|
||||
"TTS model to use. Maps to OmniVoice engine IDs: "
|
||||
"'omnivoice', 'voxcpm2', 'cosyvoice', 'mlx-audio', 'kittentts', 'moss-tts-nano'. "
|
||||
"Also accepts 'tts-1' and 'tts-1-hd' as aliases for the active engine."
|
||||
),
|
||||
@@ -61,7 +58,7 @@ class SpeechRequest(BaseModel):
|
||||
voice: str = Field(
|
||||
default="default",
|
||||
description=(
|
||||
"Voice to use. For VoiceStudio: pass a voice profile ID, 'default', "
|
||||
"Voice to use. For OmniVoice: pass a voice profile ID, 'default', "
|
||||
"or a KittenTTS preset name. OpenAI voice names (alloy, echo, fable, "
|
||||
"onyx, nova, shimmer) are accepted but mapped to defaults."
|
||||
),
|
||||
@@ -76,7 +73,7 @@ class SpeechRequest(BaseModel):
|
||||
le=4.0,
|
||||
description="Speed of the generated audio (0.25 to 4.0).",
|
||||
)
|
||||
# VoiceStudio extensions (not part of OpenAI spec, but accepted if sent)
|
||||
# OmniVoice extensions (not part of OpenAI spec, but accepted if sent)
|
||||
language: Optional[str] = Field(default=None, description="Language code (ISO 639-1)")
|
||||
description: Optional[str] = Field(
|
||||
default=None,
|
||||
@@ -87,19 +84,19 @@ class SpeechRequest(BaseModel):
|
||||
duration: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="VoiceStudio extension: target output duration in seconds.",
|
||||
description="OmniVoice extension: target output duration in seconds.",
|
||||
)
|
||||
seed: Optional[int] = Field(
|
||||
default=None,
|
||||
description="VoiceStudio extension: deterministic sampling seed.",
|
||||
description="OmniVoice extension: deterministic sampling seed.",
|
||||
)
|
||||
denoise: bool = Field(
|
||||
default=True,
|
||||
description="VoiceStudio extension: prepend denoise control when supported.",
|
||||
description="OmniVoice extension: prepend denoise control when supported.",
|
||||
)
|
||||
preprocess_prompt: bool = Field(
|
||||
default=True,
|
||||
description="VoiceStudio extension: trim/preprocess reference prompt when supported.",
|
||||
description="OmniVoice extension: trim/preprocess reference prompt when supported.",
|
||||
)
|
||||
chunk_duration: Optional[float] = Field(
|
||||
default=None,
|
||||
@@ -120,13 +117,13 @@ class SpeechRequest(BaseModel):
|
||||
default=None,
|
||||
ge=1,
|
||||
le=128,
|
||||
description="VoiceStudio extension: iterative unmasking steps (app default 16; 32 = the model's documented quality preset).",
|
||||
description="OmniVoice extension: iterative unmasking steps (app default 16; 32 = the model's documented quality preset).",
|
||||
)
|
||||
guidance_scale: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
le=20,
|
||||
description="VoiceStudio extension: classifier-free guidance scale (app default 2.0).",
|
||||
description="OmniVoice extension: classifier-free guidance scale (app default 2.0).",
|
||||
)
|
||||
|
||||
|
||||
@@ -148,7 +145,7 @@ class VerboseTranscriptionResponse(BaseModel):
|
||||
|
||||
# ── OpenAI voice name mapping ──────────────────────────────────────────────
|
||||
|
||||
# OpenAI's 6 named voices aren't real voices in VoiceStudio. Map them to
|
||||
# OpenAI's 6 named voices aren't real voices in OmniVoice. Map them to
|
||||
# sensible defaults so callers that hardcode "alloy" don't get a 400.
|
||||
_OPENAI_VOICE_ALIASES = {
|
||||
"alloy", "echo", "fable", "onyx", "nova", "shimmer",
|
||||
@@ -159,10 +156,8 @@ _OPENAI_VOICE_ALIASES = {
|
||||
|
||||
|
||||
def _resolve_engine(model_id: str):
|
||||
"""Map an OpenAI model name to a VoiceStudio backend."""
|
||||
from services.tts_backend import (
|
||||
get_backend_class, get_active_tts_backend, get_engine_instance_for,
|
||||
)
|
||||
"""Map an OpenAI model name to an OmniVoice backend."""
|
||||
from services.tts_backend import get_backend_class, get_active_tts_backend
|
||||
|
||||
# Accept OpenAI model names as pass-through to the active engine.
|
||||
if model_id in ("tts-1", "tts-1-hd"):
|
||||
@@ -179,18 +174,8 @@ def _resolve_engine(model_id: str):
|
||||
)
|
||||
from services.tts_backend import OmniVoiceBackend
|
||||
if cls is OmniVoiceBackend:
|
||||
# OmniVoice only ever runs as the shared active engine — the
|
||||
# explicit-omnivoice request is the active-engine request.
|
||||
return get_active_tts_backend()
|
||||
# Cached singleton, not a fresh cls(): SubprocessBackend engines would
|
||||
# spawn a sidecar process and reload their model on EVERY request, and
|
||||
# register a new atexit hook each time (get_engine_instance's contract).
|
||||
# No router-local cache on top of it: the shared cache is keyed by
|
||||
# CLASS precisely so id rebinds/evictions can't serve a stale instance,
|
||||
# and cross-engine memory discipline is create_speech's
|
||||
# evict_other_tts_engines call (the same seam /generate uses) — not a
|
||||
# bespoke unload here.
|
||||
return get_engine_instance_for(model_id)
|
||||
return cls()
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -262,59 +247,20 @@ def _encode_audio(wav_tensor, sample_rate: int, fmt: str) -> tuple[bytes, str, s
|
||||
return buf.getvalue(), "audio/wav", "wav"
|
||||
|
||||
|
||||
def _typed_speech_http_error(e: Exception) -> Optional[HTTPException]:
|
||||
"""Map typed synthesis failures to actionable HTTP errors (#1172/#1173).
|
||||
|
||||
- TTSInputError (bad caller input, e.g. nothing speakable) → 400,
|
||||
matching /generate's ValueError→400 mapping.
|
||||
- InvalidBinaryError (managed engine binary is a placeholder / corrupt /
|
||||
refused by the OS) → 503 with the repair hint, instead of the bare
|
||||
"[Errno 8] Exec format error" 500.
|
||||
- TimeoutError (#1190/#1202: pool saturation or a job that overran its
|
||||
execution budget) → 503 + Retry-After + X-OmniVoice-Retryable, instead of
|
||||
the 500 a scripted client can't distinguish from a real crash. Matched on
|
||||
the BUILTIN base, not GpuJobTimeoutError by name, so a mid-suite module
|
||||
reload can't break the isinstance check (same rationale as the load-path
|
||||
catch below).
|
||||
Returns None for anything else (caller falls through to the generic 500).
|
||||
"""
|
||||
from services.binary_preflight import InvalidBinaryError
|
||||
from services.tts_backend import TTSInputError
|
||||
|
||||
if isinstance(e, TTSInputError):
|
||||
return HTTPException(status_code=400, detail=str(e))
|
||||
if isinstance(e, InvalidBinaryError):
|
||||
return HTTPException(status_code=503, detail=str(e))
|
||||
if isinstance(e, TimeoutError):
|
||||
return HTTPException(
|
||||
status_code=503, detail=str(e),
|
||||
headers={"Retry-After": str(getattr(e, "retry_after", 30)),
|
||||
"X-OmniVoice-Retryable": "true"},
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_tts(backend, text: str, kw: dict):
|
||||
"""Run TTS inference in the GPU thread pool."""
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.watermark import mark_synthetic
|
||||
wav = backend.generate(text, **kw)
|
||||
sr = backend.sample_rate
|
||||
# Engines that already emit mastered, studio-grade audio (e.g. VoxCPM2's
|
||||
# native 48 kHz) opt out of apply_mastering via `applies_own_mastering`.
|
||||
# That chain's highpass + Compressor is tuned for VoiceStudio's 24 kHz clone
|
||||
# That chain's highpass + Compressor is tuned for OmniVoice's 24 kHz clone
|
||||
# output; applied to a studio engine it adds an audible level pump that
|
||||
# degrades the very output we want clean. Loudness normalisation still
|
||||
# runs — it's a benign peak scale, not dynamics.
|
||||
if not getattr(backend, "applies_own_mastering", False):
|
||||
wav = apply_mastering(wav, sample_rate=sr)
|
||||
wav = normalize_audio(wav, target_dBFS=-2.0)
|
||||
# Invisible AudioSeal provenance mark at the tensor stage, before any
|
||||
# container encoding (#1169 — this route used to return unmarked audio
|
||||
# while /generate marked the same text). Same failure semantics as
|
||||
# /generate: pref-gated, no-op without AudioSeal, passes audio through
|
||||
# unchanged on any failure — never blocks the response.
|
||||
wav = mark_synthetic(wav, sr, context="openai_compat.speech")
|
||||
return wav, sr
|
||||
|
||||
|
||||
@@ -326,10 +272,7 @@ async def create_speech(req: SpeechRequest):
|
||||
# Routing gate (#21 — no silent CPU fallback), identical to REST /generate.
|
||||
from core.device_caps import detect_host_caps
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0),
|
||||
)
|
||||
_routing = resolve_routing(getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
raise HTTPException(status_code=400, detail=_routing["routing_reason"])
|
||||
_routing_notice = routing_notice(_routing) # (status, reason) or None
|
||||
@@ -397,18 +340,6 @@ async def create_speech(req: SpeechRequest):
|
||||
from services.text_normalization import normalize_for_tts
|
||||
text = normalize_for_tts(req.input, req.language)
|
||||
|
||||
# VRAM eviction runs in get_model()'s warm-return path now, covering every
|
||||
# native TTS generate (this route, WS TTS, dub, batch, audiobook).
|
||||
|
||||
# Single-active-engine memory discipline (MM2-01), the same call /generate
|
||||
# makes before its load: hand back every OTHER resident TTS engine's model
|
||||
# before this one warms up, so switching `model` ids across requests —
|
||||
# explicit id → explicit id, or explicit id → the tts-1/omnivoice aliases —
|
||||
# can't stack multi-GB engines/sidecars. No-op when nothing else is
|
||||
# resident; opt out with OMNIVOICE_SINGLE_ENGINE_RESIDENT=0.
|
||||
from services.engine_memory import evict_other_tts_engines
|
||||
await evict_other_tts_engines(backend.id)
|
||||
|
||||
# ── #1033/#1037/#1014: warm the engine under the LOAD budget before the
|
||||
# generate clock starts. The T4 verification (#1014) measured a fresh
|
||||
# install's first /v1/audio/speech burning its whole 300s generate budget
|
||||
@@ -436,54 +367,18 @@ async def create_speech(req: SpeechRequest):
|
||||
detail=(
|
||||
f"TTS engine '{backend.id}' did not finish loading within its "
|
||||
f"model-load budget — on a first run this usually means the weight "
|
||||
f"download is slow or stalled (check Model Catalogue → Models for "
|
||||
f"download is slow or stalled (check Settings → Models for "
|
||||
f"progress), not that generation failed. Retry once the model "
|
||||
f"shows as installed."
|
||||
),
|
||||
) from e
|
||||
except Exception as e:
|
||||
# A sidecar engine's load can also hit the #1172 class (broken venv
|
||||
# interpreter / placeholder binary) — surface the typed 503 here too.
|
||||
http = _typed_speech_http_error(e)
|
||||
if http is None:
|
||||
raise
|
||||
logger.warning("OpenAI TTS engine load failed: %s", e)
|
||||
raise http from e
|
||||
|
||||
# Admission control at SUBMIT (#1190/#1202). This is the scripted-client
|
||||
# surface: a script fanning out N requests at a 1-worker pool used to get N
|
||||
# silent multi-minute waits and then "too heavy for the available compute".
|
||||
# Refusing up front with 429 + Retry-After lets a client back off correctly,
|
||||
# and costs an interactive user nothing (the policy only trips when a full
|
||||
# wave of jobs is ALREADY queued — see check_gpu_admission).
|
||||
from services.model_manager import check_gpu_admission
|
||||
try:
|
||||
check_gpu_admission(what="OpenAI TTS generate")
|
||||
except TimeoutError as e:
|
||||
logger.warning("OpenAI TTS refused — GPU pool saturated: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=429, detail=str(e),
|
||||
headers={"Retry-After": str(getattr(e, "retry_after", 30)),
|
||||
"X-OmniVoice-Retryable": "true"},
|
||||
) from e
|
||||
|
||||
try:
|
||||
# Bounded + pool-reset on hang so a wedged TTS request can't starve the
|
||||
# GPU pool and brick the backend (#730 class). The budget is the shared
|
||||
# length-scaled one (#1190) — this route used to hardcode the flat 300s,
|
||||
# so long inputs failed here even after v0.3.22 shipped the scaling.
|
||||
from services.model_manager import generate_timeout_s
|
||||
# GPU pool and brick the backend (#730 class).
|
||||
wav, sr = await run_on_gpu_pool_guarded(
|
||||
lambda: _run_tts(backend, text, kw), what="OpenAI TTS generate",
|
||||
timeout=generate_timeout_s(text, engine=backend))
|
||||
lambda: _run_tts(backend, text, kw), what="OpenAI TTS generate")
|
||||
except Exception as e:
|
||||
# #1172/#1173: typed failures get their real status + actionable
|
||||
# message (400 bad input / 503 broken engine binary) instead of a
|
||||
# generic 500 wrapping an errno or an ONNX abort.
|
||||
http = _typed_speech_http_error(e)
|
||||
if http is not None:
|
||||
logger.warning("OpenAI TTS failed (typed): %s", e)
|
||||
raise http from e
|
||||
logger.exception("OpenAI TTS failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -491,7 +386,7 @@ async def create_speech(req: SpeechRequest):
|
||||
|
||||
_headers = {
|
||||
"Content-Length": str(len(audio_bytes)),
|
||||
"Content-Disposition": content_disposition(f"speech.{ext}", disposition="inline"),
|
||||
"Content-Disposition": f'inline; filename="speech.{ext}"',
|
||||
}
|
||||
if _routing_notice:
|
||||
from services.engine_routing import header_safe_reason
|
||||
@@ -516,7 +411,7 @@ async def create_transcription(
|
||||
default="whisper-1",
|
||||
description=(
|
||||
"ASR model. Accepts 'whisper-1' (maps to active engine), or an "
|
||||
"VoiceStudio engine ID: whisperx, faster-whisper, mlx-whisper, pytorch-whisper."
|
||||
"OmniVoice engine ID: whisperx, faster-whisper, mlx-whisper, pytorch-whisper."
|
||||
),
|
||||
),
|
||||
language: Optional[str] = Form(
|
||||
@@ -537,25 +432,7 @@ async def create_transcription(
|
||||
),
|
||||
):
|
||||
"""Transcribe audio to text. Compatible with OpenAI's POST /v1/audio/transcriptions."""
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
load_active_asr_backend,
|
||||
)
|
||||
|
||||
# TTS-only install: no ASR model on disk → actionable 409, BEFORE any
|
||||
# backend load could silently auto-download multi-GB whisper weights.
|
||||
# Same typed detail shape as /transcribe (capture.py): the machine fields
|
||||
# (`error`, `missing_repo_id`, `recommended`) let VoiceStudio-aware clients
|
||||
# render the one-click download CTA, while `message` keeps a human-readable
|
||||
# line for generic OpenAI-compat clients.
|
||||
missing = await asyncio.to_thread(asr_model_missing_error)
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
|
||||
# Write uploaded file to a temp location
|
||||
suffix = os.path.splitext(file.filename or "audio.wav")[1] or ".wav"
|
||||
@@ -568,25 +445,18 @@ async def create_transcription(
|
||||
raise HTTPException(status_code=400, detail=f"Could not read audio file: {e}")
|
||||
|
||||
try:
|
||||
backend = get_active_asr_backend()
|
||||
|
||||
# Run transcription in the thread pool to avoid blocking the event loop,
|
||||
# bounded so a stuck/starved ASR returns a 504 with guidance instead of
|
||||
# hanging the request forever (see run_transcribe_guarded).
|
||||
from services.asr_backend import run_transcribe_guarded
|
||||
word_ts = response_format == "verbose_json"
|
||||
|
||||
# `load_active_asr_backend`, not `get_active_asr_backend`: the latter is
|
||||
# a pure selector, so a backend whose shallow `is_available()` probe
|
||||
# passes but whose deep import chain is broken (whisperx →
|
||||
# ctranslate2 failing to dlopen on a hardened kernel) reached
|
||||
# `.transcribe()` and 500'd, even with a healthy engine next in line.
|
||||
# The loader does select + ensure_loaded + degrade (#1185). It loads
|
||||
# weights, so it belongs inside the pool with the transcribe call —
|
||||
# never on the event loop.
|
||||
def _run():
|
||||
backend = load_active_asr_backend()
|
||||
return backend.transcribe(tmp_path, word_timestamps=word_ts)
|
||||
|
||||
result = await run_transcribe_guarded(_gpu_pool, _run, what="OpenAI")
|
||||
result = await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
|
||||
what="OpenAI",
|
||||
)
|
||||
|
||||
# Extract the full text from segments
|
||||
segments = result.get("segments", [])
|
||||
@@ -654,14 +524,6 @@ async def create_transcription(
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except ASRModelMissingError as e:
|
||||
# A degraded-to candidate has no weights on disk. Same typed 409 the
|
||||
# preflight above raises — never a 500, and never a silent multi-GB
|
||||
# auto-download.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
except TimeoutError as e:
|
||||
# ASRTimeoutError (subclass): backend alive, ASR too heavy for compute.
|
||||
logger.warning("OpenAI transcription timed out: %s", e)
|
||||
@@ -677,12 +539,12 @@ async def create_transcription(
|
||||
pass
|
||||
|
||||
|
||||
# ── Voices: GET /v1/audio/voices (VoiceStudio extension) ─────────────────────
|
||||
# ── Voices: GET /v1/audio/voices (OmniVoice extension) ─────────────────────
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
def list_voices():
|
||||
"""List available voices. VoiceStudio extension to the OpenAI API."""
|
||||
"""List available voices. OmniVoice extension to the OpenAI API."""
|
||||
from services.tts_backend import list_backends
|
||||
|
||||
backends = list_backends()
|
||||
@@ -694,7 +556,7 @@ def list_voices():
|
||||
"voice_id": name,
|
||||
"name": name.capitalize(),
|
||||
"type": "openai_alias",
|
||||
"description": f"OpenAI '{name}' voice — maps to the active VoiceStudio engine's default voice.",
|
||||
"description": f"OpenAI '{name}' voice — maps to the active OmniVoice engine's default voice.",
|
||||
})
|
||||
|
||||
# Include voice profiles from the database
|
||||
@@ -712,7 +574,7 @@ def list_voices():
|
||||
"language": row["language"],
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Voice profiles could not be loaded; returning built-in aliases only")
|
||||
pass
|
||||
|
||||
return {"voices": voices, "engines": backends}
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
|
||||
from utils.fsops import safe_replace
|
||||
import time
|
||||
import uuid
|
||||
|
||||
@@ -28,8 +26,6 @@ from core import event_bus
|
||||
from core.config import VOICES_DIR # noqa: F401 — re-exported for tests/monkeypatch
|
||||
from core.db import db_conn
|
||||
from core.version import APP_VERSION
|
||||
from core.logging_utils import log_safe
|
||||
from core.http_headers import content_disposition
|
||||
from services import persona_bundle as pb
|
||||
|
||||
router = APIRouter()
|
||||
@@ -89,8 +85,8 @@ async def export_persona(
|
||||
detail="This profile has no readable reference or locked audio to "
|
||||
"build a preview from — re-create or re-import it.",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("persona export failed for %s: %s", log_safe(profile_id), log_safe(exc))
|
||||
except Exception:
|
||||
logger.exception("persona export failed for %s", profile_id)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Could not build the persona bundle — see Settings → Logs.",
|
||||
@@ -102,7 +98,7 @@ async def export_persona(
|
||||
BytesIO(content),
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": content_disposition(filename),
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(content)),
|
||||
},
|
||||
)
|
||||
@@ -237,18 +233,15 @@ async def import_persona(file: UploadFile = File(...)):
|
||||
_insert(profile_id)
|
||||
|
||||
except HTTPException:
|
||||
if not _cleanup(written):
|
||||
raise HTTPException(status_code=500, detail="Import failed, and temporary files could not be removed. Close any app using them and retry cleanup.")
|
||||
_cleanup(written)
|
||||
raise
|
||||
except Exception:
|
||||
cleaned = _cleanup(written)
|
||||
logger.warning("Persona import failed")
|
||||
detail = ("Import failed; no files were kept." if cleaned else
|
||||
"Import failed, and temporary files could not be removed. Close any app using them and retry cleanup.")
|
||||
raise HTTPException(status_code=500, detail=detail)
|
||||
_cleanup(written)
|
||||
logger.exception("persona import failed")
|
||||
raise HTTPException(status_code=500, detail="Import failed; no files were kept.")
|
||||
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
logger.info("Imported persona %s as %s (verified=%s)", log_safe(persona.get("name")), log_safe(profile_id), verified)
|
||||
logger.info("Imported persona %r as %s (verified=%s)", persona.get("name"), profile_id, verified)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -264,16 +257,13 @@ async def import_persona(file: UploadFile = File(...)):
|
||||
}
|
||||
|
||||
|
||||
def _cleanup(paths: list[str]) -> bool:
|
||||
complete = True
|
||||
def _cleanup(paths: list[str]) -> None:
|
||||
for p in paths:
|
||||
try:
|
||||
if p and os.path.exists(p):
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
complete = False
|
||||
logger.warning("Persona import temporary-file cleanup did not complete")
|
||||
return complete
|
||||
pass
|
||||
|
||||
|
||||
def _rename_for_new_id(written: list[str], new_id: str) -> list[str]:
|
||||
@@ -286,7 +276,7 @@ def _rename_for_new_id(written: list[str], new_id: str) -> list[str]:
|
||||
new_base = new_id + base[8:]
|
||||
new_path = os.path.join(d, new_base)
|
||||
try:
|
||||
safe_replace(p, new_path)
|
||||
os.replace(p, new_path)
|
||||
out.append(new_path)
|
||||
except OSError:
|
||||
out.append(p)
|
||||
|
||||
@@ -13,7 +13,7 @@ from core.config import VOICES_DIR, OUTPUTS_DIR
|
||||
from core import event_bus
|
||||
from core.personalities import get_personalities
|
||||
from omnivoice.utils.voice_design import heal_design_instruct, sanitize_instruct
|
||||
from core.path_security import UnsafePath, resolve_within
|
||||
from core.analytics import capture as ph_capture
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -96,14 +96,6 @@ async def create_profile(
|
||||
# rebuild the tags from vd_states — so the row is always generation-safe
|
||||
# regardless of which frontend build saved it.
|
||||
instruct = heal_design_instruct(instruct, parsed)
|
||||
else:
|
||||
# Clone-kind saves get the same server-side choke point (audit finding:
|
||||
# this class — "Unsupported instruct items" 400s on every later use —
|
||||
# recurred THREE times via clients that bypassed the frontend filter,
|
||||
# and the save-time heal above was gated to design-kind). A clone
|
||||
# profile has no vd_states to rebuild from, so this is sanitize-only:
|
||||
# valid tags survive, prose/"[object Object]" is dropped.
|
||||
instruct = sanitize_instruct(instruct)
|
||||
|
||||
profile_id = str(uuid.uuid4())[:8]
|
||||
|
||||
@@ -167,6 +159,7 @@ async def create_profile(
|
||||
os.remove(audio_path)
|
||||
raise
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
ph_capture("voice_profile_created", {"kind": kind, "language": language})
|
||||
return {"id": profile_id, "name": name, "kind": kind}
|
||||
|
||||
@router.get("/profiles/{profile_id}")
|
||||
@@ -378,18 +371,13 @@ async def lock_profile(
|
||||
if not history or not history["audio_path"]:
|
||||
raise HTTPException(status_code=404, detail="History item not found or has no audio")
|
||||
|
||||
try:
|
||||
src_path = resolve_within(OUTPUTS_DIR, history["audio_path"])
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid history audio path") from exc
|
||||
if not src_path.is_file():
|
||||
src_path = os.path.join(OUTPUTS_DIR, history["audio_path"])
|
||||
if not os.path.exists(src_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
locked_filename = f"{profile_id}_locked.wav"
|
||||
locked_path = _voices_path(locked_filename)
|
||||
if locked_path is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid profile id")
|
||||
shutil.copy2(str(src_path), locked_path)
|
||||
locked_path = os.path.join(VOICES_DIR, locked_filename)
|
||||
shutil.copy2(src_path, locked_path)
|
||||
|
||||
ref_text = history["text"][:100] if history["text"] else ""
|
||||
|
||||
@@ -398,6 +386,7 @@ async def lock_profile(
|
||||
(locked_filename, seed, ref_text, profile_id)
|
||||
)
|
||||
event_bus.emit("profiles", {"action": "locked", "id": profile_id})
|
||||
ph_capture("voice_profile_locked", {})
|
||||
return {"locked": True, "profile_id": profile_id, "locked_audio_path": locked_filename}
|
||||
|
||||
@router.post("/profiles/{profile_id}/unlock")
|
||||
@@ -411,8 +400,8 @@ async def unlock_profile(profile_id: str):
|
||||
)
|
||||
|
||||
if profile["locked_audio_path"]:
|
||||
locked_path = _voices_path(profile["locked_audio_path"])
|
||||
if locked_path and os.path.exists(locked_path):
|
||||
locked_path = os.path.join(VOICES_DIR, profile["locked_audio_path"])
|
||||
if os.path.exists(locked_path):
|
||||
os.remove(locked_path)
|
||||
|
||||
conn.execute(
|
||||
@@ -543,4 +532,5 @@ def delete_profile(profile_id: str):
|
||||
conn.execute("UPDATE generation_history SET profile_id = NULL WHERE profile_id=?", (profile_id,))
|
||||
conn.execute("DELETE FROM voice_profiles WHERE id=?", (profile_id,))
|
||||
event_bus.emit("profiles", {"action": "deleted", "id": profile_id})
|
||||
ph_capture("voice_profile_deleted", {})
|
||||
return {"deleted": profile_id}
|
||||
|
||||
@@ -7,7 +7,7 @@ CRUD for the DB-backed, per-language pronunciation dictionary the
|
||||
before synthesis (see ``services/pronunciation.apply_pronunciation`` and the
|
||||
generate path), so a saved entry actually changes the audio on every engine.
|
||||
|
||||
Endpoints (admin-gated; loopback or authenticated server mode):
|
||||
Endpoints (loopback-only, like the dictation router):
|
||||
GET /pronunciation → list every entry
|
||||
POST /pronunciation → create one entry
|
||||
PUT /pronunciation/{entry_id} → update an entry (partial)
|
||||
@@ -30,12 +30,12 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from api.dependencies import require_loopback
|
||||
from core.db import db_conn
|
||||
from services.pronunciation import apply_pronunciation, entries_for_language
|
||||
|
||||
logger = logging.getLogger("omnivoice.pronunciation")
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
router = APIRouter()
|
||||
|
||||
_VALID_TYPES = ("respelling", "ipa", "cmu")
|
||||
_ALL_LANG = "*"
|
||||
@@ -133,7 +133,7 @@ class PronImportRequest(BaseModel):
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/pronunciation")
|
||||
@router.get("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
def list_entries():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -143,7 +143,7 @@ def list_entries():
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/pronunciation")
|
||||
@router.post("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
def create_entry(entry: PronEntry):
|
||||
term = entry.term.strip()
|
||||
if not term:
|
||||
@@ -171,7 +171,7 @@ def create_entry(entry: PronEntry):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/pronunciation/{entry_id}")
|
||||
@router.put("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -226,7 +226,7 @@ def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/pronunciation/{entry_id}")
|
||||
@router.delete("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
def delete_entry(entry_id: str):
|
||||
with db_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM pronunciation_entries WHERE id = ?", (entry_id,))
|
||||
@@ -236,7 +236,7 @@ def delete_entry(entry_id: str):
|
||||
# ── Dry-run + import/export ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/pronunciation/test")
|
||||
@router.post("/pronunciation/test", dependencies=[Depends(require_loopback)])
|
||||
def test_substitution(req: PronTestRequest):
|
||||
"""Show the post-substitution text for ``req.text`` — no model call.
|
||||
|
||||
@@ -258,7 +258,7 @@ def test_substitution(req: PronTestRequest):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pronunciation/export")
|
||||
@router.get("/pronunciation/export", dependencies=[Depends(require_loopback)])
|
||||
def export_entries():
|
||||
"""Every entry as a JSON-serializable list (round-trips ``/import``)."""
|
||||
with db_conn() as conn:
|
||||
@@ -273,7 +273,7 @@ def export_entries():
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/pronunciation/import")
|
||||
@router.post("/pronunciation/import", dependencies=[Depends(require_loopback)])
|
||||
def import_entries(req: PronImportRequest):
|
||||
"""Bulk-add entries. ``replace=true`` clears the table first.
|
||||
|
||||
|
||||
+38
-181
@@ -1,10 +1,11 @@
|
||||
"""Settings API — HF token save/clear/state endpoints (Phase 1 AUTH-03 backend half).
|
||||
|
||||
These endpoints are the backend half of the Wave 2 Settings → API Keys
|
||||
panel. Threat T-01-03 mitigation: the router-level `require_admin` dependency
|
||||
keeps desktop callers loopback-only and requires the long API key for every
|
||||
remote server-mode mutation. Read-only bare-Docker discovery remains available
|
||||
until an API key is configured; once configured, reads require it too.
|
||||
panel. Threat T-01-03 mitigation: every write endpoint is gated by the
|
||||
router-level `require_loopback` dep, so non-loopback origins get 403
|
||||
before the handler runs. Reads are loopback-gated too — the masked
|
||||
token preview is useful telemetry that we still don't want exposed on
|
||||
the LAN.
|
||||
|
||||
The state endpoint duplicates `/system/hf-token/state` (which lives on
|
||||
`system.py` for legacy-router compatibility); both return the same shape.
|
||||
@@ -19,15 +20,14 @@ from dataclasses import asdict
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.logging_utils import log_safe
|
||||
from api.dependencies import require_admin, require_admin_action
|
||||
from api.dependencies import require_loopback
|
||||
|
||||
logger = logging.getLogger("omnivoice.api.settings")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/settings",
|
||||
tags=["settings"],
|
||||
dependencies=[Depends(require_admin)],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
)
|
||||
|
||||
|
||||
@@ -35,11 +35,11 @@ class _HFTokenBody(BaseModel):
|
||||
token: str = Field(..., min_length=1, description="HuggingFace access token")
|
||||
|
||||
|
||||
def _state_response(*, validate: bool = False) -> dict:
|
||||
def _state_response() -> dict:
|
||||
"""Return the same shape the React panel renders. Never includes raw token."""
|
||||
from services import token_resolver
|
||||
|
||||
s = token_resolver.state(validate=validate)
|
||||
s = token_resolver.state()
|
||||
return {
|
||||
"active": s["active"],
|
||||
"sources": [asdict(row) for row in s["sources"]],
|
||||
@@ -65,7 +65,8 @@ def save_hf_token(body: _HFTokenBody):
|
||||
|
||||
@router.delete("/hf-token")
|
||||
def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
|
||||
"""Clear the App token and optionally recognized local Hub token files."""
|
||||
"""Clear the App-source token. Optionally also call huggingface_hub.logout
|
||||
to clear the canonical HF file. Returns the updated cascade state."""
|
||||
from services import token_resolver
|
||||
try:
|
||||
token_resolver.clear_app_token(also_clear_hf_cli=also_clear_hf_cli)
|
||||
@@ -81,17 +82,18 @@ def get_hf_token_state(fresh: bool = Query(False)):
|
||||
|
||||
``fresh=1`` drops the resolver's whoami validation cache first so the
|
||||
response re-runs whoami for every source — this is what the panel's
|
||||
"Test now" button sends. Plain GETs only inspect local token presence.
|
||||
"Test now" button sends. Plain GETs (panel mounts) keep the 300s cache
|
||||
so repeat Settings visits don't hammer the HF API.
|
||||
"""
|
||||
from services import token_resolver
|
||||
if fresh:
|
||||
token_resolver.invalidate_cache()
|
||||
return _state_response(validate=fresh)
|
||||
return _state_response()
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
# Threat T-02-04: same admin guard as the hf-token endpoints via the
|
||||
# router-level `require_admin` dep.
|
||||
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
|
||||
# router-level `require_loopback` dep.
|
||||
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
@@ -131,83 +133,6 @@ def set_torch_compile_disabled(body: _TorchCompileBody):
|
||||
return _torch_compile_state()
|
||||
|
||||
|
||||
# ── Compute-device override (Settings → Performance) ──────────────────────
|
||||
|
||||
|
||||
class _ComputeDeviceBody(BaseModel):
|
||||
value: str = Field(..., description="auto | cuda | rocm | xpu | mps | cpu")
|
||||
|
||||
|
||||
def _compute_device_state() -> dict:
|
||||
"""Everything the Performance panel needs to render the device control:
|
||||
the resolved pick (env > prefs > auto), what this process actually applied
|
||||
at probe time (differs after a change until restart — caps are immutable
|
||||
per process), what auto would pick, and which families exist here."""
|
||||
from core import device_caps
|
||||
|
||||
caps = device_caps.detect_host_caps()
|
||||
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
|
||||
auto_family = next(
|
||||
(f for f in device_caps.ACCELERATOR_PRIORITY if f in caps.available_families),
|
||||
"cpu",
|
||||
)
|
||||
value = device_caps.requested_device_override()
|
||||
return {
|
||||
"value": value,
|
||||
"applied": caps.requested_family,
|
||||
"restart_required": value != caps.requested_family,
|
||||
# The running process asked for a family it doesn't have (env pin on
|
||||
# the wrong machine, hardware removed): auto is in effect, and a
|
||||
# restart would not change that — the panel says so instead of
|
||||
# pretending the pick took.
|
||||
"override_ignored": (
|
||||
caps.requested_family not in ("auto", caps.family)
|
||||
),
|
||||
"effective_family": caps.family,
|
||||
"auto_family": auto_family,
|
||||
"available_families": list(caps.available_families),
|
||||
"env_pinned": env_pin in device_caps.DEVICE_OVERRIDE_CHOICES and env_pin != "",
|
||||
"choices": list(device_caps.DEVICE_OVERRIDE_CHOICES),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/compute-device")
|
||||
def get_compute_device():
|
||||
"""Current compute-device override state (Settings → Performance)."""
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
@router.put("/compute-device")
|
||||
def set_compute_device(body: _ComputeDeviceBody):
|
||||
"""Persist the compute-device pick. Applied by the capability probe at
|
||||
the next backend start (host caps are immutable per process — same
|
||||
restart contract as the rest of the Performance tab). ``OMNIVOICE_DEVICE``
|
||||
always wins over this pick; the UI shows the pin instead of pretending."""
|
||||
from core import device_caps, prefs
|
||||
|
||||
value = (body.value or "").strip().lower()
|
||||
if value not in device_caps.DEVICE_OVERRIDE_CHOICES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown device '{value}'. Valid: {', '.join(device_caps.DEVICE_OVERRIDE_CHOICES)}",
|
||||
)
|
||||
caps = device_caps.detect_host_caps()
|
||||
if value not in ("auto", "cpu") and value not in caps.available_families:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"'{value}' is not available on this host "
|
||||
f"(have: {', '.join(caps.available_families)})"
|
||||
),
|
||||
)
|
||||
try:
|
||||
prefs.set_("compute_device", value)
|
||||
except Exception:
|
||||
logger.exception("set_compute_device failed")
|
||||
raise HTTPException(status_code=500, detail="Failed to persist setting")
|
||||
return _compute_device_state()
|
||||
|
||||
|
||||
# ── Generation-history retention (Studio takes rail) ──────────────────────
|
||||
|
||||
|
||||
@@ -520,46 +445,16 @@ def test_llm_provider(provider_id: str):
|
||||
"reply": reply[:80],
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — classify without exposing diagnostics
|
||||
kind = _classify_llm_error(e)
|
||||
from core.public_errors import provider_failure
|
||||
failure = provider_failure(kind)
|
||||
# A successful local catalog probe proves the cached model is stale.
|
||||
# Invalidate it, but never include catalog or exception text in the
|
||||
# response: both are controlled by the provider.
|
||||
if kind == "not_found" and p.local:
|
||||
available = _local_models(base_url, api_key)
|
||||
if available is not None:
|
||||
llm_providers.forget_discovered_models(p.id)
|
||||
except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI
|
||||
return {
|
||||
"ok": False,
|
||||
**failure,
|
||||
"kind": _classify_llm_error(e),
|
||||
"detail": _scrub_llm_detail(e, api_key),
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
|
||||
|
||||
def _local_models(base_url: str, api_key: str):
|
||||
"""Model ids a local OpenAI-compatible server currently serves.
|
||||
|
||||
``None`` when the listing itself failed, ``[]`` when it succeeded and the
|
||||
server has nothing loaded. The distinction is load-bearing: collapsing both
|
||||
to ``[]`` let the caller state "reports no loaded models" on a lookup that
|
||||
never happened, which is a confident wrong diagnosis in place of a vague
|
||||
right one (CodeRabbit). Only used to sharpen an error message, so it must
|
||||
never raise a second error on top of the first.
|
||||
"""
|
||||
try:
|
||||
from openai import OpenAI
|
||||
client = OpenAI(api_key=api_key, base_url=base_url, max_retries=0)
|
||||
return sorted(m.id for m in client.models.list(timeout=5))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/llm-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
@router.get("/llm-providers/{provider_id}/models")
|
||||
def list_llm_provider_models(provider_id: str):
|
||||
"""List model ids the provider's key can access (OpenAI-compat /models).
|
||||
|
||||
@@ -585,10 +480,10 @@ def list_llm_provider_models(provider_id: str):
|
||||
# can say "first 200 shown" rather than implying it's the full list.
|
||||
return {"ok": True, "models": ids[:200], "truncated": len(ids) > 200}
|
||||
except Exception as e: # noqa: BLE001
|
||||
from core.public_errors import provider_failure
|
||||
return {
|
||||
"ok": False,
|
||||
**provider_failure(_classify_llm_error(e)),
|
||||
"kind": _classify_llm_error(e),
|
||||
"detail": _scrub_llm_detail(e, api_key),
|
||||
"models": [],
|
||||
}
|
||||
|
||||
@@ -653,7 +548,7 @@ def set_llm_skill(skill_id: str, body: _LLMSkillBody):
|
||||
#: Engines that have an in-tree acceptance dialog. Adding a new engine
|
||||
#: here means adding a corresponding frontend dialog + a license URLs
|
||||
#: dict in its constants module. Until that, the API refuses the write.
|
||||
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3", "pockettts"})
|
||||
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3"})
|
||||
|
||||
|
||||
class _LicenseAcceptBody(BaseModel):
|
||||
@@ -682,8 +577,8 @@ def post_license_acceptance(body: _LicenseAcceptBody) -> dict:
|
||||
from services import settings_store
|
||||
try:
|
||||
settings_store.set_license_accepted(eid, body.accepted)
|
||||
except Exception as exc:
|
||||
logger.error("set_license_accepted failed for %s: %s", log_safe(eid), log_safe(exc))
|
||||
except Exception:
|
||||
logger.exception("set_license_accepted failed for %s", eid)
|
||||
raise HTTPException(status_code=500, detail="Failed to persist license acceptance")
|
||||
return {"ok": True, "engine_id": eid, "accepted": bool(body.accepted)}
|
||||
|
||||
@@ -708,8 +603,8 @@ def get_license_acceptance(engine_id: str) -> dict:
|
||||
from services import settings_store
|
||||
try:
|
||||
accepted = settings_store.get_license_accepted(eid)
|
||||
except Exception as exc:
|
||||
logger.error("get_license_accepted failed for %s: %s", log_safe(eid), log_safe(exc))
|
||||
except Exception:
|
||||
logger.exception("get_license_accepted failed for %s", eid)
|
||||
raise HTTPException(status_code=500, detail="Failed to read license acceptance")
|
||||
return {"engine_id": eid, "accepted": bool(accepted)}
|
||||
|
||||
@@ -741,7 +636,7 @@ def _effective_models_dir() -> str:
|
||||
|
||||
|
||||
class _ModelsDirBody(BaseModel):
|
||||
authorization: str = Field(description="One-shot native desktop authorization")
|
||||
path: str = Field(default="", description="Absolute directory; empty clears → default cache")
|
||||
|
||||
|
||||
@router.get("/storage/models-dir")
|
||||
@@ -770,18 +665,17 @@ def set_models_dir(body: _ModelsDirBody):
|
||||
saved. Returns restart_required=True.
|
||||
"""
|
||||
from core import user_env
|
||||
from core.path_authorization import PathAuthorizationError, consume
|
||||
|
||||
try:
|
||||
raw = consume(body.authorization, "models_dir").strip()
|
||||
except PathAuthorizationError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
raw = (body.path or "").strip()
|
||||
if not raw:
|
||||
user_env.unset_user_env(_MODELS_DIR_ENV)
|
||||
return {"configured": None, "default": _default_models_dir(), "restart_required": True}
|
||||
|
||||
# Tauri already validates this before issuing the capability. Keep the
|
||||
# backend checks as defense in depth against a corrupt capability file.
|
||||
# Reject control characters / NUL before touching the filesystem: an
|
||||
# embedded NUL makes os.makedirs raise ValueError (→ 500). This is also
|
||||
# the input-validation barrier for the path before it reaches any fs call
|
||||
# (the dir is user-chosen by design — this is a loopback-gated, same-user
|
||||
# local file picker, not a cross-privilege boundary).
|
||||
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
|
||||
raise HTTPException(status_code=400, detail="Path contains invalid control characters")
|
||||
|
||||
@@ -842,7 +736,7 @@ async def get_storage_report(refresh: bool = Query(False)):
|
||||
|
||||
@router.post("/storage/temp/clear")
|
||||
async def clear_temp_files():
|
||||
"""Delete VoiceStudio-owned temp files (Settings → Storage → Temporary files).
|
||||
"""Delete OmniVoice-owned temp files (Settings → Storage → Temporary files).
|
||||
|
||||
Removes only the ``omnivoice*`` entries in the OS temp dir — the exact
|
||||
population the storage report's "temp" category counts — and invalidates
|
||||
@@ -1033,10 +927,9 @@ def set_asr_openai_compat(body: _ASROpenAICompatBody):
|
||||
from services import asr_backend, settings_store
|
||||
|
||||
if body.base_url is not None:
|
||||
try:
|
||||
url = asr_backend.normalize_openai_compat_asr_base_url(body.base_url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
url = body.base_url.strip().rstrip("/")
|
||||
if url and not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(status_code=400, detail="Base URL must start with http(s)://")
|
||||
settings_store.set_text(asr_backend._ASR_OPENAI_COMPAT_BASE_URL_KEY, url)
|
||||
if body.model is not None:
|
||||
settings_store.set_text(
|
||||
@@ -1108,39 +1001,3 @@ def get_db_backup_state():
|
||||
"count": len(db_backup.list_backups(DB_PATH)),
|
||||
"keep": db_backup.KEEP_BACKUPS,
|
||||
}
|
||||
|
||||
|
||||
# ── Opt-in product analytics (hardened; default OFF) ───────────────────────
|
||||
# Local-first means silence is not consent: analytics runs only when the user
|
||||
# explicitly turns it on AND the build ships a destination token. See
|
||||
# core/analytics.py for the three rules (opt-in, no exception autocapture,
|
||||
# allowlisted metadata only).
|
||||
|
||||
class _AnalyticsBody(BaseModel):
|
||||
enabled: bool = Field(..., description="User's explicit choice. Default is OFF.")
|
||||
|
||||
|
||||
@router.get("/analytics")
|
||||
def get_analytics():
|
||||
from core import analytics
|
||||
|
||||
return {
|
||||
"enabled": analytics.enabled(),
|
||||
"opted_in": analytics.user_opted_in(),
|
||||
# True for source builds too since #1193 (in-repo default token; env/baked
|
||||
# overrides). False only for a destination-less build, where the UI can
|
||||
# say so instead of offering a toggle that does nothing.
|
||||
"available": analytics.token_configured(),
|
||||
# Whether the user has ever been explicitly asked (first-run consent step
|
||||
# or the one-time banner). The UI uses this to ask exactly once — it never
|
||||
# enables anything by itself.
|
||||
"prompted": analytics.user_prompted(),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/analytics")
|
||||
def set_analytics(body: _AnalyticsBody):
|
||||
from core import analytics
|
||||
|
||||
analytics.set_opted_in(body.enabled)
|
||||
return get_analytics()
|
||||
|
||||
@@ -13,15 +13,12 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core import prefs
|
||||
from core.failure import is_hf_connectivity_error
|
||||
from services.hf_revisions import revision_for
|
||||
from utils import hf_progress
|
||||
from utils import download_aggregator
|
||||
# Weight-floor scan (MM2-07 / #352) lives in ``models.py`` — the lowest module in
|
||||
@@ -70,14 +67,6 @@ def clear_install_cooldowns() -> None:
|
||||
# cancelled, and clears the cooldown so a cancel isn't rate-limited.
|
||||
_cancelled: set[str] = set()
|
||||
|
||||
# One worker per repo. Repeated clicks and feature-level recovery can converge
|
||||
# on the same install; starting a second snapshot_download against the same HF
|
||||
# cache is wasteful and can corrupt the user-visible progress stream.
|
||||
_active_installs: set[str] = set()
|
||||
_active_installs_lock = threading.Lock()
|
||||
_install_tasks: set[asyncio.Task] = set()
|
||||
_install_tasks_by_repo: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
def _download_max_workers() -> int:
|
||||
"""Parallel-FILES worker count for snapshot_download (FDL-02). Default 8 —
|
||||
@@ -180,7 +169,7 @@ def _repo_cancelled(repo_id: str) -> bool:
|
||||
return repo_id in _cancelled
|
||||
|
||||
|
||||
def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str) -> str:
|
||||
def _segmented_snapshot(repo_id: str, *, endpoint: "str | None") -> str:
|
||||
"""Fetch every file of a repo via the segmented downloader into the HF
|
||||
cache, mirroring hf_hub_download's blob+snapshot+refs layout so the result
|
||||
is indistinguishable from snapshot_download (FDL-09) — keeping /models
|
||||
@@ -197,10 +186,10 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
|
||||
|
||||
token = _resolve_token()
|
||||
api = HfApi(endpoint=endpoint, token=token)
|
||||
info = api.repo_info(repo_id, repo_type="model", revision=revision)
|
||||
info = api.repo_info(repo_id, repo_type="model")
|
||||
commit = info.sha
|
||||
files = [s.rfilename for s in (info.siblings or [])]
|
||||
if commit != revision or not files:
|
||||
if not commit or not files:
|
||||
raise RuntimeError("repo_info returned no commit/siblings")
|
||||
|
||||
repo_dir = os.path.join(_C.HF_HUB_CACHE, repo_folder_name(repo_id=repo_id, repo_type="model"))
|
||||
@@ -232,23 +221,11 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str)
|
||||
_create_symlink(blob_path, pointer, new_blob=True)
|
||||
|
||||
# refs/main → commit so scan_cache_dir maps the revision correctly.
|
||||
ref_path = os.path.join(refs_dir, "main")
|
||||
ref_tmp = ref_path + ".tmp"
|
||||
try:
|
||||
with open(ref_tmp, "w") as f:
|
||||
with open(os.path.join(refs_dir, "main"), "w") as f:
|
||||
f.write(commit)
|
||||
os.replace(ref_tmp, ref_path)
|
||||
except OSError as exc:
|
||||
logger.warning("Downloaded model revision could not be finalized")
|
||||
try:
|
||||
os.remove(ref_tmp)
|
||||
except FileNotFoundError:
|
||||
pass # Idempotent cleanup: the failed write may not create it.
|
||||
except OSError:
|
||||
logger.warning("Downloaded model revision temporary-file cleanup did not complete")
|
||||
raise RuntimeError(
|
||||
"Downloaded model revision could not be finalized. Retry the install."
|
||||
) from exc
|
||||
except OSError:
|
||||
pass
|
||||
return snap_dir
|
||||
|
||||
|
||||
@@ -297,19 +274,17 @@ def _validate_snapshot_has_weights(repo_id: str, snapshot_path: str) -> None:
|
||||
f"{repo_id}: download finished but no model weights were found in the "
|
||||
"snapshot (largest file "
|
||||
f"{biggest} bytes). The download was likely interrupted — delete the "
|
||||
"model in Model Catalogue → Models and install it again."
|
||||
"model in Settings → Models and install it again."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup/download-stream")
|
||||
async def setup_download_stream(target: str | None = None):
|
||||
async def setup_download_stream():
|
||||
"""SSE: forward every HuggingFace download tqdm update as a JSON event."""
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=512)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def listener(event):
|
||||
if target and event.get("target", "local") != target:
|
||||
return
|
||||
try:
|
||||
loop.call_soon_threadsafe(_safe_put, queue, event)
|
||||
except RuntimeError:
|
||||
@@ -343,42 +318,6 @@ async def setup_download_stream(target: str | None = None):
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
|
||||
def _is_retryable_download_error(exc: BaseException) -> bool:
|
||||
"""Whether a failed download attempt is worth retrying.
|
||||
|
||||
Decides by CLASSIFICATION, not by exception type. The type-based tuple this
|
||||
replaced — ``(HfHubHTTPError, LocalEntryNotFoundError, OSError)`` — silently
|
||||
excluded ``httpx.RemoteProtocolError``, which inherits ``Exception``: a
|
||||
4.6 GB model truncated at 4.0 GB escaped all five attempts and aborted the
|
||||
install (#1224). Any future transport error with a novel base class would
|
||||
have reopened the same hole.
|
||||
|
||||
A user cancel is never retryable, and neither is anything
|
||||
``is_hf_connectivity_error`` does not recognise.
|
||||
"""
|
||||
# Imported here, not at module scope, for the same reason the worker does:
|
||||
# huggingface_hub is heavy and this module is on the setup import path.
|
||||
from huggingface_hub.utils import HfHubHTTPError, LocalEntryNotFoundError
|
||||
|
||||
if isinstance(exc, _InstallCancelled):
|
||||
return False
|
||||
if isinstance(exc, HfHubHTTPError):
|
||||
# An auth / not-found / gone answer from the Hub is a settled verdict:
|
||||
# the token is wrong, the repo is gated, or it isn't there. Retrying
|
||||
# five times with backoff just delays the same message and postpones
|
||||
# the install cooldown. (Pre-existing behaviour — the type-based tuple
|
||||
# this replaced retried every HfHubHTTPError; surfaced in #1224 review.)
|
||||
status = getattr(getattr(exc, "response", None), "status_code", None)
|
||||
if status in (401, 403, 404, 410):
|
||||
return False
|
||||
return True
|
||||
if isinstance(exc, (LocalEntryNotFoundError, OSError)):
|
||||
return True
|
||||
return is_hf_connectivity_error(str(exc))
|
||||
|
||||
|
||||
@router.post("/models/install")
|
||||
@@ -393,21 +332,6 @@ async def install_model(req: InstallModelRequest):
|
||||
+ ", ".join(m["repo_id"] for m in KNOWN_MODELS)
|
||||
),
|
||||
)
|
||||
target = (req.target or "").strip()
|
||||
if target != "local":
|
||||
from services import gpu_gateway # noqa: PLC0415
|
||||
from worker import routing # noqa: PLC0415
|
||||
|
||||
decision = routing.decide()
|
||||
if target and target != "local" and (
|
||||
not decision.remote or decision.worker_id != target
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="The selected GPU target changed; try again.")
|
||||
if decision.remote:
|
||||
try:
|
||||
return await gpu_gateway.download(req.repo_id, decision=decision)
|
||||
except gpu_gateway.GatewayError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
# Cooldown guard — don't retry if the same model just failed.
|
||||
import time as _time_check
|
||||
_sweep_cooldowns(_time_check.time()) # bound the dict (MM2-06)
|
||||
@@ -425,7 +349,7 @@ async def install_model(req: InstallModelRequest):
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
target_token = hf_progress.current_target.set("local")
|
||||
_cancelled.discard(req.repo_id) # clear any stale cancel from a prior run
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -447,7 +371,6 @@ async def install_model(req: InstallModelRequest):
|
||||
# parallel-files worker count, and honour an optional mirror endpoint.
|
||||
dl_kwargs: dict = {
|
||||
"repo_id": req.repo_id,
|
||||
"revision": revision_for(req.repo_id),
|
||||
"max_workers": _download_max_workers(),
|
||||
}
|
||||
_tqdm_cls = hf_progress.tracked_tqdm_class()
|
||||
@@ -488,15 +411,11 @@ async def install_model(req: InstallModelRequest):
|
||||
# bytes that will actually download — BEFORE any byte flows. Seeds
|
||||
# the overall aggregator so its bar/ETA are correct from the first
|
||||
# event. Degrades gracefully (totals=None) on older/gated repos.
|
||||
_preflight_kwargs = {
|
||||
"repo_id": req.repo_id,
|
||||
"revision": dl_kwargs["revision"],
|
||||
"dry_run": True,
|
||||
}
|
||||
_preflight_kwargs = {"repo_id": req.repo_id, "dry_run": True}
|
||||
if _endpoint:
|
||||
_preflight_kwargs["endpoint"] = _endpoint
|
||||
try:
|
||||
_plan = snapshot_download(**_preflight_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
_plan = snapshot_download(**_preflight_kwargs)
|
||||
_summary = compute_plan(_plan)
|
||||
# Disk-space guard (before a single byte flows): the preflight
|
||||
# gives an exact "to download" size, so reject an install that
|
||||
@@ -520,7 +439,6 @@ async def install_model(req: InstallModelRequest):
|
||||
return
|
||||
download_aggregator.start(
|
||||
req.repo_id,
|
||||
target=target or "local",
|
||||
total_bytes=_summary["to_download_bytes"],
|
||||
files_total=max(0, _summary["n_files"] - _summary["n_cached"]),
|
||||
)
|
||||
@@ -534,7 +452,7 @@ async def install_model(req: InstallModelRequest):
|
||||
# No preflight (older/gated repo, mirror without dry-run, etc.):
|
||||
# fall back to today's fill-in-as-files-appear behaviour.
|
||||
logger.info("model install %s: preflight unavailable (%s)", req.repo_id, _pf_err)
|
||||
download_aggregator.start(req.repo_id, target=target or "local")
|
||||
download_aggregator.start(req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -561,11 +479,7 @@ async def install_model(req: InstallModelRequest):
|
||||
_snapshot_path = None
|
||||
if _attempt == 1 and _segmented_enabled() and not _xet_active():
|
||||
try:
|
||||
_snapshot_path = _segmented_snapshot(
|
||||
req.repo_id,
|
||||
endpoint=_endpoint,
|
||||
revision=dl_kwargs["revision"],
|
||||
)
|
||||
_snapshot_path = _segmented_snapshot(req.repo_id, endpoint=_endpoint)
|
||||
except _InstallCancelled:
|
||||
raise
|
||||
except Exception as _seg_err:
|
||||
@@ -575,28 +489,11 @@ async def install_model(req: InstallModelRequest):
|
||||
)
|
||||
_snapshot_path = None
|
||||
if _snapshot_path is None:
|
||||
_snapshot_path = snapshot_download(**dl_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
_snapshot_path = snapshot_download(**dl_kwargs)
|
||||
_validate_snapshot_has_weights(req.repo_id, _snapshot_path)
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
from services.hf_revisions import remember_revision
|
||||
remember_revision(req.repo_id, dl_kwargs["revision"], HF_HUB_CACHE)
|
||||
break
|
||||
except Exception as net_err:
|
||||
# #1224: a truncated body ("peer closed connection without
|
||||
# sending complete message body") arrives as
|
||||
# httpx.RemoteProtocolError, which inherits from Exception
|
||||
# — NOT OSError — so it escaped the old
|
||||
# (HfHubHTTPError, LocalEntryNotFoundError, OSError) tuple
|
||||
# and aborted a 4.6 GB install at 4.0 GB with no retry.
|
||||
# Widen to Exception and decide by CLASSIFICATION:
|
||||
# is_hf_connectivity_error is already the single source of
|
||||
# truth for "transient download failure" and now knows the
|
||||
# truncation signatures. Anything unrecognised (a cancel, a
|
||||
# validation failure, a bug) propagates untouched, exactly
|
||||
# as before.
|
||||
if _attempt >= _max_attempts or not _is_retryable_download_error(
|
||||
net_err
|
||||
):
|
||||
except (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
|
||||
if _attempt >= _max_attempts:
|
||||
raise
|
||||
_backoff = min(30, 2 ** _attempt)
|
||||
logger.info(
|
||||
@@ -634,8 +531,10 @@ async def install_model(req: InstallModelRequest):
|
||||
# Flush the overall bar to 100% with the true byte total (FDL-06):
|
||||
# under Xet the per-file byte bars don't surface completion, so the
|
||||
# aggregator can sit below 100% even though every file landed.
|
||||
download_aggregator.complete(req.repo_id, target=target or "local")
|
||||
download_aggregator.complete(req.repo_id)
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
from core.analytics import capture as _ph_capture
|
||||
_ph_capture("model_installed", {"repo_id": req.repo_id})
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -678,59 +577,13 @@ async def install_model(req: InstallModelRequest):
|
||||
})
|
||||
finally:
|
||||
_cancelled.discard(req.repo_id)
|
||||
download_aggregator.finish(req.repo_id, target=target or "local")
|
||||
download_aggregator.finish(req.repo_id)
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
hf_progress.current_target.reset(target_token)
|
||||
with _active_installs_lock:
|
||||
_active_installs.discard(req.repo_id)
|
||||
|
||||
with _active_installs_lock:
|
||||
if req.repo_id in _active_installs:
|
||||
return {"status": "already_running", "repo_id": req.repo_id}
|
||||
_active_installs.add(req.repo_id)
|
||||
# Admission and task publication are one atomic generation boundary:
|
||||
# cancellation can never observe an admitted install without its task.
|
||||
_cancelled.discard(req.repo_id)
|
||||
try:
|
||||
task = loop.create_task(asyncio.to_thread(_do))
|
||||
_install_tasks.add(task)
|
||||
_install_tasks_by_repo[req.repo_id] = task
|
||||
except Exception:
|
||||
_active_installs.discard(req.repo_id)
|
||||
raise
|
||||
|
||||
def install_finished(completed: asyncio.Task) -> None:
|
||||
with _active_installs_lock:
|
||||
_install_tasks.discard(completed)
|
||||
if _install_tasks_by_repo.get(req.repo_id) is completed:
|
||||
_install_tasks_by_repo.pop(req.repo_id, None)
|
||||
|
||||
task.add_done_callback(install_finished)
|
||||
loop.create_task(asyncio.to_thread(_do))
|
||||
return {"status": "install_started", "repo_id": req.repo_id}
|
||||
|
||||
|
||||
async def cancel_install_and_wait(repo_id: str) -> None:
|
||||
"""Request cancellation and retain authority until its thread exits."""
|
||||
from worker.async_utils import drain_task # noqa: PLC0415
|
||||
|
||||
with _active_installs_lock:
|
||||
_cancelled.add(repo_id)
|
||||
_install_cooldowns.pop(repo_id, None)
|
||||
task = _install_tasks_by_repo.get(repo_id)
|
||||
if task is None:
|
||||
return
|
||||
try:
|
||||
# asyncio.to_thread cannot stop snapshot_download mid-file. Cancelling
|
||||
# its wrapper would only detach the thread, so wait until the blocking
|
||||
# call observes the flag or naturally returns.
|
||||
await drain_task(task)
|
||||
finally:
|
||||
with _active_installs_lock:
|
||||
current = _install_tasks_by_repo.get(repo_id)
|
||||
if current is None or current is task:
|
||||
_cancelled.discard(repo_id)
|
||||
|
||||
|
||||
@router.post("/models/install/cancel")
|
||||
async def cancel_install(req: InstallModelRequest):
|
||||
"""Request cancellation of an in-flight install (FDL-11).
|
||||
|
||||
@@ -41,8 +41,8 @@ def _load_models_from_yaml() -> list[dict]:
|
||||
except FileNotFoundError:
|
||||
logger.warning("models.yaml not found at %s — using empty catalog", _YAML_PATH)
|
||||
return []
|
||||
except Exception:
|
||||
logger.exception("Failed to load models.yaml — using empty catalog")
|
||||
except Exception as e:
|
||||
logger.error("Failed to load models.yaml: %s — using empty catalog", e)
|
||||
return []
|
||||
|
||||
|
||||
@@ -90,86 +90,17 @@ def get_model_catalog() -> ModelCatalog:
|
||||
|
||||
# ── Platform Detection ─────────────────────────────────────────────────────
|
||||
|
||||
def _target_worker():
|
||||
"""Selected live remote worker, or None when the catalog targets local."""
|
||||
try:
|
||||
from worker import routing, service # noqa: PLC0415
|
||||
|
||||
decision = routing.decide()
|
||||
plane = service.control_plane
|
||||
return plane.pool.get(decision.worker_id) if decision.remote and plane.pool else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _target_host() -> dict | None:
|
||||
"""Selected remote worker host, or None when the catalog targets local."""
|
||||
live = _target_worker()
|
||||
return dict(live.record.host or {}) if live is not None else None
|
||||
|
||||
|
||||
def _target_repo_inventory() -> tuple[str, set[str]] | None:
|
||||
"""Selected worker id and the catalog repositories it reports on disk."""
|
||||
live = _target_worker()
|
||||
if live is None:
|
||||
return None
|
||||
downloaded: set[str] = set()
|
||||
for capability in live.record.capabilities or []:
|
||||
if capability.get("downloaded"):
|
||||
downloaded.update(str(repo) for repo in capability.get("repo_ids") or [])
|
||||
return live.id, downloaded
|
||||
|
||||
|
||||
def _current_platform_tags() -> list[str]:
|
||||
"""Return platform tags that the current host supports.
|
||||
|
||||
Beyond the OS/arch tags, emits the acceleration family so both the
|
||||
``platforms`` gate and the ``curated_on`` recommendation field can key on
|
||||
it: ``cuda`` (NVIDIA — also present on ROCm hosts, where torch reports
|
||||
CUDA available, so existing ``platforms: [cuda]`` entries keep working),
|
||||
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all —
|
||||
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
|
||||
"""
|
||||
target = _target_host()
|
||||
if target is not None:
|
||||
target_os = {"windows": "win32", "darwin": "darwin"}.get(
|
||||
str(target.get("os") or "").lower(), "linux"
|
||||
)
|
||||
arch = str(target.get("arch") or "").lower()
|
||||
arch = {"amd64": "x86_64", "aarch64": "arm64"}.get(arch, arch)
|
||||
tags = [target_os, f"{target_os}-{arch}"]
|
||||
backend = ""
|
||||
if target.get("gpus"):
|
||||
backend = str(target["gpus"][0].get("backend") or "").lower()
|
||||
if backend:
|
||||
tags.append(backend)
|
||||
if backend == "rocm":
|
||||
tags.append("cuda")
|
||||
if not backend and not (target_os == "darwin" and arch == "arm64"):
|
||||
tags.append("cpu")
|
||||
return tags
|
||||
|
||||
"""Return platform tags that the current host supports."""
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
has_gpu = False
|
||||
try:
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
tags.append("cuda")
|
||||
has_gpu = True
|
||||
# ROCm torch masquerades through the CUDA API (torch.version.hip
|
||||
# set, torch.cuda.is_available() True when the AMD GPU is usable).
|
||||
# Grant 'rocm' only when BOTH hold: a ROCm *build* on a host whose
|
||||
# AMD GPU isn't actually visible must curate as CPU, not as a
|
||||
# working ROCm host.
|
||||
if getattr(torch.version, "hip", None):
|
||||
tags.append("rocm")
|
||||
except Exception:
|
||||
pass
|
||||
is_apple_silicon = sys.platform == "darwin" and arch == "arm64"
|
||||
if not has_gpu and not is_apple_silicon:
|
||||
tags.append("cpu")
|
||||
return tags
|
||||
|
||||
|
||||
@@ -181,30 +112,6 @@ def _model_supported(model: dict) -> bool:
|
||||
return bool(set(plats) & set(_current_platform_tags()))
|
||||
|
||||
|
||||
def _model_curated(model: dict, tags: "set[str] | None" = None) -> bool:
|
||||
"""True when this model is a curated "best for your system" pick here.
|
||||
|
||||
Driven by the ``curated_on`` field in models.yaml (``all`` matches every
|
||||
host). Required models are always curated — the preset must include them.
|
||||
"""
|
||||
if model.get("required"):
|
||||
return True
|
||||
curated_on = model.get("curated_on") or []
|
||||
if "all" in curated_on:
|
||||
return True
|
||||
if tags is None:
|
||||
tags = set(_current_platform_tags())
|
||||
# A ROCm host also carries the 'cuda' tag (HIP masquerades through the
|
||||
# CUDA API; the tag keeps `platforms: [cuda]` support-gates working). For
|
||||
# *curation* ignore it: `curated_on: [cuda]` means NVIDIA-tuned picks —
|
||||
# sweeping them into the AMD preset recommended models that are slow or
|
||||
# broken there. Entries that want AMD list 'rocm' explicitly (the CT2
|
||||
# large-v3 already does).
|
||||
if "rocm" in tags:
|
||||
tags = tags - {"cuda"}
|
||||
return bool(set(curated_on) & tags)
|
||||
|
||||
|
||||
# ── HF Cache Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def hf_cache_dir() -> str:
|
||||
@@ -287,7 +194,7 @@ def _hub_cache_roots() -> list[str]:
|
||||
HF stores repos under ``$HF_HUB_CACHE`` (== ``$HF_HOME/hub`` by default). When
|
||||
only ``HF_HOME`` (or the ``~/.cache/huggingface`` default) is known, the repos
|
||||
live under the ``hub`` subdir — so we probe both ``<dir>`` (the
|
||||
``HF_HUB_CACHE``-is-set case, e.g. VoiceStudio's Windows short cache) and
|
||||
``HF_HUB_CACHE``-is-set case, e.g. OmniVoice's Windows short cache) and
|
||||
``<dir>/hub`` (the ``HF_HOME``-only case). Without this the WinError-448
|
||||
fallback would look one level too high and miss the cache (CodeRabbit #137).
|
||||
"""
|
||||
@@ -504,52 +411,34 @@ def list_models():
|
||||
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
||||
walks when the frontend polls.
|
||||
"""
|
||||
platform_tags = _current_platform_tags()
|
||||
remote_inventory = _target_repo_inventory()
|
||||
target_key = remote_inventory[0] if remote_inventory else "local"
|
||||
cache_key = "models:" + target_key + ":" + ",".join(sorted(platform_tags))
|
||||
cached_response = _cached(cache_key)
|
||||
cached_response = _cached("models")
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
cached_by_repo: dict[str, dict] = {}
|
||||
if remote_inventory is not None:
|
||||
for model in KNOWN_MODELS:
|
||||
if model["repo_id"] in remote_inventory[1]:
|
||||
cached_by_repo[model["repo_id"]] = {
|
||||
"size_on_disk": int(float(model.get("size_gb") or 0) * _GIB),
|
||||
"last_accessed": None,
|
||||
"nb_files": 0,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
|
||||
# models still show as installed instead of offering a re-download.
|
||||
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
for entry in info.repos:
|
||||
cached_by_repo[entry.repo_id] = {
|
||||
"size_on_disk": entry.size_on_disk,
|
||||
"last_accessed": entry.last_accessed,
|
||||
"nb_files": entry.nb_files,
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): use a direct disk scan so installed
|
||||
# models still show as installed instead of offering a re-download.
|
||||
logger.warning("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_by_repo = _scan_cache_on_disk()
|
||||
|
||||
out = []
|
||||
host_tags = set(platform_tags)
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
on_disk = (
|
||||
m["repo_id"] in remote_inventory[1]
|
||||
if remote_inventory is not None
|
||||
else cached is not None and cached["size_on_disk"] > 0
|
||||
)
|
||||
on_disk = cached is not None and cached["size_on_disk"] > 0
|
||||
# A size-positive cache can still be a truncated download (config landed,
|
||||
# weight shard didn't). Treat that as not-installed + incomplete so the
|
||||
# wizard re-offers the download instead of stranding the user (#622).
|
||||
incomplete = on_disk and remote_inventory is None and not cache_is_complete(m)
|
||||
incomplete = on_disk and not cache_is_complete(m)
|
||||
out.append({
|
||||
**m,
|
||||
"installed": on_disk and not incomplete,
|
||||
@@ -557,114 +446,104 @@ def list_models():
|
||||
"size_on_disk_bytes": cached["size_on_disk"] if cached else 0,
|
||||
"nb_files": cached["nb_files"] if cached else 0,
|
||||
"supported": _model_supported(m),
|
||||
# Curated "best for your system" pick (curated_on in models.yaml) —
|
||||
# drives the recommended badge in the wizard and Settings model store.
|
||||
"curated": _model_curated(m, host_tags),
|
||||
})
|
||||
response = {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": "" if remote_inventory is not None else hf_cache_dir(),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
# Free space on the cache volume, so the Model Store header can warn
|
||||
# BEFORE an "Install all" overruns the disk (pairs with the per-install
|
||||
# disk_space_error guard in setup/download.py).
|
||||
"disk_free_gb": None if remote_inventory is not None else round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": platform_tags,
|
||||
"disk_free_gb": round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
}
|
||||
_set_cache(cache_key, response)
|
||||
_set_cache("models", response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/setup/recommendations")
|
||||
def recommendations():
|
||||
"""Return a curated model preset for the caller's device + architecture.
|
||||
"""Return a curated model preset for the caller's device + architecture."""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
Data-driven from the ``curated_on`` field in models.yaml — adding or
|
||||
retargeting a curated pick is a catalog edit, not a code change. Only the
|
||||
TTS model is required; the ASR picks here are the optional "best for your
|
||||
system" set the wizard and Settings surface for on-demand install.
|
||||
"""
|
||||
tags = set(_current_platform_tags())
|
||||
target_os = "darwin" if "darwin" in tags else "win32" if "win32" in tags else "linux"
|
||||
target_arch = next((tag.split("-", 1)[1] for tag in tags if tag.startswith(target_os + "-")), _platform.machine())
|
||||
is_mac_arm = target_os == "darwin" and target_arch == "arm64"
|
||||
is_mac_intel = target_os == "darwin" and target_arch == "x86_64"
|
||||
is_linux = target_os == "linux"
|
||||
is_windows = target_os == "win32"
|
||||
has_cuda = "cuda" in tags and "rocm" not in tags
|
||||
has_rocm = "rocm" in tags
|
||||
has_cuda = False
|
||||
try:
|
||||
import torch
|
||||
has_cuda = bool(torch.cuda.is_available())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
device_label = f"Apple Silicon ({target_arch})"
|
||||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
device_label = "Windows x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
device_label = "Windows x64" + (" + CUDA" if has_cuda else "")
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else "")
|
||||
else:
|
||||
device_label = f"{target_os} / {target_arch}"
|
||||
|
||||
# Curated preset for this host, in catalog order (required entries lead).
|
||||
curated = [
|
||||
m for m in KNOWN_MODELS
|
||||
if _model_curated(m, tags) and _model_supported(m)
|
||||
]
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
|
||||
# Pick the preset for this device.
|
||||
if is_mac_arm:
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"mlx-community/whisper-large-v3-mlx",
|
||||
"mlx-community/whisper-large-v3-turbo",
|
||||
"mlx-community/Kokoro-82M-bf16",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
rationale = (
|
||||
"Apple Silicon preset: VoiceStudio (required) covers multilingual TTS + "
|
||||
"cloning on its own. The optional picks are Metal-native: MLX Whisper "
|
||||
"large-v3 for dubbing/transcription, Whisper Turbo (MLX) + Parakeet TDT "
|
||||
"v3 for live dictation, Kokoro + KittenTTS for instant English TTS."
|
||||
)
|
||||
elif has_cuda:
|
||||
rationale = (
|
||||
"NVIDIA preset: VoiceStudio (required) runs standalone. Optional ASR picks "
|
||||
"are CUDA-accelerated via CTranslate2 — Whisper large-v3 for dubbing "
|
||||
"(best word timestamps), Turbo for 5× faster transcription, Parakeet TDT "
|
||||
"v3 for live dictation. KittenTTS adds CPU-realtime English."
|
||||
)
|
||||
elif has_rocm:
|
||||
rationale = (
|
||||
"AMD/ROCm preset: VoiceStudio (required) runs standalone. CTranslate2 has "
|
||||
"no ROCm backend, so the PyTorch Whisper large-v3 build is the "
|
||||
"GPU-accelerated ASR route; faster-whisper works on CPU, and Parakeet "
|
||||
"TDT v3 handles live dictation."
|
||||
"Apple Silicon gets the full stack: OmniVoice for multilingual clone + "
|
||||
"WhisperX (faster-whisper weights) for cross-platform ASR + MLX-Whisper "
|
||||
"for the Apple-optimised speedup + Whisper Turbo (5× faster) for live "
|
||||
"dictation + Kokoro (mlx-audio) for fast local English + KittenTTS as "
|
||||
"a CPU-realtime backup."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"CPU preset: VoiceStudio (required) runs standalone. Optional picks favour "
|
||||
"speed on CPU — Whisper large-v3 (int8) for accuracy, Turbo when speed "
|
||||
"matters, Parakeet TDT v3 (int8 ONNX) for live dictation, KittenTTS for "
|
||||
"instant English TTS."
|
||||
)
|
||||
recommended_ids = [
|
||||
"k2-fsa/OmniVoice",
|
||||
"Systran/faster-whisper-large-v3",
|
||||
"KittenML/kitten-tts-mini-0.8",
|
||||
]
|
||||
if has_cuda:
|
||||
recommended_ids.append("openai/whisper-large-v3")
|
||||
rationale = (
|
||||
"Cross-platform stack + pytorch-whisper as a CUDA-accelerated "
|
||||
"ASR fallback. MLX / mlx-audio are Apple-Silicon-only and don't "
|
||||
"apply here."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"Cross-platform stack: OmniVoice (multilingual clone) + WhisperX "
|
||||
"(faster-whisper ASR) + KittenTTS (English turbo, CPU-realtime). "
|
||||
"Clean install, every model runs on CPU."
|
||||
)
|
||||
|
||||
remote_inventory = _target_repo_inventory()
|
||||
known_by_id = {m["repo_id"]: m for m in KNOWN_MODELS}
|
||||
cached_ids: set[str] = set()
|
||||
if remote_inventory is not None:
|
||||
cached_ids = remote_inventory[1]
|
||||
else:
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
|
||||
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
info = scan_cache_dir()
|
||||
cached_ids = {
|
||||
entry.repo_id for entry in info.repos if entry.size_on_disk > 0
|
||||
}
|
||||
except Exception as e:
|
||||
# WinError-448 fallback (#117/#118): recommend based on the disk scan.
|
||||
logger.debug("scan_cache_dir failed (%s); using disk fallback", e)
|
||||
cached_ids = set(_scan_cache_on_disk().keys())
|
||||
|
||||
entries = []
|
||||
for meta in curated:
|
||||
rid = meta["repo_id"]
|
||||
for rid in recommended_ids:
|
||||
meta = known_by_id.get(rid, {})
|
||||
# Mirror /models: a truncated cache (weights missing) is not installed, so
|
||||
# the wizard counts it toward the remaining download instead of "all set".
|
||||
installed = rid in cached_ids and (
|
||||
remote_inventory is not None or cache_is_complete(meta)
|
||||
)
|
||||
installed = rid in cached_ids and cache_is_complete(meta or {"repo_id": rid})
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
@@ -680,8 +559,8 @@ def recommendations():
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"os": target_os,
|
||||
"arch": target_arch,
|
||||
"os": sys.platform,
|
||||
"arch": _platform.machine(),
|
||||
"is_mac_arm": is_mac_arm,
|
||||
"is_mac_intel": is_mac_intel,
|
||||
"is_linux": is_linux,
|
||||
|
||||
@@ -62,11 +62,6 @@ def setup_status():
|
||||
_MIN_NVIDIA_DRIVER = 555
|
||||
_RAM_FAIL_GB = 8
|
||||
_RAM_WARN_GB = 12
|
||||
# Installed DIMMs never fully reach the OS: firmware, integrated graphics and
|
||||
# kernel reservations shave off up to ~7% (an "8 GB" Windows laptop reports
|
||||
# ~7.8 GB usable). Thresholds are compared with this allowance applied so the
|
||||
# machines a threshold is meant to admit aren't blocked by that gap (#1618).
|
||||
_RAM_RESERVED_ALLOWANCE = 0.93
|
||||
|
||||
|
||||
def _run_cmd(args: list[str], timeout: float = 2.0) -> tuple[int, str]:
|
||||
@@ -188,7 +183,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
"""Host/port of the Hugging Face endpoint actually in effect.
|
||||
|
||||
Mirror-aware: restricted-network users (e.g. behind the Great Firewall)
|
||||
point HF_ENDPOINT at a mirror via Model Catalogue → Models → Hugging Face
|
||||
point HF_ENDPOINT at a mirror via Settings → Models → Hugging Face
|
||||
mirror. Probing hardcoded huggingface.co would fail them even when their
|
||||
configured mirror works fine.
|
||||
"""
|
||||
@@ -196,8 +191,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
from core.failure import configured_hf_mirror
|
||||
mirror = configured_hf_mirror()
|
||||
except Exception:
|
||||
logger.warning("Configured Hugging Face endpoint could not be read")
|
||||
return "", 0
|
||||
mirror = ""
|
||||
if mirror:
|
||||
try:
|
||||
from urllib.parse import urlsplit
|
||||
@@ -205,10 +199,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
if u.hostname:
|
||||
return u.hostname, u.port or (80 if u.scheme == "http" else 443)
|
||||
except Exception:
|
||||
logger.warning("Configured Hugging Face endpoint could not be parsed")
|
||||
return "", 0
|
||||
logger.warning("Configured Hugging Face endpoint has no host")
|
||||
return "", 0
|
||||
pass
|
||||
return "huggingface.co", 443
|
||||
|
||||
|
||||
@@ -281,14 +272,6 @@ def _network_check() -> dict:
|
||||
|
||||
# Manual mode (explicit endpoint) — probe exactly what the user chose.
|
||||
net_host, net_port = _hf_endpoint_host()
|
||||
if not net_host:
|
||||
return {
|
||||
"id": "network", "label": "Network (configured endpoint)",
|
||||
"status": "warn",
|
||||
"detail": "The configured Hugging Face endpoint could not be validated.",
|
||||
"fix": "Review the endpoint in Model Catalogue → Models, then re-check.",
|
||||
"mirror_reachable": False,
|
||||
}
|
||||
net_ok = _probe_network(net_host, net_port)
|
||||
mirror_reachable = False
|
||||
if not net_ok and net_host == "huggingface.co":
|
||||
@@ -357,28 +340,17 @@ def preflight():
|
||||
|
||||
# ── RAM
|
||||
ram = _ram_gb()
|
||||
# Escape hatch (#1618): a preflight should inform, not brick setup —
|
||||
# OMNIVOICE_RAM_PREFLIGHT=0 downgrades the hard block to a warning for
|
||||
# users who accept the OOM risk. Same opt-out shape as
|
||||
# OMNIVOICE_ASR_VRAM_PREFLIGHT.
|
||||
ram_gate = os.environ.get(
|
||||
"OMNIVOICE_RAM_PREFLIGHT", "1"
|
||||
).strip().lower() not in ("0", "false", "no")
|
||||
if ram == 0:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", "Could not detect system RAM.",
|
||||
"Install psutil in the backend environment or ignore this warning.",
|
||||
)
|
||||
elif ram < _RAM_FAIL_GB * _RAM_RESERVED_ALLOWANCE:
|
||||
elif ram < _RAM_FAIL_GB:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"fail" if ram_gate else "warn",
|
||||
f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||||
"The app will OOM on first dub. Close other apps or upgrade RAM."
|
||||
if ram_gate else
|
||||
"RAM check disabled via OMNIVOICE_RAM_PREFLIGHT=0 — dubbing may "
|
||||
"OOM on this machine.",
|
||||
"fail", f"{ram:.1f} GB total (need ≥ {_RAM_FAIL_GB} GB)",
|
||||
"The app will OOM on first dub. Close other apps or upgrade RAM.",
|
||||
)
|
||||
elif ram < _RAM_WARN_GB * _RAM_RESERVED_ALLOWANCE:
|
||||
elif ram < _RAM_WARN_GB:
|
||||
ram_status, ram_detail, ram_fix = (
|
||||
"warn", f"{ram:.1f} GB total ({_RAM_WARN_GB}+ GB recommended)",
|
||||
"Long videos may hit swap. Keep other apps closed during dubbing.",
|
||||
@@ -512,10 +484,10 @@ def preflight():
|
||||
elif _rs == "unavailable":
|
||||
r_status, r_detail, r_fix = "fail", (
|
||||
f"{_eng} can't run on this host: {_why or 'needs a GPU this machine lacks'}"), (
|
||||
"Select an engine with a CPU path in Model Catalogue → Engines.")
|
||||
"Select an engine with a CPU path in Settings → Engines.")
|
||||
else: # "none" / unknown
|
||||
r_status, r_detail, r_fix = "warn", "No active TTS engine resolved for routing.", (
|
||||
"Pick an engine in Model Catalogue → Engines.")
|
||||
"Pick an engine in Settings → Engines.")
|
||||
checks.append({
|
||||
"id": "gpu_routing", "label": "Active engine routing",
|
||||
"status": r_status, "detail": r_detail, "fix": r_fix,
|
||||
@@ -580,4 +552,6 @@ async def setup_warmup():
|
||||
logger.warning("setup/warmup: model load failed: %s", e)
|
||||
|
||||
loop.create_task(_do_warmup())
|
||||
from core.analytics import capture as _ph_capture
|
||||
_ph_capture("setup_completed", {})
|
||||
return {"status": "warmup_started"}
|
||||
|
||||
@@ -5,11 +5,10 @@ SoniTranslate sidecar integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from api.dependencies import require_native_access
|
||||
from services import sonitranslate as soni
|
||||
|
||||
router = APIRouter(prefix="/engines/sonitranslate", tags=["SoniTranslate"])
|
||||
@@ -64,54 +63,31 @@ async def sonitranslate_stop():
|
||||
|
||||
|
||||
class DubRequest(BaseModel):
|
||||
video_authorization: str
|
||||
video_path: str
|
||||
target_language: str = "Spanish (es)"
|
||||
source_language: str = "Automatic detection"
|
||||
tts_voice: str = "es-ES-AlvaroNeural-Male"
|
||||
max_speakers: int = 1
|
||||
output_authorization: str | None = None
|
||||
output_dir: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/dub", dependencies=[Depends(require_native_access)])
|
||||
@router.post("/dub")
|
||||
async def sonitranslate_dub(body: DubRequest):
|
||||
"""Run full dubbing pipeline via SoniTranslate.
|
||||
|
||||
Transcribes, translates, generates TTS, and mixes audio.
|
||||
Returns the path to the dubbed output video.
|
||||
|
||||
KNOWN PROVENANCE GAP (#1169, documented — not silently ignored): the
|
||||
dubbed audio is synthesized and muxed entirely inside the external
|
||||
SoniTranslate sidecar (its own venv + gradio pipeline, Edge-TTS voices),
|
||||
which hands back a finished video file. VoiceStudio's tensor-stage
|
||||
mark_synthetic chokepoint never sees that audio; marking it would require
|
||||
a demux → embed → re-mux post-pass on the sidecar's output, which is a
|
||||
lossy re-encode of a pipeline we don't control. This opt-in engine
|
||||
(explicit install + start) is therefore NOT covered by the invisible
|
||||
AudioSeal provenance mark that every built-in synthesis path carries.
|
||||
"""
|
||||
try:
|
||||
from core.path_authorization import PathAuthorizationError, consume
|
||||
|
||||
try:
|
||||
video_path = consume(body.video_authorization, "soni_input")
|
||||
output_dir = (
|
||||
consume(body.output_authorization, "soni_output_dir")
|
||||
if body.output_authorization
|
||||
else None
|
||||
)
|
||||
except PathAuthorizationError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
result = await soni.dub_video(
|
||||
video_path=video_path,
|
||||
video_path=body.video_path,
|
||||
target_language=body.target_language,
|
||||
source_language=body.source_language,
|
||||
tts_voice=body.tts_voice,
|
||||
max_speakers=body.max_speakers,
|
||||
output_dir=output_dir,
|
||||
output_dir=body.output_dir,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("SoniTranslate dub failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Discovery contract for VoiceStudio's local speech platform.
|
||||
|
||||
Interfaces should discover this document instead of hard-coding whichever
|
||||
dictation route the desktop happens to use. Endpoint URLs are relative so the
|
||||
same response works on loopback, a tailnet GPU host, and a reverse proxy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from core.version import APP_VERSION
|
||||
|
||||
router = APIRouter(tags=["Speech Platform"])
|
||||
|
||||
SPEECH_PROTOCOL = "voicestudio.speech.v1"
|
||||
STREAM_PATH = "/v1/audio/transcriptions/stream"
|
||||
|
||||
|
||||
class EndpointCapability(BaseModel):
|
||||
path: str
|
||||
transport: Literal["http", "websocket", "mcp-streamable-http", "mcp-stdio"]
|
||||
method: str | None = None
|
||||
protocol: str | None = None
|
||||
|
||||
|
||||
class StreamInputCapability(BaseModel):
|
||||
framing: Literal["binary"] = "binary"
|
||||
formats: list[str]
|
||||
default_format: str
|
||||
sample_rate_query: str = "sr"
|
||||
end_control: dict[str, str]
|
||||
|
||||
|
||||
class StreamOutputCapability(BaseModel):
|
||||
framing: Literal["json"] = "json"
|
||||
events: list[str]
|
||||
final_kinds: list[str]
|
||||
|
||||
|
||||
class SpeechFeatureCapabilities(BaseModel):
|
||||
batch_transcription: bool = True
|
||||
streaming_transcription: bool = True
|
||||
partial_transcripts: bool = True
|
||||
utterance_finals: bool = True
|
||||
session_summary: bool = True
|
||||
word_timestamps: bool = True
|
||||
local_refinement: bool = True
|
||||
acoustic_echo_cancellation: bool = True
|
||||
native_dictation_control: bool = False
|
||||
|
||||
|
||||
class SpeechAuthCapabilities(BaseModel):
|
||||
loopback: Literal["none"] = "none"
|
||||
remote: Literal["bearer"] = "bearer"
|
||||
header: str = "Authorization: Bearer <OMNIVOICE_API_KEY>"
|
||||
browser_session_endpoint: str = "/api/auth/session"
|
||||
websocket_ticket_endpoint: str = "/api/auth/ws-ticket"
|
||||
websocket_ticket_query_parameter: Literal["ws_ticket"] = "ws_ticket"
|
||||
|
||||
|
||||
class SpeechCapabilities(BaseModel):
|
||||
schema_: Literal["voicestudio.speech-capabilities"] = Field(
|
||||
default="voicestudio.speech-capabilities",
|
||||
serialization_alias="schema",
|
||||
)
|
||||
protocol: Literal["voicestudio.speech.v1"] = SPEECH_PROTOCOL
|
||||
protocol_version: Literal["1.0"] = "1.0"
|
||||
service: str = "VoiceStudio"
|
||||
service_version: str = APP_VERSION
|
||||
local_first: bool = True
|
||||
endpoints: dict[str, EndpointCapability]
|
||||
stream_input: StreamInputCapability
|
||||
stream_output: StreamOutputCapability
|
||||
features: SpeechFeatureCapabilities
|
||||
authentication: SpeechAuthCapabilities
|
||||
|
||||
|
||||
def speech_capabilities() -> SpeechCapabilities:
|
||||
"""Return the stable, side-effect-free integration contract."""
|
||||
endpoints = {
|
||||
"capabilities": EndpointCapability(
|
||||
path="/.well-known/voicestudio-speech",
|
||||
transport="http",
|
||||
method="GET",
|
||||
),
|
||||
"batch_transcription": EndpointCapability(
|
||||
path="/v1/audio/transcriptions",
|
||||
transport="http",
|
||||
method="POST",
|
||||
protocol="openai.audio.transcriptions",
|
||||
),
|
||||
"streaming_transcription": EndpointCapability(
|
||||
path=STREAM_PATH,
|
||||
transport="websocket",
|
||||
protocol=SPEECH_PROTOCOL,
|
||||
),
|
||||
"mcp": EndpointCapability(
|
||||
path="/mcp",
|
||||
transport="mcp-streamable-http",
|
||||
method="POST",
|
||||
protocol="mcp",
|
||||
),
|
||||
"mcp_stdio": EndpointCapability(
|
||||
path="python -m backend.mcp_shim",
|
||||
transport="mcp-stdio",
|
||||
protocol="mcp",
|
||||
),
|
||||
}
|
||||
native_control = False
|
||||
try:
|
||||
control_port = int(os.environ.get("VOICESTUDIO_SPEECH_CONTROL_PORT", ""))
|
||||
except (TypeError, ValueError):
|
||||
control_port = 0
|
||||
if 0 < control_port <= 65535:
|
||||
native_control = True
|
||||
endpoints["native_dictation_control"] = EndpointCapability(
|
||||
path=f"http://127.0.0.1:{control_port}/v1/capabilities",
|
||||
transport="http",
|
||||
method="GET",
|
||||
protocol=SPEECH_PROTOCOL,
|
||||
)
|
||||
|
||||
return SpeechCapabilities(
|
||||
endpoints=endpoints,
|
||||
stream_input=StreamInputCapability(
|
||||
formats=[
|
||||
"audio/pcm;encoding=s16le;channels=1",
|
||||
"audio/webm;codecs=opus",
|
||||
],
|
||||
default_format="audio/webm;codecs=opus",
|
||||
end_control={"type": "input_audio.end"},
|
||||
),
|
||||
stream_output=StreamOutputCapability(
|
||||
events=["session.started", "status", "partial", "final", "error"],
|
||||
final_kinds=["utterance", "summary"],
|
||||
),
|
||||
features=SpeechFeatureCapabilities(
|
||||
native_dictation_control=native_control,
|
||||
),
|
||||
authentication=SpeechAuthCapabilities(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/.well-known/voicestudio-speech",
|
||||
response_model=SpeechCapabilities,
|
||||
response_model_by_alias=True,
|
||||
)
|
||||
@router.get(
|
||||
"/v1/audio/capabilities",
|
||||
response_model=SpeechCapabilities,
|
||||
response_model_by_alias=True,
|
||||
)
|
||||
async def get_speech_capabilities() -> SpeechCapabilities:
|
||||
"""Advertise batch, streaming, and agent-facing speech transports."""
|
||||
return speech_capabilities()
|
||||
@@ -14,7 +14,6 @@ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from fastapi.responses import Response
|
||||
|
||||
from services.ffmpeg_utils import find_ffmpeg, spawn_subprocess
|
||||
from core.http_headers import content_disposition
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -33,15 +32,7 @@ async def stories_encode(
|
||||
format: str = Form("mp3"),
|
||||
bitrate: str = Form("192k"),
|
||||
):
|
||||
"""Transcode an uploaded WAV to MP3/M4B/OGG and return the encoded bytes.
|
||||
|
||||
Provenance note (#1169): this endpoint is a pure TRANSCODER, not a
|
||||
synthesis producer — it never calls a TTS engine, so it must not call
|
||||
mark_synthetic (the upload may be arbitrary user audio, and marking human
|
||||
speech as synthetic would be wrong). Audio the Stories Editor stitched
|
||||
from VoiceStudio generations is already marked at its producing route, and
|
||||
the AudioSeal mark survives the lossy encode here.
|
||||
"""
|
||||
"""Transcode an uploaded WAV to MP3/M4B/OGG and return the encoded bytes."""
|
||||
fmt = (format or "mp3").lower()
|
||||
if fmt not in _FORMATS:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported format: {format}")
|
||||
@@ -77,7 +68,7 @@ async def stories_encode(
|
||||
return Response(
|
||||
content=encoded,
|
||||
media_type=mime,
|
||||
headers={"Content-Disposition": content_disposition(f"story.{ext}")},
|
||||
headers={"Content-Disposition": f'attachment; filename="story.{ext}"'},
|
||||
)
|
||||
finally:
|
||||
for p in (in_path, out_path):
|
||||
|
||||
+66
-249
@@ -11,28 +11,27 @@ from core.prefs import set_ as prefs_set, delete as prefs_delete
|
||||
from services import network_share
|
||||
from services import tailscale as _tailscale
|
||||
from api.schemas import SysinfoResponse, SystemInfoResponse, ModelStatusResponse
|
||||
from api.dependencies import is_loopback, require_admin, require_admin_action
|
||||
from api.dependencies import require_loopback
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
import torch
|
||||
import shutil
|
||||
|
||||
from core.config import OUTPUTS_DIR, DATA_DIR, CRASH_LOG_PATH, LOG_PATH, IDLE_TIMEOUT_SECONDS
|
||||
from core.version import APP_VERSION
|
||||
from core.logging_utils import log_safe
|
||||
from core.public_errors import public_failure
|
||||
from services.model_manager import get_model_status, get_best_device, resolve_omnivoice_checkpoint
|
||||
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
|
||||
|
||||
# Router-level admin gate. Every route mounted on `router` (GET + POST,
|
||||
# present and future) is gated by `require_admin`: desktop requests must be
|
||||
# loopback; server-mode mutations require the long API key. This closes the trust
|
||||
# Router-level loopback gate. Every route mounted on `router` (GET + POST,
|
||||
# present and future) is gated by `require_loopback`, which 403s any request
|
||||
# whose `client.host` is not a loopback address. This closes the same trust
|
||||
# boundary that PR #81 only patched on `/system/set-env` and that the
|
||||
# 260518-ivy deferred-items file enumerated for follow-up: /model/unload/*,
|
||||
# /system/logs/clear, /system/logs/tauri/clear, /system/flush-memory,
|
||||
# /clean-audio (POSTs) plus the read-side info-disclosure routes
|
||||
# /system/info, /system/logs, /system/logs/tauri, /system/logs/stream.
|
||||
# Native Tauri/dev callers remain loopback and need no credential.
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
# This router only ever serves the local Tauri shell and the dev frontend
|
||||
# at http://127.0.0.1:3901 — both are loopback origins.
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
# Cache device checks at module load — they don't change at runtime
|
||||
@@ -203,22 +202,8 @@ def system_info():
|
||||
"""
|
||||
try:
|
||||
_ffmpeg = find_ffmpeg()
|
||||
from services import model_manager as _mm
|
||||
from core import prefs as _prefs_mod
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"generate_timeout_s": _mm.GPU_JOB_TIMEOUT_S,
|
||||
"cpu_generate_timeout_s": _mm.CPU_JOB_TIMEOUT_S,
|
||||
# #1787 review fix: a saved prefs.json value for either key can be
|
||||
# silently shadowed by an external env var (os.environ.setdefault
|
||||
# in core.prefs.restore_env is a no-op when one is already
|
||||
# present) — the Settings panel must say so rather than promise a
|
||||
# restart will apply a value that never will.
|
||||
"generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S"),
|
||||
"cpu_generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
|
||||
"OMNIVOICE_CPU_GENERATE_TIMEOUT_S"),
|
||||
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": CRASH_LOG_PATH,
|
||||
@@ -254,11 +239,6 @@ def system_info():
|
||||
logger.exception("system_info failed — returning safe defaults")
|
||||
return {
|
||||
"app_version": APP_VERSION,
|
||||
"generate_timeout_s": 300.0,
|
||||
"cpu_generate_timeout_s": 600.0,
|
||||
"generate_timeout_shadowed": False,
|
||||
"cpu_generate_timeout_shadowed": False,
|
||||
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
|
||||
"data_dir": DATA_DIR,
|
||||
"outputs_dir": OUTPUTS_DIR,
|
||||
"crash_log_path": str(CRASH_LOG_PATH),
|
||||
@@ -286,7 +266,7 @@ def system_info():
|
||||
"backend_port": network_share.backend_port(),
|
||||
"share_port_base": network_share.share_port_base(),
|
||||
"ui_port": _ui_port(),
|
||||
"error": "System information is temporarily unavailable; check the backend log for details.",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@@ -308,7 +288,7 @@ def _tauri_log_candidates():
|
||||
`com.debpalash.omnivoice-studio` (frontend/src-tauri/tauri.conf.json).
|
||||
- backend.rs::backend_log_path() redirects the spawned backend's
|
||||
stdout/stderr to `backend.log` / `backend_err.log` under
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/VoiceStudio` falling
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/OmniVoice` falling
|
||||
back to `~/.local/state/OmniVoice` (Linux), and
|
||||
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
|
||||
startup banners and hard-crash tracebacks land — keep all three OS
|
||||
@@ -319,7 +299,7 @@ def _tauri_log_candidates():
|
||||
if sys.platform == "darwin":
|
||||
return [
|
||||
os.path.join(home, "Library/Logs", bid, "tauri.log"),
|
||||
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
|
||||
os.path.join(home, "Library/Logs", bid, "OmniVoice Studio.log"),
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend.log"),
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
|
||||
]
|
||||
@@ -383,14 +363,7 @@ async def system_logs_tauri(tail: int = 200):
|
||||
lines, total = await asyncio.to_thread(_tail_file, p, tail)
|
||||
return {"lines": lines, "path": p, "exists": True, "total_lines": total}
|
||||
except Exception as e:
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Could not read Tauri log",
|
||||
e,
|
||||
response="Could not read the Tauri log; check the backend log for details.",
|
||||
traceback=True,
|
||||
)
|
||||
return {"lines": [], "path": p, "exists": True, "error": error}
|
||||
return {"lines": [], "path": p, "exists": True, "error": str(e)}
|
||||
return {"lines": [], "path": None, "exists": False, "candidates": candidates}
|
||||
|
||||
|
||||
@@ -419,18 +392,13 @@ async def stream_logs(
|
||||
if not path or not os.path.exists(path):
|
||||
raise HTTPException(status_code=404, detail=f"Log file not found for source={source}")
|
||||
|
||||
try:
|
||||
initial_position = os.path.getsize(path)
|
||||
except OSError as exc:
|
||||
logger.warning("Log stream could not determine its starting position")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="The log stream could not be started. Retry after checking file permissions.",
|
||||
) from exc
|
||||
|
||||
async def _generate():
|
||||
"""Yield SSE events whenever new lines appear in the log file."""
|
||||
last_pos = initial_position
|
||||
last_pos = 0
|
||||
try:
|
||||
last_pos = os.path.getsize(path)
|
||||
except Exception:
|
||||
pass
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
@@ -485,12 +453,8 @@ async def clear_system_logs():
|
||||
for key in ("crash_log_acked", "crash_log_acked_size"):
|
||||
try:
|
||||
prefs_delete(key)
|
||||
except Exception as exc:
|
||||
logger.warning("Cleared logs but could not reset crash acknowledgement state")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Logs were cleared, but notification state could not be reset. Retry the clear operation.",
|
||||
) from exc
|
||||
except Exception:
|
||||
pass
|
||||
return {"cleared": cleared_any}
|
||||
|
||||
|
||||
@@ -504,20 +468,14 @@ def _truncate_file(path: str):
|
||||
async def clear_tauri_logs():
|
||||
"""Truncate whichever Tauri-side log files we know about. OS-level rotation may recreate them."""
|
||||
cleared = []
|
||||
failed = 0
|
||||
for p in _tauri_log_candidates():
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
await asyncio.to_thread(_truncate_file, p)
|
||||
cleared.append(p)
|
||||
except OSError:
|
||||
failed += 1
|
||||
if failed:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="One or more desktop log files could not be cleared. Close any app using them and retry.",
|
||||
)
|
||||
return {"cleared": cleared, "failed": 0}
|
||||
except Exception:
|
||||
pass
|
||||
return {"cleared": cleared}
|
||||
|
||||
@router.get("/sysinfo", response_model=SysinfoResponse)
|
||||
def get_sys_info():
|
||||
@@ -563,10 +521,9 @@ async def flush_memory(unload_model: bool = False):
|
||||
if unload_model:
|
||||
import services.model_manager as mm
|
||||
async with mm._model_lock:
|
||||
# Also drops the clone-prompt side cache, which this path used to
|
||||
# leave resident — an "unload" that kept the encoded reference
|
||||
# tensors belonging to the model it just released (#1495).
|
||||
freed_model = mm.unload_shared_model()
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
freed_model = True
|
||||
|
||||
# Multi-pass GC to break reference cycles
|
||||
gc.collect(generation=2)
|
||||
@@ -575,25 +532,15 @@ async def flush_memory(unload_model: bool = False):
|
||||
|
||||
free_vram()
|
||||
|
||||
# Snapshot after flush. Two numbers, because one of them is a lie by
|
||||
# omission: `memory_allocated` counts live tensors only, so it reads ~0
|
||||
# after an unload while nvidia-smi still shows gigabytes — which is exactly
|
||||
# the report we keep getting ("flush says it worked, the GPU says it
|
||||
# didn't"). `memory_reserved` is what the caching allocator holds from the
|
||||
# driver, and the gap between reserved and the driver's own figure is the
|
||||
# CUDA context plus kernel workspaces, which no in-process call can return.
|
||||
# Snapshot after flush
|
||||
vram_after = 0.0
|
||||
vram_reserved = 0.0
|
||||
try:
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
driver = getattr(torch.mps, "driver_allocated_memory", None)
|
||||
if driver:
|
||||
vram_after = driver() / (1024**3)
|
||||
current = getattr(torch.mps, "current_allocated_memory", None)
|
||||
vram_reserved = (current() / (1024**3)) if current else vram_after
|
||||
elif torch.cuda.is_available():
|
||||
vram_after = torch.cuda.memory_allocated() / (1024**3)
|
||||
vram_reserved = torch.cuda.memory_reserved() / (1024**3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -604,7 +551,6 @@ async def flush_memory(unload_model: bool = False):
|
||||
"unloaded_model": freed_model,
|
||||
"ram_after": round(ram_after, 2),
|
||||
"vram_after": round(vram_after, 2),
|
||||
"vram_reserved": round(vram_reserved, 2),
|
||||
}
|
||||
|
||||
|
||||
@@ -706,7 +652,7 @@ def system_notifications():
|
||||
"id": "disk-low",
|
||||
"level": "warn",
|
||||
"title": f"Low disk space ({free_gb:.1f} GB free)",
|
||||
"message": "VoiceStudio needs disk space for models, audio, and temp files.",
|
||||
"message": "OmniVoice needs disk space for models, audio, and temp files.",
|
||||
"action": None,
|
||||
})
|
||||
except Exception:
|
||||
@@ -726,41 +672,6 @@ def system_notifications():
|
||||
"action": None,
|
||||
})
|
||||
|
||||
# 5a. The previous backend RUN died without a clean shutdown (#1164) —
|
||||
# the run-sentinel record is the browser/dev/Docker equivalent of the
|
||||
# desktop shell's crash marker. The id embeds detected_at so a NEW
|
||||
# unclean death re-notifies even after an older one was dismissed.
|
||||
# Coexists with the crash-last-session note below (that one covers
|
||||
# caught unhandled exceptions; this one covers process death).
|
||||
try:
|
||||
from core import run_sentinel
|
||||
|
||||
rec = run_sentinel.newest_record()
|
||||
if rec is not None and not rec[1]:
|
||||
record = rec[0]
|
||||
last = record.get("last_activity") or {}
|
||||
doing = f" Last activity: {last.get('kind')}." if last.get("kind") else ""
|
||||
notes.append({
|
||||
# ms resolution: two deaths in the same second must still get
|
||||
# distinct ids, or the second one stays invisible post-ack.
|
||||
"id": f"last-run-crash-{int((record.get('detected_at') or 0) * 1000)}",
|
||||
"level": "error",
|
||||
"title": "The backend did not shut down cleanly last run",
|
||||
"message": (
|
||||
"The previous backend process ended without a clean "
|
||||
"shutdown — it likely crashed or was killed (for example "
|
||||
"by the OS running out of memory)." + doing +
|
||||
" A log tail was captured for bug reports."
|
||||
),
|
||||
"action": {
|
||||
"label": "View logs",
|
||||
"type": "navigate",
|
||||
"target": "settings",
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Previous-run crash record could not be checked")
|
||||
|
||||
# 5. A previous session logged a crash the user never saw.
|
||||
# crash_log grew past the last acknowledged size AND predates this
|
||||
# process — i.e. it happened last run, not just now (errors from the
|
||||
@@ -782,7 +693,7 @@ def system_notifications():
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
logger.warning("Previous-session crash log could not be checked")
|
||||
pass
|
||||
|
||||
return {"notifications": notes, "count": len(notes)}
|
||||
|
||||
@@ -815,33 +726,6 @@ def _crashed_last_session() -> bool:
|
||||
return mtime < _PROCESS_START_TS
|
||||
|
||||
|
||||
@router.get("/system/last-run-crash")
|
||||
async def get_last_run_crash():
|
||||
"""Newest unclean-shutdown record from the previous backend run (#1164)
|
||||
— the deployment-agnostic twin of the desktop shell's crash marker
|
||||
(`get_last_backend_crash`), for browser/dev/Docker frontends that have no
|
||||
shell to ask. Version-gated like the shell's markers: records from a
|
||||
different release than the running build are ignored (kept on disk)."""
|
||||
from core import run_sentinel
|
||||
|
||||
rec = run_sentinel.newest_record()
|
||||
if rec is None:
|
||||
return {"record": None, "acknowledged": True}
|
||||
record, acked = rec
|
||||
return {"record": record, "acknowledged": acked}
|
||||
|
||||
|
||||
@router.post("/system/last-run-crash/ack")
|
||||
async def ack_last_run_crash():
|
||||
"""Mark the newest unclean-shutdown record as seen. Watermark semantics
|
||||
(like the shell's ack): the record itself is retained so bug reports can
|
||||
still attach the evidence; a NEWER death re-arms the notice."""
|
||||
from core import run_sentinel
|
||||
|
||||
run_sentinel.acknowledge()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/system/crash/ack")
|
||||
async def ack_crash():
|
||||
"""Mark the current crash log as seen — dismisses the
|
||||
@@ -860,6 +744,7 @@ async def ack_crash():
|
||||
PERSISTENT_KEYS = {
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY",
|
||||
"http_proxy", "https_proxy", "all_proxy",
|
||||
"FFMPEG_PATH", "FFPROBE_PATH",
|
||||
"TRANSLATE_BASE_URL", "TRANSLATE_API_KEY", "TRANSLATE_MODEL",
|
||||
"DEEPL_API_KEY", "DEEPL_BASE_URL",
|
||||
"MICROSOFT_API_KEY", "MICROSOFT_BASE_URL",
|
||||
@@ -867,14 +752,6 @@ PERSISTENT_KEYS = {
|
||||
# the Rust sidecar reads OMNIVOICE_PORT at startup and the backend derives
|
||||
# the LAN-share/UI ports from the others.
|
||||
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
|
||||
# Per-job compute-time budgets (#1787). Both are captured at import time
|
||||
# by services/model_manager.py (GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S), so
|
||||
# a value saved here takes effect on the NEXT backend restart — same
|
||||
# contract as OMNIVOICE_PORT above. Restored into os.environ during the
|
||||
# "env_prefs" startup step (main.py), which runs before model_manager is
|
||||
# first imported ("ml_imports"), so the restored value is what the module
|
||||
# captures. The Settings UI must say so (RestartBadge).
|
||||
"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S",
|
||||
}
|
||||
|
||||
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
|
||||
@@ -892,16 +769,6 @@ except Exception: # pragma: no cover — defensive: env panel > installer wirin
|
||||
# being set so a bad value never reaches uvicorn / the share listener.
|
||||
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
|
||||
|
||||
# Keys whose value is a wall-clock compute-time budget in seconds (#1787).
|
||||
# Validated the same way as _PORT_KEYS: reject anything that isn't a
|
||||
# positive number before it reaches services/model_manager.py. Upper bound is
|
||||
# generous — long enough that a legitimate multi-hour, audiobook-length CPU
|
||||
# render is never blocked — but still bounded, so a fat-fingered extra digit
|
||||
# (300 -> 3000000) can't turn a wedged job into one that silently occupies a
|
||||
# worker for days before the guard ever fires.
|
||||
_TIMEOUT_KEYS = {"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S"}
|
||||
_MAX_GENERATE_TIMEOUT_S = 21600.0 # 6 hours
|
||||
|
||||
|
||||
@router.post("/system/set-env")
|
||||
async def set_env_var(body: dict):
|
||||
@@ -910,11 +777,11 @@ async def set_env_var(body: dict):
|
||||
Persistent keys (proxy, FFMPEG_PATH, translation provider keys, …) are
|
||||
saved to ``prefs.json`` so they survive backend restarts (restored at
|
||||
startup in ``main.py``). HF_TOKEN is persisted via
|
||||
``huggingface_hub.login()`` (and cleared with the shared token-file helper). Other keys
|
||||
``huggingface_hub.login()`` (and cleared via ``logout()``). Other keys
|
||||
are set on ``os.environ`` for the running process.
|
||||
|
||||
The loopback-origin gate that previously lived inline here is now applied
|
||||
at the router level via `dependencies=[Depends(require_admin)]` on
|
||||
at the router level via `dependencies=[Depends(require_loopback)]` on
|
||||
`router` — see the top of this file. Every route on this router is
|
||||
gated, including this one. The 403 body and behavior are unchanged.
|
||||
"""
|
||||
@@ -929,6 +796,23 @@ async def set_env_var(body: dict):
|
||||
)
|
||||
|
||||
if value:
|
||||
# Validate executable paths if the user is setting them manually.
|
||||
# Reject control characters / null bytes (defense-in-depth against
|
||||
# path-injection), then require an existing regular file. NOTE: this
|
||||
# endpoint is loopback-only and MUST remain so — a remote caller able
|
||||
# to set FFMPEG_PATH/FFPROBE_PATH could point it at an arbitrary
|
||||
# binary (RCE). Network sharing must never expose /system/set-env.
|
||||
if key in ("FFMPEG_PATH", "FFPROBE_PATH"):
|
||||
if any(ord(c) < 0x20 or ord(c) == 0x7F for c in value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid path: control characters are not allowed",
|
||||
)
|
||||
if not os.path.isfile(value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File not found: {value}",
|
||||
)
|
||||
# Port keys must be a numeric string in the unprivileged range so a
|
||||
# typo can't drop the backend onto a privileged port (<1024) or an
|
||||
# out-of-range value uvicorn would reject at bind time.
|
||||
@@ -945,24 +829,8 @@ async def set_env_var(body: dict):
|
||||
status_code=400,
|
||||
detail=f"Invalid port for {key}: must be between 1024 and 65535.",
|
||||
)
|
||||
if key in _TIMEOUT_KEYS:
|
||||
try:
|
||||
timeout_n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid timeout for {key}: '{value}' is not a number.",
|
||||
)
|
||||
if not (0 < timeout_n <= _MAX_GENERATE_TIMEOUT_S):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
f"Invalid timeout for {key}: must be greater than 0 "
|
||||
f"and at most {_MAX_GENERATE_TIMEOUT_S:.0f} seconds."
|
||||
),
|
||||
)
|
||||
os.environ[key] = value
|
||||
logger.info("Environment variable set (length=%d)", len(value))
|
||||
logger.info("Set environment variable: %s (length=%d)", key, len(value))
|
||||
|
||||
# Capability 1 / issue #35: HF_TOKEN persists across restarts via
|
||||
# huggingface_hub.login() — writes the token to $HF_HOME/token so
|
||||
@@ -977,22 +845,22 @@ async def set_env_var(body: dict):
|
||||
# Non-fatal — the runtime env var is still set, so the
|
||||
# current process will still see the token. We just lose
|
||||
# persistence across restarts.
|
||||
logger.warning("Could not persist HF token to disk: %s", log_safe(e))
|
||||
logger.warning("Could not persist HF token to disk: %s", e)
|
||||
else:
|
||||
os.environ.pop(key, None)
|
||||
logger.info("Environment variable cleared")
|
||||
logger.info("Cleared environment variable: %s", key)
|
||||
|
||||
# Mirror the persistence on clear — wipe the saved token file too.
|
||||
if key == "HF_TOKEN":
|
||||
try:
|
||||
from services.token_resolver import clear_hf_cli_tokens
|
||||
clear_hf_cli_tokens()
|
||||
logger.info("Local Hugging Face token files cleared")
|
||||
except Exception:
|
||||
raise HTTPException(status_code=500, detail="Could not clear local Hugging Face token files") from None
|
||||
from huggingface_hub import logout as _hf_logout
|
||||
_hf_logout()
|
||||
logger.info("HF token cleared from $HF_HOME/token via logout()")
|
||||
except Exception as e:
|
||||
logger.warning("Could not clear HF token file: %s", e)
|
||||
|
||||
# HF_TOKEN persistence is handled above via huggingface_hub.login()/
|
||||
# clear_hf_cli_tokens() — it never touches prefs.json. Everything else in
|
||||
# logout() — it never touches prefs.json. Everything else in
|
||||
# PERSISTENT_KEYS (proxy, FFMPEG_PATH, translation provider keys, …) is
|
||||
# saved to prefs.json so it survives backend restarts (restored at
|
||||
# startup in main.py). Non-persistent keys stay process-local.
|
||||
@@ -1003,13 +871,7 @@ async def set_env_var(body: dict):
|
||||
else:
|
||||
prefs_delete(prefs_key)
|
||||
|
||||
# #1787 review fix: tell the caller up front when the value just saved is
|
||||
# being shadowed by an external env var — set at THIS process's startup,
|
||||
# before our own prefs restore ran, so it predicts the next restart too.
|
||||
# A response that just said {"set": True} let the Settings panel promise
|
||||
# a restart would apply a value that never will.
|
||||
from core.prefs import is_env_shadowed
|
||||
return {"key": key, "set": bool(value), "shadowed": is_env_shadowed(key)}
|
||||
return {"key": key, "set": bool(value)}
|
||||
|
||||
|
||||
@router.post("/clean-audio")
|
||||
@@ -1061,26 +923,18 @@ async def _do_clean_audio(audio, tmp_dir, clean_id):
|
||||
clean_filename = f"mic_{clean_id}.wav"
|
||||
final_path = os.path.join(OUTPUTS_DIR, clean_filename)
|
||||
|
||||
conversion_fallback = False
|
||||
try:
|
||||
rc, _, _ = await run_ffmpeg(
|
||||
await run_ffmpeg(
|
||||
[ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path],
|
||||
timeout=120.0,
|
||||
)
|
||||
conversion_fallback = rc != 0
|
||||
except asyncio.TimeoutError:
|
||||
conversion_fallback = True
|
||||
logger.warning("Final clean-audio conversion timed out; returning the cleaned source format")
|
||||
if conversion_fallback:
|
||||
shutil.copy2(clean_path, final_path)
|
||||
elif not os.path.exists(final_path):
|
||||
pass
|
||||
if not os.path.exists(final_path):
|
||||
shutil.copy2(clean_path, final_path)
|
||||
|
||||
headers = {"X-Clean-Filename": clean_filename}
|
||||
if conversion_fallback:
|
||||
headers["X-Clean-Conversion"] = "fallback"
|
||||
return FileResponse(final_path, media_type="audio/wav", filename=clean_filename,
|
||||
headers=headers)
|
||||
headers={"X-Clean-Filename": clean_filename})
|
||||
|
||||
|
||||
@router.get("/system/asr-backends")
|
||||
@@ -1100,7 +954,7 @@ def asr_backends():
|
||||
def hf_token_state():
|
||||
"""Return the 3-source HF token cascade state for the Settings UI
|
||||
(Wave 2 React panel consumes this). Never returns the raw token —
|
||||
only a masked preview and local presence; no outbound validation.
|
||||
only a masked preview, whoami username, and per-source validity.
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
from services import token_resolver
|
||||
@@ -1148,10 +1002,7 @@ async def diagnostic_bundle(network: bool = Query(False, description="Include th
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/system/diagnose",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
@router.get("/system/diagnose")
|
||||
async def system_diagnose(
|
||||
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
|
||||
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
|
||||
@@ -1188,21 +1039,12 @@ def quarantine_status():
|
||||
# ── Network sharing (loopback-only control surface) ──────────────────────────
|
||||
|
||||
@router.get("/system/network/state")
|
||||
async def network_state(request: Request):
|
||||
async def network_state():
|
||||
st = network_share.get_state()
|
||||
# PIN-only server mode permits unauthenticated read-only discovery, but the
|
||||
# PIN is itself a consumption credential. Reveal it only to the native
|
||||
# loopback UI or to a remote caller that already passed the configured
|
||||
# long API-key gate. The boolean lets headless dashboards remain useful.
|
||||
host = request.client.host if request.client else None
|
||||
may_reveal_pin = is_loopback(host) or bool(
|
||||
os.environ.get("OMNIVOICE_API_KEY", "").strip()
|
||||
)
|
||||
return {
|
||||
"enabled": st.enabled,
|
||||
"share_port": st.share_port,
|
||||
"pin": st.pin if may_reveal_pin else None,
|
||||
"pin_required": bool(st.pin),
|
||||
"pin": st.pin,
|
||||
"lan_addresses": st.lan_addresses,
|
||||
}
|
||||
|
||||
@@ -1233,34 +1075,9 @@ async def tailscale_status():
|
||||
|
||||
@router.post("/system/tailscale/enable")
|
||||
async def tailscale_enable():
|
||||
result = _tailscale.serve_enable()
|
||||
if result.get("ok"):
|
||||
return result
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Tailscale serve failed",
|
||||
result.get("error", "unknown error"),
|
||||
response="Tailscale sharing could not be enabled; check the backend log for details.",
|
||||
)
|
||||
return {"ok": False, "error": error}
|
||||
return _tailscale.serve_enable()
|
||||
|
||||
|
||||
@router.post("/system/tailscale/disable")
|
||||
async def tailscale_disable():
|
||||
return _tailscale.serve_disable()
|
||||
|
||||
|
||||
# ── Local-only usage insights (the user's own numbers, never transmitted) ───
|
||||
# This answers "how am I using this?" for the USER by aggregating the history
|
||||
# the app has ALREADY written to their own database. It collects nothing new,
|
||||
# stores nothing new, and transmits nothing anywhere: the only consumer is the
|
||||
# user's own UI over loopback. Read-only, content-free (counts and totals,
|
||||
# never the text of a take). Product analytics for the PROJECT is a separate,
|
||||
# consent-gated path (core/analytics.py: opt-in PostHog behind the first-run
|
||||
# prompt, allowlisted content-free metadata only) — this endpoint stays local
|
||||
# regardless of that consent.
|
||||
@router.get("/stats/usage")
|
||||
def stats_usage():
|
||||
from services.local_stats import usage_summary
|
||||
|
||||
return usage_summary()
|
||||
|
||||
@@ -21,16 +21,13 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services import director, speech_rate, incremental
|
||||
from services.ffmpeg_utils import find_ffprobe, spawn_subprocess
|
||||
from api.dependencies import require_native_access
|
||||
from core.path_security import UnsafePath, resolve_within
|
||||
|
||||
logger = logging.getLogger("omnivoice.tools")
|
||||
router = APIRouter()
|
||||
@@ -43,7 +40,7 @@ class ProbeReq(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
@router.post("/tools/probe", dependencies=[Depends(require_native_access)])
|
||||
@router.post("/tools/probe")
|
||||
async def probe(req: ProbeReq):
|
||||
target = os.path.realpath(os.path.expanduser(req.path))
|
||||
if not os.path.exists(target):
|
||||
@@ -84,11 +81,6 @@ class IncrementalReq(BaseModel):
|
||||
# scoped to that language (pass that language's stored hashes alongside);
|
||||
# omitted → legacy language-agnostic hashing, kept for old callers.
|
||||
lang: Optional[str] = None
|
||||
# Voice-identity mode the client will generate with (DubRequest.voice_match).
|
||||
# Only "consistent" changes the hash (per_line/omitted == legacy), so
|
||||
# flipping the Voice-match toggle marks every segment stale — the audio
|
||||
# really would come out with a different reference (#281 class).
|
||||
voice_match: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tools/incremental")
|
||||
@@ -97,7 +89,6 @@ def plan_incremental(req: IncrementalReq):
|
||||
req.segments,
|
||||
stored_hashes=req.stored_hashes or {},
|
||||
track_lang=req.lang,
|
||||
voice_match=req.voice_match,
|
||||
)
|
||||
|
||||
|
||||
@@ -177,26 +168,18 @@ async def analyse_video_context(job_id: str):
|
||||
from core.config import DUB_DIR
|
||||
from services.video_context import analyse_video
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", job_id or ""):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
try:
|
||||
job_dir = resolve_within(DUB_DIR, job_id)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid job id") from exc
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
video_path = resolve_within(DUB_DIR, job_dir / "source.mp4")
|
||||
if not video_path.is_file():
|
||||
try:
|
||||
video_path = resolve_within(DUB_DIR, job.get("video_path", ""))
|
||||
except UnsafePath:
|
||||
return {"error": "Source video not found", "segments": {}}
|
||||
video_path = os.path.join(DUB_DIR, job_id, "source.mp4")
|
||||
if not os.path.exists(video_path):
|
||||
video_path = job.get("video_path", "")
|
||||
|
||||
if not video_path.is_file():
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
return {"error": "Source video not found", "segments": {}}
|
||||
|
||||
segments = job.get("segments") or []
|
||||
ctx = await analyse_video(str(video_path), segments)
|
||||
ctx = await analyse_video(video_path, segments)
|
||||
return ctx.to_dict()
|
||||
|
||||
@@ -10,8 +10,7 @@ as they're generated. This unlocks:
|
||||
Protocol:
|
||||
→ Client sends JSON: {"text": "...", "voice": "profile_id", ...}
|
||||
← Server sends binary audio chunks (PCM16 @ 24kHz mono) as generated
|
||||
← Server sends JSON: {"type": "done", "duration_s": 4.2,
|
||||
"gen_time_s": 1.1, "ttfa_ms": 180.0, "rtf": 0.262}
|
||||
← Server sends JSON: {"type": "done", "duration_s": 4.2, "gen_time_s": 1.1}
|
||||
← Server sends JSON: {"type": "error", "detail": "..."}
|
||||
|
||||
The chunked delivery targets <100ms time-to-first-audio (TTFA) on warm models.
|
||||
@@ -34,30 +33,6 @@ logger = logging.getLogger("omnivoice.tts_stream")
|
||||
# Smaller chunks = lower latency but more WebSocket overhead.
|
||||
CHUNK_SAMPLES = int(os.environ.get("OMNIVOICE_STREAM_CHUNK", "4800"))
|
||||
|
||||
# Module seam for deterministic latency-contract tests. Keep every timing
|
||||
# sample on the same monotonic clock.
|
||||
_perf_counter = time.perf_counter
|
||||
|
||||
|
||||
async def _resolve_stream_backend(engine_id: str | None):
|
||||
"""Resolve the live-stream engine without bypassing host isolation."""
|
||||
from services.tts_backend import (
|
||||
OmniVoiceBackend,
|
||||
active_backend_id,
|
||||
get_active_tts_backend,
|
||||
get_backend_class,
|
||||
)
|
||||
|
||||
if engine_id:
|
||||
return get_backend_class(engine_id)()
|
||||
|
||||
cls = get_backend_class(active_backend_id())
|
||||
if cls is OmniVoiceBackend:
|
||||
from services.model_manager import get_model
|
||||
|
||||
return get_active_tts_backend(model=await get_model())
|
||||
return get_active_tts_backend()
|
||||
|
||||
|
||||
class StreamTTSRequest(BaseModel):
|
||||
"""Client request for streaming TTS."""
|
||||
@@ -87,11 +62,6 @@ async def ws_tts(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.info("TTS streaming WebSocket connected")
|
||||
|
||||
# Said once per socket, not once per utterance: a conversational client
|
||||
# sends many requests down one connection and a repeated notice would be
|
||||
# noise. See `_announce_local_only`.
|
||||
announced_local_only = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Wait for a text request from the client
|
||||
@@ -110,64 +80,23 @@ async def ws_tts(websocket: WebSocket):
|
||||
})
|
||||
continue
|
||||
|
||||
t0 = _perf_counter()
|
||||
t0 = time.perf_counter()
|
||||
text = data["text"]
|
||||
|
||||
# Remote GPU: this socket stays on this machine, and says so.
|
||||
#
|
||||
# /generate's port trades progressive playback for the remote
|
||||
# render — the classic path was always a single wait, so spending
|
||||
# it on a faster GPU is a straight win. This route is the opposite
|
||||
# shape: it exists to put audio in the user's ear before the
|
||||
# sentence has finished synthesizing, and sending each utterance to
|
||||
# a worker would pay queue admission, a round trip and cold-load
|
||||
# risk per utterance, for the one surface where latency IS the
|
||||
# feature.
|
||||
#
|
||||
# Silence would be worse than the limitation: the header badge
|
||||
# would read "gpu2" while this machine does 100% of the work, the
|
||||
# same class of lie the op-aware picker exists to stop. Said once
|
||||
# per socket — a conversational client sends many requests down one
|
||||
# connection — and BEFORE engine resolution, so an engine that
|
||||
# cannot load still tells the user where it would have run.
|
||||
if not announced_local_only:
|
||||
announced_local_only = True
|
||||
try:
|
||||
from worker import routing as worker_routing
|
||||
|
||||
target = worker_routing.decide(op="tts")
|
||||
except Exception: # noqa: BLE001 — advisory; never break audio
|
||||
target = None
|
||||
if target is not None and target.remote:
|
||||
from core.scrub import scrub_text as _scrub
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "routing",
|
||||
"status": "local_stream",
|
||||
"reason": _scrub(
|
||||
f"{target.label} is your GPU target, but live "
|
||||
f"streaming runs on this machine"
|
||||
),
|
||||
})
|
||||
|
||||
try:
|
||||
# Resolve engine
|
||||
from services.tts_backend import (
|
||||
get_active_tts_backend,
|
||||
get_backend_class,
|
||||
)
|
||||
engine_id = data.get("engine")
|
||||
# #1224: leave a breadcrumb when memory is already tight before
|
||||
# a heavy load. /generate has done this since the 16 GB-Mac
|
||||
# reports, but the streaming path — which the desktop UI tries
|
||||
# FIRST — never did, so the load most likely to tip the machine
|
||||
# into an OS OOM kill was the one load with no trail. The
|
||||
# captured stderr tail is what a SIGKILL report has to go on.
|
||||
# Advisory only: the OS can reclaim cache, and refusing here
|
||||
# would brick loads that would actually have coped.
|
||||
try:
|
||||
from services.memory_budget import log_if_low
|
||||
|
||||
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
|
||||
except Exception:
|
||||
pass
|
||||
backend = await _resolve_stream_backend(engine_id)
|
||||
if engine_id:
|
||||
cls = get_backend_class(engine_id)
|
||||
backend = cls()
|
||||
else:
|
||||
from services.model_manager import get_model
|
||||
model = await get_model()
|
||||
backend = get_active_tts_backend(model=model)
|
||||
|
||||
# ── Routing gate (#21 — no silent CPU fallback). WebSockets have
|
||||
# no response headers, so this uses frames: an error frame +
|
||||
@@ -177,8 +106,7 @@ async def ws_tts(websocket: WebSocket):
|
||||
from services.engine_routing import resolve_routing, routing_notice
|
||||
from core.scrub import scrub_text
|
||||
_routing = resolve_routing(
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps(),
|
||||
getattr(backend, "min_vram_gb", 0.0))
|
||||
getattr(backend, "gpu_compat", ("cpu",)), detect_host_caps())
|
||||
if _routing["routing_status"] == "unavailable":
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
@@ -273,13 +201,7 @@ async def ws_tts(websocket: WebSocket):
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
|
||||
def _generate(sentence_text):
|
||||
# Timed INSIDE the pool worker: the guarded dispatch below
|
||||
# can queue behind other jobs, and queue wait is not
|
||||
# synthesis (review on #1620) — under contention it would
|
||||
# inflate rtf without the engine slowing at all.
|
||||
_synth_t0 = _perf_counter()
|
||||
from services.audio_dsp import apply_mastering, normalize_audio
|
||||
from services.watermark import mark_synthetic
|
||||
wav = backend.generate(sentence_text, **kw)
|
||||
sr_actual = backend.sample_rate
|
||||
# Like _run_tts in openai_compat: studio engines (VoxCPM2)
|
||||
@@ -289,44 +211,22 @@ async def ws_tts(websocket: WebSocket):
|
||||
if not getattr(backend, "applies_own_mastering", False):
|
||||
wav = apply_mastering(wav, sample_rate=sr_actual)
|
||||
wav = normalize_audio(wav, target_dBFS=-2.0)
|
||||
# Invisible provenance mark per sentence, at the tensor
|
||||
# stage before PCM16 conversion (#1169) — streaming is a
|
||||
# delivery channel, not a watermark exemption. AudioSeal's
|
||||
# 16-bit message repeats through the audio, so per-sentence
|
||||
# embedding keeps whole-stream detection working; embedding
|
||||
# strength does degrade on sub-second sentences (AudioSeal
|
||||
# embeds poorly on very short segments — see
|
||||
# watermark._iter_chunks), which is inherent to marking
|
||||
# ultra-short clips, not a coverage gap.
|
||||
wav = mark_synthetic(wav, sr_actual, context="tts_stream.sentence")
|
||||
return wav, sr_actual, _perf_counter() - _synth_t0
|
||||
return wav, sr_actual
|
||||
|
||||
import torch
|
||||
total_samples = 0
|
||||
sr = backend.sample_rate
|
||||
started = False
|
||||
first_audio_at: float | None = None
|
||||
# Synthesis time only. The wall clock below also carries socket
|
||||
# delivery and the per-chunk event-loop yields, so deriving RTF
|
||||
# from it reports "how slow was the client" as if it were engine
|
||||
# throughput — on a slow consumer that inflates RTF without the
|
||||
# engine having changed at all.
|
||||
synth_time = 0.0
|
||||
|
||||
for sentence in sentences:
|
||||
# Bounded + pool-reset on hang so a wedged generate can't
|
||||
# starve the GPU pool and brick the backend (#730 class). On
|
||||
# timeout GpuJobTimeoutError propagates to the handler below,
|
||||
# which sends an actionable error frame.
|
||||
# Length-scaled budget per sentence (#1190) — the flat 300s
|
||||
# default is gone from every dispatch.
|
||||
from services.model_manager import generate_timeout_s
|
||||
wav_tensor, sr, sentence_synth_s = await run_on_gpu_pool_guarded(
|
||||
wav_tensor, sr = await run_on_gpu_pool_guarded(
|
||||
functools.partial(_generate, sentence),
|
||||
what="TTS generate",
|
||||
timeout=generate_timeout_s(sentence, engine=backend),
|
||||
)
|
||||
synth_time += sentence_synth_s
|
||||
|
||||
if not started:
|
||||
# Send metadata after the first generation so
|
||||
@@ -353,49 +253,25 @@ async def ws_tts(websocket: WebSocket):
|
||||
end = min(sent_samples + CHUNK_SAMPLES, n_samples)
|
||||
chunk = pcm_bytes[sent_samples * 2: end * 2]
|
||||
await websocket.send_bytes(chunk)
|
||||
if first_audio_at is None:
|
||||
# TTFA ends when the first audio bytes have been
|
||||
# handed to the socket. The previous log used the
|
||||
# whole-render duration and called it TTFA.
|
||||
first_audio_at = _perf_counter()
|
||||
sent_samples = end
|
||||
# Yield to event loop between chunks for responsiveness
|
||||
await asyncio.sleep(0)
|
||||
total_samples += n_samples
|
||||
|
||||
finished_at = _perf_counter()
|
||||
wall_time_raw = max(0.0, finished_at - t0)
|
||||
synth_time_raw = max(0.0, synth_time)
|
||||
gen_time = round(wall_time_raw, 3)
|
||||
gen_time = round(time.perf_counter() - t0, 3)
|
||||
duration = round(total_samples / sr, 3)
|
||||
ttfa_ms = (
|
||||
round(max(0.0, first_audio_at - t0) * 1000.0, 1)
|
||||
if first_audio_at is not None
|
||||
else None
|
||||
)
|
||||
# RTF is a render metric: synthesis seconds per audio second.
|
||||
rtf = (
|
||||
round(synth_time_raw / (total_samples / sr), 3)
|
||||
if total_samples > 0
|
||||
else None
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "done",
|
||||
"duration_s": duration,
|
||||
"gen_time_s": gen_time,
|
||||
"ttfa_ms": ttfa_ms,
|
||||
"rtf": rtf,
|
||||
"samples": total_samples,
|
||||
"sample_rate": sr,
|
||||
"engine": backend.id,
|
||||
})
|
||||
logger.info(
|
||||
"TTS stream: %.1fs audio in %.1fs (TTFA=%s, RTF=%s)",
|
||||
duration,
|
||||
gen_time,
|
||||
f"{ttfa_ms:.0f}ms" if ttfa_ms is not None else "n/a",
|
||||
f"{rtf:.3f}" if rtf is not None else "n/a",
|
||||
"TTS stream: %.1fs audio in %.1fs (TTFA=%.0fms)",
|
||||
duration, gen_time, gen_time * 1000,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
"""Speech-to-speech voice changer — Studio's Convert method (POST /convert).
|
||||
|
||||
The user drops (or records) a source clip, picks an existing voice profile,
|
||||
and gets the same words back in that profile's voice: the active ASR backend
|
||||
transcribes the clip (no word timestamps — the text is all we need), the
|
||||
active TTS engine re-synthesizes it conditioned on the profile's reference
|
||||
audio, and — by default — the take is pitch-preservingly time-stretched
|
||||
(ffmpeg atempo, clamped to one well-behaved 0.5–2.0 stage) so it lands near
|
||||
the source clip's duration.
|
||||
|
||||
Deliberately reuses the /generate choke points instead of re-deriving them:
|
||||
|
||||
* profile row → conditioning via ``generation._resolve_profile_conditioning``
|
||||
(lock wins, ``kind`` authoritative, #533 language fill),
|
||||
* engine resolution via ``services.tts_backend.resolve_generation_backend``
|
||||
(never a silent OmniVoice fallback; ``require_cloning=True`` refuses
|
||||
clone-less engines with the actionable switch-engine message),
|
||||
* synthesis via ``generation._run_backend_inference`` on the guarded GPU
|
||||
pool (#730 bound + reset; busy/timeout → retryable 503),
|
||||
* provenance + persistence via ``services.watermark.mark_synthetic_async``
|
||||
and ``generation._finalize_generation`` (watermark → WAV in OUTPUTS_DIR →
|
||||
history row → retention prune), marked AFTER the stretch so the take users
|
||||
keep carries exactly one whole-take mark.
|
||||
|
||||
Local-first: no network calls; ASR-model-less installs get the same typed
|
||||
409 download CTA as /transcribe; a backend mid-shutdown surfaces the global
|
||||
503 ``[shutting_down]`` (ModelLoadInterruptedByShutdown → main.py handler).
|
||||
Reachability matches /generate: loopback bind by default, with the shared
|
||||
network-share PIN / API-key middleware gating any non-loopback exposure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.convert")
|
||||
|
||||
#: ffmpeg's atempo filter is well-behaved in [0.5, 2.0] per stage. Convert
|
||||
#: clamps to ONE stage by design: needing more than 2× either way means the
|
||||
#: synthesized speech differs so much from the source that "matching" it
|
||||
#: would produce chipmunk/slow-motion artifacts worse than the mismatch.
|
||||
ATEMPO_MIN = 0.5
|
||||
ATEMPO_MAX = 2.0
|
||||
|
||||
#: Within this relative tolerance the durations already match — stretching
|
||||
#: would resample the whole take for an inaudible gain.
|
||||
_MATCH_TOLERANCE = 0.02
|
||||
|
||||
#: Convert clips are short conversational inputs, not long-form media. Stream
|
||||
#: them to disk in bounded chunks so a network-share client cannot make the
|
||||
#: backend materialize an arbitrarily large multipart upload in memory.
|
||||
_MAX_SOURCE_AUDIO_BYTES = 64 * 1024 * 1024
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
async def _copy_source_upload(audio: UploadFile, destination) -> int:
|
||||
"""Stream ``audio`` into ``destination`` with the Convert upload cap."""
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await audio.read(_UPLOAD_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
return total
|
||||
total += len(chunk)
|
||||
if total > _MAX_SOURCE_AUDIO_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail="Source audio is too large (maximum 64 MB).",
|
||||
)
|
||||
destination.write(chunk)
|
||||
|
||||
|
||||
def _clamped_tempo_ratio(tts_duration_s: float, source_duration_s: float) -> "float | None":
|
||||
"""The atempo ratio that fits the take into the source duration, or None.
|
||||
|
||||
ratio > 1 speeds the take up (it came out longer than the source),
|
||||
ratio < 1 slows it down. Clamped to a single atempo stage's [0.5, 2.0];
|
||||
None when either duration is unusable or they already match.
|
||||
"""
|
||||
if not source_duration_s or source_duration_s <= 0:
|
||||
return None
|
||||
if not tts_duration_s or tts_duration_s <= 0:
|
||||
return None
|
||||
ratio = tts_duration_s / source_duration_s
|
||||
if abs(ratio - 1.0) <= _MATCH_TOLERANCE:
|
||||
return None
|
||||
return min(ATEMPO_MAX, max(ATEMPO_MIN, ratio))
|
||||
|
||||
|
||||
async def _match_source_duration(audio_tensor, sample_rate: int, source_duration_s: float):
|
||||
"""Best-effort pitch-preserving stretch of the take toward the source
|
||||
clip's duration. Returns the input unchanged when no stretch is needed
|
||||
or ffmpeg fails — a duration mismatch is better than a failed convert."""
|
||||
n_samples = int(audio_tensor.shape[-1])
|
||||
ratio = _clamped_tempo_ratio(n_samples / sample_rate, source_duration_s)
|
||||
if ratio is None:
|
||||
return audio_tensor
|
||||
target_samples = max(1, int(round(n_samples / ratio)))
|
||||
from services.ffmpeg_utils import _pitch_preserving_stretch
|
||||
try:
|
||||
return await _pitch_preserving_stretch(audio_tensor, target_samples, sample_rate)
|
||||
except Exception as e: # noqa: BLE001 — stretch is opt-in polish, never fatal
|
||||
logger.warning("duration match skipped — atempo stretch failed: %s", e)
|
||||
return audio_tensor
|
||||
|
||||
|
||||
async def _transcribe_source(tmp_path: str, *, source_lease=None) -> dict:
|
||||
"""Active-ASR transcription of the uploaded clip (no word timestamps).
|
||||
|
||||
Mirrors POST /transcribe: typed 409 + download CTA before any backend
|
||||
is constructed (never a silent multi-GB auto-download), the guarded GPU
|
||||
pool dispatch (#730), 504 on timeout, and the same 409 when the loader
|
||||
degrades onto an engine with no weights on disk (#1185).
|
||||
"""
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
asr_model_missing_detail,
|
||||
asr_model_missing_error,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
|
||||
missing = await asyncio.to_thread(asr_model_missing_error, purpose="transcribe")
|
||||
if missing is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**missing, "message": asr_model_missing_detail(missing)},
|
||||
)
|
||||
|
||||
def _run():
|
||||
# `load_*`, not `get_*`: the loader runs ensure_loaded() and degrades
|
||||
# past an engine whose deep import chain is broken (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
return backend.transcribe(tmp_path, word_timestamps=False)
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
release = source_lease.acquire() if source_lease is not None else None
|
||||
abandoned = False
|
||||
try:
|
||||
return await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
_run,
|
||||
what="Voice convert",
|
||||
on_abandon=release,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# The guard now owns the lease token until the native worker drains.
|
||||
abandoned = True
|
||||
raise
|
||||
except ASRTimeoutError as e:
|
||||
abandoned = True
|
||||
logger.warning("Convert transcription timed out: %s", e)
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
finally:
|
||||
if release is not None and not abandoned:
|
||||
release()
|
||||
|
||||
|
||||
@router.post("/convert")
|
||||
async def convert_speech(
|
||||
audio: UploadFile = File(...),
|
||||
profile_id: str = Form(...),
|
||||
match_duration: bool = Form(True),
|
||||
):
|
||||
"""Convert a spoken clip into an existing voice profile's voice.
|
||||
|
||||
Multipart form: ``audio`` (the source clip), ``profile_id`` (an existing
|
||||
voice profile), optional ``match_duration`` (default on — atempo the take
|
||||
toward the source clip's length, clamped to 0.5–2.0×).
|
||||
|
||||
Returns JSON ``{audio_url, text, duration_s, id}`` — the take is saved to
|
||||
OUTPUTS_DIR and served from the ``/audio`` mount like every other take.
|
||||
"""
|
||||
from core.db import db_conn
|
||||
from api.routers.generation import _resolve_profile_conditioning, _TempReferenceLease
|
||||
|
||||
# ── Profile first: strict 404, unlike /generate's silent skip — Convert
|
||||
# has no meaning without a target voice.
|
||||
with db_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM voice_profiles WHERE id=?", (profile_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="That voice profile doesn't exist. It may have been deleted from another tab.",
|
||||
)
|
||||
cond = _resolve_profile_conditioning(row)
|
||||
|
||||
# ── Save the upload before loading an engine. Every ASR backend (and
|
||||
# ffprobe) needs a file path; the bounded streaming copy rejects oversized
|
||||
# network-share requests without materializing them in process memory or
|
||||
# starting heavyweight model work.
|
||||
ext = os.path.splitext(audio.filename or "audio.wav")[1] or ".wav"
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
|
||||
source_lease = None
|
||||
try:
|
||||
try:
|
||||
await _copy_source_upload(audio, tmp)
|
||||
finally:
|
||||
tmp.close()
|
||||
source_lease = _TempReferenceLease(tmp.name)
|
||||
|
||||
# ── Engine gate before ASR/TTS work: the shared resolver refuses a
|
||||
# clone-less engine with the actionable switch-engine message (→ 400),
|
||||
# and a backend mid-shutdown raises ModelLoadInterruptedByShutdown out
|
||||
# of the model load → the global 503 [shutting_down] handler.
|
||||
from services.tts_backend import resolve_generation_backend
|
||||
try:
|
||||
backend = await resolve_generation_backend(
|
||||
require_cloning=True, cloning_purpose="voice conversion",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
result = await _transcribe_source(tmp.name, source_lease=source_lease)
|
||||
|
||||
segments = result.get("segments", [])
|
||||
text = result.get("text", "")
|
||||
if not text and segments:
|
||||
text = " ".join(s.get("text", "") for s in segments).strip()
|
||||
# Same final-text hygiene as /transcribe: strip Whisper hallucination
|
||||
# loops, then deterministic polish (leading capital + terminal
|
||||
# punctuation) so the TTS input reads as typed text.
|
||||
from services.refinement import collapse_repetitive_artifacts
|
||||
from services.text_polish import polish_text
|
||||
text = polish_text(collapse_repetitive_artifacts(text))
|
||||
if not text or not text.strip():
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"No speech was recognized in the source clip, so there is "
|
||||
"nothing to convert. Record or drop a clip with clear, "
|
||||
"audible speech and try again."
|
||||
),
|
||||
)
|
||||
|
||||
# #308/#1032 parity with /generate: a clone profile saved without a
|
||||
# transcript conditions better when its reference clip is transcribed,
|
||||
# and that transcript is cached onto the row so it happens ONCE, not
|
||||
# per convert. Best-effort exactly like /generate — a timeout/failure
|
||||
# degrades to ref_text=None and the engine's own fallback. The ASR
|
||||
# model is already warm here (the source transcribe above just used it).
|
||||
if cond["ref_audio_path"] and not cond["ref_text"]:
|
||||
from api.routers.generation import (
|
||||
_generate_timeout_s,
|
||||
_persist_profile_ref_text,
|
||||
)
|
||||
from services.asr_backend import transcribe_reference
|
||||
from services.model_manager import run_on_gpu_pool_guarded
|
||||
try:
|
||||
cond["ref_text"] = await run_on_gpu_pool_guarded(
|
||||
functools.partial(transcribe_reference, cond["ref_audio_path"]),
|
||||
what="Reference transcribe",
|
||||
timeout=_generate_timeout_s(""),
|
||||
)
|
||||
except TimeoutError as e:
|
||||
logger.warning(
|
||||
"reference transcribe hung (%s); using engine ASR fallback", e,
|
||||
)
|
||||
cond["ref_text"] = None
|
||||
if cond["ref_text"] and cond["persist_ref_text"]:
|
||||
_persist_profile_ref_text(profile_id, cond["ref_text"])
|
||||
|
||||
# Source duration for the optional match: the container's own length
|
||||
# (ffprobe), falling back to the last ASR segment end. Best-effort —
|
||||
# None just skips the stretch.
|
||||
source_duration_s = None
|
||||
if match_duration:
|
||||
from services.ffmpeg_utils import probe_duration
|
||||
source_duration_s = await probe_duration(
|
||||
tmp.name, allowed_root=os.path.dirname(tmp.name),
|
||||
)
|
||||
if not source_duration_s and segments:
|
||||
source_duration_s = max((s.get("end", 0) or 0) for s in segments) or None
|
||||
|
||||
# ── Same text choke point as /generate: engine-agnostic normalization
|
||||
# (numbers→words, junk strip) on the fully resolved language.
|
||||
from services.text_normalization import normalize_for_tts
|
||||
language = cond["language"]
|
||||
text = normalize_for_tts(text, language)
|
||||
|
||||
used_seed = cond["seed"]
|
||||
if used_seed is None:
|
||||
import random
|
||||
used_seed = random.randint(0, 2**31 - 1)
|
||||
|
||||
from api.routers.generation import (
|
||||
_finalize_generation,
|
||||
_generate_timeout_s,
|
||||
_run_backend_inference,
|
||||
)
|
||||
from services.model_manager import (
|
||||
GpuJobTimeoutError,
|
||||
GpuPoolBusyError,
|
||||
run_on_gpu_pool_guarded,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
_render = functools.partial(
|
||||
_run_backend_inference,
|
||||
backend, text, language, cond["ref_audio_path"], cond["ref_text"],
|
||||
cond["instruct"],
|
||||
None, # duration — the model picks; match_duration owns pacing
|
||||
16, 2.0, # num_step / guidance_scale (the /generate defaults)
|
||||
1.0, # speed
|
||||
True, True, # denoise / postprocess_output
|
||||
used_seed,
|
||||
)
|
||||
try:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
_render,
|
||||
what="Voice convert",
|
||||
timeout=_generate_timeout_s(
|
||||
text, min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
|
||||
),
|
||||
min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
|
||||
)
|
||||
except GpuPoolBusyError as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail=str(e),
|
||||
headers={"Retry-After": str(e.retry_after),
|
||||
"X-OmniVoice-Retryable": "true"},
|
||||
) from e
|
||||
except GpuJobTimeoutError as e:
|
||||
raise HTTPException(
|
||||
status_code=503, detail=str(e),
|
||||
headers={"Retry-After": "30", "X-OmniVoice-Retryable": "true"},
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
sample_rate = backend.sample_rate
|
||||
|
||||
if match_duration and source_duration_s:
|
||||
audio_tensor = await _match_source_duration(
|
||||
audio_tensor, sample_rate, source_duration_s,
|
||||
)
|
||||
|
||||
# Provenance mark AFTER the stretch (one whole-take mark on the audio
|
||||
# the user actually keeps), then the shared finalize tail — WAV in
|
||||
# OUTPUTS_DIR, self-healing history row, retention prune, event emit.
|
||||
from services.watermark import mark_synthetic_async
|
||||
audio_tensor = await mark_synthetic_async(
|
||||
audio_tensor, sample_rate, context="convert.finalize",
|
||||
)
|
||||
_, meta = await _finalize_generation(
|
||||
audio_tensor, sample_rate, text=text, history_mode="convert",
|
||||
ref_audio_path=cond["ref_audio_path"], language=language,
|
||||
instruct=cond["instruct"], resolved_profile_id=profile_id,
|
||||
used_seed=used_seed, start_time=start_time,
|
||||
already_marked=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": meta["id"],
|
||||
"audio_url": f"/audio/{meta['filename']}",
|
||||
"text": text,
|
||||
"duration_s": meta["duration"],
|
||||
"gen_time_s": meta["gen_time"],
|
||||
}
|
||||
finally:
|
||||
if source_lease is not None:
|
||||
source_lease.finish_request()
|
||||
else:
|
||||
try:
|
||||
os.unlink(tmp.name)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Watermark detection API — upload audio, check if it was generated by VoiceStudio.
|
||||
Watermark detection API — upload audio, check if it was generated by OmniVoice.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
@@ -9,7 +9,6 @@ from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
|
||||
from services.watermark import detect_watermark, is_enabled, _check_available
|
||||
from core.prefs import get as pref_get, set_ as pref_set
|
||||
from core.public_errors import public_failure
|
||||
|
||||
logger = logging.getLogger("omnivoice.watermark_api")
|
||||
|
||||
@@ -19,7 +18,7 @@ router = APIRouter()
|
||||
@router.post("/watermark/detect")
|
||||
async def detect_audio_watermark(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload an audio file and check whether it contains a VoiceStudio watermark.
|
||||
Upload an audio file and check whether it contains an OmniVoice watermark.
|
||||
|
||||
Returns confidence score, decoded message, and source attribution.
|
||||
"""
|
||||
@@ -50,14 +49,8 @@ async def detect_audio_watermark(file: UploadFile = File(...)):
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
detail = public_failure(
|
||||
logger,
|
||||
"Watermark detection failed",
|
||||
e,
|
||||
response="Watermark detection failed; check the backend log for details.",
|
||||
traceback=True,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=detail) from e
|
||||
logger.error("Watermark detection failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -1,819 +0,0 @@
|
||||
"""Remote worker management API.
|
||||
|
||||
Deliberately small. The council's warning about the original design was that
|
||||
seven strategies times three execution modes times priorities times weights
|
||||
times per-model concurrency is a configuration surface nobody can test and
|
||||
every knob is a compatibility promise forever. So this exposes what a user
|
||||
actually needs to run their other GPU: see workers, add one, name it, prefer
|
||||
one, pause one, remove one.
|
||||
|
||||
Two things here are not conveniences and must not be softened:
|
||||
|
||||
* **Consent is explicit and per worker.** Audio, reference voices, and text
|
||||
leave the machine for a worker, so each one is approved individually. There
|
||||
is no global "trust all workers".
|
||||
* **A token is shown exactly once.** Only its hash is stored, so it cannot be
|
||||
re-displayed — which is the point.
|
||||
|
||||
One endpoint here is not part of that surface: `POST /workers/tasks` submits a
|
||||
single task and waits for it, and exists only because the scheduler otherwise
|
||||
has no caller at all outside the tests. It is marked dev-only everywhere it
|
||||
appears and is replaced by the GPU gateway.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from worker import registry, routing, service
|
||||
from worker.async_utils import drain_task, to_thread_and_defer_cancellation
|
||||
|
||||
logger = logging.getLogger("omnivoice.worker")
|
||||
|
||||
# How often an awaiting request checks whether its caller is still there.
|
||||
# Starlette does not cancel a handler when the client hangs up, so polling is
|
||||
# the only way the "cancel what nobody is waiting for" rule can fire before
|
||||
# the task's own deadline does.
|
||||
_DISCONNECT_POLL_SECONDS = 1.0
|
||||
|
||||
# Management is admin-gated: these endpoints mint join tokens and revoke
|
||||
# machines, so Docker writes require the API key while desktop stays loopback.
|
||||
router = APIRouter(prefix="/workers", tags=["workers"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class EnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
label: str = Field("", max_length=120)
|
||||
endpoint: str = Field("", max_length=256)
|
||||
ttl_seconds: int = Field(900, ge=60, le=24 * 3600)
|
||||
|
||||
|
||||
class JoinRequest(BaseModel):
|
||||
"""A join code, as pasted (or scanned) from the control plane."""
|
||||
|
||||
token: str = Field(..., max_length=4096)
|
||||
|
||||
|
||||
class TargetRequest(BaseModel):
|
||||
"""`local`, or the id of an enrolled worker."""
|
||||
|
||||
target: str = Field(..., max_length=64)
|
||||
|
||||
|
||||
class WorkerUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=120)
|
||||
enabled: bool | None = None
|
||||
priority: int | None = Field(None, ge=0, le=100)
|
||||
|
||||
|
||||
class SubmitTaskRequest(BaseModel):
|
||||
"""One unit of work for a remote worker. **Dev only** — see `submit_task`."""
|
||||
|
||||
engine: str = Field(..., max_length=64)
|
||||
operation: str = Field("tts", max_length=32)
|
||||
model_id: str = Field("", max_length=128)
|
||||
params: dict = Field(default_factory=dict)
|
||||
# Mandatory, and deliberately without a default: the sweeper fails a task
|
||||
# on its deadline only while it is QUEUED, so one submitted without a
|
||||
# deadline while no worker is online waits forever with nothing left in
|
||||
# the system that would ever time it out.
|
||||
deadline_seconds: float = Field(..., gt=0, le=6 * 3600)
|
||||
idempotency_key: str | None = Field(None, max_length=128)
|
||||
|
||||
|
||||
class _ClientGone(Exception):
|
||||
"""The caller hung up while its task was still running."""
|
||||
|
||||
|
||||
class _WaitExpired(Exception):
|
||||
"""The task did not reach a terminal state inside its deadline."""
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_workers() -> dict:
|
||||
"""Everything the workers panel renders, in one call."""
|
||||
return service.control_plane.snapshot()
|
||||
|
||||
|
||||
@router.get("/target")
|
||||
def get_target(op: str = "") -> dict:
|
||||
"""What the GPU picker shows: the choice, the resolved answer, the options.
|
||||
|
||||
`active` is the same answer the generation path uses, so the badge cannot
|
||||
claim work goes somewhere the router will not send it. Pass `op` for the
|
||||
surface being rendered — omitting it answers for the target as a whole,
|
||||
which is what the picker's own menu asks.
|
||||
"""
|
||||
return routing.status(op=op.strip() or None)
|
||||
|
||||
|
||||
@router.post("/target")
|
||||
def set_target(request: TargetRequest) -> dict:
|
||||
"""Choose where work runs. Exactly one target is active at a time."""
|
||||
chosen = request.target.strip() or routing.LOCAL
|
||||
if chosen != routing.LOCAL:
|
||||
worker = registry.get(chosen)
|
||||
if worker is None or worker.revoked:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
routing.set_target_id(chosen)
|
||||
return routing.status()
|
||||
|
||||
|
||||
@router.post("/enabled")
|
||||
async def set_enabled(request: EnableRequest) -> dict:
|
||||
"""Turn the feature on or off.
|
||||
|
||||
Off means off: the control plane stops, the listening socket closes, and
|
||||
the app is exactly what it was before the toggle existed.
|
||||
"""
|
||||
service.set_remote_workers_enabled(request.enabled)
|
||||
if request.enabled:
|
||||
try:
|
||||
await service.control_plane.start()
|
||||
except Exception as exc:
|
||||
service.control_plane.startup_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
else:
|
||||
await service.control_plane.stop()
|
||||
return service.control_plane.snapshot()
|
||||
|
||||
|
||||
@router.get("/agent")
|
||||
def agent_status() -> dict:
|
||||
"""The other side of the same feature: is THIS machine lending its GPU?
|
||||
|
||||
Separate from `GET /workers`, which answers for the control plane. A
|
||||
machine can legitimately be both — a desktop that borrows a laptop's GPU
|
||||
and lends its own to a colleague — so neither status can stand in for the
|
||||
other.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.get("/agent/readiness", include_in_schema=False, response_model=None)
|
||||
def agent_readiness() -> JSONResponse:
|
||||
"""Container readiness: 200 only after this process registered as a worker."""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
readiness = worker_agent.agent.readiness()
|
||||
return JSONResponse(
|
||||
status_code=200 if readiness["ready"] else 503,
|
||||
content=readiness,
|
||||
headers={} if readiness["ready"] else {"Retry-After": "2"},
|
||||
)
|
||||
|
||||
|
||||
def _refuse_when_env_pinned(worker_agent) -> None:
|
||||
"""OMNIVOICE_WORKER_MODE wins over the setting everywhere else.
|
||||
|
||||
`worker_mode_enabled()` reads the variable first and `status()` reports the
|
||||
machine as env-pinned, so a route that changed worker mode anyway would
|
||||
contradict both: it writes a setting nothing consults, and the next restart
|
||||
undoes whatever the user just saw happen.
|
||||
"""
|
||||
if worker_agent.agent.status()["env_pinned"]:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"OMNIVOICE_WORKER_MODE controls this machine's worker mode. Unset it "
|
||||
"and restart VoiceStudio to manage it from here."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _finish_cleanup(awaitable):
|
||||
"""Run rollback to completion even if its HTTP task was cancelled."""
|
||||
task = asyncio.create_task(awaitable)
|
||||
await drain_task(task)
|
||||
return task.result()
|
||||
|
||||
|
||||
async def _set_worker_mode(worker_agent, enabled: bool) -> None:
|
||||
_result, cancelled = await to_thread_and_defer_cancellation(
|
||||
worker_agent.set_worker_mode_enabled, enabled
|
||||
)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
|
||||
async def _restore_agent_transaction(
|
||||
worker_agent, previous: dict, *, was_running: bool
|
||||
) -> None:
|
||||
"""Restore durable enrollment/settings and the exact prior live state."""
|
||||
try:
|
||||
await _finish_cleanup(worker_agent.agent.stop())
|
||||
await _finish_cleanup(worker_agent.restore_enrollment(previous))
|
||||
if was_running and not worker_agent.agent.running:
|
||||
await _finish_cleanup(worker_agent.agent.start())
|
||||
elif not was_running and worker_agent.agent.running:
|
||||
await _finish_cleanup(worker_agent.agent.stop())
|
||||
except worker_agent.EnrollmentRollbackError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
message = (
|
||||
"The previous worker state could not be restored safely. "
|
||||
"Worker mode remains stopped; fix its enrollment/settings storage, then retry."
|
||||
)
|
||||
with contextlib.suppress(BaseException):
|
||||
await _finish_cleanup(worker_agent.agent.stop())
|
||||
worker_agent.agent.last_error = message
|
||||
raise worker_agent.EnrollmentRollbackError(message) from exc
|
||||
|
||||
|
||||
def _raise_agent_transaction_failure(
|
||||
worker_agent, operation: BaseException, rollback: BaseException | None
|
||||
) -> None:
|
||||
if isinstance(operation, asyncio.CancelledError):
|
||||
if rollback is not None:
|
||||
logger.error(
|
||||
"Worker rollback failed during request cancellation",
|
||||
exc_info=(type(rollback), rollback, rollback.__traceback__),
|
||||
)
|
||||
raise operation
|
||||
if rollback is not None:
|
||||
raise HTTPException(status_code=409, detail=str(rollback)) from rollback
|
||||
if isinstance(operation, Exception):
|
||||
worker_agent.agent.last_error = str(operation)
|
||||
raise HTTPException(status_code=409, detail=str(operation)) from operation
|
||||
raise operation
|
||||
|
||||
|
||||
@router.post("/agent/join")
|
||||
async def join_control_plane(request: JoinRequest) -> dict:
|
||||
"""Redeem a join code and start working for that control plane.
|
||||
|
||||
This is the endpoint that makes the feature reachable. Joining used to mean
|
||||
setting OMNIVOICE_WORKER_MODE and OMNIVOICE_WORKER_TOKEN in the environment
|
||||
and relaunching the app — a step most users will never take, on the machine
|
||||
that is usually the least convenient to configure by hand.
|
||||
|
||||
The code is single-use and short-lived, so a failure here is nearly always
|
||||
"expired" or "wrong address"; it is returned verbatim rather than as a bare
|
||||
409, because the user's next action depends on which one it was.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
token = request.token.strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=422, detail="Paste the join code first.")
|
||||
# Same rule as the toggle below: joining ENABLES worker mode, so under
|
||||
# OMNIVOICE_WORKER_MODE it would write a setting the rest of the app
|
||||
# ignores — and with the variable set to 0, hand the user a machine that
|
||||
# says it joined and never lends anything (CodeRabbit).
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
try:
|
||||
previous, cancelled = await to_thread_and_defer_cancellation(
|
||||
worker_agent.snapshot_enrollment
|
||||
)
|
||||
except worker_agent.EnrollmentStateError as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
was_running = worker_agent.agent.running
|
||||
|
||||
# A rejoin stops a working agent before the replacement is accepted.
|
||||
# Stop, acceptance and the durable setting are one transaction: every
|
||||
# failure, including cancellation, restores both trust and live state.
|
||||
try:
|
||||
await worker_agent.agent.stop()
|
||||
await worker_agent.agent.start(token_text=token)
|
||||
# Success is the control plane ACCEPTING this worker, not the
|
||||
# connection being scheduled — see wait_until_registered.
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
await _set_worker_mode(worker_agent, True)
|
||||
except BaseException as exc:
|
||||
rollback_exc = None
|
||||
try:
|
||||
await _restore_agent_transaction(
|
||||
worker_agent, previous, was_running=was_running
|
||||
)
|
||||
except BaseException as rollback_error:
|
||||
rollback_exc = rollback_error
|
||||
_raise_agent_transaction_failure(worker_agent, exc, rollback_exc)
|
||||
worker_agent.agent.last_error = ""
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.post("/agent/enabled")
|
||||
async def set_agent_enabled(request: EnableRequest) -> dict:
|
||||
"""Start or stop lending this machine, without forgetting the enrollment.
|
||||
|
||||
Off stops the agent and clears the setting, so nothing dials out; the
|
||||
pinned certificate stays, which is what lets "on" resume without asking for
|
||||
another code.
|
||||
"""
|
||||
from worker import agent as worker_agent # noqa: PLC0415
|
||||
|
||||
_refuse_when_env_pinned(worker_agent)
|
||||
async with worker_agent.agent.lifecycle:
|
||||
try:
|
||||
previous, cancelled = await to_thread_and_defer_cancellation(
|
||||
worker_agent.snapshot_enrollment
|
||||
)
|
||||
except worker_agent.EnrollmentStateError as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
was_running = worker_agent.agent.running
|
||||
|
||||
try:
|
||||
if request.enabled:
|
||||
await worker_agent.agent.start()
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
await _set_worker_mode(worker_agent, True)
|
||||
else:
|
||||
await worker_agent.agent.stop()
|
||||
await _set_worker_mode(worker_agent, False)
|
||||
except BaseException as exc:
|
||||
rollback_exc = None
|
||||
try:
|
||||
await _restore_agent_transaction(
|
||||
worker_agent, previous, was_running=was_running
|
||||
)
|
||||
except BaseException as rollback_error:
|
||||
rollback_exc = rollback_error
|
||||
_raise_agent_transaction_failure(worker_agent, exc, rollback_exc)
|
||||
worker_agent.agent.last_error = ""
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@router.post("/enrollments")
|
||||
def create_enrollment(request: EnrollRequest) -> dict:
|
||||
"""Mint a single-use join token.
|
||||
|
||||
The plaintext is returned once and never stored — the response is the only
|
||||
time it exists outside the worker that redeems it.
|
||||
"""
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Remote workers are turned off. Enable them in Settings → System → Remote workers first.",
|
||||
)
|
||||
try:
|
||||
token = service.control_plane.create_enrollment(
|
||||
endpoint=request.endpoint,
|
||||
label=request.label,
|
||||
ttl_seconds=request.ttl_seconds,
|
||||
)
|
||||
except service.EndpointCertificateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"token": token.encode(),
|
||||
"endpoint": token.endpoint,
|
||||
"fingerprint": token.cert_fingerprint,
|
||||
"expires_at": token.expires_at,
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
def _persist_worker_update(
|
||||
worker_id: str, request: WorkerUpdate
|
||||
):
|
||||
"""Write policy on a worker thread; live publication stays loop-owned."""
|
||||
return registry.update_policy(
|
||||
worker_id,
|
||||
name=request.name,
|
||||
enabled=request.enabled,
|
||||
priority=request.priority,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{worker_id}")
|
||||
async def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
|
||||
pool = service.control_plane.pool if service.control_plane.running else None
|
||||
live = None
|
||||
was_pending = False
|
||||
if pool is not None:
|
||||
# Quiesce dispatch before releasing authority for the SQLite write.
|
||||
# The publication after the await restores the exact prior state, so a
|
||||
# concurrent registration handoff remains quiesced for its own reason.
|
||||
with registry.authority_guard():
|
||||
live = pool.get(worker_id)
|
||||
if live is not None:
|
||||
was_pending = live.registration_pending
|
||||
live.registration_pending = True
|
||||
updated = None
|
||||
cancelled = False
|
||||
try:
|
||||
updated, cancelled = await to_thread_and_defer_cancellation(
|
||||
_persist_worker_update, worker_id, request
|
||||
)
|
||||
finally:
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
if updated is not None:
|
||||
# Pool state, including the cached record the scheduler
|
||||
# reads, belongs to the app's event loop.
|
||||
pool.refresh_record(updated)
|
||||
current = pool.get(worker_id)
|
||||
if current is live:
|
||||
current.registration_pending = was_pending
|
||||
if updated is None:
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return updated.to_dict()
|
||||
|
||||
|
||||
@router.post("/{worker_id}/consent")
|
||||
def grant_consent(worker_id: str) -> dict:
|
||||
"""Record the user's explicit yes to sending their audio to this machine."""
|
||||
if registry.get(worker_id) is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
registry.grant_consent(worker_id)
|
||||
worker = registry.get(worker_id)
|
||||
return worker.to_dict() if worker else {}
|
||||
|
||||
|
||||
@router.post("/{worker_id}/resume")
|
||||
async def clear_breaker(worker_id: str) -> dict:
|
||||
"""Clear a paused worker's circuit breakers.
|
||||
|
||||
The user fixed the machine and knows it — a breaker with no manual clear is
|
||||
the quarantine trap the reputation system had.
|
||||
"""
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
breakers = service.control_plane.pool.breakers
|
||||
for breaker in breakers.open_breakers(worker_id):
|
||||
breaker.force_close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{worker_id}")
|
||||
async def revoke_worker(worker_id: str) -> dict:
|
||||
"""Remove a worker — which means revoke its key, not hide the row.
|
||||
|
||||
Its in-flight work is released so it can be retried elsewhere rather than
|
||||
waiting out a lease on a machine that will never answer again.
|
||||
"""
|
||||
pool = service.control_plane.pool if service.control_plane.running else None
|
||||
live = None
|
||||
was_pending = False
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
live = pool.get(worker_id)
|
||||
if live is not None:
|
||||
was_pending = live.registration_pending
|
||||
live.registration_pending = True
|
||||
try:
|
||||
revoked, cancelled = await to_thread_and_defer_cancellation(
|
||||
registry.revoke, worker_id
|
||||
)
|
||||
except BaseException:
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
current = pool.get(worker_id)
|
||||
if current is live:
|
||||
current.registration_pending = was_pending
|
||||
raise
|
||||
if not revoked:
|
||||
if pool is not None:
|
||||
with registry.authority_guard():
|
||||
current = pool.get(worker_id)
|
||||
if current is live:
|
||||
current.registration_pending = was_pending
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
|
||||
# The tombstone committed before any egress/session mutation. Everything
|
||||
# below is loop-owned and published under the same scheduler authority read
|
||||
# used by next_assignment(), so no task can bind in the handoff window.
|
||||
with registry.authority_guard():
|
||||
if service.control_plane.running:
|
||||
if service.control_plane.servicer is not None:
|
||||
service.control_plane.servicer.revoke_worker_sessions(worker_id)
|
||||
service.control_plane.scheduler.on_disconnected(worker_id)
|
||||
service.control_plane.pool.breakers.forget_worker(worker_id)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return {"ok": True, "revoked": worker_id}
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(limit: int = 50) -> dict:
|
||||
"""Recent remote tasks, for the queue view."""
|
||||
if not service.control_plane.running:
|
||||
return {"tasks": [], "queue_depth": 0}
|
||||
from worker import task_store # noqa: PLC0415
|
||||
|
||||
return {
|
||||
"queue_depth": service.control_plane.scheduler.queue_depth,
|
||||
"tasks": [t.to_dict() for t in task_store.list_tasks(limit=min(200, max(1, limit)))],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
async def submit_task(request: Request, body: SubmitTaskRequest) -> dict:
|
||||
"""Run one task on a remote worker and wait for it. **DEV ONLY.**
|
||||
|
||||
This is the producer the remote pipeline never had: until it existed the
|
||||
scheduler had no caller outside the test suite, so picking a remote GPU
|
||||
changed the badge and nothing else — every job still ran locally. It is
|
||||
the smallest thing that makes remote execution observable end to end, not
|
||||
the shipping surface: the GPU gateway takes over routing real generation
|
||||
and this endpoint goes with it.
|
||||
|
||||
Loopback-only and behind the same opt-in as the rest of the feature, so a
|
||||
user who never enabled remote workers cannot reach it at all.
|
||||
"""
|
||||
from worker.lifecycle import TaskState # noqa: PLC0415
|
||||
from worker.scheduler import QueueFull, SchedulerStopped # noqa: PLC0415
|
||||
|
||||
if not service.remote_workers_enabled() or not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
if not routing.supports_operation(body.operation):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"'{body.operation}' does not run on a remote worker yet.",
|
||||
)
|
||||
|
||||
scheduler = service.control_plane.scheduler
|
||||
try:
|
||||
submit = getattr(scheduler, "submit_async", None)
|
||||
submit = submit if callable(submit) else scheduler.submit
|
||||
submitted = submit(
|
||||
operation=body.operation,
|
||||
engine=body.engine,
|
||||
model_id=body.model_id,
|
||||
params=body.params,
|
||||
idempotency_key=body.idempotency_key or None,
|
||||
deadline_seconds=body.deadline_seconds,
|
||||
pinned_worker_id=routing.decide().worker_id or None,
|
||||
)
|
||||
task = await submitted if asyncio.iscoroutine(submitted) else submitted
|
||||
except QueueFull as exc:
|
||||
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
||||
|
||||
settled = None
|
||||
reason = "the request was interrupted"
|
||||
try:
|
||||
settled = await _await_terminal(
|
||||
request, scheduler, task.task_id, timeout=body.deadline_seconds
|
||||
)
|
||||
except _ClientGone:
|
||||
reason = "the client disconnected"
|
||||
raise HTTPException(status_code=499, detail="The client stopped waiting.") from None
|
||||
except _WaitExpired:
|
||||
reason = "the task passed its deadline"
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"The task did not finish within {body.deadline_seconds:g}s.",
|
||||
) from None
|
||||
except SchedulerStopped as exc:
|
||||
# Deliberately no cancel: the worker was never told to stop and may
|
||||
# still be rendering, so claiming the task is cancelled would be a
|
||||
# statement about someone else's GPU that we cannot make.
|
||||
reason = None
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from None
|
||||
finally:
|
||||
# Nothing else will stop it: a worker holds its slot — often its only
|
||||
# one — until the control plane says otherwise, and the sweeper only
|
||||
# enforces deadlines on tasks that are still queued. Swallowed because
|
||||
# a failure here would replace the caller's real error with a 500.
|
||||
if settled is None and reason is not None:
|
||||
try:
|
||||
await service.control_plane.cancel(task.task_id, reason=reason)
|
||||
except Exception:
|
||||
logger.exception("Could not cancel abandoned remote task %s", task.task_id)
|
||||
|
||||
payload = settled.to_dict()
|
||||
if settled.state is TaskState.COMPLETED:
|
||||
return payload
|
||||
# A failure that answered 200 would be indistinguishable from success to
|
||||
# anything that does not read `state` — which is the whole point of this
|
||||
# endpoint existing before the gateway does.
|
||||
raise HTTPException(
|
||||
status_code=409 if settled.state is TaskState.CANCELLED else 502, detail=payload
|
||||
)
|
||||
|
||||
|
||||
async def _await_terminal(request: Request, scheduler, task_id: str, *, timeout: float):
|
||||
"""Wait for a terminal task, giving up if the caller does first."""
|
||||
waiter = asyncio.ensure_future(scheduler.wait(task_id, timeout=timeout))
|
||||
while True:
|
||||
done, _pending = await asyncio.wait({waiter}, timeout=_DISCONNECT_POLL_SECONDS)
|
||||
if done:
|
||||
try:
|
||||
settled = waiter.result()
|
||||
except (asyncio.TimeoutError, TimeoutError) as exc:
|
||||
raise _WaitExpired() from exc
|
||||
if settled is None or not settled.state.terminal:
|
||||
raise _WaitExpired()
|
||||
return settled
|
||||
if await request.is_disconnected():
|
||||
waiter.cancel()
|
||||
raise _ClientGone()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/cancel")
|
||||
async def cancel_task(task_id: str) -> dict:
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(status_code=409, detail="Remote workers are turned off.")
|
||||
cancelled = await service.control_plane.cancel(task_id, reason="cancelled by user")
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=404, detail="No such active task.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Inbound mode ───────────────────────────────────────────────────────────
|
||||
#
|
||||
# The other direction: this machine accepts connections from panels, or dials
|
||||
# out to nodes that do. Outbound enrollment above is unchanged and remains the
|
||||
# default — see docs/adr/inbound-node-mode.md for why this exists alongside it
|
||||
# rather than replacing it.
|
||||
|
||||
|
||||
class InboundEnableRequest(BaseModel):
|
||||
enabled: bool
|
||||
# Widening the bind is a separate decision from turning the feature on,
|
||||
# so it is a separate field with a safe default rather than a flag that
|
||||
# rides along with `enabled`.
|
||||
bind: str = ""
|
||||
port: int = 0
|
||||
|
||||
|
||||
class IssueKeyRequest(BaseModel):
|
||||
label: str = Field(default="", max_length=64)
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
connection_string: str = Field(min_length=1, max_length=512)
|
||||
|
||||
|
||||
@router.get("/inbound")
|
||||
def inbound_status() -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
return {
|
||||
**inbound_service.node.snapshot(),
|
||||
"connections": inbound_service.outbound.snapshot(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/inbound/enabled")
|
||||
async def set_inbound_enabled(request: InboundEnableRequest) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if inbound_service.enabled_override() is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Accept connections is controlled by OMNIVOICE_INBOUND_NODE on this "
|
||||
"machine. Change that environment setting and restart VoiceStudio."
|
||||
),
|
||||
)
|
||||
|
||||
requested_bind = (
|
||||
inbound_service.normalise_bind_host(request.bind)
|
||||
if request.bind
|
||||
else inbound_service.bind_host()
|
||||
)
|
||||
requested_port = request.port or inbound_service.bind_port()
|
||||
if (
|
||||
request.enabled
|
||||
and inbound_service.node.running
|
||||
and (
|
||||
requested_bind != inbound_service.bind_host()
|
||||
or requested_port != inbound_service.node.port
|
||||
)
|
||||
):
|
||||
# start() is intentionally idempotent while a listener owns its
|
||||
# socket. Persisting a new endpoint here would make the UI report a
|
||||
# narrower/different bind while the original socket stayed live.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Turn off Accept connections before changing its bind address "
|
||||
"or port."
|
||||
),
|
||||
)
|
||||
if request.bind:
|
||||
inbound_service.set_bind_host(requested_bind)
|
||||
if request.port:
|
||||
inbound_service.set_bind_port(request.port)
|
||||
inbound_service.set_enabled(request.enabled)
|
||||
|
||||
if inbound_service.enabled():
|
||||
await inbound_service.node.start()
|
||||
if inbound_service.node.startup_error:
|
||||
logger.error("Inbound worker listener failed to start; details withheld.")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"The inbound worker listener could not start; "
|
||||
"check the backend log for details."
|
||||
),
|
||||
)
|
||||
else:
|
||||
await inbound_service.node.stop()
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/keys")
|
||||
def issue_inbound_key(request: IssueKeyRequest) -> dict:
|
||||
"""Mint one panel's key and return the string it pastes.
|
||||
|
||||
The secret is in this response and nowhere else afterwards — only its hash
|
||||
is stored, so it cannot be shown again, only replaced.
|
||||
"""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.keys import KeyLimitExceeded # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"This machine is not accepting connections yet. Turn on "
|
||||
"Settings → System → Remote workers → Accept connections first."
|
||||
),
|
||||
)
|
||||
try:
|
||||
issued = inbound_service.node.keys.issue(request.label)
|
||||
except KeyLimitExceeded as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {
|
||||
"key_id": issued.key.key_id,
|
||||
"label": issued.key.label,
|
||||
"connection_string": inbound_service.node.connection_string(issued.secret),
|
||||
"exposed": inbound_service.is_exposed(),
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/inbound/keys/{key_id}")
|
||||
async def revoke_inbound_key(key_id: str) -> dict:
|
||||
"""Revoke one panel. Everyone else stays connected — the whole reason keys
|
||||
are per panel rather than one shared node key."""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not await inbound_service.node.revoke_key(key_id):
|
||||
raise HTTPException(status_code=404, detail="No such key.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/sessions/{session_id}/disconnect")
|
||||
def disconnect_inbound_session(session_id: str) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
|
||||
if not inbound_service.node.log.kick(session_id):
|
||||
raise HTTPException(status_code=404, detail="That connection has already ended.")
|
||||
return inbound_service.node.snapshot()
|
||||
|
||||
|
||||
@router.post("/inbound/connections")
|
||||
async def add_inbound_connection(request: ConnectRequest) -> dict:
|
||||
"""Paste a connection string from a GPU machine and dial it."""
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.connection_string import InvalidConnectionString # noqa: PLC0415
|
||||
from worker.inbound.connector import InboundConnectionError # noqa: PLC0415
|
||||
|
||||
if not service.control_plane.running:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Remote workers are turned off. Enable them in "
|
||||
"Settings → System → Remote workers first."
|
||||
),
|
||||
)
|
||||
try:
|
||||
connection = await inbound_service.outbound.add(
|
||||
request.connection_string, service.control_plane.servicer
|
||||
)
|
||||
except InvalidConnectionString as exc:
|
||||
# 400 with the parser's own words: every one of these otherwise
|
||||
# surfaces as "cannot connect", which is what a firewall, a wrong port
|
||||
# and a dead node all say too.
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except InboundConnectionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"endpoint": connection.endpoint, "connections": inbound_service.outbound.snapshot()}
|
||||
|
||||
|
||||
@router.delete("/inbound/connections/{endpoint}")
|
||||
async def remove_inbound_connection(endpoint: str) -> dict:
|
||||
from worker.inbound import service as inbound_service # noqa: PLC0415
|
||||
from worker.inbound.connector import InboundConnectionError # noqa: PLC0415
|
||||
|
||||
try:
|
||||
await inbound_service.outbound.remove(endpoint)
|
||||
except InboundConnectionError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"connections": inbound_service.outbound.snapshot()}
|
||||
@@ -26,21 +26,6 @@ class SystemInfoResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
app_version: str = ""
|
||||
# Effective compute-time budgets (seconds) for one synthesis job — the
|
||||
# values services/model_manager.py's GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S
|
||||
# captured at backend import time (#1787). A value just saved via
|
||||
# /system/set-env is NOT reflected here until the next restart.
|
||||
generate_timeout_s: float = 300.0
|
||||
cpu_generate_timeout_s: float = 600.0
|
||||
# True when an external env var (shell, `.env`, Docker, …) is currently
|
||||
# shadowing a prefs.json save for this key — see core.prefs.is_env_shadowed.
|
||||
generate_timeout_shadowed: bool = False
|
||||
cpu_generate_timeout_shadowed: bool = False
|
||||
# #1770: the desktop attach handshake's code fingerprint — whatever
|
||||
# Tauri set OMNIVOICE_BUILD_FINGERPRINT to when it spawned this process,
|
||||
# echoed back verbatim. Blank when unset (dev mode, a manually started
|
||||
# backend). See frontend/src-tauri/src/backend.rs::code_fingerprint_is_current.
|
||||
code_fingerprint: str = ""
|
||||
data_dir: str
|
||||
outputs_dir: str
|
||||
crash_log_path: str
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,720
|
||||
VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:15,000
|
||||
VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer.
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:16,560
|
||||
VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,200
|
||||
VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"rendered_by": "omnivoice engine + ffmpeg showwaves",
|
||||
"rendered_at": "2026-08-12T19:47:29Z",
|
||||
"license": "MIT (synthetic, no third-party IP)",
|
||||
"source": {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
"video": "source.mp4",
|
||||
"srt": "source.srt",
|
||||
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
},
|
||||
"dubbed": [
|
||||
{
|
||||
"code": "es",
|
||||
"label": "Español",
|
||||
"video": "dubbed_es.mp4",
|
||||
"srt": "dubbed_es.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"label": "Français",
|
||||
"video": "dubbed_fr.mp4",
|
||||
"srt": "dubbed_fr.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"label": "中文",
|
||||
"video": "dubbed_zh.mp4",
|
||||
"srt": "dubbed_zh.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"label": "日本語",
|
||||
"video": "dubbed_ja.mp4",
|
||||
"srt": "dubbed_ja.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user