Compare commits
498
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60a4743a63 | ||
|
|
d5b9f17c1a | ||
|
|
cb581f38df | ||
|
|
efa750bc88 | ||
|
|
d04611cbc5 | ||
|
|
ce1f7c3b83 | ||
|
|
ca14764083 | ||
|
|
dffb02e953 | ||
|
|
92b1ee5d1b | ||
|
|
9760f0c501 | ||
|
|
41a8a7b644 | ||
|
|
41722afe3b | ||
|
|
41c098e009 | ||
|
|
a310141114 | ||
|
|
4fc07c21fd | ||
|
|
8fdc5292e4 | ||
|
|
a4fd62f85f | ||
|
|
6eaeb23403 | ||
|
|
fa8fddc456 | ||
|
|
7a7ce47b64 | ||
|
|
7810581beb | ||
|
|
a089359b02 | ||
|
|
4227cbd07d | ||
|
|
4b86692653 | ||
|
|
b99eadfafc | ||
|
|
3f32f97e09 | ||
|
|
45eda7db01 | ||
|
|
fce5075e77 | ||
|
|
0fb9b38bfa | ||
|
|
64973d1829 | ||
|
|
57e435d3c0 | ||
|
|
20d9cb5f1b | ||
|
|
d9a939dff8 | ||
|
|
6f1e26a950 | ||
|
|
06ca67650e | ||
|
|
2e8a08973d | ||
|
|
f47cec164f | ||
|
|
6c58294d56 | ||
|
|
b548a7ab9d | ||
|
|
b74703d506 | ||
|
|
76d8c11553 | ||
|
|
5818e19137 | ||
|
|
80eca8cc6f | ||
|
|
037a5689de | ||
|
|
5b806124c2 | ||
|
|
50dd851bf8 | ||
|
|
2165339a36 | ||
|
|
b0a6fdfdc2 | ||
|
|
ffc6fade50 | ||
|
|
5bb50f7832 | ||
|
|
642513d205 | ||
|
|
090cc37144 | ||
|
|
0f69b4d3ca | ||
|
|
232784cb5a | ||
|
|
32bd5cfedb | ||
|
|
6ba2e2a914 | ||
|
|
d5a496a8ad | ||
|
|
6a6f3fbc29 | ||
|
|
8a8059b9c6 | ||
|
|
5ebf21166d | ||
|
|
b8fb5a14c2 | ||
|
|
17a364c476 | ||
|
|
ce5f051252 | ||
|
|
1ac3dcf3fe | ||
|
|
6535ed3284 | ||
|
|
5ef5d9b73a | ||
|
|
735cdd6b6f | ||
|
|
6c679bfd75 | ||
|
|
16a1ee63ee | ||
|
|
2b0cd04599 | ||
|
|
f0382e0290 | ||
|
|
99e865600b | ||
|
|
680fa8fdef | ||
|
|
40569d0657 | ||
|
|
cf316b18bc | ||
|
|
5832a81bb6 | ||
|
|
9ef0f4a61b | ||
|
|
8e5a023058 | ||
|
|
b718b2be46 | ||
|
|
8d8765315f | ||
|
|
dc5c9cf43e | ||
|
|
fd7f06d62e | ||
|
|
33714b2fe0 | ||
|
|
008c8a70a6 | ||
|
|
4f2dea97b8 | ||
|
|
3dbae35feb | ||
|
|
569517e5d8 | ||
|
|
eaad1017df | ||
|
|
e121e69d0f | ||
|
|
0988a48caa | ||
|
|
9930a0b41a | ||
|
|
2b6f49c596 | ||
|
|
29ccf8ee52 | ||
|
|
53cb316854 | ||
|
|
e6096a4eab | ||
|
|
eb97a54105 | ||
|
|
9687611e6f | ||
|
|
ef671de36e | ||
|
|
1fc0b89778 | ||
|
|
a95afc28ea | ||
|
|
13c342c238 | ||
|
|
19e352560e | ||
|
|
606d02a3ea | ||
|
|
b28d0f5f08 | ||
|
|
539a8bb571 | ||
|
|
e369179efb | ||
|
|
918fca3ead | ||
|
|
465b08cb9c | ||
|
|
b52165d2a7 | ||
|
|
441099bf68 | ||
|
|
c960dcb7e2 | ||
|
|
283642340b | ||
|
|
8b6b9383f7 | ||
|
|
ed48861008 | ||
|
|
50e9b9795e | ||
|
|
673e544812 | ||
|
|
c0e1753248 | ||
|
|
c16db04f86 | ||
|
|
de9e488238 | ||
|
|
670d9bc37a | ||
|
|
aa1d739843 | ||
|
|
7a3367be53 | ||
|
|
cedd038e3e | ||
|
|
b7caa494eb | ||
|
|
bda169c900 | ||
|
|
fe467d15e7 | ||
|
|
b54cd28403 | ||
|
|
c88955fb37 | ||
|
|
69a6867e5c | ||
|
|
935deba22b | ||
|
|
084c1d1ebb | ||
|
|
137c76abb9 | ||
|
|
8af10ea0b9 | ||
|
|
7eec1d7d11 | ||
|
|
aa14c9ade3 | ||
|
|
29232f4591 | ||
|
|
2d43fb5e7f | ||
|
|
c10596b02f | ||
|
|
5ae23f37d1 | ||
|
|
f34c2cad3e | ||
|
|
794756e601 | ||
|
|
7053f1d1c0 | ||
|
|
94af4c9f3a | ||
|
|
aaa5bfa0c6 | ||
|
|
bb5f7acbe5 | ||
|
|
e1964dfbb3 | ||
|
|
fb0d62e0c1 | ||
|
|
27d76955da | ||
|
|
ae247fa493 | ||
|
|
b68d1c68de | ||
|
|
04410a458d | ||
|
|
3df44486b2 | ||
|
|
9b73571497 | ||
|
|
c5ab4310bc | ||
|
|
c643706d07 | ||
|
|
54afcfb758 | ||
|
|
95a35b8e07 | ||
|
|
412740cbe5 | ||
|
|
34ff83a04a | ||
|
|
581c51662b | ||
|
|
7cde2fcd06 | ||
|
|
7924b35f8d | ||
|
|
9eb1ec7591 | ||
|
|
e6728cf068 | ||
|
|
3e292ba831 | ||
|
|
6db15aa556 | ||
|
|
a2745f1029 | ||
|
|
8b710a380b | ||
|
|
c4d241a15b | ||
|
|
53fcbd3316 | ||
|
|
6673190854 | ||
|
|
32d9a8a964 | ||
|
|
88906fb23f | ||
|
|
1437d37096 | ||
|
|
b90d6169d0 | ||
|
|
794942faf4 | ||
|
|
603402afce | ||
|
|
27a8f477b7 | ||
|
|
f45e8ecc07 | ||
|
|
53794d73ec | ||
|
|
a1a7d25342 | ||
|
|
fd2e39d5c0 | ||
|
|
7a78573a83 | ||
|
|
8c241bfbbb | ||
|
|
e086cb03f1 | ||
|
|
9ae554f0ac | ||
|
|
40662aeb06 | ||
|
|
74367e4bbf | ||
|
|
38a00cbf30 | ||
|
|
72ce9b79f2 | ||
|
|
7aed875fb3 | ||
|
|
f21b76350b | ||
|
|
c716b05aa9 | ||
|
|
27fcaabf0a | ||
|
|
bdcd3923eb | ||
|
|
02ec8e3675 | ||
|
|
047e7d901e | ||
|
|
8a58e30a8f | ||
|
|
4e401360ca | ||
|
|
9f2c8ac0a4 | ||
|
|
9aa82f3fd5 | ||
|
|
c1a1839911 | ||
|
|
586650e326 | ||
|
|
f991709dd9 | ||
|
|
c1793dac41 | ||
|
|
b8f44e089d | ||
|
|
657d633940 | ||
|
|
4f4d9c6e3e | ||
|
|
9b71eb5e46 | ||
|
|
77d6507318 | ||
|
|
1f875fa392 | ||
|
|
360ddf2152 | ||
|
|
14b00f681c | ||
|
|
e5bd11bf85 | ||
|
|
44190389a7 | ||
|
|
43de1c794c | ||
|
|
a540354cb7 | ||
|
|
dd2c306bc4 | ||
|
|
7a7dc422a6 | ||
|
|
5a043538b8 | ||
|
|
b3fc23316e | ||
|
|
566f822260 | ||
|
|
252c6fd149 | ||
|
|
820817ad68 | ||
|
|
30cb1ea3e6 | ||
|
|
d0909dcabe | ||
|
|
c2da73c5c2 | ||
|
|
d664c93140 | ||
|
|
aeda504a03 | ||
|
|
180d13c59e | ||
|
|
722a7a7e57 | ||
|
|
657dfe5ac6 | ||
|
|
501450e8ec | ||
|
|
0c5be01e39 | ||
|
|
5050c7da2b | ||
|
|
44bfc94c49 | ||
|
|
2cd0fd2972 | ||
|
|
7cc173d8be | ||
|
|
4259806d02 | ||
|
|
363f71feca | ||
|
|
fe29fe0966 | ||
|
|
1c289028d5 | ||
|
|
405888a21b | ||
|
|
f4c33f2f06 | ||
|
|
2fb0feae1a | ||
|
|
d1548f5f07 | ||
|
|
4d2595eb00 | ||
|
|
c7a2de9ff5 | ||
|
|
193d3b6dd3 | ||
|
|
321823bc52 | ||
|
|
9ee30766db | ||
|
|
1a71fbea72 | ||
|
|
6b1ae60cb5 | ||
|
|
e8eff3c6a0 | ||
|
|
2a81d76182 | ||
|
|
f70199db32 | ||
|
|
30d073d2dd | ||
|
|
f8f992ed9c | ||
|
|
cd48ba5537 | ||
|
|
fef07cee13 | ||
|
|
c4f9e787df | ||
|
|
6ec3cd30fc | ||
|
|
7a87e5b5cd | ||
|
|
654da87ee4 | ||
|
|
68029b40cb | ||
|
|
90f5a4fd1a | ||
|
|
ed03ab6b07 | ||
|
|
bbe9496bc6 | ||
|
|
c2b9951f28 | ||
|
|
fb78d87ad9 | ||
|
|
705544df96 | ||
|
|
cb33559138 | ||
|
|
368e777847 | ||
|
|
e65876bdcc | ||
|
|
73df87d54f | ||
|
|
80fc38169d | ||
|
|
0d3bc2d772 | ||
|
|
524a8cde1e | ||
|
|
3b808ed20d | ||
|
|
1551060172 | ||
|
|
25c84da606 | ||
|
|
e6b68aaf11 | ||
|
|
c8659eb36a | ||
|
|
87ee81c3d1 | ||
|
|
ff41aa5c8c | ||
|
|
3d7ec3bdab | ||
|
|
bebe9c6e26 | ||
|
|
369e9fddf9 | ||
|
|
c3d18412fe | ||
|
|
74e6d4582b | ||
|
|
bfbf78a270 | ||
|
|
a2f587bbc1 | ||
|
|
b5a056990d | ||
|
|
7fdc377d3d | ||
|
|
a756bc27fa | ||
|
|
c3ad072a84 | ||
|
|
e7f6f3beb5 | ||
|
|
fcdbac9683 | ||
|
|
b076dcf8aa | ||
|
|
d8c77210ee | ||
|
|
a7f0ab0838 | ||
|
|
67ccfafbd3 | ||
|
|
ac610b2aa4 | ||
|
|
270666fac8 | ||
|
|
ee313ad892 | ||
|
|
7c4e7f0cac | ||
|
|
8f449fa7ee | ||
|
|
c642379c3c | ||
|
|
7e55468892 | ||
|
|
aaba64e000 | ||
|
|
da31ef9aec | ||
|
|
e806470324 | ||
|
|
3c95d9c52d | ||
|
|
a7a5cf84be | ||
|
|
c4e13fc07c | ||
|
|
3dde7b52db | ||
|
|
6fd4db8779 | ||
|
|
2b79d43e56 | ||
|
|
c6f7167d06 | ||
|
|
82a7039988 | ||
|
|
16cbf1db3a | ||
|
|
b77e6b2030 | ||
|
|
28f40f1d59 | ||
|
|
cf59a508fe | ||
|
|
c3209e3d32 | ||
|
|
3634c3b9d6 | ||
|
|
d35e8e881d | ||
|
|
544d345069 | ||
|
|
bebe462dc6 | ||
|
|
c8e63082ea | ||
|
|
5be4a26903 | ||
|
|
7f67e1622b | ||
|
|
f122b84a7e | ||
|
|
600a8dc936 | ||
|
|
9e9c9d031f | ||
|
|
3f706336f6 | ||
|
|
c53f1a8d1c | ||
|
|
ae46f187d0 | ||
|
|
96a6c7574a | ||
|
|
6e7f12f391 | ||
|
|
fe856c9c5e | ||
|
|
511d21a8b9 | ||
|
|
8d4a9d11c8 | ||
|
|
86f49effcb | ||
|
|
ac8fef19a6 | ||
|
|
aa5de23bd1 | ||
|
|
e32f913315 | ||
|
|
b4d845535f | ||
|
|
77be03b130 | ||
|
|
c3de97ac95 | ||
|
|
ae87be6dd4 | ||
|
|
74511db595 | ||
|
|
c4a42d7e55 | ||
|
|
033feb457d | ||
|
|
d8268ca99c | ||
|
|
854a637b83 | ||
|
|
e0a2c84ed8 | ||
|
|
f6e01c142a | ||
|
|
4cd5d6681f | ||
|
|
c20e1c1685 | ||
|
|
196b03f943 | ||
|
|
fa3ba2f366 | ||
|
|
93143849d2 | ||
|
|
c9c60d182f | ||
|
|
3e6679c03e | ||
|
|
18118796f8 | ||
|
|
6f443ad387 | ||
|
|
7abc07e7f3 | ||
|
|
bc2c219726 | ||
|
|
f6f2bc5dcd | ||
|
|
9a770b15d1 | ||
|
|
b03b67ef00 | ||
|
|
7d6bd7af82 | ||
|
|
70a5d69622 | ||
|
|
f1aec13089 | ||
|
|
afc51b2920 | ||
|
|
f8eeb5a963 | ||
|
|
ce2f2e9397 | ||
|
|
400fd97810 | ||
|
|
034dd2333d | ||
|
|
4945dec04e | ||
|
|
b1b18dd1ba | ||
|
|
6bf6c37683 | ||
|
|
34edae313a | ||
|
|
b991e59a0f | ||
|
|
83ea2fe996 | ||
|
|
6079689500 | ||
|
|
7c64a7270c | ||
|
|
5c069feb50 | ||
|
|
828ac1a2c8 | ||
|
|
9f33129fee | ||
|
|
c870f794ed | ||
|
|
4d2ce060b4 | ||
|
|
4c3ac1ad59 | ||
|
|
31c654a35a | ||
|
|
1eb59c6f18 | ||
|
|
44f2cff6c1 | ||
|
|
961eee1b74 | ||
|
|
2df66e2628 | ||
|
|
30c05fa038 | ||
|
|
a47e59fda4 | ||
|
|
e80fcab8fa | ||
|
|
b3b2bc3e44 | ||
|
|
187f5a4b24 | ||
|
|
d8d5a02f54 | ||
|
|
21d42aca45 | ||
|
|
992a63b8ad | ||
|
|
c46ad1bed8 | ||
|
|
6b82aa348e | ||
|
|
bcb547b9f2 | ||
|
|
a9cea4847f | ||
|
|
2a32ab8de2 | ||
|
|
b1d86b2c96 | ||
|
|
05b1d096b4 | ||
|
|
46c47b68ce | ||
|
|
ea2d7155bc | ||
|
|
82687d849a | ||
|
|
14979c324a | ||
|
|
821ab3cb5d | ||
|
|
7865b6552d | ||
|
|
dd8143c088 | ||
|
|
0c82167839 | ||
|
|
abf0bcfcf0 | ||
|
|
129fee71b3 | ||
|
|
93025d9a81 | ||
|
|
74796bb2ab | ||
|
|
43d3537adf | ||
|
|
5cab8e0149 | ||
|
|
e61078fd09 | ||
|
|
67ab934907 | ||
|
|
a23e69d014 | ||
|
|
d2eaee9fde | ||
|
|
71ddfa5eda | ||
|
|
247057b449 | ||
|
|
e953af3730 | ||
|
|
85d8df2707 | ||
|
|
9877e7c218 | ||
|
|
eeffe6c2d1 | ||
|
|
0419f8dc0f | ||
|
|
fedb41ecbc | ||
|
|
df049e959f | ||
|
|
22301d4088 | ||
|
|
08a1b8f92c | ||
|
|
d23e56a5ec | ||
|
|
5d6e05ef1b | ||
|
|
973f7a77a6 | ||
|
|
e8bb174a0b | ||
|
|
c819372449 | ||
|
|
f259a3481f | ||
|
|
acd36badee | ||
|
|
3183bf5fcd | ||
|
|
c117bca09e | ||
|
|
946ef97af7 | ||
|
|
680c57f9b9 | ||
|
|
1cbd1c72fa | ||
|
|
097589295f | ||
|
|
1a4b95890a | ||
|
|
c03e3f2525 | ||
|
|
95de0afb02 | ||
|
|
810b598739 | ||
|
|
e60c5364ea | ||
|
|
202f1285aa | ||
|
|
8f9f778307 | ||
|
|
f3607918f9 | ||
|
|
5f39f9ff84 | ||
|
|
8da377a1cb | ||
|
|
a89c5e8fb3 | ||
|
|
f32f21e7ec | ||
|
|
168e8e5c61 | ||
|
|
850ae8e192 | ||
|
|
26bcd95088 | ||
|
|
576756f2ad | ||
|
|
9c0ca38b3f | ||
|
|
0c4d5073db | ||
|
|
15afc6611d | ||
|
|
2de27038d9 | ||
|
|
42d017ef9b | ||
|
|
b4fc74fa65 | ||
|
|
1600f02abf | ||
|
|
7dd7e89e7b | ||
|
|
4f1135ac62 | ||
|
|
817e9a9c8b | ||
|
|
8287d0c476 | ||
|
|
5afcdf787d | ||
|
|
7db063dede | ||
|
|
d76c2ba675 | ||
|
|
0e9733bc16 | ||
|
|
47c51698c9 | ||
|
|
36e3397613 | ||
|
|
07263ef42e | ||
|
|
3553965f41 | ||
|
|
602ea6f53e | ||
|
|
574b2832c5 | ||
|
|
98c6d8bccf | ||
|
|
b01c635bfe | ||
|
|
e9307f2dc0 | ||
|
|
c3d0d6b123 | ||
|
|
f99832de90 |
@@ -1,13 +1,13 @@
|
||||
---
|
||||
name: owner-judge
|
||||
description: Reviews proposed changes to OmniVoice Studio 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.
|
||||
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 **OmniVoice Studio** the way its owner would. You are a
|
||||
You review changes to **VoiceStudio** the way its owner would. You are a
|
||||
**critic**, not an approver.
|
||||
|
||||
## What you are, precisely
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
name: omnivoice
|
||||
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'."
|
||||
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'."
|
||||
---
|
||||
|
||||
# OmniVoice
|
||||
# VoiceStudio
|
||||
|
||||
## Overview
|
||||
|
||||
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`.
|
||||
Generate audio locally via the VoiceStudio MCP server. Tools: `generate_speech`, `list_voices`, `list_personalities`, `list_languages`, `check_health`. Resources: `voice://{id}`, `history://recent`.
|
||||
|
||||
## Prerequisites — Backend Must Be Running
|
||||
|
||||
The MCP tools all hit `$OMNIVOICE_API_URL` (default `http://localhost:3900`). If the backend is down, every tool returns a connection error. Install + boot:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git "$OMNIVOICE_HOME"
|
||||
git clone https://github.com/debpalash/VoiceStudio.git "$OMNIVOICE_HOME"
|
||||
cd "$OMNIVOICE_HOME"
|
||||
uv sync
|
||||
VIRTUAL_ENV="$(pwd)/.venv" uv pip install 'mcp[cli]'
|
||||
@@ -41,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 OmniVoice 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 VoiceStudio over kokoro / Edge TTS / ElevenLabs), see [references/engines-comparison.md](references/engines-comparison.md).
|
||||
|
||||
For MCP wiring details, backend lifecycle, troubleshooting, and a clean teardown, see [references/mcp-setup.md](references/mcp-setup.md).
|
||||
|
||||
@@ -52,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 OmniVoice generating speech locally.",
|
||||
text="Hello — this is VoiceStudio generating speech locally.",
|
||||
profile_id="demo0001",
|
||||
language="English",
|
||||
steps=16, # 8 = fast/draft · 16 = balanced · 32 = quality
|
||||
@@ -148,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 OmniVoice
|
||||
## When NOT to use VoiceStudio
|
||||
|
||||
- **Fast English-only narration on weak hardware** → `kokoro-tts` is ~10× smaller and 2× realtime on CPU (see [references/engines-comparison.md](references/engines-comparison.md))
|
||||
- **Lowest-friction one-off TTS** → Edge TTS needs no install or backend
|
||||
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; OmniVoice ties or wins on multilingual + cloning
|
||||
- **Real-time streaming dictation** → use the OmniVoice desktop widget (`⌘+⇧+Space`), not the MCP server
|
||||
- **Highest possible quality regardless of cost** → ElevenLabs still wins on English narration polish; VoiceStudio ties or wins on multilingual + cloning
|
||||
- **Real-time streaming dictation** → use the VoiceStudio desktop widget (`⌘+⇧+Space`), not the MCP server
|
||||
|
||||
## Resources
|
||||
|
||||
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across OmniVoice / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
|
||||
- [references/engines-comparison.md](references/engines-comparison.md) — Decision tree across VoiceStudio / kokoro / Voicebox / Edge TTS / ElevenLabs / cloud APIs
|
||||
- [references/mcp-setup.md](references/mcp-setup.md) — MCP wiring, backend lifecycle, env vars, troubleshooting
|
||||
- [scripts/check-health.sh](scripts/check-health.sh) — `curl /health`, exit 0/1
|
||||
- [scripts/start-backend.sh](scripts/start-backend.sh) — Start uvicorn on 127.0.0.1:3900 with health probe
|
||||
@@ -166,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/OmniVoice-Studio — FSL-1.1-ALv2 (free for personal/internal/non-commercial; auto-converts to Apache-2.0 two years after each release).
|
||||
Upstream: github.com/debpalash/VoiceStudio — FSL-1.1-ALv2 (free for personal/internal/non-commercial; auto-converts to Apache-2.0 two years after each release).
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
# TTS Engine Selection — Decision Tree
|
||||
|
||||
When to pick OmniVoice vs other engines available in this workspace. Match the user's constraint to the right column.
|
||||
When to pick VoiceStudio vs other engines available in this workspace. Match the user's constraint to the right column.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```
|
||||
Is voice cloning required?
|
||||
├─ yes → OmniVoice (3-sec ref clip, zero-shot, 646 langs)
|
||||
├─ yes → VoiceStudio (3-sec ref clip, zero-shot, 646 langs)
|
||||
└─ no →
|
||||
Is the language non-English?
|
||||
├─ yes → OmniVoice (646 langs) or Edge TTS (subset, cloud)
|
||||
├─ yes → VoiceStudio (646 langs) or Edge TTS (subset, cloud)
|
||||
└─ no (English) →
|
||||
Is privacy required (no cloud)?
|
||||
├─ yes →
|
||||
│ Is GPU available?
|
||||
│ ├─ yes (CUDA/MPS) → OmniVoice (best quality) or Voicebox
|
||||
│ └─ no (CPU only) → kokoro-tts (2× realtime CPU) or OmniVoice on CPU (slow)
|
||||
│ ├─ yes (CUDA/MPS) → VoiceStudio (best quality) or Voicebox
|
||||
│ └─ no (CPU only) → kokoro-tts (2× realtime CPU) or VoiceStudio 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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **OmniVoice** | 8-9/10 | ✅ 3-sec ref | 646 langs | Free | Local | Bun + uv install | Multilingual, cloning, privacy-critical |
|
||||
| **VoiceStudio** | 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 OmniVoice |
|
||||
| Voicebox (Qwen3-TTS) | 8-9/10 | ✅ | Multi | Free | Local | Docker | Self-hosted alternative to VoiceStudio |
|
||||
| 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 OmniVoice wins decisively
|
||||
## When VoiceStudio wins decisively
|
||||
|
||||
1. **Voice cloning** — 3-sec reference clip, zero-shot, no fine-tuning. ElevenLabs is the only competitor; OmniVoice is free and local.
|
||||
1. **Voice cloning** — 3-sec reference clip, zero-shot, no fine-tuning. ElevenLabs is the only competitor; VoiceStudio 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. OmniVoice is free at any volume.
|
||||
5. **Bulk generation without metered cost** — ElevenLabs bills per character. VoiceStudio is free at any volume.
|
||||
|
||||
## When OmniVoice loses
|
||||
## When VoiceStudio 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 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.
|
||||
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.
|
||||
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
|
||||
|
||||
OmniVoice fits between visual asset generation and video assembly:
|
||||
VoiceStudio fits between visual asset generation and video assembly:
|
||||
|
||||
```
|
||||
research → narrative → visual assets → AUDIO (OmniVoice) → video assembly → distribution
|
||||
research → narrative → visual assets → AUDIO (VoiceStudio) → video assembly → distribution
|
||||
```
|
||||
|
||||
Default for blog-post audio narration:
|
||||
|
||||
- **English, no cloning needed, fast** → kokoro-tts (cheap CPU)
|
||||
- **English, want a specific cloned voice** → OmniVoice with a saved profile
|
||||
- **Non-English** → OmniVoice
|
||||
- **English, want a specific cloned voice** → VoiceStudio with a saved profile
|
||||
- **Non-English** → VoiceStudio
|
||||
- **One-time, no install** → Edge TTS
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# OmniVoice MCP Setup, Lifecycle, Troubleshooting
|
||||
# VoiceStudio MCP Setup, Lifecycle, Troubleshooting
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Pick any location. The scripts in this skill default to ~/OmniVoice-Studio if
|
||||
# Pick any location. The scripts in this skill default to ~/VoiceStudio if
|
||||
# $OMNIVOICE_HOME is unset.
|
||||
export OMNIVOICE_HOME="${HOME}/OmniVoice-Studio"
|
||||
export OMNIVOICE_HOME="${HOME}/VoiceStudio"
|
||||
|
||||
git clone https://github.com/debpalash/OmniVoice-Studio.git "$OMNIVOICE_HOME"
|
||||
git clone https://github.com/debpalash/VoiceStudio.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 `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`.
|
||||
> **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`.
|
||||
|
||||
## 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` (OmniVoice redirects via `backend/core/config.py` to keep the cache off the system drive root)
|
||||
- **Windows**: `%LOCALAPPDATA%\OmniVoice\hf_cache` (VoiceStudio 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` | `~/OmniVoice-Studio` | Where the OmniVoice Studio repo is cloned (used by scripts in this skill) |
|
||||
| `OMNIVOICE_HOME` | `~/VoiceStudio` | Where the VoiceStudio 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/OmniVoice-Studio/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/VoiceStudio/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/OmniVoice/` 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/VoiceStudio/` on Linux). Preserve across reinstalls if you want to keep your saved voice profiles.
|
||||
|
||||
+5
-1
@@ -98,7 +98,11 @@ 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.
|
||||
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.
|
||||
- path: "tests/**/*.py"
|
||||
instructions: >-
|
||||
Review as a test-infrastructure engineer. Check: the test would fail
|
||||
|
||||
@@ -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
|
||||
**OmniVoice@palash.dev**.
|
||||
**VoiceStudio@palash.dev**.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
|
||||
+48
-13
@@ -1,18 +1,24 @@
|
||||
# Contributing to OmniVoice Studio
|
||||
# Contributing to VoiceStudio
|
||||
|
||||
Thanks for your interest in improving OmniVoice Studio! This guide covers everything you need to get started.
|
||||
Thanks for your interest in improving VoiceStudio! This guide covers everything you need to get started.
|
||||
|
||||
## Quick Links
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 💬 **Chat** | [Discord](https://discord.gg/bzQavDfVV9) |
|
||||
| 🐛 **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) |
|
||||
| 🐛 **Bugs** | [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues) |
|
||||
| 🏷️ **Good First Issues** | [Filtered list](https://github.com/debpalash/VoiceStudio/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
|
||||
@@ -22,13 +28,29 @@ Thanks for your interest in improving OmniVoice Studio! This guide covers everyt
|
||||
- [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 \
|
||||
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/OmniVoice-Studio.git
|
||||
cd OmniVoice-Studio
|
||||
git clone https://github.com/debpalash/VoiceStudio.git
|
||||
cd VoiceStudio
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
@@ -62,6 +84,18 @@ 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).
|
||||
@@ -72,7 +106,7 @@ The most common from-source cause is `uv` or Python not being on your PATH.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
OmniVoice-Studio/
|
||||
VoiceStudio/
|
||||
├── backend/ # Python FastAPI server
|
||||
│ ├── api/ # Route handlers
|
||||
│ ├── core/ # Config, prefs, constants
|
||||
@@ -96,7 +130,7 @@ OmniVoice-Studio/
|
||||
|
||||
### Bug Reports
|
||||
|
||||
Open an [issue](https://github.com/debpalash/OmniVoice-Studio/issues/new) with:
|
||||
Open an [issue](https://github.com/debpalash/VoiceStudio/issues/new) with:
|
||||
|
||||
1. **What happened** vs **what you expected**
|
||||
2. **Steps to reproduce**
|
||||
@@ -120,7 +154,7 @@ Open an [issue](https://github.com/debpalash/OmniVoice-Studio/issues/new) with:
|
||||
|
||||
### Adding a New TTS Engine
|
||||
|
||||
OmniVoice's TTS backend is a plugin registry. Adding a new engine takes ~50 lines:
|
||||
VoiceStudio'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`:
|
||||
@@ -168,7 +202,8 @@ class MyEngineBackend(TTSBackend):
|
||||
|
||||
- **Components**: Functional components with hooks
|
||||
- **State**: Zustand stores in `src/stores/`, organized by slice
|
||||
- **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`.)
|
||||
- **Brand assets**: Reuse the canonical mark, palette, naming, and compatibility rules in [`docs/branding.md`](../docs/branding.md); do not redraw or rename runtime identifiers ad hoc
|
||||
- **CSS**: **Utilities-first + shadcn/ui, one stylesheet.** UI is built on the shadcn/ui primitives in `src/components/ui/` (wrapped by the `src/ui/` barrel, themed to the VoiceStudio palette), composed with Tailwind v4 utility classes. **All styling now lives in a single file — `src/index.css`**: the `@theme` / `[data-theme]` token foundation plus the irreducible set utilities can't express (`@keyframes`, glassmorphism/`backdrop-filter`, pseudo-elements, `:has()`, unlayered cascade overrides, and styling hooks on library-generated DOM like virtualized rows / WaveSurfer). The per-component `.css` files were eliminated in the CSS→Tailwind/shadcn migration — **do not create new ones.** Reach for shadcn primitives + utilities; if a rule is genuinely irreducible, add it to `src/index.css` with a provenance comment. (The only other `.css` is the test-only visual harness. See `docs/shadcn-migration.md`.)
|
||||
- **Naming**: `PascalCase` for components, `camelCase` for hooks and utils
|
||||
|
||||
### Rust (Tauri)
|
||||
@@ -297,7 +332,7 @@ hard rules from the first prompt.
|
||||
|
||||
## Contribution licensing
|
||||
|
||||
OmniVoice Studio is **AGPL-3.0-only**, and the maintainer also offers a
|
||||
VoiceStudio is **AGPL-3.0-only**, and the maintainer also offers a
|
||||
**commercial license** (see [LICENSE](LICENSE)). By submitting a contribution
|
||||
you agree that:
|
||||
|
||||
@@ -317,7 +352,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/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
|
||||
- **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
|
||||
|
||||
Thank you for contributing! 🎙️
|
||||
|
||||
+1
-1
@@ -4,6 +4,6 @@
|
||||
ko_fi: debpalash
|
||||
custom:
|
||||
- "https://paypal.me/palashCoder"
|
||||
- "https://github.com/debpalash/OmniVoice-Studio/blob/main/SPONSORS.md"
|
||||
- "https://github.com/debpalash/VoiceStudio/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 OmniVoice Studio! 🎙️
|
||||
Thanks for helping improve VoiceStudio! 🎙️
|
||||
|
||||
**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/OmniVoice-Studio/issues?q=is%3Aissue) and this isn't a duplicate.
|
||||
- label: I searched [existing issues](https://github.com/debpalash/VoiceStudio/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/OmniVoice-Studio/discussions
|
||||
url: https://github.com/debpalash/VoiceStudio/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/OmniVoice-Studio/security/policy
|
||||
url: https://github.com/debpalash/VoiceStudio/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/OmniVoice-Studio/issues?q=is%3Aissue) and [discussions](https://github.com/debpalash/OmniVoice-Studio/discussions) for this idea.
|
||||
- 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.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
@@ -45,6 +45,6 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> OmniVoice is **local-first** — features must work fully offline with no accounts,
|
||||
> VoiceStudio is **local-first** — core features work offline without an account,
|
||||
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 OmniVoice and (optionally) claim a logo slot. Not for bugs or feature requests.
|
||||
description: Support VoiceStudio 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 **OmniVoice Studio** 💛
|
||||
Thanks for considering sponsoring **VoiceStudio** 💛
|
||||
|
||||
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.
|
||||
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.
|
||||
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 — OmniVoice 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 — VoiceStudio 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 OmniVoice 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 VoiceStudio permission to display it in the README, the app, and the project website.
|
||||
required: false
|
||||
|
||||
+4
-4
@@ -10,7 +10,7 @@
|
||||
|
||||
## Model supply chain
|
||||
|
||||
OmniVoice supports models from **public, verifiable sources only** (Hugging
|
||||
VoiceStudio 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/OmniVoice-Studio/security/advisories/new)
|
||||
1. **GitHub Security Advisories** (preferred) — [Report a vulnerability](https://github.com/debpalash/VoiceStudio/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
|
||||
|
||||
OmniVoice Studio runs **100% locally** by default. The primary attack surface is:
|
||||
VoiceStudio 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 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
+5
-5
@@ -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/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 |
|
||||
| [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 |
|
||||
| Security issues | **Never a public issue** — see [SECURITY.md](SECURITY.md) for private reporting |
|
||||
|
||||
## Uninstalling / removing all data
|
||||
|
||||
OmniVoice is fully local — there's nothing to deactivate, just folders to
|
||||
VoiceStudio is fully local — there's nothing to deactivate, just folders to
|
||||
delete. `scripts/uninstall.sh` (macOS/Linux) or `scripts\uninstall.ps1`
|
||||
(Windows) lists every OmniVoice folder with its size (dry-run first) and
|
||||
(Windows) lists every VoiceStudio 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
|
||||
|
||||
OmniVoice is built on the idea that everything it runs is **open and available
|
||||
VoiceStudio 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.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## Release cadence
|
||||
|
||||
OmniVoice ships **continuous-to-main** — no release candidates, no soak windows.
|
||||
VoiceStudio 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.
|
||||
|
||||
@@ -102,4 +102,6 @@ jobs:
|
||||
name: omnivoice-tts-${{ matrix.platform }}
|
||||
path: |
|
||||
bin/omnivoice-tts-${{ matrix.platform }}*
|
||||
bin/libggml*
|
||||
bin/ggml*.dll
|
||||
bin/checksums.sha256
|
||||
|
||||
+113
-16
@@ -23,6 +23,12 @@ jobs:
|
||||
test:
|
||||
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
|
||||
|
||||
@@ -51,7 +57,7 @@ jobs:
|
||||
|
||||
# apt install ffmpeg is ~30 s every run; cache the resolved .debs.
|
||||
- name: System deps (ffmpeg)
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
@@ -61,7 +67,14 @@ 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.
|
||||
run: uv sync --all-extras
|
||||
#
|
||||
# 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
|
||||
|
||||
# HF_HUB_OFFLINE=1 is a recurrence guard, not an optimization: a test
|
||||
# that reaches huggingface.co fails fast and loud instead of silently
|
||||
@@ -71,7 +84,7 @@ jobs:
|
||||
# interactions in tests are stubbed; anything that trips this is a
|
||||
# test-isolation bug.
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1"
|
||||
|
||||
@@ -83,12 +96,23 @@ 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 pytest backend/tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest backend/tests/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as tests/
|
||||
|
||||
@@ -262,12 +286,33 @@ 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 }}
|
||||
timeout-minutes: 10
|
||||
# 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
|
||||
env:
|
||||
# Restricted-network resilience (RESEARCH Pitfall #6) — keeps uv from
|
||||
# giving up on the first slow PyPI / python-build-standalone fetch.
|
||||
@@ -291,33 +336,85 @@ 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'
|
||||
if: runner.os == 'macOS' && matrix.backend_supported
|
||||
run: brew install ffmpeg libsndfile || true
|
||||
|
||||
- name: System deps (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
if: runner.os == 'Windows' && matrix.backend_supported
|
||||
shell: bash
|
||||
run: |
|
||||
# The community chocolatey feed 504s intermittently (broke a PR run
|
||||
# on 2026-07-20) — retry with backoff before failing the job.
|
||||
# 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 && break
|
||||
echo "choco attempt $i failed — retrying in $((i * 30))s"
|
||||
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
|
||||
ffmpeg -version
|
||||
|
||||
- name: System deps (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
if: runner.os == 'Linux' && matrix.backend_supported
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: ffmpeg libsndfile1
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
- 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: Run smoke tests
|
||||
run: uv run pytest tests/smoke/ -q --tb=short
|
||||
if: matrix.backend_supported
|
||||
run: uv run --no-sync pytest tests/smoke/ -q --tb=short
|
||||
env:
|
||||
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
|
||||
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
|
||||
|
||||
# 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
|
||||
|
||||
@@ -29,7 +29,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 OmniVoice (FastAPI
|
||||
# NOTE: the Docker image is the headless web-server build of VoiceStudio (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,7 +48,16 @@ permissions:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
# 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
|
||||
DOCKERHUB_IMAGE: palashdeb/omnivoice-studio
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
|
||||
@@ -38,14 +38,14 @@ jobs:
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Install deps
|
||||
run: uv sync
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
|
||||
- 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 python tests/evals/run_evals.py --output eval-report.json
|
||||
run: uv run --no-sync python tests/evals/run_evals.py --output eval-report.json
|
||||
|
||||
- name: Upload report artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -51,6 +51,19 @@ 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
|
||||
@@ -90,16 +103,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@latest
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: ffmpeg
|
||||
version: 1.0
|
||||
|
||||
- name: Install Python deps
|
||||
run: uv sync
|
||||
run: bash scripts/uv-sync-retry.sh
|
||||
|
||||
- name: Run pytest
|
||||
run: uv run pytest tests/ -q --tb=short
|
||||
run: uv run --no-sync pytest tests/ -q --tb=short
|
||||
|
||||
- name: Cache bun deps
|
||||
uses: actions/cache@v4
|
||||
@@ -515,6 +528,83 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Build + release (Tauri)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -552,7 +642,7 @@ 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 — OmniVoice Studio' || format('{0} — OmniVoice Studio', github.ref_name) }}
|
||||
releaseName: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'Preview — VoiceStudio' || format('{0} — VoiceStudio', 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' }}
|
||||
@@ -575,8 +665,10 @@ 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 — the volume name has a space ("OmniVoice
|
||||
# Studio"), so `awk '{print $3}'` would truncate it to /Volumes/OmniVoice.
|
||||
# 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.
|
||||
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; }
|
||||
@@ -629,7 +721,7 @@ jobs:
|
||||
echo "Smoke-testing MSI: $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/OmniVoice Studio"
|
||||
INSTALL="/c/Program Files/VoiceStudio"
|
||||
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.
|
||||
@@ -659,9 +751,16 @@ 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 "OmniVoice Studio" -o -name "omnivoice-studio" \) | grep -q .; } || fail "shell binary / AppRun missing"
|
||||
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "VoiceStudio" -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"
|
||||
@@ -843,7 +942,162 @@ 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 }}
|
||||
@@ -877,7 +1131,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 preview updater manifest (prerelease + platform parity)
|
||||
- name: Verify the published preview manifest (prerelease + parity + served bytes)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
@@ -902,6 +1156,24 @@ 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: |
|
||||
uv sync
|
||||
bash scripts/uv-sync-retry.sh
|
||||
uv run --with pip-audit pip-audit
|
||||
|
||||
# Pin a floor: `bun audit` was added in bun 1.2.x, so guarantee it exists.
|
||||
|
||||
+13
@@ -138,6 +138,10 @@ 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
|
||||
@@ -150,3 +154,12 @@ playwright-report/
|
||||
|
||||
# probe — generated HTML reports
|
||||
tests/probe/reports/
|
||||
|
||||
# Local architecture/planning scratch (goal docs, review briefs, council
|
||||
# reports). Working notes for whoever is driving a change, not a repo artifact.
|
||||
/remote/
|
||||
|
||||
# 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
|
||||
|
||||
+14
-4
@@ -1,7 +1,8 @@
|
||||
# Gitleaks config — extends the default ruleset.
|
||||
#
|
||||
# The ONLY sanctioned allowlist entry is PostHog's publishable project token
|
||||
# (owner decision 2026-07-20, #1193). Per PostHog's docs the `phc_` project
|
||||
# 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.
|
||||
@@ -12,7 +13,16 @@
|
||||
useDefault = true
|
||||
|
||||
[allowlist]
|
||||
description = "PostHog publishable write-only project token (public by design; #1193)"
|
||||
description = "Exact public/test literals misclassified as generic API keys"
|
||||
regexes = [
|
||||
'''phc_v5wMjnYMPMaEcRNLRKQsTYCzPaYWh7wcHPhXNkNajVf9''',
|
||||
# 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$''',
|
||||
]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Agent Rules — OmniVoice Studio
|
||||
# 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)
|
||||
## 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`).
|
||||
@@ -10,6 +11,11 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
|
||||
- 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.
|
||||
@@ -26,3 +32,17 @@ Binding for every AI agent (Claude, Codex, Cursor, review bots, …). CLAUDE.md
|
||||
- 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
|
||||
|
||||
### 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`.
|
||||
|
||||
+291
-44
@@ -1,17 +1,259 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to OmniVoice Studio.
|
||||
All notable changes to VoiceStudio.
|
||||
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
Versions track the desktop app (`tauri.conf.json` + `frontend/src-tauri/Cargo.toml`).
|
||||
The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
`frontend/package.json` is the app-version source of truth; Cargo, Python, and
|
||||
the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
|
||||
- Docker/server mode now requires an API key for remote changes and side-effectful admin checks across workers, engines, media tools, MCP, pronunciation, diagnostics, and LLM providers. (#1525) — thanks @bultodepapas!
|
||||
- The unified Support page no longer throws while opening a section in browsers or test environments without `scrollIntoView`. (#1525) — thanks @bultodepapas!
|
||||
- A faster, cleaner Dub workspace for multilingual production (#1489)
|
||||
- VoiceStudio now gives the app, desktop chrome, documentation, and package metadata one clear identity
|
||||
- A local-first creative studio: voice cloning, design, dubbing, dictation, stories, audiobooks, and transcription without a subscription meter
|
||||
- Reliability first: automatic cache repair, truthful hardware routing, safer sidecars, and actionable recovery instead of mystery failures
|
||||
- Security boundaries now match the product: native file access stays native, untrusted network destinations fail closed, and public errors keep private diagnostics local
|
||||
- RTX 40-series GPUs are used again instead of being sent to the CPU
|
||||
- A warning before a slow generation, rather than after a five-minute wait
|
||||
- The watermark can be turned off in Settings, as the docs always said
|
||||
- Your other GPU can take the work now — send individual jobs to a second machine, opt-in
|
||||
- More than one person can share one GPU machine, without shell access to it or taking turns
|
||||
- A Model Catalogue workspace: every engine and model in one place, with the defaults set there
|
||||
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
|
||||
- macOS support now matches what the app actually delivers
|
||||
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
|
||||
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
|
||||
- A failed audiobook chapter says why, instead of turning red and saying nothing
|
||||
|
||||
### Fixed
|
||||
|
||||
- The guard that keeps transcription on the degrading ASR loader now scans the whole backend, not just the routers — a service that transcribes on a request's behalf skipped `ensure_loaded()` just as thoroughly. (#1519) — thanks @ahov520!
|
||||
- The Linux app icon is no longer blank. Every AppImage since v0.4.2 shipped `.DirIcon` as an absolute symlink into the machine that built it (`/home/runner/work/…`), so the link dangled on every user's computer and file managers, app menus and desktop integration all drew nothing. The release build now verifies the icon resolves inside the bundle before publishing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
|
||||
### Added
|
||||
|
||||
- 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)
|
||||
|
||||
### Added
|
||||
|
||||
- A machine can now join a control plane from the app: Settings → System → Remote workers → **Lend this machine's GPU**, paste the join code, done — no environment variables and no restart. The address travels with the code, so the machine reconnects on its own afterwards. (#1516)
|
||||
- Join codes and connection strings are shown as a **QR code** alongside the text, with a live expiry countdown — scan it from the other machine instead of retyping forty characters. (#1516)
|
||||
- A **Compute** control in the status bar: pick local or a remote machine, turn remote workers on or off, and mint a join code without opening Settings. It appears only once you have opted in or enrolled a machine. (#1516)
|
||||
- A worker waiting for approval can be approved from its row. The panel labelled that state before but offered no way out of it. (#1516)
|
||||
- The demo audio the app has always advertised now actually ships: previews for all seven voice-design presets, the three dictation replay clips, and the dubbing demo's source video plus four dubbed languages with subtitles. Every one of those was a dead link before — the tooling that renders them required macOS, so on Windows and Linux the files were never built. (#1517)
|
||||
- Demo assets are rendered by VoiceStudio's own engine, so the tooling runs wherever the app does, and the demos are made by the thing they demonstrate. (#1517)
|
||||
|
||||
### Changed
|
||||
|
||||
- Sponsoring, commercial licensing and getting in touch are one page now. They answered the same question between them and each used to live somewhere else, so they are three sections on a single scroll — the footer heart, the commercial-licence links and Contact all land on it, at the section you asked for. (#1522)
|
||||
- Model Catalogue switches panes with tabs instead of a two-state toggle, and the Engine Compatibility Matrix's TTS / ASR / LLM switcher is now tabs too — arrow-key navigable, and each tab still shows the engine it would use. (#1522)
|
||||
- Engines you can actually use sort to the top of the compatibility matrix, and an unavailable engine's name recedes instead of the whole row fading — the status badge and GPU chips that say *why* it is unavailable stay legible. (#1522)
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- The GPU picker and the new status-bar control paint their status dots and menu surfaces from themed tokens instead of fixed palette classes, so they stop showing Gruvbox colours on Midnight and Catppuccin. (#1516)
|
||||
- Dictation shows the pill again: a capture puts a small always-on-top capsule near the bottom of the screen you are working on — listening, transcribing, the result, and any error — and takes it away when the session ends. It never takes focus, so the text still lands in the app you were typing into. On Wayland the compositor decides where it sits; everywhere else it is bottom-centred.
|
||||
- Remote workers reads as a device list: status dot, address, latency, a live task meter, resident models and last-seen per machine, with housekeeping actions revealed on hover and a three-step empty state. (#1516)
|
||||
- Engines and models moved out of Settings into a new Model Catalogue workspace, reachable from the icon rail (or the title-bar tabs); Settings → Engines and Settings → Models now point there, and Settings keeps the models directory and Hugging Face mirror.
|
||||
- The Settings sidebar is keyboard-navigable: ⌘K / Ctrl+K jumps to the filter, ↑/↓ and Home/End move between categories, and Enter or ↓ from the filter drops into the list. Matching text in a filtered category name is highlighted, and group headers stay pinned while the list scrolls.
|
||||
- The Launchpad has a quieter, more spacious look: borderless feature tiles that light up on hover or keyboard focus, plain-numeral counts, hairline section rules, and one shared page column for the hero, tiles, recent files and project lists.
|
||||
- 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
|
||||
|
||||
- **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)
|
||||
|
||||
### CI
|
||||
|
||||
- The stdio wire protocol every engine sidecar speaks is now tested once across all nine of them, instead of against a single engine — a bug in any one sidecar's copy gets caught — thanks @paoloantinori! (#1408)
|
||||
|
||||
### Fixed
|
||||
|
||||
- The Linux app icon is no longer blank: the AppImage shipped `.DirIcon` as a symlink into the machine that built it, so file managers and app menus drew nothing. (#1518)
|
||||
- The Linux desktop entry no longer ships an empty `Categories=`, which `desktop-file-validate` rejects and menu builders skip. (#1518)
|
||||
- Wayland: the dictation shortcut now actually starts dictation. The desktop portal registered the key correctly — GNOME and KDE even showed it back — but every press was discarded while decoding the compositor's signal, so the hotkey did nothing on any Wayland session. (#1490)
|
||||
- The first-run "Choose a comfortable UI size" screen no longer stutters while you sit there. Applying a scale resizes the window's own viewport, which the screen was reading back to re-pick a size — so it flipped between two sizes forever without anyone touching it. (#1514)
|
||||
- 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)
|
||||
|
||||
### Docs
|
||||
|
||||
- Engine acceptance: new `docs/engine-acceptance.md` documents the job map, the bar a new engine must clear, and the out-of-tree path (#1306)
|
||||
- macOS install notes and the README support table now state the real floor (#1268)
|
||||
- Contact: the project X account is listed alongside Discord (#1313)
|
||||
- `OMNIVOICE_ALLOWED_ORIGINS` is finally documented: a browser loading the UI from another machine's origin needs the backend's CORS allow-list, which neither server mode nor trusted networks touches — thanks @vanderlpp! (#1348)
|
||||
|
||||
### CI
|
||||
|
||||
- Windows 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 OmniVoice doesn't recognize" now say what actually went wrong
|
||||
- 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
|
||||
@@ -34,6 +276,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
|
||||
### 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
|
||||
@@ -44,7 +287,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- 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 OmniVoice 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)
|
||||
- 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)
|
||||
@@ -65,6 +308,10 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- 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**
|
||||
@@ -101,7 +348,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- 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/OmniVoice_Studio_Colab.ipynb`) — full app + API feature tour on a free T4
|
||||
- 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)
|
||||
@@ -131,7 +378,7 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
|
||||
- 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 OmniVoice backend" (#1177)
|
||||
- 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)
|
||||
@@ -195,11 +442,11 @@ The dubbing release. Dubbed videos stop sounding like a compromise: the music ke
|
||||
|
||||
- **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.** OmniVoice 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 OmniVoice"**, **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.
|
||||
- **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 OmniVoice 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).
|
||||
- **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
|
||||
|
||||
@@ -235,13 +482,13 @@ The dubbing release. Dubbed videos stop sounding like a compromise: the music ke
|
||||
|
||||
- **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 OmniVoice 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)
|
||||
- **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, OmniVoice 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)
|
||||
- **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.** OmniVoice 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)
|
||||
- **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)
|
||||
|
||||
@@ -255,59 +502,59 @@ 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 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.
|
||||
- **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.
|
||||
|
||||
- **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 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.
|
||||
- **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.
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
- **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.** OmniVoice 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.
|
||||
- **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 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.
|
||||
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.
|
||||
|
||||
### 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 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)
|
||||
- **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)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **"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)
|
||||
- **"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)
|
||||
|
||||
- **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 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.
|
||||
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.
|
||||
|
||||
### 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?"** 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)
|
||||
- **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)
|
||||
|
||||
### 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 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.
|
||||
- **"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.
|
||||
|
||||
- **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 OmniVoice Studio 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 VoiceStudio 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.
|
||||
|
||||
@@ -420,7 +667,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 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)
|
||||
- **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)
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -436,7 +683,7 @@ The community-fixes release. Two contributors didn't just report bugs — they d
|
||||
|
||||
### Changed
|
||||
|
||||
- **Removed the donate heart from the nav rail.** Support OmniVoice is still one click away from Settings and the Contact page.
|
||||
- **Removed the donate heart from the nav rail.** Support VoiceStudio is still one click away from Settings and the Contact page.
|
||||
|
||||
### CI
|
||||
|
||||
@@ -453,14 +700,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. 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)
|
||||
- **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)
|
||||
- **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 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)
|
||||
- **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)
|
||||
- **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 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)
|
||||
- **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)
|
||||
- **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)
|
||||
|
||||
@@ -510,15 +757,15 @@ The dictation release — and a deep reliability pass driven by live-testing the
|
||||
|
||||
### Added
|
||||
|
||||
- **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)
|
||||
- **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)
|
||||
- **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 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)
|
||||
- **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)
|
||||
- **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, 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)
|
||||
- **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)
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -527,7 +774,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 "OmniVoice Studio v0.3.8" hid the version; releases are now named "vX.Y.Z — OmniVoice Studio" so the version is always visible. (#922)
|
||||
- **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)
|
||||
- **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)
|
||||
@@ -557,7 +804,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 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)
|
||||
- **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)
|
||||
|
||||
### CI
|
||||
|
||||
@@ -780,9 +1027,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 OmniVoice's ffmpeg is a bundled
|
||||
it only looked on PATH — so on Windows (where VoiceStudio'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 OmniVoice resolves. (#712)
|
||||
could start. yt-dlp is now pointed at the same ffmpeg VoiceStudio 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
|
||||
@@ -880,7 +1127,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. OmniVoice downloads to a throwaway
|
||||
`[Errno 22]` and aborts the whole URL ingest. VoiceStudio downloads to a throwaway
|
||||
file and never uses its mtime, so it now skips the stamp entirely
|
||||
(`updatetime=False`). (#642)
|
||||
|
||||
@@ -977,7 +1224,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 OmniVoice TTS cache was missing weight shards (the usual aftermath of
|
||||
When the VoiceStudio 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
|
||||
@@ -1255,7 +1502,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 OmniVoice install. A privacy toggle ships a **preview-only** bundle so
|
||||
another VoiceStudio 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
|
||||
@@ -1267,7 +1514,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-OmniVoice-Routing` header instead
|
||||
host's GPU returns an explicit error or an `X-VoiceStudio-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
|
||||
@@ -1309,7 +1556,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.** OmniVoice mounts an MCP server on `/mcp` (with a stdio shim
|
||||
- **MCP server v1.** VoiceStudio 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
|
||||
@@ -1325,7 +1572,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 OmniVoice's own validated ffmpeg
|
||||
Windows). WhisperX now decodes audio through VoiceStudio'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
|
||||
@@ -1574,4 +1821,4 @@ Region selector, realtime download speed, retry buttons, recheck top-right, HF m
|
||||
|
||||
## Earlier releases
|
||||
|
||||
See [GitHub Releases](https://github.com/debpalash/OmniVoice-Studio/releases) for prior versions.
|
||||
See [GitHub Releases](https://github.com/debpalash/VoiceStudio/releases) for prior versions.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**OmniVoice Studio**
|
||||
**VoiceStudio**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**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,7 +13,7 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -76,7 +76,7 @@ Direct repo edits are authorized (owner decision, 2026-07-08). The GSD command g
|
||||
|
||||
**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):** 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.
|
||||
**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 -->
|
||||
@@ -89,3 +89,17 @@ 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`.
|
||||
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
# OmniVoice Studio — License Notice
|
||||
# VoiceStudio — License Notice
|
||||
|
||||
## Abbreviation
|
||||
|
||||
@@ -6,9 +6,9 @@ AGPL-3.0-only
|
||||
|
||||
## Notice
|
||||
|
||||
Copyright 2024-present Palash Debnath and OmniVoice Studio contributors.
|
||||
Copyright 2024-present Palash Debnath and VoiceStudio contributors.
|
||||
|
||||
OmniVoice Studio is **free and open-source software, licensed under the GNU
|
||||
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 — and that **includes commercial and internal
|
||||
business use**: run the app, use its outputs commercially, sell the audio you
|
||||
@@ -16,22 +16,22 @@ 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
|
||||
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
|
||||
OmniVoice Studio in a closed-source or proprietary product or service without
|
||||
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 `OmniVoice@palash.dev`.
|
||||
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 OmniVoice Studio application — the Tauri desktop shell
|
||||
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/`).
|
||||
|
||||
@@ -1,96 +1,57 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="OmniVoice Logo" width="120" />
|
||||
<h1>OmniVoice Studio</h1>
|
||||
<h3>The open-source ElevenLabs alternative.</h3>
|
||||
<p>Real-time dictation, zero-shot voice cloning, and cinematic video dubbing — all on your desktop.<br/><b>No accounts. No API keys. No cloud.</b> Everything runs on your machine. Open-source, <b>646 languages.</b></p>
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="120" height="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub><em>previously OmniVoice-Studio</em></sub></p>
|
||||
<h3>Make voices. Tell stories. Keep the files. ♡</h3>
|
||||
<p>Clone, design, dub, dictate, and build audiobooks in one open-source desktop studio.<br/><b>Local-first by default.</b> No subscription or usage meter. Optional online services stay opt-in.</p>
|
||||
|
||||
<p>
|
||||
<a href="#quickstart">Quickstart</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#why-ovs">vs Others</a> ·
|
||||
<a href="#why-voicestudio">Why VoiceStudio</a> ·
|
||||
<a href="#tts-engines">Engines</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">Donate</a> ·
|
||||
<a href="#contributing">Contributing</a> ·
|
||||
<a href="https://voicestudio.sh">Website</a> ·
|
||||
<a href="https://voicestudio.sh/docs">Docs</a> ·
|
||||
<a href="https://status.voicestudio.sh">Status</a> ·
|
||||
<a href="https://discord.gg/bzQavDfVV9">Discord</a> ·
|
||||
<a href="https://x.com/idebpalash">X</a> ·
|
||||
<a href="README_CN.md"><strong>简体中文</strong></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases"><img src="https://img.shields.io/github/downloads/debpalash/OmniVoice-Studio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Stars" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases"><img src="https://img.shields.io/github/downloads/debpalash/VoiceStudio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Release" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/X-Follow_for_updates-000000?style=flat-square&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download the latest release" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="Download the latest release" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://trendshift.io/repositories/28176?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/28176/daily?language=Python" alt="debpalash%2FOmniVoice-Studio | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/28176?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/28176/daily?language=Python" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="OmniVoice Studio — Launchpad" width="100%"/>
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — Launchpad" width="100%"/>
|
||||
</div>
|
||||
|
||||
> **Your voice is the most personal data you have. So why rent it back from a cloud?** Every mainstream voice tool ships your audio to someone else's server and bills you monthly for the privilege. OmniVoice Studio flips that: clone, design, dub, and dictate on your own hardware — 646 languages, no meter running, nothing leaving your machine.
|
||||
> **Your voice is personal. Your studio should feel personal too.** VoiceStudio keeps its core workflow on your hardware: clone, design, dub, dictate, and publish in 646 languages without a subscription or usage meter. Network-backed engines and services are optional, visible choices—not hidden requirements.
|
||||
|
||||
> [!WARNING]
|
||||
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/OmniVoice-Studio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
<a id="screenshots"></a>
|
||||
|
||||
## 📸 See it in action
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-studio.png" alt="Studio" width="100%"/>
|
||||
<br/><b>Studio</b><br/>
|
||||
<sub>Generate & clone in one workspace — a 3-second clip mirrors any voice, 646 languages, zero-shot.</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-design.png" alt="Voice Design" width="100%"/>
|
||||
<br/><b>Voice Design</b><br/>
|
||||
<sub>Build new voices from scratch — gender, age, accent, pitch, emotion, dialect.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-gallery.png" alt="Voice Gallery" width="100%"/>
|
||||
<br/><b>Voice Gallery</b><br/>
|
||||
<sub>Browse ready-made archetype voices with language filters, or build your own — then pick any of them in Studio, Audiobook, Stories, and Dubbing.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-dub.png" alt="Video Dubbing" width="100%"/>
|
||||
<br/><b>Video Dubbing</b><br/>
|
||||
<sub>A real dub, end to end: 37 segments transcribed, translated to Bengali, re-voiced, and timed — ready to export as MP4.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-engines.png" alt="Settings — Engines" width="100%"/>
|
||||
<br/><b>Settings → Engines</b><br/>
|
||||
<sub>The engine compatibility matrix — 14 TTS engines with per-engine GPU preflight, no silent CPU fallback.</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-settings.png" alt="Settings — Models" width="100%"/>
|
||||
<br/><b>Settings → Models</b><br/>
|
||||
<sub>One-click model store — auto-detects your platform (CUDA / MPS / CPU) and recommends the right models.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
> **Active beta.** Things may break between releases — for the newest fixes, run from source. Bug reports and PRs are very welcome: [open an issue](https://github.com/debpalash/VoiceStudio/issues) or [join Discord](https://discord.gg/bzQavDfVV9).
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
@@ -116,7 +77,7 @@ Three flagships, five more headliners, and a dozen under the fold.
|
||||
<td align="center" width="20%">📖<br/><b>Audiobook</b><br/><sub>EPUB/PDF → .m4b, multi-voice cast</sub></td>
|
||||
<td align="center" width="20%">🎭<br/><b>Stories</b><br/><sub>Multi-voice script editor</sub></td>
|
||||
<td align="center" width="20%">⌨️<br/><b>Dictation Widget</b><br/><sub><kbd>⌘⇧Space</kbd> in any app</sub></td>
|
||||
<td align="center" width="20%">🔐<br/><b>100% Local</b><br/><sub>No keys, no cloud, no accounts</sub></td>
|
||||
<td align="center" width="20%">🔐<br/><b>Local-first</b><br/><sub>Core creation stays on your machine</sub></td>
|
||||
<td align="center" width="20%">🤖<br/><b>MCP Server</b><br/><sub>Use from Claude, Cursor, …</sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -132,7 +93,9 @@ Three flagships, five more headliners, and a dozen under the fold.
|
||||
- 🛡️ **AI Watermark** — AudioSeal (Meta): invisible, survives compression.
|
||||
- 🔬 **Diagnostics** — self-check suite, error journal, scrubbed diagnostic bundles.
|
||||
- ⚡ **GPU Auto-Detect** — CUDA · MPS · ROCm (Linux, opt-in) · CPU; ≤8 GB VRAM auto-offloads.
|
||||
- 📥 **Remote Model Downloads** — install pinned model weights on the selected worker with live progress.
|
||||
- 🧭 **Engine routing** — preflight GPU check per engine; no silent CPU fallback.
|
||||
- 📚 **Model Catalogue** — one workspace listing every TTS/ASR/LLM engine and model: set the defaults, install or remove weights.
|
||||
- 🧩 **Extensible** — subclass `TTSBackend`, add any engine in ~50 lines.
|
||||
- 🎒 **Portable personas** — export voices as `.ovsvoice` bundles: identity + watermark.
|
||||
- ♾️ **Unlimited TTS** — sentence-chunked generation, no length cap, streaming via WebSocket.
|
||||
@@ -148,11 +111,11 @@ Three flagships, five more headliners, and a dozen under the fold.
|
||||
## ⚡ Quickstart
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Download Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
<sub><b>macOS:</b> first launch needs a one-time approval — right-click → <b>Open</b> (or System Settings → Privacy & Security → <b>"Open Anyway"</b> on macOS 15). No Terminal needed. <a href="docs/install/macos.md#gatekeeper-quarantine">Why?</a> · <b>Intel Macs:</b> local backend unsupported (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — <a href="docs/install/macos.md">details</a>.</sub>
|
||||
</div>
|
||||
|
||||
**Install guide:** [🍎 macOS](docs/install/macos.md) · [🪟 Windows](docs/install/windows.md) · [🐧 Linux](docs/install/linux.md) · [🐳 Docker](docs/install/docker.md)
|
||||
@@ -172,22 +135,22 @@ Three flagships, five more headliners, and a dozen under the fold.
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ovs"></a>
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## ⚖️ vs Others
|
||||
## ⚖️ Why VoiceStudio
|
||||
|
||||
ElevenLabs charges **$5–$330/mo** and processes your audio on their servers. OmniVoice Studio runs **on your hardware, with no usage limits.**
|
||||
Cloud voice tools are convenient, but they put your workflow behind an account, a meter, and somebody else's infrastructure. VoiceStudio gives you a capable studio that runs on your hardware, with optional integrations when you choose them.
|
||||
|
||||
| | **ElevenLabs** | **OmniVoice Studio** |
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
|---|---|---|
|
||||
| **Pricing** | $5–$330/mo, per-character billing | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Pricing** | Subscription and usage limits | Free & open-source (AGPL-3.0) · [Commercial license](#license) for proprietary use |
|
||||
| **Voice Cloning** | ✅ 3s clip | ✅ 3s clip, zero-shot |
|
||||
| **Voice Design** | ✅ Gender, age | ✅ Gender, age, accent, pitch, style, dialect |
|
||||
| **Audiobook / Stories** | ❌ | ✅ Full audiobook editor + multi-voice stories (EPUB/PDF import, .m4b export) |
|
||||
| **Languages** | 32 | **646** |
|
||||
| **Languages** | Plan/model dependent | **646** |
|
||||
| **Video Dubbing** | ✅ Cloud-only | ✅ Fully local |
|
||||
| **Data Privacy** | Audio sent to cloud | **Nothing leaves your machine** |
|
||||
| **API Keys** | Required | Not needed |
|
||||
| **Data Privacy** | Audio is processed remotely | Core workflow runs locally; online services are explicit opt-ins |
|
||||
| **API Keys** | Account required | Not needed for the local workflow |
|
||||
| **GPU Support** | N/A (cloud) | CUDA · Apple Silicon · ROCm (Linux) · CPU |
|
||||
| **Desktop App** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS Engines** | 1 | **14** — [full matrix](#tts-engines) |
|
||||
@@ -211,7 +174,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
| | **Minimum** | **Recommended** |
|
||||
|---|---|---|
|
||||
| **OS** | Windows 10, macOS 12+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
|
||||
| **OS** | Windows 10, macOS 13.3+ (Apple Silicon), Ubuntu 24.04+ (glibc 2.39+) | Any modern 64-bit OS |
|
||||
| **RAM** | 8 GB | 16 GB+ |
|
||||
| **VRAM (GPU)** | 4 GB (auto-offloads TTS to CPU) | 8 GB+ (NVIDIA RTX 3060+) |
|
||||
| **Disk** | 10 GB free (models + cache) | 20 GB+ SSD |
|
||||
@@ -219,13 +182,13 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
| **GPU** | Optional — CPU works | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm (Linux only) |
|
||||
|
||||
> [!NOTE]
|
||||
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889) · [macOS](docs/install/macos.md)).
|
||||
> **A GPU is optional** — the whole pipeline runs on CPU (just slower), and on ≤8 GB VRAM, TTS auto-offloads to CPU. Caveats: **AMD ROCm** is Linux-only + opt-in ([Linux](docs/install/linux.md#amd-gpu-rocm)) — Windows AMD/Ryzen AI is CPU-only ([Windows](docs/install/windows.md#gpu-support)); **macOS Intel** can't run the local backend, so point it at a remote one ([#889](https://github.com/debpalash/VoiceStudio/issues/889) · [macOS](docs/install/macos.md)).
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### 🗣️ TTS Engines
|
||||
|
||||
**14 engines, one picker.** OmniVoice (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
|
||||
**14 engines, one picker.** VoiceStudio (default, 600+ languages) is always available; seven more are opt-in and auto-detected (CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX), plus six lazy-installed heavyweights (IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS). Switch in **Settings → TTS Engine**; the choice applies everywhere synthesis happens.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full matrix</b> — 14 engines × platform × clone/instruct × license</summary>
|
||||
@@ -234,7 +197,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
| **OmniVoice** (default) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
|
||||
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Built-in |
|
||||
| **CosyVoice 3** | 9 + 18 dialects | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | ✅ | — | ✅ CUDA/CPU | — | ✅ CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
@@ -242,16 +205,26 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
| **KittenTTS** | English | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
|
||||
| **MLX-Audio** (Kokoro, Qwen3-TTS, CSM, Dia, …) | Multi | Varies | Varies | ❌ | ✅ Native | ❌ | Varies |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2** ⚡ | Multi | ✅ | — | ✅ CUDA | — | ✅ CUDA | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili model license¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | Built-in |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡ (8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡ (2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|
||||
¹ IndexTTS 2.5 requires a separate written Bilibili license above 100 million
|
||||
monthly active users or RMB 1 billion in annual revenue. Review its
|
||||
[model license](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)
|
||||
before enabling the optional sidecar.
|
||||
|
||||
GPT-SoVITS connects to `http://127.0.0.1:9880` by default. To use a server on
|
||||
another machine, set `OMNIVOICE_GPTSOVITS_URL` to its credential-free
|
||||
`http://` or `https://` origin and add that machine's CIDR to
|
||||
`OMNIVOICE_TRUSTED_NETWORKS`; redirects and untrusted destinations are rejected.
|
||||
|
||||
> **CUDA** = GPU-accelerated · **MPS** = Apple Silicon Metal · **CPU** = runs everywhere, slower for large models · KittenTTS and MOSS-TTS-Nano run realtime on CPU · MLX-Audio is Apple Silicon only · ⚡ = lazy-registered (installed on first use)
|
||||
>
|
||||
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to OmniVoice.
|
||||
> **Clone** matters beyond single-clip generation: Video Dubbing (and any Batch job with a pinned voice) needs reference-audio cloning to preserve speaker identity, so picking a Clone-less engine (KittenTTS, Sherpa-ONNX, Supertonic 3) as the active engine fails those jobs up front with an actionable message instead of silently falling back to VoiceStudio.
|
||||
>
|
||||
> **MOSS-TTS-v1.5** (8B, ~16 GB), **dots.tts** (2B, ~9 GB), and **Confucius4-TTS** are heavyweight opt-ins that run in their own isolated venv from a local clone. None claims Apple-Silicon MPS (CPU on Macs); dots.tts has no Windows path; Confucius4 wants CUDA (CPU works, ~17× realtime). Details: [MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md).
|
||||
|
||||
@@ -261,7 +234,7 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
|
||||
### 🎧 ASR Engines
|
||||
|
||||
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Settings → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
**11 engines** — they power dictation, video dubbing, and subtitles. **WhisperX** is the cross-platform default (~100 languages, word-level timing); the rest are opt-in and auto-detected. Switch in **Model Catalogue → Engines**. Ten run fully on-device; the eleventh (OpenAI-compatible) is an optional remote client for Qwen3-ASR or any compatible server.
|
||||
|
||||
<details>
|
||||
<summary><b>📊 The full lineup</b> — 11 engines, what each is best at, and compute-type notes</summary>
|
||||
@@ -276,15 +249,17 @@ Professional-grade voice AI, minus the subscription and the cloud.
|
||||
| **MLX Whisper** | `mlx-whisper` | ~100 | Native Apple Silicon speed (Apple MLX / Metal) |
|
||||
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA / CPU fallback via 🤗 Transformers (no cuDNN 8 needed) |
|
||||
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | SOTA accuracy at ~10× realtime even on CPU, auto language detection (NVIDIA NeMo, CUDA/CPU) |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Settings → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | The Parakeet tier for Apple Silicon — TDT word timestamps, ~2 GB unified memory, dictation-grade speed on the GPU via MLX. Install the model from **Model Catalogue → Models** and dictation prefers it automatically when your system language is one of its 25 (European) languages; other languages (CJK, Arabic, …) keep the multilingual Whisper engine so dictation coverage never regresses. |
|
||||
| **Moonshine** | `moonshine` | English | Edge / low-latency, ONNX |
|
||||
| **FunASR** | `funasr` | 50+ | All-in-one multilingual — built-in VAD + inline speaker diarization (SenseVoice) |
|
||||
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | 25 EU + 90+ | Live, faster-than-real-time dictation — small streaming/offline ONNX models (Parakeet TDT v3/v2, streaming Zipformer & Paraformer, Whisper Tiny), CPU, identical on macOS / Windows / Linux. Picked per-model in **Settings → Voice**. |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Settings → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | A path to **Qwen3-ASR** today (self-hosted server, no transformers wait), any OpenAI-compatible transcription endpoint, or OpenAI's own API — no install, configure + test the connection in **Model Catalogue → Engines** (ASR tab). Audio leaves your machine to whatever server you point it at; see [docs/engines/openai-compatible-asr.md](docs/engines/openai-compatible-asr.md). |
|
||||
|
||||
> Whisper-family engines cover ~100 languages; **FunASR / SenseVoice** adds an all-in-one multilingual path with built-in voice-activity detection and inline speaker diarization. **sherpa-onnx** powers the live dictation model picker — you talk and text appears as you speak. Every engine runs on-device — no API keys, no cloud.
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and OmniVoice automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
> If Dubbing needs an ASR model that is not installed yet, it offers the recommended download in place, shows its progress, and retries transcription on the same job when the model is ready.
|
||||
|
||||
> **GPU without efficient float16?** On older NVIDIA GPUs (Maxwell/Pascal, GTX 16xx) or after a CTranslate2/cuDNN mismatch, the CTranslate2 ASR engines (WhisperX, Faster-Whisper) can't run `float16` and VoiceStudio automatically retries on `int8` — no config needed. If transcription still fails, pin the compute type with the `ASR_COMPUTE_TYPE` env var (escape hatch): `ASR_COMPUTE_TYPE=int8` (or `float32` for CPU). Set it to `int8` and restart the backend.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -345,7 +320,7 @@ Your existing scripts, agents, and OpenAI/ElevenLabs SDK calls now run **locally
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS — text in; `mp3` / `opus` / `aac` / `flac` / `wav` / `pcm` out. `model`: `tts-1`/`tts-1-hd` (active engine) or a specific one (`voxcpm2`, `cosyvoice`, `kittentts`, …). `voice`: a cloned profile ID, `default`, or an OpenAI name (`alloy`, …). `speed` supported. |
|
||||
| `POST /v1/audio/transcriptions` | STT — audio file in; `json` / `text` / `verbose_json` / `srt` / `vtt` out (`verbose_json` adds word-level timings). `whisper-1` maps to your active ASR engine. |
|
||||
| `GET /v1/audio/voices` | OmniVoice extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
| `GET /v1/audio/voices` | VoiceStudio extension — lists every voice profile and engine, so clients can discover your clones. |
|
||||
|
||||
**Speak with your own cloned voice** — list the IDs, then pass one as `voice`:
|
||||
|
||||
@@ -379,13 +354,13 @@ Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? I
|
||||
|
||||
### 📓 Run on Google Colab
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
No local GPU? The [official notebook](notebooks/OmniVoice_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
|
||||
No local GPU? The [official notebook](notebooks/VoiceStudio_Studio_Colab.ipynb) boots the full app — web UI included — on a free Colab T4, then walks the whole feature surface (TTS, cloning, design, transcription, dubbing, audiobook, watermarking, the OpenAI-compatible API) as a guided tour with inline playback. No tunnels, no API keys.
|
||||
|
||||
### 🤝 Agent Skills
|
||||
|
||||
Teach your coding agent to speak and listen through your local OmniVoice — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
|
||||
Teach your coding agent to speak and listen through your local VoiceStudio — one command, works with **Claude Code, Codex, Cursor, Grok, Kimi, opencode**, and any [skills.sh](https://skills.sh)-compatible agent:
|
||||
|
||||
```sh
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
@@ -403,7 +378,7 @@ Ships two [skills](https://skills.sh):
|
||||
### 🔜 Up Next
|
||||
|
||||
- 🎬 **Lip-sync v2** — visual speech timing with wav2lip
|
||||
- 🌐 **Hosted Demo** — try OmniVoice without installing anything
|
||||
- 🌐 **Hosted Demo** — try VoiceStudio without installing anything
|
||||
- 🔌 **Plugin Marketplace** — community-contributed TTS engines and effects
|
||||
- 🎵 **Real-time Voice Changer** — live microphone transformation during calls
|
||||
|
||||
@@ -418,19 +393,19 @@ Ships two [skills](https://skills.sh):
|
||||
| **Dubbing** | Full pipeline (transcribe→translate→synthesize→mux), scene-aware splitting, lip-sync scoring, streaming TTS, per-speaker voice assignment, Smart Fit timing + second-pass QC, paste-in translations from any external tool, dedicated Dub home |
|
||||
| **Voice** | Zero-shot cloning, voice design, A/B comparison, voice preview widget, gallery with favorites/tags (its voices selectable in every picker — Studio, Audiobook, Stories, Dubbing), portable persona bundles (`.ovsvoice`), voice console workspace |
|
||||
| **Audio** | Demucs vocal isolation, per-segment gain, selective track export, stem/SRT/VTT/MP3 export, unlimited-length TTS via sentence-chunked generation |
|
||||
| **Multi-Lang** | Multi-language batch picker, batch dubbing queue with sequential GPU execution |
|
||||
| **Multi-Lang** | Translate All preserves the primary language plus every extra language chip; Generate renders and exports one retained track per language with sequential GPU execution |
|
||||
| **Diarization** | Pyannote ML diarization, auto speaker clone extraction, per-speaker voice assignment |
|
||||
| **ASR** | 11 engines (WhisperX, Faster-Whisper, isolated Faster-Whisper, MLX Whisper, PyTorch Whisper, Parakeet TDT, Parakeet TDT v3 MLX, Moonshine, FunASR/SenseVoice, sherpa-onnx live dictation, OpenAI-compatible remote), crash-isolated subprocess backend |
|
||||
| **TTS** | 14 engines (OmniVoice, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **TTS** | 14 engines (VoiceStudio, CosyVoice 3, GPT-SoVITS, VoxCPM2, MOSS-TTS-Nano, KittenTTS, MLX-Audio, Sherpa-ONNX, + lazy: IndexTTS 2.5, OmniVoice GGUF, Supertonic 3, MOSS-TTS-v1.5, dots.tts, Confucius4-TTS), engine routing with GPU preflight |
|
||||
| **Infra** | Docker deployment, CUDA/MPS/ROCm auto-detect, cuDNN 8 compat, VRAM-aware model offloading, engine routing (no silent CPU fallback), diagnostics suite & error journal, restricted-network mirror support |
|
||||
| **AI Provenance** | AudioSeal invisible watermarking (SynthID-like), video logo overlay, watermark detection API |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, glassmorphism design system, UI scale fix for Linux/WebKitGTK |
|
||||
| **UX** | Undo/redo, keyboard shortcuts, drag-and-drop, session persistence, screen-sized first-run UI scaling, and native WebKitGTK scaling |
|
||||
| **Real-time Events** | WebSocket event bus — instant sidebar refresh on data mutations, exponential backoff reconnect |
|
||||
| **State Management** | Zustand store migration — `uiSlice`, `pillSlice`, `dubSlice`, `generateSlice`, `prefsSlice`, `glossarySlice` |
|
||||
| **Desktop** | Cross-platform Tauri installers (macOS DMG — Apple Silicon; Intel unsupported for the local backend, #889 — Windows MSI, Linux deb/AppImage), auto-update infrastructure, single-instance enforcement, close-to-tray, macOS Gatekeeper fix |
|
||||
| **Dictation** | Global system-wide hotkey (`⌘+⇧+Space`), frameless floating widget, streaming ASR via WebSocket, auto-paste, customizable hotkey, local-LLM transcript refinement |
|
||||
| **Batch Pipeline** | Full batch TTS: extract → transcribe → translate → generate → mix → export, with live progress tracking |
|
||||
| **MCP Server** | OmniVoice as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **MCP Server** | VoiceStudio as a local TTS/STT provider for Claude, Cursor, and any MCP client |
|
||||
| **Remote Backend** | Point the desktop UI at a remote backend URL with bearer auth (Tailscale-documented) |
|
||||
| **Reliability** | Stall watchdog on bootstrap splash, per-engine GPU compatibility matrix, actionable errors for non-executable engine binaries, setuptools auto-repair |
|
||||
|
||||
@@ -442,7 +417,7 @@ Ships two [skills](https://skills.sh):
|
||||
|
||||
## 💜 Sponsor / Donate
|
||||
|
||||
One developer, real AI-agent bills. If OmniVoice is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
|
||||
One developer, real AI-agent bills. If VoiceStudio is useful to you, chipping in keeps development full-time — every dollar goes straight to the bills.
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -464,7 +439,7 @@ One developer, real AI-agent bills. If OmniVoice is useful to you, chipping in k
|
||||
|
||||
### 🌟 Sponsors
|
||||
|
||||
OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
|
||||
VoiceStudio is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponsors keep development going, and in return get a logo slot here, in the app, and (for top tiers) on the project website. It's a thank-you, never a paywall. **[See tiers & become a sponsor →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -484,6 +459,7 @@ OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponso
|
||||
|
||||
<div align="center">
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/💬_Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord" /></a>
|
||||
<a href="https://x.com/idebpalash"><img src="https://img.shields.io/badge/𝕏_Follow-for_updates-000000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X" /></a>
|
||||
<br/>
|
||||
<sub>We respond to setup questions within hours, not days.</sub>
|
||||
</div>
|
||||
@@ -513,8 +489,9 @@ OmniVoice is **free** and **AGPL-3.0** — no paid tier, no SaaS revenue. Sponso
|
||||
Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, translations. All of it.
|
||||
|
||||
- 📖 Read the **[Contributing Guide](.github/CONTRIBUTING.md)** for setup, code style, and PR workflow
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
|
||||
- 🐛 Browse [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- 💬 Join our [Discord](https://discord.gg/bzQavDfVV9) to discuss ideas or ask for help
|
||||
- 𝕏 Follow [@idebpalash](https://x.com/idebpalash) for updates and what's being built next
|
||||
|
||||
---
|
||||
|
||||
@@ -525,7 +502,7 @@ Yes please — bug fixes, new TTS engine adapters, UI improvements, docs, transl
|
||||
<br/>
|
||||
Honest answer: <b>it depends on what you're doing.</b>
|
||||
|
||||
<b>Where OmniVoice is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
|
||||
<b>Where VoiceStudio is genuinely competitive:</b> voice cloning from a clean reference clip (state-of-the-art open diffusion TTS), language coverage (646 languages vs. their 32), and everything structural — no per-character billing, no usage caps, no audio leaving your machine, full pipeline customizability (14 TTS engines, 11 ASR engines, your choice of translation).
|
||||
|
||||
<b>Where ElevenLabs still wins:</b> out-of-the-box consistency and polish, especially for English TTS. Their one model is heavily tuned; our quality depends on which engine you pick, your hardware, and — for cloning — the reference audio (a dry, close-mic clip clones dramatically better than a noisy or echoey one).
|
||||
|
||||
@@ -537,7 +514,7 @@ Try it on your real material — it's free and takes one download. Many users re
|
||||
<details>
|
||||
<summary><b>Why doesn't a longer reference clip sound more like me?</b></summary>
|
||||
<br/>
|
||||
Because OmniVoice's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
|
||||
Because VoiceStudio's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> the model conditions on at generation time — it is never trained on. Feeding it 2 hours doesn't teach it your voice; past a short window the extra audio is simply not used. The dubbing pipeline's reference builder targets ~8 s and hard-caps at 15 s (<code>backend/services/speaker_clone.py</code>), and engines cap the prompt themselves (VoxCPM2 trims references to 30 s). This is different from ElevenLabs <i>Professional</i> Voice Cloning, which fine-tunes a model on hours of your audio — that's a training job, not a bigger prompt.
|
||||
|
||||
<b>What actually moves clone quality is the clip, not its length.</b> Zero-shot cloning mirrors the acoustics and delivery of the prompt, so: record 5–15 seconds (~8 s is the sweet spot) of continuous natural speech, close to the mic, in a quiet room with no reverb or music — an echoey clip clones echoey. One speaker only, and read in the tone and pace you want the output to have, because the clone copies your delivery, not just your timbre. Recording a few candidate clips and comparing results beats any amount of extra footage.
|
||||
|
||||
@@ -547,7 +524,7 @@ Because OmniVoice's cloning is <b>zero-shot</b>: your clip is a <i>prompt</i> th
|
||||
<details>
|
||||
<summary><b>Does it work on Apple Silicon (M1/M2/M3/M4)?</b></summary>
|
||||
<br/>
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
|
||||
Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are available for faster transcription on Apple hardware. <b>Intel Macs are not supported</b>: the app UI installs, but the local Python backend cannot run because PyTorch no longer ships Intel-Mac wheels (<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>) — an Intel Mac can only be used with a remote backend.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -559,13 +536,13 @@ Yes. MPS acceleration is auto-detected. MLX-optimized Whisper models are availab
|
||||
<details>
|
||||
<summary><b>Can I use this commercially?</b></summary>
|
||||
<br/>
|
||||
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> OmniVoice and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
|
||||
<b>Yes — commercial use is free</b> under the <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>: run it, sell the audio you make, dub client videos, deploy it across your team. One obligation: if you <b>modify</b> VoiceStudio and offer the modified version to others over a network, you must share that modified source under the same terms. Embedding it in a closed-source product instead? A commercial license is available — see <a href="#license">License</a>.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What languages are supported?</b></summary>
|
||||
<br/>
|
||||
646 languages for TTS via the OmniVoice model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
646 languages for TTS via the VoiceStudio model. Transcription (WhisperX) supports 99 languages. Translation coverage depends on the target language pair.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -575,17 +552,17 @@ Yes. Subclass <code>TTSBackend</code> in <code>backend/services/tts_backend.py</
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Does OmniVoice collect any data about me?</b></summary>
|
||||
<summary><b>Does VoiceStudio collect any data about me?</b></summary>
|
||||
<br/>
|
||||
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, OmniVoice sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
|
||||
<b>Not unless you explicitly say yes.</b> On first run the app <i>asks</i> — one screen, two equal-weight buttons, no pre-ticked box — and until you answer yes, VoiceStudio sends nothing: no analytics, no telemetry, no accounts, no phone-home. Skipping the question means no. Your text, audio, voices, and projects never leave your machine either way.
|
||||
|
||||
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve OmniVoice"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
|
||||
If you do opt in (also togglable anytime under <b>Settings → Privacy → "Help improve VoiceStudio"</b>), what's sent is anonymous, content-free usage stats: generations (engine, language, generation time, character <i>count</i>, error <i>type</i>), plus app lifecycle — an install ping, updates (version-to-version), crashes (error class and a <i>bucketed</i> uptime, never logs), error <i>types</i> (capped, deduplicated), and a single uninstall ping if you remove it. Never your text, audio, file names, or anything identifying — enforced in code by a property allowlist (<code>backend/core/analytics.py</code>), not just a promise. Every build — installer, Docker, or built from source — asks the same first-run question and stays off unless you say yes (the destination is PostHog's publishable write-only client key; skipping the question means off). Your own numbers live in <b>Settings → Usage</b>, computed locally, sent nowhere.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>How do I uninstall it / remove all its data?</b></summary>
|
||||
<br/>
|
||||
OmniVoice is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
|
||||
VoiceStudio is fully local — uninstalling is just deleting the app plus the folders it wrote (model cache, Python env, your voices/projects, config). Run <code>scripts/uninstall.sh</code> (macOS/Linux) or <code>scripts\uninstall.ps1</code> (Windows) — it prints every folder with its size as a dry-run first, then deletes on <code>--yes</code>. The full per-platform path list and app-removal steps are in <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>.
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -594,11 +571,11 @@ OmniVoice is fully local — uninstalling is just deleting the app plus the fold
|
||||
|
||||
## 📜 License
|
||||
|
||||
OmniVoice Studio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
VoiceStudio is free and open-source software under the [**GNU Affero General Public License v3.0 (AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** OmniVoice Studio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
**Free for any use — including commercial and internal business use.** Run it, sell the audio you produce with it, dub your own or clients' videos, roll it out across your team — all free, no license needed. As a **network copyleft** license, AGPL adds one obligation: if you **modify** VoiceStudio and offer that modified version to others over a network, you must make the complete corresponding source of your modified version available to them under the same AGPL-3.0 terms.
|
||||
|
||||
A **commercial license** is available for organizations that want to embed OmniVoice Studio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **OmniVoice@palash.dev**.
|
||||
A **commercial license** is available for organizations that want to embed VoiceStudio in a **closed-source or proprietary** product or service without the AGPL-3.0 copyleft obligations. **Pricing tiers coming soon.** Inquiries: **VoiceStudio@palash.dev**.
|
||||
|
||||
The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [`LICENSE`](LICENSE) for the full, binding terms, and [`LICENSE-NOTICE.md`](LICENSE-NOTICE.md) for the plain-language summary and scope.
|
||||
|
||||
@@ -606,11 +583,11 @@ The bundled `omnivoice/` TTS model by Han Zhu remains Apache-2.0 upstream. See [
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
OmniVoice Studio is built on the shoulders of exceptional open-source work:
|
||||
VoiceStudio is built on the shoulders of exceptional open-source work:
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| [**OmniVoice (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
|
||||
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | Zero-shot diffusion TTS engine — the core voice synthesis model |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | Word-level speech recognition and alignment |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | Music source separation for vocal isolation |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | Speaker diarization — who said what |
|
||||
@@ -663,17 +640,17 @@ Like the local-first philosophy? It runs in the family — same maker, same rule
|
||||
<br/>
|
||||
|
||||
If you read this far, you're our kind of person.<br/>
|
||||
**[⭐ Star this repo](https://github.com/debpalash/OmniVoice-Studio)** so others can find it too.<br/>
|
||||
**[⭐ Star this repo](https://github.com/debpalash/VoiceStudio)** so others can find it too.<br/>
|
||||
**[💬 Join the Discord](https://discord.gg/bzQavDfVV9)** to share what you build.<br/>
|
||||
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep OmniVoice shipping.
|
||||
**[❤️ Support development](https://ko-fi.com/debpalash)** — fund the AI agent bills that keep VoiceStudio shipping.
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://star-history.com/#debpalash/OmniVoice-Studio&Date">
|
||||
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="600" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
|
||||
<img alt="Star History" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+72
-112
@@ -1,15 +1,16 @@
|
||||
*本文档是 [README.md](README.md) 的简体中文翻译;若与英文版有出入,以英文版为准。*
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="OmniVoice 徽标" width="120" />
|
||||
<h1>OmniVoice Studio</h1>
|
||||
<h3>开源版 ElevenLabs 替代品。</h3>
|
||||
<p>实时听写、零样本语音克隆、电影级视频配音——全部在你的桌面上完成。<br/><b>无需账号。无需 API 密钥。无需云端。</b>一切都在你自己的设备上运行。开源,支持 <b>646 种语言</b>。</p>
|
||||
<img src="docs/logo.png" alt="VoiceStudio 徽标" width="120" height="120" />
|
||||
<h1>VoiceStudio</h1>
|
||||
<p><sub><em>原名 OmniVoice-Studio</em></sub></p>
|
||||
<h3>创造声音,讲述故事,文件始终属于你。♡</h3>
|
||||
<p>在一个开源桌面工作室里完成克隆、设计、配音、听写和有声书制作。<br/><b>默认本地优先。</b>没有订阅,也没有用量计费;联网服务始终由你主动选择。</p>
|
||||
|
||||
<p>
|
||||
<a href="#quickstart">快速开始</a> ·
|
||||
<a href="#features">功能</a> ·
|
||||
<a href="#why-ovs">为什么选择 OVS</a> ·
|
||||
<a href="#why-voicestudio">为什么选择 VoiceStudio</a> ·
|
||||
<a href="#tts-engines">引擎</a> ·
|
||||
<a href="#openai-api">API</a> ·
|
||||
<a href="#sponsor--donate">捐赠</a> ·
|
||||
@@ -19,75 +20,30 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/OmniVoice-Studio?style=flat-square&color=f59e0b" alt="Star 数" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/OmniVoice-Studio?style=flat-square&color=10b981" alt="版本" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="Star 数" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="版本" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square" alt="许可证" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/issues"><img src="https://img.shields.io/github/issues/debpalash/OmniVoice-Studio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/issues"><img src="https://img.shields.io/github/issues/debpalash/VoiceStudio?style=flat-square&color=ef4444" alt="Issues" /></a>
|
||||
<a href="https://discord.gg/bzQavDfVV9"><img src="https://img.shields.io/badge/Discord-Join_Community-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://ko-fi.com/debpalash"><img src="https://img.shields.io/badge/Ko--fi-Support_Us-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" /></a>
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=flat-square&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="下载最新版本" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/⬇_Download-macOS_·_Windows_·_Linux-10b981?style=for-the-badge" alt="下载最新版本" /></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/screenshot-launchpad.png" alt="OmniVoice Studio — 启动台" width="100%"/>
|
||||
<img src="docs/screenshot-launchpad.png" alt="VoiceStudio — 启动台" width="100%"/>
|
||||
</div>
|
||||
|
||||
> **你的声音是你最私密的数据。为什么还要按月付费,从云端把它租回来?** 每一款主流语音工具都会把你的音频送到别人的服务器上,并按月向你收费。OmniVoice Studio 反其道而行:克隆、设计、配音、听写,全部在你自己的硬件上完成——646 种语言,没有计费表在转,任何数据都不离开你的设备。
|
||||
> **声音很私人,创作空间也应该真正属于你。** VoiceStudio 的核心流程运行在你的硬件上:克隆、设计、配音、听写,并以 646 种语言创作,不需要订阅,也没有用量计费。联网引擎和服务始终是清晰可见的可选项,而不是隐藏依赖。
|
||||
|
||||
> [!WARNING]
|
||||
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR:[提交 Issue](https://github.com/debpalash/OmniVoice-Studio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
|
||||
|
||||
<a id="screenshots"></a>
|
||||
|
||||
## 📸 实际效果
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-studio.png" alt="工作室" width="100%"/>
|
||||
<br/><b>工作室(Studio)</b><br/>
|
||||
<sub>在同一个工作区里生成与克隆——3 秒音频即可复刻任何声音,646 种语言,零样本。</sub>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<img src="docs/screenshot-design.png" alt="声音设计" width="100%"/>
|
||||
<br/><b>声音设计</b><br/>
|
||||
<sub>从零构建新声音——性别、年龄、口音、音高、情感、方言。</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-gallery.png" alt="声音库" width="100%"/>
|
||||
<br/><b>声音库</b><br/>
|
||||
<sub>浏览现成的原型声音,支持语言筛选——或构建你自己的声音库。</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-dub.png" alt="视频配音" width="100%"/>
|
||||
<br/><b>视频配音</b><br/>
|
||||
<sub>一次端到端的真实配音:37 个片段完成转录、翻译成孟加拉语、重新配音并对齐时间轴——随时可导出为 MP4。</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-engines.png" alt="设置 — 引擎" width="100%"/>
|
||||
<br/><b>设置 → 引擎</b><br/>
|
||||
<sub>引擎兼容性矩阵——14 个 TTS 引擎,逐引擎 GPU 预检,绝不静默回退到 CPU。</sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="docs/screenshot-settings.png" alt="设置 — 模型" width="100%"/>
|
||||
<br/><b>设置 → 模型</b><br/>
|
||||
<sub>一键模型商店——自动检测你的平台(CUDA / MPS / CPU)并推荐合适的模型。</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
> **活跃 Beta 阶段。** 各版本之间可能出现故障——如需最新修复,请从源码运行。非常欢迎 Bug 报告和 PR:[提交 Issue](https://github.com/debpalash/VoiceStudio/issues) 或 [加入 Discord](https://discord.gg/bzQavDfVV9)。
|
||||
|
||||
<a id="features"></a>
|
||||
|
||||
@@ -124,12 +80,12 @@
|
||||
<p>在<b>任何应用</b>中按 <kbd>⌘</kbd>+<kbd>⇧</kbd>+<kbd>Space</kbd>。<br/>转录、自动粘贴、随即消失。</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🔐 100% 本地</h3>
|
||||
<p>无需密钥、无需云端、无需账号。<br/><b>只在你的设备上</b>。</p>
|
||||
<h3>🔐 本地优先</h3>
|
||||
<p>核心创作流程<br/><b>留在你的设备上</b>。</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<h3>🤖 MCP 服务器</h3>
|
||||
<p>从 <b>Claude</b>、Cursor 或<br/>任何 MCP 客户端使用 OmniVoice。</p>
|
||||
<p>从 <b>Claude</b>、Cursor 或<br/>任何 MCP 客户端使用 VoiceStudio。</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -161,11 +117,11 @@
|
||||
## ⚡ 快速开始
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/OmniVoice-Studio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/macOS-DMG_(Apple_Silicon)-000?style=for-the-badge&logo=apple&logoColor=white" alt="下载 macOS DMG" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Windows-MSI_(x64)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="下载 Windows MSI" /></a>
|
||||
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/badge/Linux-AppImage_(x64)-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="下载 Linux AppImage" /></a>
|
||||
<br/>
|
||||
<sub><b>macOS:</b>首次启动需要一次性批准——右键点击 → <b>打开</b>(macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac:</b>不支持本地后端(<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
|
||||
<sub><b>macOS:</b>首次启动需要一次性批准——右键点击 → <b>打开</b>(macOS 15 上为 系统设置 → 隐私与安全性 → <b>“仍要打开”</b>)。无需终端。<a href="docs/install/macos.md#gatekeeper-quarantine">为什么?</a> · <b>Intel Mac:</b>不支持本地后端(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——<a href="docs/install/macos.md">详情</a>。</sub>
|
||||
</div>
|
||||
|
||||
选择你的操作系统,按指南从头到尾操作:
|
||||
@@ -199,22 +155,22 @@ Hugging Face Token 的配置见
|
||||
|
||||
---
|
||||
|
||||
<a id="why-ovs"></a>
|
||||
<a id="why-voicestudio"></a>
|
||||
|
||||
## 💡 为什么选择 OmniVoice?
|
||||
## 💡 为什么选择 VoiceStudio?
|
||||
|
||||
ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音频。OmniVoice Studio **在你的硬件上运行,没有任何用量限制。**
|
||||
云端语音工具很方便,但工作流会依赖账号、用量计费和他人的基础设施。VoiceStudio 在你的硬件上提供完整工作室;只有你主动选择时,才会使用联网集成。
|
||||
|
||||
| | **ElevenLabs** | **OmniVoice Studio** |
|
||||
| | **ElevenLabs** | **VoiceStudio** |
|
||||
|---|---|---|
|
||||
| **价格** | $5–$330/月,按字符计费 | 免费且开源(AGPL-3.0)· 专有用途可选 [商业许可证](#license) |
|
||||
| **价格** | 订阅与用量限制 | 免费且开源(AGPL-3.0)· 专有用途可选 [商业许可证](#license) |
|
||||
| **语音克隆** | ✅ 3 秒音频 | ✅ 3 秒音频,零样本 |
|
||||
| **声音设计** | ✅ 性别、年龄 | ✅ 性别、年龄、口音、音高、风格、方言 |
|
||||
| **有声书 / 故事** | ❌ | ✅ 完整有声书编辑器 + 多声音故事(EPUB/PDF 导入,.m4b 导出) |
|
||||
| **语言** | 32 | **646** |
|
||||
| **语言** | 取决于套餐和模型 | **646** |
|
||||
| **视频配音** | ✅ 仅云端 | ✅ 完全本地 |
|
||||
| **数据隐私** | 音频发送到云端 | **数据不离开你的设备** |
|
||||
| **API 密钥** | 需要 | 不需要 |
|
||||
| **数据隐私** | 音频在远端处理 | 核心流程在本地运行;联网服务必须主动选择 |
|
||||
| **API 密钥** | 需要账号 | 本地流程不需要 |
|
||||
| **GPU 支持** | 不适用(云端) | CUDA · Apple Silicon · ROCm(Linux)· CPU |
|
||||
| **桌面应用** | ❌ | ✅ macOS · Windows · Linux |
|
||||
| **TTS 引擎** | 1 | **14** — [完整矩阵](#tts-engines) |
|
||||
@@ -246,19 +202,19 @@ ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音
|
||||
| **GPU** | 可选——CPU 也能跑 | NVIDIA CUDA · Apple Silicon MPS · AMD ROCm(仅 Linux) |
|
||||
|
||||
> [!TIP]
|
||||
> 对于显存 **≤8 GB** 的 GPU,OmniVoice 会在转录期间自动将 TTS 卸载到 CPU——无需配置。不需要专用 GPU;整条流水线都可以在 CPU 上运行(只是慢一些)。
|
||||
> 对于显存 **≤8 GB** 的 GPU,VoiceStudio 会在转录期间自动将 TTS 卸载到 CPU——无需配置。不需要专用 GPU;整条流水线都可以在 CPU 上运行(只是慢一些)。
|
||||
|
||||
> [!NOTE]
|
||||
> **AMD GPU:** ROCm 加速**仅限 Linux 且需手动开启**——在首次运行的设置界面选择 **“AMD GPU (ROCm)”**,或设置 `OMNIVOICE_TORCH_VARIANT=rocm`([docs/install/linux.md](docs/install/linux.md#amd-gpu-rocm))。在 **Docker/Podman** 中请改用专门的 ROCm 镜像:`ghcr.io/debpalash/omnivoice-studio:rocm`([docs/install/docker.md](docs/install/docker.md#pull-and-run-amd-gpu--rocm))。**在 Windows 上,AMD GPU(含 Ryzen AI 核显)只能以 CPU 运行**:PyTorch 没有 Windows 版 ROCm 轮子,因此 Windows 上的 GPU 加速仅限 NVIDIA/CUDA([docs/install/windows.md](docs/install/windows.md#gpu-support))。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **macOS Intel(x86_64)不支持本地后端:** 应用 UI 可以安装,但 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子([#889](https://github.com/debpalash/OmniVoice-Studio/issues/889))。Intel Mac 用户仍可让 UI 指向另一台机器上的远程后端——参见 [docs/install/macos.md](docs/install/macos.md)。
|
||||
> **macOS Intel(x86_64)不支持本地后端:** 应用 UI 可以安装,但 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子([#889](https://github.com/debpalash/VoiceStudio/issues/889))。Intel Mac 用户仍可让 UI 指向另一台机器上的远程后端——参见 [docs/install/macos.md](docs/install/macos.md)。
|
||||
|
||||
<a id="tts-engines"></a>
|
||||
|
||||
### 🗣️ TTS 引擎
|
||||
|
||||
**14 个引擎,一个选择器。** OmniVoice(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加六个按需延迟安装的重量级引擎(IndexTTS 2、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
|
||||
**14 个引擎,一个选择器。** VoiceStudio(默认,支持 600+ 语言)始终可用;另有七个引擎可选装并自动检测(CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX),外加六个按需延迟安装的重量级引擎(IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)。在 **设置 → TTS 引擎** 中切换;所选引擎将应用于所有语音合成场景。
|
||||
|
||||
<details>
|
||||
<summary><b>📊 完整矩阵</b>——14 个引擎 × 平台 × 克隆/指令 × 许可证</summary>
|
||||
@@ -267,7 +223,7 @@ ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音
|
||||
|
||||
| 引擎 | 语言 | 克隆 | 指令 | Linux | macOS ARM | Windows | 许可证 |
|
||||
|--------|:---------:|:-----:|:--------:|:-----:|:---------:|:-------:|:-------:|
|
||||
| **OmniVoice**(默认) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | 内置 |
|
||||
| **VoiceStudio**(默认,由 k2-fsa/OmniVoice 驱动) | 600+ | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | 内置 |
|
||||
| **CosyVoice 3** | 9 + 18 种方言 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **GPT-SoVITS** | 5 | ✅ | — | ✅ CUDA/CPU | — | ✅ CUDA/CPU | MIT |
|
||||
| **VoxCPM2** | 30 | ✅ | ✅ | ✅ CUDA/CPU | ✅ MPS | ✅ CUDA/CPU | Apache-2.0 |
|
||||
@@ -275,16 +231,20 @@ ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音
|
||||
| **KittenTTS** | 英语 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | MIT |
|
||||
| **MLX-Audio**(Kokoro、Qwen3-TTS、CSM、Dia 等) | 多语言 | 因模型而异 | 因模型而异 | ❌ | ✅ 原生 | ❌ | 因模型而异 |
|
||||
| **Sherpa-ONNX** | 20+ | — | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **IndexTTS 2** ⚡ | 多语言 | ✅ | — | ✅ CUDA | — | ✅ CUDA | Apache-2.0 |
|
||||
| **IndexTTS 2.5** ⚡ | 中文 · 英语 · 日语 · 西班牙语 · 阿拉伯语 | ✅ | — | ✅ CUDA | — | ✅ CUDA | Bilibili 模型许可¹ |
|
||||
| **OmniVoice GGUF** ⚡ | 600+ | ✅ | ✅ | ✅ CPU | ✅ CPU | ✅ CPU | 内置 |
|
||||
| **Supertonic 3** ⚡ | 31 | — | — | ✅ CPU | ✅ CPU | ✅ CPU | OpenRAIL-M |
|
||||
| **MOSS-TTS-v1.5** ⚡(8B) | 31 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
| **dots.tts** ⚡(2B) | 24 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ❌ | Apache-2.0 |
|
||||
| **Confucius4-TTS** ⚡ | 14 | ✅ | — | ✅ CUDA/CPU | ✅ CPU | ✅ CUDA/CPU | Apache-2.0 |
|
||||
|
||||
¹ 若月活跃用户超过 1 亿,或年收入超过人民币 10 亿元,使用 IndexTTS 2.5
|
||||
前必须另行取得 Bilibili 的书面许可。启用可选边车前,请审阅其
|
||||
[模型许可](https://huggingface.co/IndexTeam/IndexTTS-2.5/blob/main/LICENSE)。
|
||||
|
||||
> **CUDA** = GPU 加速 · **MPS** = Apple Silicon Metal · **CPU** = 随处可运行,大模型较慢 · KittenTTS 和 MOSS-TTS-Nano 可在 CPU 上实时运行 · MLX-Audio 仅限 Apple Silicon · ⚡ = 延迟注册(首次使用时安装)
|
||||
>
|
||||
> **克隆**能力的意义不止于单段生成:视频配音(以及任何固定了声音的批量任务)需要参考音频克隆来保持说话人身份,因此把不支持克隆的引擎(KittenTTS、Sherpa-ONNX、Supertonic 3)设为当前引擎时,这些任务会在开始前就给出可操作的失败提示,而不是静默回退到 OmniVoice。
|
||||
> **克隆**能力的意义不止于单段生成:视频配音(以及任何固定了声音的批量任务)需要参考音频克隆来保持说话人身份,因此把不支持克隆的引擎(KittenTTS、Sherpa-ONNX、Supertonic 3)设为当前引擎时,这些任务会在开始前就给出可操作的失败提示,而不是静默回退到 VoiceStudio。
|
||||
>
|
||||
> **MOSS-TTS-v1.5**(8B,约 16 GB)、**dots.tts**(2B,约 9 GB)和 **Confucius4-TTS** 是重量级可选引擎,从本地克隆在各自独立的 venv 中运行。三者均不支持 Apple Silicon MPS(在 Mac 上以 CPU 运行);dots.tts 没有 Windows 路径;Confucius4 建议使用 CUDA(CPU 可用,约为实时时长的 17 倍)。详情:[MOSS-TTS-v1.5](docs/engines/moss-tts-v15.md) · [dots.tts](docs/engines/dots-tts.md) · [Confucius4-TTS](docs/engines/confucius4-tts.md)。
|
||||
|
||||
@@ -316,7 +276,7 @@ ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音
|
||||
|
||||
> Whisper 系列引擎覆盖约 100 种语言;**FunASR / SenseVoice** 额外提供一条多语言一体化路径,内置语音活动检测与行内说话人分离。**sherpa-onnx** 驱动实时听写的模型选择器——你边说,文字边出现。每个引擎都在本地设备上运行——无需 API 密钥,无需云端。
|
||||
|
||||
> **GPU 不支持高效 float16?** 在较老的 NVIDIA GPU(Maxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`,OmniVoice 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`(CPU 用 `float32`)。将其设为 `int8` 并重启后端。
|
||||
> **GPU 不支持高效 float16?** 在较老的 NVIDIA GPU(Maxwell/Pascal、GTX 16xx)上,或在 CTranslate2/cuDNN 版本不匹配之后,CTranslate2 系 ASR 引擎(WhisperX、Faster-Whisper)无法运行 `float16`,VoiceStudio 会自动改用 `int8` 重试——无需配置。如果转录仍然失败,可用 `ASR_COMPUTE_TYPE` 环境变量固定计算类型(逃生舱口):`ASR_COMPUTE_TYPE=int8`(CPU 用 `float32`)。将其设为 `int8` 并重启后端。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -333,7 +293,7 @@ ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音
|
||||
│ Backend (FastAPI) │
|
||||
│ 100+ API endpoints · SSE+WSS streaming · SQLite │
|
||||
├──────────┬──────────┬──────────┬──────────┬────────────────┤
|
||||
│ WhisperX │ Demucs │OmniVoice │ Pyannote │ Engine Routing │
|
||||
│ WhisperX │ Demucs │VoiceStudio │ Pyannote │ Engine Routing │
|
||||
│ (+7 ASR │ Source │ (+10 │ Diariz- │ ↳ GPU preflight │
|
||||
│ engines) │ Sep. │ TTS) │ ation │ ↳ No silent CPU │
|
||||
└──────────┴──────────┴──────────┴──────────┴────────────────┘
|
||||
@@ -350,7 +310,7 @@ ElevenLabs 收费 **$5–$330/月**,并在他们的服务器上处理你的音
|
||||
|---|---|
|
||||
| `POST /v1/audio/speech` | TTS——输入文本;输出 `mp3` / `wav` / `flac` / `opus` / `pcm`。`tts-1` / `tts-1-hd` 映射到你当前启用的引擎;也接受 OpenAI 的声音名称(`alloy` 等)。 |
|
||||
| `POST /v1/audio/transcriptions` | STT——输入音频文件;输出 `json`、`text`、`verbose_json`、`srt` 或 `vtt`。`whisper-1` 映射到你当前启用的 ASR 引擎。 |
|
||||
| `GET /v1/audio/voices` | OmniVoice 扩展——列出所有声音配置和引擎,客户端可据此发现你的克隆声音。 |
|
||||
| `GET /v1/audio/voices` | VoiceStudio 扩展——列出所有声音配置和引擎,客户端可据此发现你的克隆声音。 |
|
||||
|
||||
```sh
|
||||
curl http://localhost:3900/v1/audio/speech \
|
||||
@@ -371,13 +331,13 @@ print(result.text)
|
||||
|
||||
### 📓 在 Google Colab 上运行
|
||||
|
||||
[](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
|
||||
[](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/VoiceStudio_Studio_Colab.ipynb)
|
||||
|
||||
没有本地 GPU?官方笔记本([notebooks/OmniVoice_Studio_Colab.ipynb](notebooks/OmniVoice_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
|
||||
没有本地 GPU?官方笔记本([notebooks/VoiceStudio_Studio_Colab.ipynb](notebooks/VoiceStudio_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
|
||||
|
||||
### 🤝 智能体技能(Agent Skills)
|
||||
|
||||
用一条命令教会你的 AI 智能体(Claude Code、Cursor、Codex 等)使用 OmniVoice:
|
||||
用一条命令教会你的 AI 智能体(Claude Code、Cursor、Codex 等)使用 VoiceStudio:
|
||||
|
||||
```sh
|
||||
npx skills add debpalash/omnivoice-studio
|
||||
@@ -392,7 +352,7 @@ npx skills add debpalash/omnivoice-studio
|
||||
### 🔜 即将推出
|
||||
|
||||
- 🎬 **唇形同步 v2** — 使用 wav2lip 进行视觉语音时间对齐
|
||||
- 🌐 **在线演示** — 无需安装即可体验 OmniVoice
|
||||
- 🌐 **在线演示** — 无需安装即可体验 VoiceStudio
|
||||
- 🔌 **插件市场** — 社区贡献的 TTS 引擎与特效
|
||||
- 🎵 **实时变声器** — 通话中的麦克风实时变声
|
||||
|
||||
@@ -410,16 +370,16 @@ npx skills add debpalash/omnivoice-studio
|
||||
| **多语言** | 多语言批量选择器、顺序 GPU 执行的批量配音队列 |
|
||||
| **说话人分离** | Pyannote 机器学习分离、自动说话人克隆提取、逐说话人声音分配 |
|
||||
| **ASR** | 9 个引擎(WhisperX、Faster-Whisper、隔离版 Faster-Whisper、MLX Whisper、PyTorch Whisper、Parakeet TDT、Moonshine、FunASR/SenseVoice、sherpa-onnx 实时听写)、崩溃隔离的子进程后端 |
|
||||
| **TTS** | 14 个引擎(OmniVoice、CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX,+ 延迟安装:IndexTTS 2、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)、带 GPU 预检的引擎路由 |
|
||||
| **TTS** | 14 个引擎(VoiceStudio、CosyVoice 3、GPT-SoVITS、VoxCPM2、MOSS-TTS-Nano、KittenTTS、MLX-Audio、Sherpa-ONNX,+ 延迟安装:IndexTTS 2.5、OmniVoice GGUF、Supertonic 3、MOSS-TTS-v1.5、dots.tts、Confucius4-TTS)、带 GPU 预检的引擎路由 |
|
||||
| **基础设施** | Docker 部署、CUDA/MPS/ROCm 自动检测、cuDNN 8 兼容、显存感知模型卸载、引擎路由(绝不静默回退 CPU)、诊断套件与错误日志、受限网络镜像支持 |
|
||||
| **AI 溯源** | AudioSeal 不可见水印(类似 SynthID)、视频徽标叠加、水印检测 API |
|
||||
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、毛玻璃设计系统、Linux/WebKitGTK 的 UI 缩放修复 |
|
||||
| **用户体验** | 撤销/重做、键盘快捷键、拖放、会话持久化、首次启动按屏幕推荐界面缩放,以及原生 WebKitGTK 缩放 |
|
||||
| **实时事件** | WebSocket 事件总线——数据变更时即时刷新侧边栏、指数退避重连 |
|
||||
| **状态管理** | Zustand 状态迁移——`uiSlice`、`pillSlice`、`dubSlice`、`generateSlice`、`prefsSlice`、`glossarySlice` |
|
||||
| **桌面** | 跨平台 Tauri 安装程序(macOS DMG——Apple Silicon;Intel 不支持本地后端,#889——Windows MSI、Linux deb/AppImage)、自动更新基础设施、单实例约束、关闭最小化到托盘、macOS Gatekeeper 修复 |
|
||||
| **听写** | 全局系统级热键(`⌘+⇧+Space`)、无边框浮动控件、WebSocket 流式 ASR、自动粘贴、可自定义热键、本地 LLM 转录润色 |
|
||||
| **批量流水线** | 完整批量 TTS:提取 → 转录 → 翻译 → 生成 → 混音 → 导出,带实时进度追踪 |
|
||||
| **MCP 服务器** | 让 OmniVoice 成为 Claude、Cursor 及任何 MCP 客户端的本地 TTS/STT 提供方 |
|
||||
| **MCP 服务器** | 让 VoiceStudio 成为 Claude、Cursor 及任何 MCP 客户端的本地 TTS/STT 提供方 |
|
||||
| **远程后端** | 让桌面 UI 指向远程后端 URL,支持 Bearer 认证(附 Tailscale 文档) |
|
||||
| **可靠性** | 启动开屏的卡死看门狗、逐引擎 GPU 兼容矩阵、引擎二进制不可执行时的可操作报错、setuptools 自动修复 |
|
||||
|
||||
@@ -431,7 +391,7 @@ npx skills add debpalash/omnivoice-studio
|
||||
|
||||
## 💜 赞助 / 捐赠
|
||||
|
||||
OmniVoice Studio 由一位开发者使用 Claude Code 和 AI 智能体独立打造——而智能体账单是实打实的(过去三个月花了数千美元)。如果 OmniVoice 为你创造了价值,帮忙分担一小部分账单,就能让开发保持全职推进。
|
||||
VoiceStudio 由一位开发者使用 Claude Code 和 AI 智能体独立打造——而智能体账单是实打实的(过去三个月花了数千美元)。如果 VoiceStudio 为你创造了价值,帮忙分担一小部分账单,就能让开发保持全职推进。
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -446,11 +406,11 @@ OmniVoice Studio 由一位开发者使用 Claude Code 和 AI 智能体独立打
|
||||
<a href="https://paypal.me/palashCoder"><img src="https://img.shields.io/badge/PayPal-Donate-00457C?style=for-the-badge&logo=paypal&logoColor=white" alt="PayPal" /></a>
|
||||
|
||||
<br/>
|
||||
<sub>每一美元都直接用于支付智能体账单——让 OmniVoice 的开发持续不断。</sub>
|
||||
<sub>每一美元都直接用于支付智能体账单——让 VoiceStudio 的开发持续不断。</sub>
|
||||
|
||||
<br/><br/>
|
||||
|
||||
<sub><b>来自 OmniVoice Studio 作者的更多应用</b>——同样的本地优先理念:
|
||||
<sub><b>来自 VoiceStudio 作者的更多应用</b>——同样的本地优先理念:
|
||||
<a href="https://github.com/debpalash/Opal"><b>Opal</b> 💠</a>(播放一切——AI 时代的媒体播放器)·
|
||||
<a href="https://github.com/debpalash/memxt"><b>memxt</b> 🧠</a>(Claude Code 与编码智能体的本地记忆)。
|
||||
给它们点个 ⭐ 也是一种支持 → <a href="#more-from-the-maker">详见下文</a>。</sub>
|
||||
@@ -461,7 +421,7 @@ OmniVoice Studio 由一位开发者使用 Claude Code 和 AI 智能体独立打
|
||||
|
||||
### 🌟 赞助商
|
||||
|
||||
OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有 SaaS 收入。赞助商让开发得以持续,作为回报,可以在这里、在应用内(顶级档位还包括项目官网)获得一个徽标位。这是一份感谢,绝不是付费墙。**[查看档位并成为赞助商 →](SPONSORS.md)**
|
||||
VoiceStudio **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有 SaaS 收入。赞助商让开发得以持续,作为回报,可以在这里、在应用内(顶级档位还包括项目官网)获得一个徽标位。这是一份感谢,绝不是付费墙。**[查看档位并成为赞助商 →](SPONSORS.md)**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -510,7 +470,7 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
|
||||
非常欢迎——Bug 修复、新的 TTS 引擎适配器、UI 改进、文档、翻译。统统欢迎。
|
||||
|
||||
- 📖 阅读 **[贡献指南](.github/CONTRIBUTING.md)** 了解环境搭建、代码风格和 PR 工作流
|
||||
- 🐛 浏览 [good first issues](https://github.com/debpalash/OmniVoice-Studio/labels/good%20first%20issue)
|
||||
- 🐛 浏览 [good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue)
|
||||
- 💬 加入我们的 [Discord](https://discord.gg/bzQavDfVV9) 讨论想法或寻求帮助
|
||||
|
||||
---
|
||||
@@ -522,7 +482,7 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
|
||||
<br/>
|
||||
诚实的回答:<b>取决于你要做什么。</b>
|
||||
|
||||
<b>OmniVoice 真正有竞争力的地方:</b>从干净的参考音频进行语音克隆(最先进的开源扩散 TTS)、语言覆盖(646 种语言对他们的 32 种),以及所有结构性优势——没有按字符计费、没有用量上限、音频不离开你的设备、完整的流水线可定制性(14 个 TTS 引擎、10 个 ASR 引擎、翻译方案随你选)。
|
||||
<b>VoiceStudio 真正有竞争力的地方:</b>从干净的参考音频进行语音克隆(最先进的开源扩散 TTS)、语言覆盖(646 种语言对他们的 32 种),以及所有结构性优势——没有按字符计费、没有用量上限、音频不离开你的设备、完整的流水线可定制性(14 个 TTS 引擎、10 个 ASR 引擎、翻译方案随你选)。
|
||||
|
||||
<b>ElevenLabs 仍然领先的地方:</b>开箱即用的稳定性与打磨程度,尤其是英语 TTS。他们的单一模型经过深度调优;我们的质量取决于你选择的引擎、你的硬件,以及(对克隆而言)参考音频——干燥、近麦的音频比嘈杂或有回声的音频克隆效果好得多。
|
||||
|
||||
@@ -534,7 +494,7 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
|
||||
<details>
|
||||
<summary><b>能在 Apple Silicon(M1/M2/M3/M4)上运行吗?</b></summary>
|
||||
<br/>
|
||||
可以。MPS 加速会被自动检测。在 Apple 硬件上,MLX 优化的 Whisper 模型可提供更快的转录速度。<b>不支持 Intel Mac</b>:应用 UI 可以安装,但本地 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子(<a href="https://github.com/debpalash/OmniVoice-Studio/issues/889">#889</a>)——Intel Mac 只能配合远程后端使用。
|
||||
可以。MPS 加速会被自动检测。在 Apple 硬件上,MLX 优化的 Whisper 模型可提供更快的转录速度。<b>不支持 Intel Mac</b>:应用 UI 可以安装,但本地 Python 后端无法运行,因为 PyTorch 已不再发布 Intel Mac 轮子(<a href="https://github.com/debpalash/VoiceStudio/issues/889">#889</a>)——Intel Mac 只能配合远程后端使用。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -546,13 +506,13 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
|
||||
<details>
|
||||
<summary><b>可以用于商业用途吗?</b></summary>
|
||||
<br/>
|
||||
<b>可以——商业使用免费</b>,基于 <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>:运行它、出售用它生成的音频、为客户的视频配音、在团队中部署。只有一项义务:如果你<b>修改</b>了 OmniVoice 并通过网络向他人提供该修改版本,你必须依据相同条款分享修改后的源代码。想把它嵌入闭源产品?可获取商业许可证——参见<a href="#license">许可证</a>。
|
||||
<b>可以——商业使用免费</b>,基于 <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL-3.0</a>:运行它、出售用它生成的音频、为客户的视频配音、在团队中部署。只有一项义务:如果你<b>修改</b>了 VoiceStudio 并通过网络向他人提供该修改版本,你必须依据相同条款分享修改后的源代码。想把它嵌入闭源产品?可获取商业许可证——参见<a href="#license">许可证</a>。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>支持哪些语言?</b></summary>
|
||||
<br/>
|
||||
通过 OmniVoice 模型的 TTS 支持 646 种语言。转录(WhisperX)支持 99 种语言。翻译覆盖范围取决于目标语言对。
|
||||
通过 VoiceStudio 模型的 TTS 支持 646 种语言。转录(WhisperX)支持 99 种语言。翻译覆盖范围取决于目标语言对。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -562,17 +522,17 @@ OmniVoice **免费**且采用 **AGPL-3.0** 许可——没有付费版,没有
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>OmniVoice 会收集我的任何数据吗?</b></summary>
|
||||
<summary><b>VoiceStudio 会收集我的任何数据吗?</b></summary>
|
||||
<br/>
|
||||
<b>除非你明确同意,否则不会。</b>首次运行时应用会<i>询问</i>你——一个页面、两个同等分量的按钮,没有预先勾选。在你回答“是”之前,OmniVoice 什么都不发送:没有分析、没有遥测、没有账号、没有“回传”。跳过提问就等于“否”。无论如何,你的文本、音频、声音和项目永远不会离开你的设备。
|
||||
<b>除非你明确同意,否则不会。</b>首次运行时应用会<i>询问</i>你——一个页面、两个同等分量的按钮,没有预先勾选。在你回答“是”之前,VoiceStudio 什么都不发送:没有分析、没有遥测、没有账号、没有“回传”。跳过提问就等于“否”。无论如何,你的文本、音频、声音和项目永远不会离开你的设备。
|
||||
|
||||
如果你选择同意(也可随时在 <b>设置 → 隐私 → “帮助改进 OmniVoice”</b> 中开关),发送的只是匿名、不含内容的使用统计:生成信息(引擎、语言、生成耗时、字符<i>数量</i>、错误<i>类型</i>),以及应用生命周期——一次安装信号、版本更新(版本号之间)、崩溃(错误类别和<i>分桶后的</i>运行时长,绝不含日志)、错误<i>类型</i>(有上限、去重),以及卸载时的一次告别信号。绝不包含你的文本、音频、文件名或任何可识别信息——这由代码中的属性白名单强制保证(<code>backend/core/analytics.py</code>),而不只是一句承诺。源码构建根本没有分析数据的接收端,因此根本不会询问。你自己的统计数字在 <b>设置 → 用量</b> 中查看,本地计算,不发送到任何地方。
|
||||
如果你选择同意(也可随时在 <b>设置 → 隐私 → “帮助改进 VoiceStudio”</b> 中开关),发送的只是匿名、不含内容的使用统计:生成信息(引擎、语言、生成耗时、字符<i>数量</i>、错误<i>类型</i>),以及应用生命周期——一次安装信号、版本更新(版本号之间)、崩溃(错误类别和<i>分桶后的</i>运行时长,绝不含日志)、错误<i>类型</i>(有上限、去重),以及卸载时的一次告别信号。绝不包含你的文本、音频、文件名或任何可识别信息——这由代码中的属性白名单强制保证(<code>backend/core/analytics.py</code>),而不只是一句承诺。源码构建根本没有分析数据的接收端,因此根本不会询问。你自己的统计数字在 <b>设置 → 用量</b> 中查看,本地计算,不发送到任何地方。
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>如何卸载它 / 删除它的所有数据?</b></summary>
|
||||
<br/>
|
||||
OmniVoice 完全本地运行——卸载就是删除应用及其写入的文件夹(模型缓存、Python 环境、你的声音/项目、配置)。运行 <code>scripts/uninstall.sh</code>(macOS/Linux)或 <code>scripts\uninstall.ps1</code>(Windows)——它会先以干跑方式列出每个文件夹及其大小,加 <code>--yes</code> 才会真正删除。完整的各平台路径列表和应用移除步骤见 <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>。
|
||||
VoiceStudio 完全本地运行——卸载就是删除应用及其写入的文件夹(模型缓存、Python 环境、你的声音/项目、配置)。运行 <code>scripts/uninstall.sh</code>(macOS/Linux)或 <code>scripts\uninstall.ps1</code>(Windows)——它会先以干跑方式列出每个文件夹及其大小,加 <code>--yes</code> 才会真正删除。完整的各平台路径列表和应用移除步骤见 <a href="docs/install/uninstall.md"><b>docs/install/uninstall.md</b></a>。
|
||||
</details>
|
||||
|
||||
---
|
||||
@@ -581,11 +541,11 @@ OmniVoice 完全本地运行——卸载就是删除应用及其写入的文件
|
||||
|
||||
## 📜 许可证
|
||||
|
||||
OmniVoice Studio 是基于 [**GNU Affero 通用公共许可证 v3.0(AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html) 的自由开源软件。
|
||||
VoiceStudio 是基于 [**GNU Affero 通用公共许可证 v3.0(AGPL-3.0)**](https://www.gnu.org/licenses/agpl-3.0.html) 的自由开源软件。
|
||||
|
||||
**可免费用于任何用途——包括商业和企业内部用途。** 运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中推广——全部免费,无需许可证。作为一份**网络著佐权(copyleft)**许可证,AGPL 增加了一项义务:如果你**修改**了 OmniVoice Studio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL-3.0 条款向他们提供该修改版本的完整对应源代码。
|
||||
**可免费用于任何用途——包括商业和企业内部用途。** 运行它、出售用它生成的音频、为自己或客户的视频配音、在团队中推广——全部免费,无需许可证。作为一份**网络著佐权(copyleft)**许可证,AGPL 增加了一项义务:如果你**修改**了 VoiceStudio 并通过网络向他人提供该修改版本,你必须依据相同的 AGPL-3.0 条款向他们提供该修改版本的完整对应源代码。
|
||||
|
||||
希望将 OmniVoice Studio 嵌入**闭源或专有**产品或服务、又不受 AGPL-3.0 著佐权义务约束的组织,可获取**商业许可证**。**定价方案即将推出。** 咨询:**OmniVoice@palash.dev**。
|
||||
希望将 VoiceStudio 嵌入**闭源或专有**产品或服务、又不受 AGPL-3.0 著佐权义务约束的组织,可获取**商业许可证**。**定价方案即将推出。** 咨询:**VoiceStudio@palash.dev**。
|
||||
|
||||
捆绑的 `omnivoice/` TTS 模型(作者 Han Zhu)在上游仍为 Apache-2.0 许可。完整且具约束力的条款请参见 [`LICENSE`](LICENSE)。
|
||||
|
||||
@@ -593,11 +553,11 @@ OmniVoice Studio 是基于 [**GNU Affero 通用公共许可证 v3.0(AGPL-3.0
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
OmniVoice Studio 站在这些杰出开源工作的肩膀上:
|
||||
VoiceStudio 站在这些杰出开源工作的肩膀上:
|
||||
|
||||
| 项目 | 作用 |
|
||||
|---------|------|
|
||||
| [**OmniVoice (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | 零样本扩散 TTS 引擎——核心语音合成模型 |
|
||||
| [**VoiceStudio (k2-fsa)**](https://github.com/k2-fsa/OmniVoice) | 零样本扩散 TTS 引擎——核心语音合成模型 |
|
||||
| [**WhisperX**](https://github.com/m-bain/whisperX) | 词级别语音识别与时间对齐 |
|
||||
| [**Demucs (Meta)**](https://github.com/facebookresearch/demucs) | 音乐源分离,用于人声分离 |
|
||||
| [**Pyannote**](https://github.com/pyannote/pyannote-audio) | 说话人分离——谁说了什么 |
|
||||
@@ -650,17 +610,17 @@ OmniVoice Studio 站在这些杰出开源工作的肩膀上:
|
||||
<br/>
|
||||
|
||||
如果你读到了这里,你就是我们的同路人。<br/>
|
||||
**[⭐ 给这个仓库点个 Star](https://github.com/debpalash/OmniVoice-Studio)**,让更多人能找到它。<br/>
|
||||
**[⭐ 给这个仓库点个 Star](https://github.com/debpalash/VoiceStudio)**,让更多人能找到它。<br/>
|
||||
**[💬 加入 Discord](https://discord.gg/bzQavDfVV9)**,分享你的作品。<br/>
|
||||
**[❤️ 支持开发](https://ko-fi.com/debpalash)**——资助让 OmniVoice 持续发布的 AI 智能体账单。
|
||||
**[❤️ 支持开发](https://ko-fi.com/debpalash)**——资助让 VoiceStudio 持续发布的 AI 智能体账单。
|
||||
|
||||
<br/>
|
||||
|
||||
<a href="https://star-history.com/#debpalash/OmniVoice-Studio&Date">
|
||||
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date" />
|
||||
<img alt="Star 历史" src="https://api.star-history.com/svg?repos=debpalash/OmniVoice-Studio&type=Date&theme=dark" width="600" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" />
|
||||
<img alt="Star 历史" src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date&theme=dark" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
+13
-13
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<img src="docs/logo.png" alt="OmniVoice Logo" width="96" />
|
||||
<h1>Sponsor OmniVoice Studio</h1>
|
||||
<img src="docs/logo.png" alt="VoiceStudio Logo" width="96" height="96" />
|
||||
<h1>Sponsor VoiceStudio</h1>
|
||||
<p><b>Keep the open-source ElevenLabs alternative free, local, and shipping.</b></p>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,15 @@
|
||||
|
||||
## Why sponsor?
|
||||
|
||||
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 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Where your money goes
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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/OmniVoice-Studio/issues/new?template=sponsor.yml)**
|
||||
> **[→ Open a sponsorship inquiry](https://github.com/debpalash/VoiceStudio/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/OmniVoice-Studio/issues/new?template=sponsor.yml) — the maintainer places it.
|
||||
- **Easiest:** attach the asset and link in your [sponsorship inquiry](https://github.com/debpalash/VoiceStudio/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 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Current sponsors
|
||||
|
||||
OmniVoice doesn't have any sponsors yet — **you could be the first.** These slots fill in as sponsors come aboard.
|
||||
VoiceStudio 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 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.
|
||||
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.
|
||||
|
||||
OmniVoice stays local-first and fully functional with zero dollars spent. Sponsoring just helps it keep getting better, faster.
|
||||
VoiceStudio 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/OmniVoice-Studio/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/VoiceStudio/issues/new?template=sponsor.yml">Open an inquiry</a> · <a href="https://discord.gg/bzQavDfVV9">Discord</a></sub>
|
||||
</div>
|
||||
|
||||
+12
-4
@@ -1,5 +1,5 @@
|
||||
# Alembic configuration for OmniVoice Studio.
|
||||
# Run from the repo root: alembic -c alembic.ini <command>
|
||||
# Alembic configuration for VoiceStudio.
|
||||
# Run from anywhere: alembic -c <repo>/alembic.ini <command>
|
||||
# Default commands:
|
||||
# alembic upgrade head — apply all pending migrations
|
||||
# alembic revision -m "…" — create a new migration
|
||||
@@ -9,8 +9,16 @@
|
||||
# See backend/migrations/env.py.
|
||||
|
||||
[alembic]
|
||||
script_location = backend/migrations
|
||||
prepend_sys_path = backend
|
||||
# %(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
|
||||
# sqlalchemy.url is set programmatically in env.py — do NOT set it here.
|
||||
sqlalchemy.url =
|
||||
|
||||
|
||||
+11
-1
@@ -1,5 +1,5 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
# PyInstaller spec for OmniVoice Studio backend.
|
||||
# PyInstaller spec for VoiceStudio backend.
|
||||
#
|
||||
# Produces a one-folder bundle at dist/omnivoice-backend/ that Tauri launches
|
||||
# as a sidecar binary. Kept intentionally permissive with collect_all(...)
|
||||
@@ -41,6 +41,16 @@ 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',
|
||||
|
||||
+126
-36
@@ -6,7 +6,12 @@ composed at the route or router level without surprises.
|
||||
|
||||
Currently exposed:
|
||||
- `require_loopback`: 403 unless the request came from a loopback origin
|
||||
(bypassed in explicit server mode — see `_server_mode`).
|
||||
(read-only bootstrap is allowed in explicit server mode; mutations still
|
||||
require the admin API key — see `_server_mode`).
|
||||
- `require_admin`: method-aware admin gate for privileged routers.
|
||||
- `require_admin_action`: strict admin gate for side-effectful GET actions.
|
||||
- `require_native_access`: true-loopback-only access to the host filesystem;
|
||||
unlike `require_loopback`, it is never bypassed by server mode.
|
||||
- `ws_remote_authorized`: whether a WebSocket handshake from a non-loopback
|
||||
client carries the remote API key (Wave 2.3) — used by WS endpoints that
|
||||
keep their own inline loopback guards.
|
||||
@@ -48,7 +53,7 @@ def _trusted_networks():
|
||||
|
||||
def is_loopback(host):
|
||||
"""True loopback address only (127.0.0.1, ::1, localhost) — NOT a trusted
|
||||
network. Admin gates (``require_loopback`` → ``/system/set-env``,
|
||||
network. Admin gates (``require_admin`` → ``/system/set-env``,
|
||||
``/api/settings/*``) use this so a trusted-network CIDR exempts consumption
|
||||
(TTS / dictation) but never the RCE-class admin surface."""
|
||||
return host in _LOOPBACK_HOSTS
|
||||
@@ -72,6 +77,7 @@ def is_local_host(host):
|
||||
return any(ip in net for net in _trusted_networks())
|
||||
|
||||
_TRUTHY = frozenset({"1", "true", "yes", "on"})
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def _server_mode() -> bool:
|
||||
@@ -94,6 +100,34 @@ def _server_mode() -> bool:
|
||||
return os.environ.get("OMNIVOICE_SERVER_MODE", "").strip().lower() in _TRUTHY
|
||||
|
||||
|
||||
def remote_api_key() -> str | None:
|
||||
"""The normalized remote-backend bearer key, or None when remote mode is
|
||||
off. Surrounding whitespace is configuration noise, never a valid secret.
|
||||
Read at call time so tests can monkeypatch the environment."""
|
||||
return os.environ.get("OMNIVOICE_API_KEY", "").strip() or None
|
||||
|
||||
|
||||
def presented_api_key(connection) -> str:
|
||||
"""Return the first non-empty normalized API key on an HTTP/WS connection.
|
||||
|
||||
Authorization wins over query, which wins over cookie. Each channel is
|
||||
stripped before fallback so whitespace in a higher-priority channel cannot
|
||||
shadow a valid lower-priority credential.
|
||||
"""
|
||||
headers = getattr(connection, "headers", None) or {}
|
||||
query = getattr(connection, "query_params", None) or {}
|
||||
cookies = getattr(connection, "cookies", None) or {}
|
||||
|
||||
auth = headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if supplied:
|
||||
return supplied
|
||||
supplied = (query.get("api_key") or "").strip()
|
||||
if supplied:
|
||||
return supplied
|
||||
return (cookies.get("ov_key") or "").strip()
|
||||
|
||||
|
||||
def _configured_pin(request) -> str | None:
|
||||
"""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
|
||||
@@ -105,10 +139,13 @@ def _configured_pin(request) -> str | None:
|
||||
|
||||
|
||||
def _admin_credential_configured(request) -> bool:
|
||||
"""Whether the operator has set ANY credential gate — the remote API key or
|
||||
a share PIN. When neither is set, server mode leaves admin open (the Docker
|
||||
issue #261 flow the image depends on)."""
|
||||
if os.environ.get("OMNIVOICE_API_KEY"):
|
||||
"""Whether an API key or share PIN is configured.
|
||||
|
||||
The PIN cannot authorize admin access, but its presence means the operator
|
||||
opted out of bare-server discovery. Remote admin then remains closed until
|
||||
they configure and present the long API key.
|
||||
"""
|
||||
if remote_api_key():
|
||||
return True
|
||||
return bool(_configured_pin(request))
|
||||
|
||||
@@ -127,17 +164,10 @@ def _request_presents_admin_credential(request) -> bool:
|
||||
admin. Net: remote admin in server mode requires the API key; a PIN-only
|
||||
deployment keeps admin loopback-only. getattr-defensive so a minimal Request
|
||||
stub never raises."""
|
||||
api_key = os.environ.get("OMNIVOICE_API_KEY") or ""
|
||||
api_key = remote_api_key() or ""
|
||||
if not api_key:
|
||||
return False
|
||||
headers = getattr(request, "headers", None) or {}
|
||||
query = getattr(request, "query_params", None) or {}
|
||||
cookies = getattr(request, "cookies", None) or {}
|
||||
|
||||
auth = headers.get("authorization", "")
|
||||
supplied = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||
if not supplied:
|
||||
supplied = query.get("api_key") or cookies.get("ov_key") or ""
|
||||
supplied = presented_api_key(request)
|
||||
return bool(supplied and secrets.compare_digest(supplied, api_key))
|
||||
|
||||
|
||||
@@ -161,9 +191,9 @@ def require_loopback(request: Request) -> None:
|
||||
unenforceable, so the gate can't require true loopback. It then applies the
|
||||
admin-credential rule instead:
|
||||
|
||||
- No credential configured (no API key, no PIN) → open, matching the #261
|
||||
Docker flow where the operator reaches ``/system/*`` off the bridge
|
||||
gateway with nothing set.
|
||||
- No credential configured (no API key, no PIN) → read-only requests are
|
||||
open, matching the #261 Docker bootstrap flow. State-changing requests
|
||||
fail closed even if a route accidentally kept this legacy dependency.
|
||||
- A credential IS configured → the request must present the **API key**.
|
||||
This keeps the two-tier privilege model intact under server mode:
|
||||
``OMNIVOICE_TRUSTED_NETWORKS`` is a *consumption* exemption
|
||||
@@ -171,14 +201,20 @@ def require_loopback(request: Request) -> None:
|
||||
NEVER by itself unlock the admin surface (``/system/set-env`` — RCE-class —
|
||||
and ``/api/settings/*``). The 6-digit share PIN is a consumption credential
|
||||
too and does not gate admin, so a PIN-only deployment keeps admin
|
||||
loopback-only; remote admin requires the (long) API key. A LAN client in a
|
||||
trusted CIDR — or one holding only the PIN — gets 403 here even though it
|
||||
sails through the consumption gates. See docs/api-auth.md (#1213).
|
||||
loopback-only; remote admin requires the long API key. See
|
||||
docs/api-auth.md (#1213).
|
||||
"""
|
||||
host = request.client.host if request.client else None
|
||||
if is_loopback(host):
|
||||
return
|
||||
if _server_mode():
|
||||
method = str(getattr(request, "method", "GET")).upper()
|
||||
if method not in _READ_ONLY_METHODS:
|
||||
# Defense in depth. Privileged routers should declare
|
||||
# ``require_admin`` directly, but a missed migration must not turn
|
||||
# into an unauthenticated Docker write primitive.
|
||||
require_admin(request)
|
||||
return
|
||||
if not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
@@ -186,14 +222,69 @@ def require_loopback(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="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 _READ_ONLY_METHODS
|
||||
if read_only and not _admin_credential_configured(request):
|
||||
return
|
||||
if _request_presents_admin_credential(request):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
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):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="loopback origin or admin API key required")
|
||||
|
||||
|
||||
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_loopback`.
|
||||
dictation WebSocket. Admin routes stay on :func:`require_admin`.
|
||||
|
||||
In server mode the gate is a no-op (same as :func:`require_loopback`)."""
|
||||
In server mode this consumption gate is a no-op. Admin dependencies remain
|
||||
method-aware and independent from this exemption."""
|
||||
host = request.client.host if request.client else None
|
||||
if is_local_host(host):
|
||||
return
|
||||
@@ -202,10 +293,17 @@ def require_local(request: Request) -> None:
|
||||
raise HTTPException(status_code=403, detail="loopback origin required")
|
||||
|
||||
|
||||
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 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 ws_remote_authorized(websocket) -> bool:
|
||||
@@ -219,12 +317,4 @@ def ws_remote_authorized(websocket) -> bool:
|
||||
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)
|
||||
return secrets.compare_digest(presented_api_key(websocket), key)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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"]
|
||||
)
|
||||
safe.append(item)
|
||||
return safe
|
||||
|
||||
|
||||
def public_unavailability(detail: object) -> str | None:
|
||||
return None if detail is None else _UNAVAILABLE
|
||||
@@ -15,6 +15,13 @@ 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
|
||||
|
||||
@@ -26,11 +33,12 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from core import archetypes
|
||||
from core.config import OUTPUTS_DIR, VOICES_DIR
|
||||
from services import gallery
|
||||
|
||||
logger = logging.getLogger("omnivoice.archetypes")
|
||||
|
||||
@@ -214,6 +222,49 @@ async def _render_archetype_wav(a: dict, out_path: Path) -> None:
|
||||
_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 (_PREVIEW_DIR / f"{key}.wav").exists():
|
||||
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.
|
||||
@@ -223,6 +274,37 @@ 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,
|
||||
@@ -262,33 +344,77 @@ 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):
|
||||
"""Serve a short preview clip — pre-rendered if cached, else render once."""
|
||||
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."""
|
||||
a = archetypes.get_archetype(archetype_id)
|
||||
if a is None:
|
||||
raise HTTPException(status_code=404, detail="Archetype not found")
|
||||
|
||||
cache_path = _PREVIEW_DIR / f"{_preview_key(a)}.wav"
|
||||
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 cache_path.exists():
|
||||
try:
|
||||
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)
|
||||
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}"
|
||||
),
|
||||
)
|
||||
# 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)
|
||||
# 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"},
|
||||
headers={"Cache-Control": "no-cache",
|
||||
"X-OmniVoice-Preview-Source": "local"},
|
||||
)
|
||||
|
||||
|
||||
@@ -300,6 +426,11 @@ 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:
|
||||
@@ -330,13 +461,19 @@ async def use_archetype(archetype_id: str, name: Optional[str] = Query(None)):
|
||||
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=(
|
||||
# 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"is unavailable. See Settings → Logs → Backend. Error: {e}"
|
||||
),
|
||||
)
|
||||
f"reported: {e}"
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
|
||||
profile_name = (name or a["name"]).strip() or a["name"]
|
||||
try:
|
||||
|
||||
@@ -25,6 +25,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
@@ -413,10 +414,10 @@ def _make_occ_counter(opts: ExpressiveOptions):
|
||||
|
||||
|
||||
def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
"""OmniVoice-model generate kwargs for the sampling knobs. UNSET reproduces
|
||||
"""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 OmniVoice config rejects unknown kwargs."""
|
||||
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": (
|
||||
@@ -433,7 +434,7 @@ def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
|
||||
|
||||
def _generic_extra_kwargs(opts: ExpressiveOptions) -> dict:
|
||||
"""Extra generate kwargs for a non-OmniVoice engine. UNSET → empty 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
|
||||
@@ -468,7 +469,7 @@ def _build_synth(
|
||||
"""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 OmniVoice it also carries the async
|
||||
per id) and ``engine_id``. For VoiceStudio 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.
|
||||
@@ -529,7 +530,7 @@ async def _prepare_synth(
|
||||
voice_map: dict | None = None,
|
||||
):
|
||||
"""Resolve :func:`_build_synth` into ``(synth, sample_rate, resolve,
|
||||
engine_id)`` — awaiting the OmniVoice model load when needed. Shared by the
|
||||
engine_id)`` — awaiting the VoiceStudio 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."""
|
||||
@@ -681,6 +682,87 @@ def _render_chapter_cached(chapter, synth, sr, engine_id, resolve, cache_dir, le
|
||||
"cached": seg_cache.hits}
|
||||
|
||||
|
||||
def _remote_chapter_call(chapter, *, engine_id, default_voice, voice_map,
|
||||
language, lexicon, opts, cache_dir):
|
||||
"""Build one opaque remote chapter task without loading a local TTS model."""
|
||||
import hashlib
|
||||
|
||||
from services import gpu_gateway
|
||||
from services.text_normalization import normalize_for_tts
|
||||
from services.watermark import is_enabled as watermark_enabled
|
||||
|
||||
rows, voices, refs = [], [], []
|
||||
for span in chapter.spans:
|
||||
profile_id = _map_span_voice(span.voice_id, default_voice, voice_map)
|
||||
voice = _resolve_voice(profile_id)
|
||||
rows.append({
|
||||
"text": normalize_for_tts(span.text, language),
|
||||
"pause_ms_after": span.pause_ms_after,
|
||||
"speed": getattr(span, "speed", None),
|
||||
})
|
||||
refs.append(voice.get("ref_audio"))
|
||||
voices.append({
|
||||
"ref_text": voice.get("ref_text"), "instruct": voice.get("instruct"),
|
||||
"seed": voice.get("seed"),
|
||||
})
|
||||
params = {
|
||||
"spans": rows, "voices": voices, "ref_audio": refs,
|
||||
"language": language, "lexicon": lexicon,
|
||||
"expressive": opts.to_manifest(), "watermark": bool(watermark_enabled()),
|
||||
}
|
||||
signature = hashlib.sha256(json.dumps(params, sort_keys=True, default=str).encode()).hexdigest()
|
||||
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):
|
||||
text: str
|
||||
chapter_index: int = 0
|
||||
@@ -700,7 +782,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.model_manager import _gpu_pool
|
||||
from services import gpu_gateway
|
||||
|
||||
plan = parse_audiobook_script(req.text, default_voice=req.default_voice)
|
||||
if not plan.chapters:
|
||||
@@ -714,16 +796,11 @@ async def audiobook_preview(req: AudiobookPreviewRequest) -> dict:
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
resolved_lang = _resolve_default_language(req.language, req.default_voice)
|
||||
opts = _expressive_opts(req)
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
req.default_voice,
|
||||
language=resolved_lang,
|
||||
opts=opts,
|
||||
voice_map=req.voice_map,
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
wav_path, dur, was_cached, _seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached, chapter, synth, sr, engine_id, resolve, cache_dir,
|
||||
req.lexicon, resolved_lang, opts, req.voice_map,
|
||||
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,
|
||||
)
|
||||
return {
|
||||
"output": os.path.relpath(wav_path, OUTPUTS_DIR), # served via /audio
|
||||
@@ -760,8 +837,9 @@ 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.model_manager import _gpu_pool
|
||||
from services import gpu_gateway
|
||||
|
||||
opts = opts or ExpressiveOptions()
|
||||
|
||||
@@ -836,19 +914,20 @@ 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)
|
||||
synth, sr, resolve, engine_id = await _prepare_synth(
|
||||
default_voice, language=resolved_lang, opts=opts, voice_map=voice_map
|
||||
)
|
||||
operation = "audiobook" if job_type == "audiobook" else "longform"
|
||||
decision = gpu_gateway.decide(operation)
|
||||
chapter_run = gpu_gateway.JobRun(operation)
|
||||
|
||||
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})
|
||||
|
||||
@@ -873,17 +952,34 @@ async def _render_longform_sse(
|
||||
interrupted = True
|
||||
break
|
||||
try:
|
||||
wav_path, dur, was_cached, seg_stats = await loop.run_in_executor(
|
||||
_gpu_pool, _render_chapter_cached,
|
||||
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
|
||||
resolved_lang, opts, voice_map,
|
||||
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,
|
||||
)
|
||||
except Exception: # isolate a bad chapter — keep going
|
||||
except Exception as e: # 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, "error": "chapter failed to render"})
|
||||
"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)})
|
||||
continue
|
||||
chapter_files.append(wav_path)
|
||||
chapters_meta.append((chapter.title, int(round(dur * 1000))))
|
||||
@@ -898,6 +994,11 @@ 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)
|
||||
@@ -920,7 +1021,32 @@ async def _render_longform_sse(
|
||||
return
|
||||
|
||||
if not chapter_files:
|
||||
yield _emit({"type": "error", "error": "all chapters failed to render"})
|
||||
# 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)
|
||||
return
|
||||
|
||||
yield _emit({"type": "assembling"})
|
||||
@@ -988,6 +1114,25 @@ async def _render_longform_sse(
|
||||
yield _emit({"type": "error", "error": "render failed (see backend log)"})
|
||||
|
||||
|
||||
async def _public_longform_stream(plan, **render_kwargs):
|
||||
"""Keep generator diagnostics local if setup fails before its own guard."""
|
||||
try:
|
||||
async for event in _render_longform_sse(plan, **render_kwargs):
|
||||
yield event
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
from core.public_errors import public_failure
|
||||
|
||||
error = public_failure(
|
||||
logger,
|
||||
"Longform response stream failed",
|
||||
exc,
|
||||
response="Render failed; check the backend log for details.",
|
||||
)
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': error})}\n\n"
|
||||
|
||||
|
||||
@router.post("/audiobook")
|
||||
async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
"""Synthesize a chapterized audiobook from a script, streaming SSE progress."""
|
||||
@@ -996,7 +1141,7 @@ async def audiobook_synthesize(req: AudiobookRequest, request: Request = None):
|
||||
# to a direct in-process call, e.g. a unit test); its disconnect poll is what
|
||||
# lets Stop cancel the render mid-book (#1216).
|
||||
return StreamingResponse(
|
||||
_render_longform_sse(
|
||||
_public_longform_stream(
|
||||
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,
|
||||
@@ -1057,7 +1202,7 @@ async def longform_render(req: LongformRenderRequest, request: Request = None):
|
||||
chapters.append(Chapter(title=c.title or f"Chapter {i + 1}", spans=spans))
|
||||
plan = AudiobookPlan(chapters=chapters)
|
||||
return StreamingResponse(
|
||||
_render_longform_sse(
|
||||
_public_longform_stream(
|
||||
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,
|
||||
@@ -1150,7 +1295,7 @@ async def resume_longform(job_id: str, request: Request = None):
|
||||
# unrendered ones synthesize. Using a fresh id means the request's job_id
|
||||
# never names a work dir / output file (defence-in-depth path-injection).
|
||||
return StreamingResponse(
|
||||
_render_longform_sse(
|
||||
_public_longform_stream(
|
||||
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"),
|
||||
|
||||
@@ -20,6 +20,8 @@ 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")
|
||||
@@ -149,14 +151,17 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
# ── 2. Transcribe ─────────────────────────────────────────────────
|
||||
_set_progress(job, "transcribe", 0)
|
||||
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
from services.asr_backend import load_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():
|
||||
backend = get_active_asr_backend()
|
||||
# `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()
|
||||
result = backend.transcribe(audio_path, word_timestamps=True)
|
||||
detected_lang = result.get("language", "en")
|
||||
segments = segment_transcript(result, duration=duration)
|
||||
@@ -188,8 +193,8 @@ async def _run_batch_pipeline(job_id: str, job: dict):
|
||||
return
|
||||
|
||||
# ── Engine resolution (issue #312 class) ────────────────────────────
|
||||
# Batch used to hardcode OmniVoice via get_model() regardless of the
|
||||
# engine selected in Settings → Engines. require_cloning only when a
|
||||
# Batch used to hardcode VoiceStudio via get_model() regardless of the
|
||||
# engine selected in Model Catalogue → 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
|
||||
@@ -531,7 +536,10 @@ async def enqueue_batch_job(
|
||||
_jobs[job_id] = job
|
||||
await _queue.put(job_id)
|
||||
|
||||
logger.info("Batch job %s enqueued: %s → %s", job_id, video.filename, lang_list)
|
||||
logger.info(
|
||||
"Batch job %s enqueued (%d target languages)",
|
||||
log_safe(job_id), len(lang_list),
|
||||
)
|
||||
return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()}
|
||||
|
||||
|
||||
@@ -573,14 +581,18 @@ 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.pop(job_id, None)
|
||||
job = _jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
if job.get("video_path") and os.path.exists(job["video_path"]):
|
||||
if job.get("video_path"):
|
||||
try:
|
||||
os.remove(job["video_path"])
|
||||
except Exception:
|
||||
pass
|
||||
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)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ 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 `get_active_asr_backend()` returns — WhisperX
|
||||
by default, or MLX Whisper on Apple Silicon when configured.
|
||||
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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -97,8 +100,12 @@ async def transcribe_audio(
|
||||
if use_accurate:
|
||||
# Accurate mode: full WhisperX with forced alignment —
|
||||
# for when the user explicitly wants word-level timing.
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
# `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()
|
||||
result = backend.transcribe(tmp.name, word_timestamps=True)
|
||||
else:
|
||||
# Fast mode (default): use the fastest available engine
|
||||
@@ -110,7 +117,11 @@ async def transcribe_audio(
|
||||
return result, backend.id
|
||||
|
||||
from services.model_manager import _gpu_pool
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
result, engine_id = await run_transcribe_guarded(
|
||||
@@ -121,6 +132,14 @@ 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,7 +9,9 @@ Protocol:
|
||||
→ Client sends binary audio frames (16-bit PCM or WebM/Opus blobs)
|
||||
← Server sends JSON messages:
|
||||
|
||||
Opt-in AEC mode (``?aec=1[&sr=16000]``, parity Action 8b): for dictating
|
||||
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
|
||||
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
|
||||
@@ -68,6 +70,19 @@ _AEC_NEAR = 0x00 # microphone frame (clean it, then buffer for ASR)
|
||||
_AEC_FAR = 0x01 # playback reference frame (feed the echo model only)
|
||||
|
||||
|
||||
def _requested_pcm_sample_rate(query_params) -> int | None:
|
||||
"""Return a bounded PCM rate for ``?pcm=1``/``?aec=1`` sessions."""
|
||||
raw_pcm = query_params.get("pcm") in ("1", "true", "on")
|
||||
aec = query_params.get("aec") in ("1", "true", "on")
|
||||
if not raw_pcm and not aec:
|
||||
return None
|
||||
try:
|
||||
sample_rate = int(query_params.get("sr", "16000"))
|
||||
except (TypeError, ValueError):
|
||||
return 16000
|
||||
return sample_rate if 8000 <= sample_rate <= 96000 else 16000
|
||||
|
||||
|
||||
def _demux_aec_frame(data: bytes) -> tuple[str, bytes]:
|
||||
"""Split a prefixed AEC binary frame into ``(kind, pcm)``.
|
||||
|
||||
@@ -138,8 +153,8 @@ def _select_sherpa_spec(websocket: WebSocket):
|
||||
async def ws_transcribe(websocket: WebSocket):
|
||||
"""Stream audio in, get partial + final transcription out."""
|
||||
# Loopback origin guard — refuse anything not from 127.0.0.1, ::1, or
|
||||
# localhost. HTTP routers use Depends(require_loopback) at router level;
|
||||
# WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# localhost. Privileged HTTP routers use Depends(require_admin) at router
|
||||
# level; WebSocket dependency injection differs across FastAPI versions, so we
|
||||
# inline the check before accept(). Without it, any local process could
|
||||
# stream the user's microphone over this endpoint.
|
||||
# Wave 2.3 (remote backend): a non-loopback client that presents the
|
||||
@@ -210,12 +225,11 @@ 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: int | None = None
|
||||
pcm_sr = _requested_pcm_sample_rate(websocket.query_params)
|
||||
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)
|
||||
aec = NlmsEchoCanceller(sample_rate=pcm_sr or 16000)
|
||||
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.
|
||||
@@ -507,7 +521,8 @@ async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "stage": stage})
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Sherpa load status could not be delivered; stopping stream setup")
|
||||
return False
|
||||
try:
|
||||
await asyncio.to_thread(backend.ensure_loaded)
|
||||
except Exception as e:
|
||||
@@ -522,7 +537,8 @@ async def _sherpa_load_with_status(websocket: WebSocket, backend, spec) -> bool:
|
||||
try:
|
||||
await websocket.send_json({"type": "status", "stage": "ready"})
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Sherpa ready status could not be delivered; stopping stream setup")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@@ -1001,5 +1017,3 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from api.dependencies import require_local
|
||||
from api.public_engine_metadata import public_unavailability
|
||||
from core import prefs
|
||||
from services import sherpa_dictation as sd
|
||||
|
||||
@@ -80,7 +81,7 @@ def list_dictation_models():
|
||||
return {
|
||||
"models": out,
|
||||
"engine_available": available,
|
||||
"engine_reason": None if available else reason,
|
||||
"engine_reason": None if available else public_unavailability(reason),
|
||||
"default_model_id": sd.DEFAULT_MODEL_ID,
|
||||
}
|
||||
|
||||
@@ -100,13 +101,13 @@ class DictationPrefsUpdate(BaseModel):
|
||||
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,6 +116,26 @@ def set_dictation_prefs(req: DictationPrefsUpdate):
|
||||
)
|
||||
# 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
|
||||
@@ -123,11 +144,4 @@ def set_dictation_prefs(req: DictationPrefsUpdate):
|
||||
sd.clear_demotion(canonical)
|
||||
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()
|
||||
|
||||
+230
-71
@@ -4,15 +4,19 @@ import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from typing import Optional
|
||||
from fastapi import Request
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
|
||||
from fastapi.responses import FileResponse, StreamingResponse, JSONResponse
|
||||
|
||||
from core.db import db_conn
|
||||
from core.config import PREVIEW_DIR
|
||||
from core.tasks import task_manager
|
||||
from core.logging_utils import log_safe
|
||||
from core import event_bus
|
||||
from schemas.requests import DubIngestUrlRequest, ParseSubtitleTextRequest
|
||||
from services.model_manager import get_model, _gpu_pool, _cpu_pool, get_diarization_pipeline, offload_tts_for_asr, restore_tts_after_asr, should_preload_tts_asr
|
||||
@@ -40,6 +44,67 @@ from services import dub_pipeline
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
_MAX_COOKIE_EXPORT_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
def _cookie_transport_allowed(
|
||||
scheme: str, client_host: str | None, origin: str | None
|
||||
) -> bool:
|
||||
"""Credentials may cross HTTP only from a local UI to a loopback peer."""
|
||||
from api.dependencies import is_local_host
|
||||
|
||||
if scheme == "https":
|
||||
return True
|
||||
try:
|
||||
origin_host = urlsplit(origin or "").hostname or ""
|
||||
except ValueError:
|
||||
return False
|
||||
return is_local_host(client_host or "") and (
|
||||
is_local_host(origin_host) or origin_host == "tauri.localhost"
|
||||
)
|
||||
|
||||
|
||||
def _stage_cookie_export(contents: str | None) -> str | None:
|
||||
"""Write an explicitly supplied cookies.txt export to a private temp file."""
|
||||
if contents is None:
|
||||
return None
|
||||
cookie_bytes = contents.encode("utf-8")
|
||||
if len(cookie_bytes) > _MAX_COOKIE_EXPORT_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Cookie file is too large (maximum 1 MB). Export cookies in "
|
||||
"Netscape cookies.txt format and try again."
|
||||
),
|
||||
)
|
||||
first_line = contents.lstrip("\ufeff\r\n ").splitlines()[0] if contents.strip() else ""
|
||||
if not first_line.startswith(("# Netscape HTTP Cookie File", "# HTTP Cookie File")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"This is not a Netscape cookies.txt export. Export cookies as "
|
||||
"cookies.txt from your browser, then choose that file."
|
||||
),
|
||||
)
|
||||
fd, cookie_path = tempfile.mkstemp(
|
||||
prefix="voicestudio-ytdlp-", suffix=".cookies.txt",
|
||||
)
|
||||
try:
|
||||
os.chmod(cookie_path, 0o600)
|
||||
with os.fdopen(fd, "wb") as cookie_handle:
|
||||
cookie_handle.write(cookie_bytes)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass # Best effort: fdopen may already have consumed/closed the descriptor.
|
||||
try:
|
||||
os.unlink(cookie_path)
|
||||
except OSError:
|
||||
pass # Best effort: preserve the original staging error.
|
||||
raise
|
||||
return cookie_path
|
||||
|
||||
|
||||
# ── Legacy-name aliases to services/dub_pipeline.py ────────────────────────
|
||||
# Phase 2.4 moved the business logic into a service. Other routers
|
||||
@@ -176,7 +241,7 @@ async def dub_import_srt(job_id: str, file: UploadFile = File(...)):
|
||||
_save_job(job_id, job)
|
||||
logger.info(
|
||||
"Imported %d cue(s) from .srt for job %s (skipped=%d, overlap_shifted=%d, clamped=%d)",
|
||||
len(segments), job_id, result.skipped_cues, result.dropped_overlaps, clamped,
|
||||
len(segments), log_safe(job_id), result.skipped_cues, result.dropped_overlaps, clamped,
|
||||
)
|
||||
return {
|
||||
"segments": segments,
|
||||
@@ -208,13 +273,18 @@ def dub_abort(job_id: str):
|
||||
with _active_procs_lock:
|
||||
had_procs = bool(_active_procs.get(job_id))
|
||||
_kill_job_procs(job_id)
|
||||
try:
|
||||
if task_manager.cancel_task(job_id) is False:
|
||||
raise RuntimeError("task cancellation was declined")
|
||||
except Exception as exc:
|
||||
logger.warning("Dub task cancellation failed")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="The dub could not be fully aborted. Retry the abort operation.",
|
||||
) from exc
|
||||
job = _dub_jobs.get(job_id)
|
||||
if job is not None:
|
||||
job["aborted"] = True
|
||||
try:
|
||||
task_manager.cancel_task(job_id)
|
||||
except Exception:
|
||||
pass
|
||||
return {"aborted": True, "had_active_procs": had_procs}
|
||||
|
||||
|
||||
@@ -252,13 +322,28 @@ def delete_single_dub_history(history_id: str):
|
||||
with db_conn() as conn:
|
||||
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
|
||||
|
||||
# #1331 (deletion half): the content-hash cache points newer jobs' paths
|
||||
# (vocals, and pre-fix clone refs) into this dir. Check BEFORE the row is
|
||||
# deleted — the scan reads dub_history, and after _delete_row this row's
|
||||
# neighbours are all that's left to consult either way.
|
||||
holders = dub_pipeline.job_dir_referenced_by_others(history_id)
|
||||
|
||||
# Atomic with the evict — see purge_jobs (#1252 review).
|
||||
dub_pipeline.purge_jobs([history_id], delete_rows=_delete_row)
|
||||
safe = _safe_job_dir(history_id)
|
||||
if safe and os.path.isdir(safe):
|
||||
if holders:
|
||||
# Keep the directory: another saved dub still renders from files in
|
||||
# it. Disk is the cheap thing here; a job that silently loses its
|
||||
# cloned voice on every regen is not. The row is gone, so the entry
|
||||
# disappears from history either way.
|
||||
logger.info(
|
||||
"dub delete %s: history row removed but directory kept — still "
|
||||
"referenced by job(s) %s (#1331)", log_safe(history_id), log_safe(", ".join(holders)),
|
||||
)
|
||||
elif safe and os.path.isdir(safe):
|
||||
shutil.rmtree(safe, ignore_errors=True)
|
||||
event_bus.emit("dub_history", {"action": "deleted", "id": history_id})
|
||||
return {"deleted": True}
|
||||
return {"deleted": True, "dir_kept_for": holders}
|
||||
|
||||
@router.post("/preview/upload")
|
||||
async def preview_upload(video: UploadFile = File(...)):
|
||||
@@ -285,7 +370,7 @@ async def preview_upload(video: UploadFile = File(...)):
|
||||
)
|
||||
has_audio = True
|
||||
except Exception as e:
|
||||
logger.warning(f"FFmpeg extraction failed: {e}")
|
||||
logger.warning("FFmpeg extraction failed: %s", log_safe(e))
|
||||
pass
|
||||
|
||||
return {
|
||||
@@ -380,7 +465,7 @@ async def dub_upload(
|
||||
|
||||
|
||||
@router.post("/dub/ingest-url")
|
||||
async def dub_ingest_url(req: DubIngestUrlRequest):
|
||||
async def dub_ingest_url(req: DubIngestUrlRequest, request: Request):
|
||||
"""Ingest a remote video URL via yt-dlp. Queues background prep task.
|
||||
|
||||
Returns 202 immediately with {job_id, task_id}. All work (download,
|
||||
@@ -409,7 +494,17 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
|
||||
status_code=400,
|
||||
detail="Invalid job_id. Must be alphanumeric + hyphens/underscores only, ≤64 chars. Generate a fresh job_id or omit it to auto-create one.",
|
||||
)
|
||||
if req.cookie_file and not _cookie_transport_allowed(
|
||||
request.url.scheme,
|
||||
request.client.host if request.client else None,
|
||||
request.headers.get("origin"),
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cookie exports require HTTPS or the local desktop app.",
|
||||
)
|
||||
os.makedirs(job_dir, exist_ok=True)
|
||||
cookie_path = _stage_cookie_export(req.cookie_file)
|
||||
|
||||
task_id = f"prep_{job_id}"
|
||||
source = {
|
||||
@@ -417,12 +512,21 @@ async def dub_ingest_url(req: DubIngestUrlRequest):
|
||||
"url": url,
|
||||
"fetch_subs": bool(req.fetch_subs),
|
||||
"sub_langs": req.sub_langs or None,
|
||||
"cookie_file": cookie_path,
|
||||
}
|
||||
await task_manager.add_task(
|
||||
task_id, "prep",
|
||||
_ingest_gen, job_id, job_dir,
|
||||
source, None,
|
||||
)
|
||||
try:
|
||||
await task_manager.add_task(
|
||||
task_id, "prep",
|
||||
_ingest_gen, job_id, job_dir,
|
||||
source, None,
|
||||
)
|
||||
except Exception:
|
||||
if cookie_path:
|
||||
try:
|
||||
os.unlink(cookie_path)
|
||||
except OSError:
|
||||
pass # Best effort: do not hide the task-enqueue failure.
|
||||
raise
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
content={"job_id": job_id, "task_id": task_id, "filename": ""},
|
||||
@@ -453,7 +557,7 @@ _prep_event_helper = dub_pipeline.prep_event # alias; we keep the module-local
|
||||
#: into one reference, which is how "made up" clone voices happen).
|
||||
CLONE_SKIP_HEURISTIC_MSG = (
|
||||
"auto voice cloning skipped: speaker labels are gap-based estimates — "
|
||||
"set up diarization (Settings → Models → pyannote) for per-speaker clones"
|
||||
"set up diarization (Model Catalogue → Models → pyannote) for per-speaker clones"
|
||||
)
|
||||
|
||||
|
||||
@@ -585,7 +689,7 @@ async def dub_transcribe_stream(
|
||||
else:
|
||||
# The TTS core model is loaded here for exactly one reason: to harvest a
|
||||
# preloaded `_asr_pipe` off it (passed to get_active_asr_backend below).
|
||||
# That attribute is only ever set by OmniVoice.from_pretrained under
|
||||
# That attribute is only ever set by VoiceStudio.from_pretrained under
|
||||
# OMNIVOICE_PRELOAD_TTS_ASR, which is off by default — so in the default
|
||||
# config this loaded ~3 GB, harvested None, and then offload_tts_for_asr()
|
||||
# freed it again 60 lines below. On unified memory that offload is a full
|
||||
@@ -616,7 +720,10 @@ async def dub_transcribe_stream(
|
||||
yield b": tts-load keepalive\n\n"
|
||||
_model = _model_task.result()
|
||||
except Exception as e:
|
||||
logger.exception("transcribe preflight: model load failed (job=%r)", job_id)
|
||||
logger.error(
|
||||
"transcribe preflight: model load failed (job=%s): %s",
|
||||
log_safe(job_id), log_safe(e),
|
||||
)
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
preflight_error = f["reason"] + (f" — {f['hint']}" if f.get("hint") else "")
|
||||
@@ -662,6 +769,16 @@ async def dub_transcribe_stream(
|
||||
preflight_payload = _missing
|
||||
if _missing is None:
|
||||
try:
|
||||
# Free recoverable TTS VRAM before ASR chooses its
|
||||
# device. Probing first falsely routed Whisper to
|
||||
# CPU even when this offload made CUDA viable.
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
_cpu_pool, offload_tts_for_asr
|
||||
)
|
||||
_tts_offloaded["v"] = True
|
||||
except Exception as e:
|
||||
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
|
||||
# The PyTorch-Whisper backend lazily builds its own pipeline
|
||||
# when no preloaded `_asr_pipe` is present (issue #255), so it
|
||||
# no longer needs OMNIVOICE_PRELOAD_TTS_ASR=1.
|
||||
@@ -719,7 +836,7 @@ async def dub_transcribe_stream(
|
||||
preflight_error = asr_model_missing_detail(e.payload)
|
||||
preflight_payload = e.payload
|
||||
except Exception as e:
|
||||
logger.exception("transcribe preflight: ASR load failed (job=%r)", job_id)
|
||||
logger.error("Transcription preflight ASR load failed")
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe-preflight", include_diagnostic=False)
|
||||
preflight_error = "ASR backend initialization failed: " + f["reason"] + (
|
||||
@@ -750,9 +867,10 @@ async def dub_transcribe_stream(
|
||||
|
||||
try:
|
||||
audio_np, sr = await loop.run_in_executor(_cpu_pool, _load)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# Terminal error → always emit `done` (see preflight note, #578).
|
||||
yield _sse_event("error", {"detail": f"audio load failed: {e}", "retryable": True})
|
||||
from core.public_errors import stream_failure
|
||||
yield _sse_event("error", stream_failure("transcription_failed"))
|
||||
yield _sse_event("done", {})
|
||||
return
|
||||
|
||||
@@ -790,17 +908,6 @@ async def dub_transcribe_stream(
|
||||
"chunk_s": transcribe_chunk_s,
|
||||
})
|
||||
|
||||
# Free VRAM: move TTS model to CPU so WhisperX + VAD can fit.
|
||||
# Only offloads when free GPU memory is < 4 GB (e.g. laptop GPUs).
|
||||
# Non-fatal: an offload failure must not drop the stream (#255) —
|
||||
# transcription can still proceed (it just has less headroom).
|
||||
try:
|
||||
await loop.run_in_executor(_cpu_pool, offload_tts_for_asr)
|
||||
# Restore is now owed on every exit path, not just success (#1191).
|
||||
_tts_offloaded["v"] = True
|
||||
except Exception as e:
|
||||
logger.warning("offload_tts_for_asr failed (continuing): %s", e)
|
||||
|
||||
all_segments: list[dict] = []
|
||||
# Words (global-timeline) retained so diarization can re-split a segment
|
||||
# that spans two speakers' turns at the word boundary (#486).
|
||||
@@ -808,6 +915,7 @@ async def dub_transcribe_stream(
|
||||
detected_lang = None
|
||||
next_seg_id = 0
|
||||
chunk_errors: list[str] = []
|
||||
chunk_error_codes: list[str] = []
|
||||
# Speaker turns from an ASR backend that diarizes inline (FunASR cam++).
|
||||
# When present, _diarize() uses them and skips pyannote (Phase 2, #182).
|
||||
asr_speaker_turns: list[dict] = []
|
||||
@@ -852,9 +960,26 @@ async def dub_transcribe_stream(
|
||||
continue
|
||||
turns.append({"start": s0 + offset, "end": s1 + offset, "speaker": spk})
|
||||
return {"chunks": shifted, "language": r.get("language"), "speaker_turns": turns}
|
||||
except Exception as e:
|
||||
logger.exception("chunk transcribe failed (backend=%s)", _asr_backend.id)
|
||||
return {"chunks": [], "language": None, "error": str(e)}
|
||||
except Exception as exc:
|
||||
# Keep diagnostics local and fixed-shape. In particular,
|
||||
# CUDA OOM is a distinct, actionable recovery class rather
|
||||
# than the generic "no segments" dead end.
|
||||
is_memory = isinstance(exc, torch.OutOfMemoryError)
|
||||
logger.error(
|
||||
"Chunk transcription failed (backend=%s; class=%s; details withheld)",
|
||||
_asr_backend.id,
|
||||
type(exc).__name__,
|
||||
)
|
||||
from core.public_errors import stream_failure
|
||||
failure = stream_failure(
|
||||
"transcription_memory" if is_memory else "transcription_failed"
|
||||
)
|
||||
return {
|
||||
"chunks": [],
|
||||
"language": None,
|
||||
"error": failure["detail"],
|
||||
"error_code": failure["code"],
|
||||
}
|
||||
|
||||
# Retry a failed/timed-out chunk once on a fresh pool before giving
|
||||
# up. Otherwise a transient wedge on the FIRST chunk (whisperx often
|
||||
@@ -871,7 +996,6 @@ async def dub_transcribe_stream(
|
||||
# worker, and raises the actionable ASRTimeoutError. Run it as
|
||||
# a task and poll so we can keep yielding pings — the
|
||||
# EventSource connection drops without them.
|
||||
pool_reset_by_guard = False
|
||||
task = asyncio.ensure_future(run_transcribe_guarded(
|
||||
_gpu_pool, _transcribe_chunk,
|
||||
what=f"Dub chunk {i + 1}/{chunks_n}",
|
||||
@@ -885,17 +1009,23 @@ async def dub_transcribe_stream(
|
||||
yield _sse_event("ping", {})
|
||||
try:
|
||||
part = task.result()
|
||||
except ASRTimeoutError as e:
|
||||
except ASRTimeoutError:
|
||||
# The guard already reset the pool; keep the actionable
|
||||
# message (it names the durable fixes, and — after repeated
|
||||
# timeouts — the crash-isolated engine escape hatch).
|
||||
pool_reset_by_guard = True
|
||||
logger.error(
|
||||
"Transcribe chunk %d/%d timed out after %.0fs (attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, transcribe_timeout_s, _attempt,
|
||||
_CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
|
||||
_CHUNK_TRANSCRIBE_ATTEMPTS, log_safe(job_id),
|
||||
)
|
||||
part = {"chunks": [], "language": None, "error": str(e)}
|
||||
from core.public_errors import stream_failure
|
||||
failure = stream_failure("transcription_timeout")
|
||||
part = {
|
||||
"chunks": [],
|
||||
"language": None,
|
||||
"error": failure["detail"],
|
||||
"error_code": failure["code"],
|
||||
}
|
||||
# Success → keep it. Failure/timeout → retry once on a fresh
|
||||
# worker (the internal _transcribe_chunk except returns an
|
||||
# error-part; the timeout path already reset the pool).
|
||||
@@ -904,14 +1034,17 @@ async def dub_transcribe_stream(
|
||||
if _attempt < _CHUNK_TRANSCRIBE_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Retrying transcribe chunk %d/%d after failure/timeout (next attempt %d/%d, job=%s)",
|
||||
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, job_id,
|
||||
i + 1, chunks_n, _attempt + 1, _CHUNK_TRANSCRIBE_ATTEMPTS, log_safe(job_id),
|
||||
)
|
||||
if not pool_reset_by_guard:
|
||||
reset_pool_after_wedge(
|
||||
_gpu_pool, what=f"Dub chunk {i + 1}/{chunks_n}")
|
||||
# A completed exception did not wedge the worker. Resetting
|
||||
# the pool here leaked a healthy executor on every ordinary
|
||||
# decode failure; run_transcribe_guarded already resets the
|
||||
# pool on the only case that needs it: a real timeout.
|
||||
if part.get("error"):
|
||||
chunk_errors.append(part["error"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, part["error"])
|
||||
if part.get("error_code"):
|
||||
chunk_error_codes.append(part["error_code"])
|
||||
logger.warning("Chunk %d/%d error: %s", i + 1, chunks_n, log_safe(part["error"]))
|
||||
if detected_lang is None and part.get("language"):
|
||||
detected_lang = part["language"]
|
||||
asr_speaker_turns.extend(part.get("speaker_turns") or [])
|
||||
@@ -949,6 +1082,7 @@ async def dub_transcribe_stream(
|
||||
"segments": chunk_segs,
|
||||
"progress": (i + 1) / chunks_n,
|
||||
"error": part.get("error"),
|
||||
"error_code": part.get("error_code"),
|
||||
})
|
||||
|
||||
if job.get("aborted"):
|
||||
@@ -974,7 +1108,9 @@ async def dub_transcribe_stream(
|
||||
seen.add(s)
|
||||
uniq.append(s)
|
||||
if uniq:
|
||||
detail = "Transcription produced no segments. " + " | ".join(uniq[:3])
|
||||
# Chunk failures already carry a complete recovery message.
|
||||
# Do not prepend another generic sentence to it.
|
||||
detail = " | ".join(uniq[:3])
|
||||
# Add the actionable hint for a recognized failure class
|
||||
# (e.g. pkg_resources missing → install setuptools).
|
||||
hint = build_failure(" ".join(uniq), stage="transcribe", include_diagnostic=False).get("hint")
|
||||
@@ -986,8 +1122,11 @@ async def dub_transcribe_stream(
|
||||
"too short, or in an unsupported format. Try re-uploading or "
|
||||
"check that the source has an audible speech track."
|
||||
)
|
||||
logger.error("transcribe yielded 0 segments (job=%s): %s", job_id, detail)
|
||||
yield _sse_event("error", {"detail": detail, "retryable": True})
|
||||
logger.error("transcribe yielded 0 segments (job=%s): %s", log_safe(job_id), log_safe(detail))
|
||||
payload = {"detail": detail, "retryable": True}
|
||||
if chunk_error_codes:
|
||||
payload["code"] = chunk_error_codes[0]
|
||||
yield _sse_event("error", payload)
|
||||
yield _sse_event("done", {})
|
||||
return
|
||||
|
||||
@@ -1064,7 +1203,7 @@ async def dub_transcribe_stream(
|
||||
f"unavailable, so the ASR engine's built-in speaker "
|
||||
f"turns were used and the detected count may differ "
|
||||
f"from the {num_speakers} you set. Set up diarization "
|
||||
f"(Settings → Models → pyannote) to enforce an exact "
|
||||
f"(Model Catalogue → Models → pyannote) to enforce an exact "
|
||||
f"speaker count."
|
||||
)
|
||||
return resplit, {
|
||||
@@ -1245,7 +1384,11 @@ async def dub_transcribe_stream(
|
||||
# new target language and have the ORIGINAL speaker speak it — the
|
||||
# central pro-grade dubbing promise.
|
||||
try:
|
||||
from services.speaker_clone import extract_speaker_clones, auto_profile_id
|
||||
from services.speaker_clone import (
|
||||
auto_profile_id,
|
||||
build_cast_sources,
|
||||
extract_speaker_clones,
|
||||
)
|
||||
vocals_for_clone = job.get("vocals_path") or asr_audio_target
|
||||
clones = {}
|
||||
if labels_source == "heuristic":
|
||||
@@ -1258,17 +1401,25 @@ async def dub_transcribe_stream(
|
||||
# warning to the user.)
|
||||
logger.info(
|
||||
"auto speaker clones skipped (labels_source=heuristic, job=%s)",
|
||||
job_id,
|
||||
log_safe(job_id),
|
||||
)
|
||||
yield _sse_event("warning", {
|
||||
"detail": CLONE_SKIP_HEURISTIC_MSG,
|
||||
"source": "speaker_clone",
|
||||
})
|
||||
else:
|
||||
# Clones are written into THIS job's dir, never alongside the
|
||||
# vocals (#1331): on a content-hash cache hit vocals_path
|
||||
# points into an OLDER job's dir, so dirname(vocals) wrote the
|
||||
# new job's clone refs into a directory the user can delete by
|
||||
# removing that older history entry — after which every
|
||||
# single-segment regen silently rendered in the default voice.
|
||||
_clone_dir = _safe_job_dir(job_id) or os.path.dirname(vocals_for_clone)
|
||||
os.makedirs(_clone_dir, exist_ok=True)
|
||||
fut_clones = loop.run_in_executor(
|
||||
_cpu_pool, lambda: extract_speaker_clones(
|
||||
vocals_for_clone, final_segs,
|
||||
os.path.dirname(vocals_for_clone),
|
||||
_clone_dir,
|
||||
labels_source=labels_source,
|
||||
),
|
||||
)
|
||||
@@ -1307,10 +1458,17 @@ async def dub_transcribe_stream(
|
||||
try:
|
||||
from services.speaker_clone import extract_segment_refs
|
||||
seg_ids_for_clone = [s.get("id", i) for i, s in enumerate(final_segs)]
|
||||
# Same #1331 rule as the per-speaker extraction above, and
|
||||
# this is the DEFAULT path: per-segment references must
|
||||
# live in THIS job's dir, or a cache-hit job's clips die
|
||||
# with the older job they were written next to (both
|
||||
# reviewers, on the first version of this fix).
|
||||
_seg_clone_dir = _safe_job_dir(job_id) or os.path.dirname(vocals_for_clone)
|
||||
os.makedirs(_seg_clone_dir, exist_ok=True)
|
||||
seg_clones = await loop.run_in_executor(
|
||||
_cpu_pool, lambda: extract_segment_refs(
|
||||
vocals_for_clone, final_segs,
|
||||
os.path.dirname(vocals_for_clone),
|
||||
_seg_clone_dir,
|
||||
seg_ids=seg_ids_for_clone,
|
||||
),
|
||||
)
|
||||
@@ -1332,7 +1490,9 @@ async def dub_transcribe_stream(
|
||||
except Exception as e:
|
||||
logger.warning("per-segment clone refs skipped: %s", e)
|
||||
|
||||
if clones or seg_clones:
|
||||
cast_sources = build_cast_sources(final_segs, clones, seg_clones)
|
||||
job["cast_sources"] = cast_sources
|
||||
if cast_sources:
|
||||
if clones:
|
||||
job["speaker_clones"] = clones
|
||||
# Default each segment's profile_id to its detected speaker's
|
||||
@@ -1354,16 +1514,11 @@ async def dub_transcribe_stream(
|
||||
if s.get("profile_id"):
|
||||
continue
|
||||
spk = s.get("speaker_id") or "Speaker 1"
|
||||
if spk in clones:
|
||||
if spk in cast_sources:
|
||||
# Keep one UI-visible value for pooled and per-segment
|
||||
# sources. Generation resolves this line's own clip
|
||||
# first and falls back to the speaker's best clip.
|
||||
s["profile_id"] = auto_profile_id(spk)
|
||||
continue
|
||||
# No per-speaker clone for this speaker (too little usable
|
||||
# audio overall) but this single line was long enough for
|
||||
# its own ref — fall back to the per-segment id. The editor
|
||||
# can't render it, but generation still clones correctly.
|
||||
sid = str(s.get("id", ""))
|
||||
if sid and sid in seg_clones:
|
||||
s["profile_id"] = f"auto-seg:{sid}"
|
||||
except Exception as e:
|
||||
logger.warning("speaker_clone extraction skipped: %s", e)
|
||||
|
||||
@@ -1396,7 +1551,10 @@ async def dub_transcribe_stream(
|
||||
"segments": final_segs,
|
||||
"source_lang": job["source_lang"],
|
||||
"full_transcript": job["full_transcript"],
|
||||
"speaker_clones": job.get("speaker_clones", {}),
|
||||
# The client only needs labels and durations. Never send host
|
||||
# paths or reference transcripts through this public event.
|
||||
"speaker_clones": job.get("cast_sources", {}),
|
||||
"cast_sources": job.get("cast_sources", {}),
|
||||
})
|
||||
yield _sse_event("done", {})
|
||||
|
||||
@@ -1417,12 +1575,10 @@ async def dub_transcribe_stream(
|
||||
try:
|
||||
async for ev in _gen_body():
|
||||
yield ev
|
||||
except Exception as e: # noqa: BLE001 — last-resort stream finalizer
|
||||
logger.exception("transcribe stream crashed (job=%r)", job_id)
|
||||
from core.failure import build_failure
|
||||
f = build_failure(e, stage="transcribe", include_diagnostic=False)
|
||||
detail = f["reason"] + (f" — {f['hint']}" if f.get("hint") else "")
|
||||
yield _sse_event("error", {"detail": detail, "retryable": True})
|
||||
except Exception: # noqa: BLE001 — last-resort stream finalizer
|
||||
logger.error("Transcription stream failed unexpectedly")
|
||||
from core.public_errors import stream_failure
|
||||
yield _sse_event("error", stream_failure("transcription_failed"))
|
||||
yield _sse_event("done", {})
|
||||
finally:
|
||||
# Last-resort VRAM release (see _loaded_asr above): covers crashes,
|
||||
@@ -1531,8 +1687,11 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
|
||||
# / mlx / pytorch based on what's installed + user preference. Works
|
||||
# identically on all platforms; the older mlx-vs-pytorch branching
|
||||
# here duplicated the logic in asr_backend.py and skipped WhisperX.
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
_asr = get_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
# `load_*`, not `get_*`: the plain selector hands back engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, which
|
||||
# then dies at `.transcribe()`. The loader degrades (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
_asr = load_active_asr_backend(asr_pipe=getattr(_model, "_asr_pipe", None))
|
||||
try:
|
||||
try:
|
||||
logger.info("Transcribing full audio via %s ...", _asr.id)
|
||||
|
||||
+302
-108
@@ -1,19 +1,22 @@
|
||||
import os
|
||||
import asyncio
|
||||
import io
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
|
||||
from core.config import DUB_DIR, dub_seg_path
|
||||
from core.tasks import task_manager
|
||||
from core.config import DUB_DIR
|
||||
from core.http_headers import content_disposition
|
||||
from api.routers.dub_core import _get_job
|
||||
from core.logging_utils import log_safe
|
||||
from core.path_security import UnsafePath, resolve_within
|
||||
from core.tasks import task_manager
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from services.ffmpeg_utils import (
|
||||
bed_mix_filter,
|
||||
explain_ffmpeg_failure,
|
||||
@@ -28,6 +31,8 @@ from services.video_retime import (
|
||||
prepare_smart_fit_video,
|
||||
)
|
||||
|
||||
from api.routers.dub_core import _get_job
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
@@ -40,6 +45,143 @@ def _unique_stamp() -> str:
|
||||
_SAFE_LANG = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
|
||||
|
||||
|
||||
def _job_dir_or_400(job_id: str) -> str:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", job_id or ""):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
try:
|
||||
return str(resolve_within(DUB_DIR, job_id))
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid job id") from exc
|
||||
|
||||
|
||||
def _existing_job_dir_or_404(job_id: str) -> str:
|
||||
"""Discover a real job directory without passing request data to a path sink."""
|
||||
_job_dir_or_400(job_id)
|
||||
try:
|
||||
for entry in os.scandir(DUB_DIR):
|
||||
if entry.name == job_id and not entry.is_symlink() and entry.is_dir(follow_symlinks=False):
|
||||
return entry.path
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=404, detail="Job directory not found") from exc
|
||||
raise HTTPException(status_code=404, detail="Job directory not found")
|
||||
|
||||
|
||||
def _resolve_dub_artifact(value: object, job_id: str) -> Path:
|
||||
"""Resolve current or safely rebased pre-relocation dub artifact paths."""
|
||||
raw = str(value or "")
|
||||
try:
|
||||
resolved = resolve_within(DUB_DIR, raw)
|
||||
relative = resolved.relative_to(Path(DUB_DIR).resolve())
|
||||
if not relative.parts or relative.parts[0] != job_id:
|
||||
raise UnsafePath("Artifact does not belong to the requested job")
|
||||
return resolved
|
||||
except UnsafePath:
|
||||
# Older job rows store absolute paths. After the user relocates the
|
||||
# data directory, preserve only the suffix rooted at the exact
|
||||
# ``dub_jobs`` boundary; never touch the old host path itself.
|
||||
if ntpath.isabs(raw):
|
||||
parts = PureWindowsPath(raw).parts
|
||||
elif os.path.isabs(raw):
|
||||
parts = Path(raw).parts
|
||||
else:
|
||||
raise
|
||||
anchor = Path(DUB_DIR).name
|
||||
positions = [index for index, part in enumerate(parts) if part == anchor]
|
||||
if not positions:
|
||||
raise
|
||||
relative_parts = parts[positions[-1] + 1:]
|
||||
if (
|
||||
not relative_parts
|
||||
or relative_parts[0] != job_id
|
||||
or any(
|
||||
part in {"", ".", ".."}
|
||||
or "/" in part
|
||||
or "\\" in part
|
||||
or ":" in part
|
||||
for part in relative_parts
|
||||
)
|
||||
):
|
||||
raise
|
||||
return resolve_within(DUB_DIR, Path(*relative_parts))
|
||||
|
||||
|
||||
def _discover_job_artifact(path: Path, job_id: str) -> Path | None:
|
||||
"""Return an existing artifact by walking the validated job directory.
|
||||
|
||||
Persisted paths select names but never reach a filesystem sink. Each
|
||||
returned path comes from ``os.scandir`` beneath the validated job root,
|
||||
and symlinks are rejected so a post-validation swap cannot escape.
|
||||
"""
|
||||
job_root = Path(_existing_job_dir_or_404(job_id)).resolve()
|
||||
try:
|
||||
parts = path.relative_to(job_root).parts
|
||||
except ValueError:
|
||||
return None
|
||||
if not parts:
|
||||
return None
|
||||
current = job_root
|
||||
for index, requested in enumerate(parts):
|
||||
if os.path.basename(requested) != requested or requested in {"", ".", ".."}:
|
||||
return None
|
||||
try:
|
||||
entry = next(
|
||||
(
|
||||
item
|
||||
for item in os.scandir(current)
|
||||
if item.name == requested and not item.is_symlink()
|
||||
),
|
||||
None,
|
||||
)
|
||||
except OSError:
|
||||
return None
|
||||
if entry is None:
|
||||
return None
|
||||
if index < len(parts) - 1 and not entry.is_dir(follow_symlinks=False):
|
||||
return None
|
||||
current = Path(entry.path)
|
||||
return current if current.is_file() else None
|
||||
|
||||
|
||||
def _dub_artifact(value: object, job_id: str, *, missing_detail: str = "File not found") -> str:
|
||||
"""Resolve a persisted job artifact inside the global dub-data boundary."""
|
||||
try:
|
||||
resolved = _resolve_dub_artifact(value, job_id)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid job artifact path") from exc
|
||||
path = _discover_job_artifact(resolved, job_id)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail=missing_detail)
|
||||
return str(path)
|
||||
|
||||
|
||||
def _optional_dub_artifact(value: object, job_id: str) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
resolved = _resolve_dub_artifact(value, job_id)
|
||||
except UnsafePath as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid job artifact path") from exc
|
||||
path = _discover_job_artifact(resolved, job_id)
|
||||
return str(path) if path is not None else None
|
||||
|
||||
|
||||
def _safe_lang_or_400(lang: str | None) -> str | None:
|
||||
if lang is not None and not _SAFE_LANG.fullmatch(lang):
|
||||
raise HTTPException(status_code=400, detail="Invalid language code")
|
||||
return lang
|
||||
|
||||
|
||||
def _consume_native_save(authorization: str) -> str | None:
|
||||
if not authorization:
|
||||
return None
|
||||
from core.path_authorization import PathAuthorizationError, consume
|
||||
|
||||
try:
|
||||
return consume(authorization, "dub_export")
|
||||
except PathAuthorizationError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _native_save(source: str, destination: str, display_name: str, media_type: str):
|
||||
"""Copy a generated export file to a user-chosen destination and return JSON."""
|
||||
import shutil
|
||||
@@ -56,7 +198,7 @@ def _native_save(source: str, destination: str, display_name: str, media_type: s
|
||||
raise HTTPException(status_code=500, detail=f"Copy failed: {e}")
|
||||
if not os.path.exists(dest) or os.path.getsize(dest) == 0:
|
||||
raise HTTPException(status_code=500, detail="Copy produced empty file at destination")
|
||||
logger.info("Native save wrote %s (%d bytes)", dest, os.path.getsize(dest))
|
||||
logger.info("Native save completed (%d bytes)", os.path.getsize(dest))
|
||||
return {
|
||||
"saved": True,
|
||||
"path": dest,
|
||||
@@ -432,7 +574,7 @@ async def dub_download(
|
||||
preserve_bg: bool = Query(True, description="Mix background noise into dubbed tracks"),
|
||||
default_track: str = Query("original"),
|
||||
include_tracks: str = Query("", description="Comma-separated list of tracks to include (e.g. 'original,de,es'). Empty = include all."),
|
||||
save_path: str = Query("", description="Absolute destination path. If set, mux output is copied there and JSON returned instead of FileResponse."),
|
||||
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
|
||||
burn_subs: bool = Query(False, description="Burn subtitles into the video stream (forces re-encode). Uses dual-subtitle layout when dual=1."),
|
||||
dual: bool = Query(False, description="When burn_subs=1, render translated on top of italicised original."),
|
||||
out_format: str = Query("m4a", description="Audio-only jobs (#119): output container — wav, m4a, mp3, or flac. Ignored for video jobs."),
|
||||
@@ -440,8 +582,7 @@ async def dub_download(
|
||||
# Strict allowlist on the path param BEFORE it reaches any filesystem
|
||||
# path or ffmpeg argv (export dir, retime work path, slice paths). Real
|
||||
# job ids are short uuid slices — alnum/hyphen/underscore only.
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", job_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
job_dir = _job_dir_or_400(job_id)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -458,12 +599,20 @@ async def dub_download(
|
||||
else:
|
||||
filtered_tracks = dict(tracks)
|
||||
|
||||
filtered_tracks = {
|
||||
key: {
|
||||
**value,
|
||||
"path": _dub_artifact(value.get("path"), job_id, missing_detail="Dubbed track not found"),
|
||||
}
|
||||
for key, value in filtered_tracks.items()
|
||||
}
|
||||
|
||||
if not filtered_tracks and not include_original:
|
||||
raise HTTPException(status_code=400, detail="No tracks selected for export")
|
||||
|
||||
video_path = job["video_path"]
|
||||
video_path = _dub_artifact(job["video_path"], job_id, missing_detail="Source video not found")
|
||||
stamp = _unique_stamp()
|
||||
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
|
||||
exports_dir = os.path.join(job_dir, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
output_path = os.path.join(exports_dir, f"dubbed_video_{stamp}.mp4")
|
||||
ffmpeg = find_ffmpeg()
|
||||
@@ -488,8 +637,7 @@ async def dub_download(
|
||||
# safe_name below).
|
||||
safe_lang = "".join(c for c in lang_code if c.isalnum() or c in "-_") or "track"
|
||||
out_path = os.path.join(exports_dir, f"dubbed_audio_{safe_lang}_{stamp}.{fmt}")
|
||||
bg = job.get("no_vocals_path") if preserve_bg else None
|
||||
bg = bg if (bg and os.path.exists(bg)) else None
|
||||
bg = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
cmd = _build_audio_export_cmd(ffmpeg, track_info["path"], bg, out_path, fmt)
|
||||
try:
|
||||
rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
|
||||
@@ -512,6 +660,7 @@ async def dub_download(
|
||||
safe_name = "".join(c for c in base_name if c.isalnum() or c in "-_ ").strip() or "output"
|
||||
dl_name = f"dubbed_{safe_name}_{safe_lang}_{stamp}.{fmt}"
|
||||
media_type = _MEDIA_TYPES.get(f".{fmt}", "audio/mp4")
|
||||
save_path = _consume_native_save(save_authorization)
|
||||
if save_path:
|
||||
return _native_save(out_path, save_path, dl_name, media_type=media_type)
|
||||
return FileResponse(
|
||||
@@ -541,7 +690,7 @@ async def dub_download(
|
||||
logger.warning(
|
||||
"stretch_video + burn_subs is not supported in one pass; "
|
||||
"skipping subtitle burn for job %s. Export the SRT/VTT separately.",
|
||||
job_id,
|
||||
log_safe(job_id),
|
||||
)
|
||||
burn_subs = False
|
||||
|
||||
@@ -600,7 +749,7 @@ async def dub_download(
|
||||
logger.exception(
|
||||
"Smart Fit video retime failed for job %s — exporting "
|
||||
"without per-segment retime",
|
||||
job_id.replace("\n", " ").replace("\r", " "),
|
||||
log_safe(job_id),
|
||||
)
|
||||
|
||||
cmd = [ffmpeg, "-i", video_path]
|
||||
@@ -612,9 +761,9 @@ async def dub_download(
|
||||
retimed_idx = input_idx
|
||||
input_idx += 1
|
||||
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
bg_idx = None
|
||||
if bg_audio and os.path.exists(bg_audio) and filtered_tracks:
|
||||
if bg_audio and filtered_tracks:
|
||||
cmd += ["-i", bg_audio]
|
||||
bg_idx = input_idx
|
||||
input_idx += 1
|
||||
@@ -778,7 +927,7 @@ async def dub_download(
|
||||
|
||||
if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
|
||||
raise HTTPException(status_code=500, detail="ffmpeg mux produced no output file")
|
||||
logger.info("Dub mux wrote %s (%d bytes)", output_path, os.path.getsize(output_path))
|
||||
logger.info("Dub mux completed (%d bytes)", os.path.getsize(output_path))
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'output'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'output'
|
||||
@@ -791,6 +940,7 @@ async def dub_download(
|
||||
if retime_warning is not None:
|
||||
extra_headers["X-Dub-Export-Warning"] = "video-retime-fallback"
|
||||
|
||||
save_path = _consume_native_save(save_authorization)
|
||||
if save_path:
|
||||
result = _native_save(output_path, save_path, dl_name, media_type="video/mp4")
|
||||
if retime_warning is not None:
|
||||
@@ -819,12 +969,11 @@ _MEDIA_TYPES = {
|
||||
|
||||
@router.get("/dub/media/{job_id}")
|
||||
async def dub_get_media(job_id: str):
|
||||
_job_dir_or_400(job_id)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
video_path = job["video_path"]
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(status_code=404, detail="Media file not found")
|
||||
video_path = _dub_artifact(job["video_path"], job_id, missing_detail="Media file not found")
|
||||
# Pass an explicit media_type. Without this Starlette falls back to
|
||||
# mimetypes.guess_type, which on some platforms returns the wrong
|
||||
# MIME (e.g. "application/octet-stream" for .mkv), and the Tauri
|
||||
@@ -863,8 +1012,8 @@ async def dub_preview_video(
|
||||
# Strict allowlist on the path param BEFORE it reaches any filesystem
|
||||
# path or ffmpeg argv (exports dir, preview/retime work paths) — same
|
||||
# boundary check as dub_download.
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", job_id):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
job_dir = _job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -874,25 +1023,19 @@ async def dub_preview_video(
|
||||
if not track_info:
|
||||
raise HTTPException(status_code=404, detail=f"No dubbed track for lang={lang}")
|
||||
|
||||
track_path = track_info.get("path")
|
||||
if not track_path or not os.path.exists(track_path):
|
||||
raise HTTPException(status_code=404, detail="Dubbed track file missing")
|
||||
track_path = _dub_artifact(track_info.get("path"), job_id, missing_detail="Dubbed track file missing")
|
||||
|
||||
video_path = job.get("video_path")
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
raise HTTPException(status_code=404, detail="Source video missing")
|
||||
video_path = _dub_artifact(job.get("video_path"), job_id, missing_detail="Source video missing")
|
||||
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
has_bg = bool(bg_audio and os.path.exists(bg_audio))
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
has_bg = bool(bg_audio)
|
||||
|
||||
if not _SAFE_LANG.match(lang):
|
||||
raise HTTPException(status_code=400, detail="Invalid lang")
|
||||
# realpath-normalised + containment-checked inline BEFORE any filesystem
|
||||
# access so the guard dominates every sink (the file's established
|
||||
# pattern — see dub_preview_segment; CodeQL does not track the guard
|
||||
# through a helper's return value).
|
||||
_base = os.path.realpath(DUB_DIR)
|
||||
exports_dir = os.path.realpath(os.path.join(_base, job_id, "exports"))
|
||||
exports_dir = os.path.realpath(os.path.join(job_dir, "exports"))
|
||||
if not exports_dir.startswith(_base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
@@ -961,7 +1104,7 @@ async def dub_preview_video(
|
||||
logger.exception(
|
||||
"Smart Fit preview retime failed for job %s — previewing "
|
||||
"without per-segment retime",
|
||||
job_id.replace("\n", " ").replace("\r", " "),
|
||||
log_safe(job_id),
|
||||
)
|
||||
|
||||
cmd = [ffmpeg, "-i", video_path]
|
||||
@@ -1112,16 +1255,16 @@ async def dub_get_onsets(job_id: str):
|
||||
``onsets.json`` in the job directory; recomputed if the source audio is
|
||||
newer than the cache (e.g. re-ingest into the same job dir).
|
||||
"""
|
||||
import json
|
||||
job_dir = _job_dir_or_400(job_id)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
vocals = job.get("vocals_path")
|
||||
mix = job.get("audio_path")
|
||||
if vocals and os.path.exists(vocals):
|
||||
vocals = _optional_dub_artifact(job.get("vocals_path"), job_id)
|
||||
mix = _optional_dub_artifact(job.get("audio_path"), job_id)
|
||||
if vocals:
|
||||
src_path, source = vocals, "vocals"
|
||||
elif mix and os.path.exists(mix):
|
||||
elif mix:
|
||||
src_path, source = mix, "mix"
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="No audio track available for onset analysis")
|
||||
@@ -1129,7 +1272,7 @@ async def dub_get_onsets(job_id: str):
|
||||
# Containment inlined (not via _safe_job_path): CodeQL can't track the
|
||||
# sanitizer through a helper's return — the file's established idiom.
|
||||
base = os.path.realpath(DUB_DIR)
|
||||
cache_path = os.path.realpath(os.path.join(base, job_id, "onsets.json"))
|
||||
cache_path = os.path.realpath(os.path.join(job_dir, "onsets.json"))
|
||||
if not cache_path.startswith(base + os.sep):
|
||||
raise HTTPException(status_code=400, detail="Invalid job id")
|
||||
try:
|
||||
@@ -1159,31 +1302,31 @@ async def dub_get_onsets(job_id: str):
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f)
|
||||
os.replace(tmp_path, cache_path)
|
||||
except OSError as e:
|
||||
logger.warning("onsets cache write failed for %s: %s", job_id, e)
|
||||
except OSError:
|
||||
logger.warning("onsets cache write failed")
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/dub/thumb/{job_id}")
|
||||
async def dub_get_thumb(job_id: str):
|
||||
"""Serve the extracted dub video thumbnail (jpg). 404 if not generated."""
|
||||
job_dir = _job_dir_or_400(job_id)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
# Resolve under DUB_DIR to prevent traversal.
|
||||
thumb = os.path.join(DUB_DIR, job_id, "thumb.jpg")
|
||||
thumb = os.path.join(job_dir, "thumb.jpg")
|
||||
if not os.path.exists(thumb):
|
||||
raise HTTPException(status_code=404, detail="Thumbnail not available")
|
||||
return FileResponse(thumb, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
|
||||
|
||||
@router.get("/dub/audio/{job_id}")
|
||||
async def dub_get_audio(job_id: str):
|
||||
_job_dir_or_400(job_id)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
audio = job.get("audio_path")
|
||||
if not audio or not os.path.exists(audio):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
audio = _dub_artifact(job.get("audio_path"), job_id, missing_detail="Audio file not found")
|
||||
return FileResponse(audio, media_type="audio/wav")
|
||||
|
||||
def _seg_wav_candidates(job: dict, lang: "str | None", seg_keys: tuple) -> list:
|
||||
@@ -1205,8 +1348,33 @@ def _seg_wav_candidates(job: dict, lang: "str | None", seg_keys: tuple) -> list:
|
||||
return keys
|
||||
|
||||
|
||||
def _existing_segment_artifact(job_id: str, candidate_ids: list) -> str | None:
|
||||
"""Discover an existing, non-symlink segment WAV inside one job root."""
|
||||
job_root = Path(_existing_job_dir_or_404(job_id))
|
||||
wanted: list[str] = []
|
||||
for value in candidate_ids:
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", str(value))
|
||||
if safe:
|
||||
wanted.append(f"seg_{safe}.wav")
|
||||
try:
|
||||
entries = {
|
||||
entry.name: entry
|
||||
for entry in os.scandir(job_root)
|
||||
if not entry.is_symlink() and entry.is_file(follow_symlinks=False)
|
||||
}
|
||||
except OSError:
|
||||
return None
|
||||
for name in wanted:
|
||||
entry = entries.get(name)
|
||||
if entry is not None:
|
||||
return entry.path
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/dub/preview/{job_id}/{segment_index}")
|
||||
async def dub_preview_segment(job_id: str, segment_index: int, lang: str = Query(None)):
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -1214,16 +1382,12 @@ async def dub_preview_segment(job_id: str, segment_index: int, lang: str = Query
|
||||
# name first (P1.3), then the legacy id/index names for jobs rendered
|
||||
# before per-language (and before id-based, #185) naming. Each candidate
|
||||
# is realpath-normalised and containment-checked BEFORE any filesystem
|
||||
# access, so the guard dominates every path sink.
|
||||
# access, and discovery returns only a non-symlink entry from that root.
|
||||
order = job.get("seg_order") or []
|
||||
seg_id = order[segment_index] if 0 <= segment_index < len(order) else segment_index
|
||||
base = os.path.realpath(DUB_DIR)
|
||||
seg_path = None
|
||||
for _sid in _seg_wav_candidates(job, lang, (seg_id, segment_index)):
|
||||
cand = os.path.realpath(dub_seg_path(job_id, _sid))
|
||||
if cand.startswith(base + os.sep) and os.path.exists(cand):
|
||||
seg_path = cand
|
||||
break
|
||||
seg_path = _existing_segment_artifact(
|
||||
job_id, _seg_wav_candidates(job, lang, (seg_id, segment_index))
|
||||
)
|
||||
if not seg_path:
|
||||
raise HTTPException(status_code=404, detail="Segment not generated yet")
|
||||
return FileResponse(seg_path, media_type="audio/wav")
|
||||
@@ -1243,19 +1407,18 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
from services import dub_qc
|
||||
from services.dub_pipeline import put_job, save_job
|
||||
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if lang and lang in tracks:
|
||||
wav_path = tracks[lang]["path"]
|
||||
wav_path = _dub_artifact(tracks[lang].get("path"), job_id, missing_detail="Dubbed audio file not found")
|
||||
elif tracks:
|
||||
wav_path = list(tracks.values())[0]["path"]
|
||||
wav_path = _dub_artifact(list(tracks.values())[0].get("path"), job_id, missing_detail="Dubbed audio file not found")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
|
||||
if not os.path.exists(wav_path):
|
||||
raise HTTPException(status_code=404, detail="Dubbed audio file not found")
|
||||
|
||||
segments = job.get("segments") or []
|
||||
if not segments:
|
||||
raise HTTPException(status_code=400, detail="Job has no segments")
|
||||
@@ -1271,23 +1434,37 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
)
|
||||
|
||||
def _recognize():
|
||||
from services.asr_backend import get_active_asr_backend
|
||||
backend = get_active_asr_backend()
|
||||
# `load_*`, not `get_*`: the plain selector returns engines whose
|
||||
# shallow probe passed but whose deep import chain is broken, which
|
||||
# then 500s at `.transcribe()`. The loader degrades (#1185).
|
||||
from services.asr_backend import load_active_asr_backend
|
||||
backend = load_active_asr_backend()
|
||||
result = backend.transcribe(wav_path, word_timestamps=False)
|
||||
return result.get("segments", []), backend.id
|
||||
|
||||
try:
|
||||
from services.asr_backend import (
|
||||
ASRModelMissingError,
|
||||
ASRTimeoutError,
|
||||
run_transcribe_guarded,
|
||||
)
|
||||
from services.model_manager import _get_gpu_pool
|
||||
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
|
||||
recognized, engine_id = await run_transcribe_guarded(
|
||||
_get_gpu_pool(), _recognize, what="QC",
|
||||
)
|
||||
except ASRTimeoutError as e:
|
||||
# Backend is alive; ASR just couldn't finish in time. 504, not 500/connection.
|
||||
logger.warning("dub QC ASR pass timed out for %s: %s", job_id, e)
|
||||
logger.warning("dub QC ASR pass timed out")
|
||||
raise HTTPException(status_code=504, detail=str(e))
|
||||
except ASRModelMissingError as e:
|
||||
# Degraded onto an engine with no weights on disk — typed 409 with the
|
||||
# download CTA, matching the preflight above.
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("dub QC ASR pass failed for %s", job_id)
|
||||
logger.exception("dub QC ASR pass failed")
|
||||
raise HTTPException(status_code=500, detail=f"QC transcription failed: {e}")
|
||||
|
||||
seg_ids = job.get("seg_order") or [s.get("id", i) for i, s in enumerate(segments)]
|
||||
@@ -1315,9 +1492,9 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
try:
|
||||
from core import job_store
|
||||
job_store.append_event(job_id, f"data: {payload}\n\n")
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
# QC event fan-out is best-effort; the scores are already in the response.
|
||||
logger.debug("QC event append failed: %s", e)
|
||||
logger.debug("QC event append failed")
|
||||
|
||||
return {
|
||||
"engine": engine_id,
|
||||
@@ -1335,31 +1512,36 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
|
||||
|
||||
@router.get("/dub/download-audio/{job_id}")
|
||||
@router.get("/dub/download-audio/{job_id}/{filename}")
|
||||
async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg: bool = Query(True), save_path: str = Query("")):
|
||||
async def dub_download_audio(
|
||||
job_id: str,
|
||||
lang: str = Query(None),
|
||||
preserve_bg: bool = Query(True),
|
||||
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
|
||||
):
|
||||
job_dir = _existing_job_dir_or_404(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if lang and lang in tracks:
|
||||
wav_path = tracks[lang]["path"]
|
||||
wav_path = _dub_artifact(tracks[lang].get("path"), job_id, missing_detail="Audio file not found")
|
||||
elif tracks:
|
||||
wav_path = list(tracks.values())[0]["path"]
|
||||
wav_path = _dub_artifact(list(tracks.values())[0].get("path"), job_id, missing_detail="Audio file not found")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
|
||||
|
||||
if not os.path.exists(wav_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
lang_label = lang or list(tracks.keys())[0]
|
||||
_safe_lang_or_400(lang_label)
|
||||
stamp = _unique_stamp()
|
||||
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
|
||||
exports_dir = os.path.join(job_dir, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
if bg_audio and os.path.exists(bg_audio):
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
if bg_audio:
|
||||
ffmpeg = find_ffmpeg()
|
||||
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{lang_label}_{stamp}.wav")
|
||||
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{stamp}.wav")
|
||||
cmd = [
|
||||
ffmpeg, "-i", bg_audio, "-i", wav_path,
|
||||
"-filter_complex", bed_mix_filter("0:a", "1:a"),
|
||||
@@ -1372,13 +1554,14 @@ async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg:
|
||||
if not os.path.exists(final_audio_path) or os.path.getsize(final_audio_path) == 0:
|
||||
raise Exception("ffmpeg mix produced no output file")
|
||||
wav_path = final_audio_path
|
||||
logger.info("Dub audio mix wrote %s (%d bytes)", final_audio_path, os.path.getsize(final_audio_path))
|
||||
logger.info("Dub audio mix completed")
|
||||
except Exception:
|
||||
logger.exception("Failed to mix audio")
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
|
||||
dl_name = f"dubbed_audio_{lang_label}_{safe_name}_{stamp}.wav"
|
||||
save_path = _consume_native_save(save_authorization)
|
||||
if save_path:
|
||||
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
|
||||
return FileResponse(
|
||||
@@ -1435,6 +1618,8 @@ async def dub_export_srt(
|
||||
dual: bool = False,
|
||||
lang: str = Query(None, description="Track language code. Emits that track's text (segments_i18n) when the job carries it; when that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
|
||||
):
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -1487,6 +1672,8 @@ async def dub_export_vtt(
|
||||
dual: bool = False,
|
||||
lang: str = Query(None, description="Track language code. Emits that track's text (segments_i18n) when the job carries it; when that track was generated under Smart Fit or stretch_video, cue times come from the fitted timeline."),
|
||||
):
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -1524,6 +1711,8 @@ async def dub_export_vtt(
|
||||
@router.get("/dub/export-segments/{job_id}")
|
||||
async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
|
||||
import zipfile
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -1534,17 +1723,13 @@ async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
order = job.get("seg_order") or []
|
||||
base = os.path.realpath(DUB_DIR)
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i, seg in enumerate(segments):
|
||||
seg_id = order[i] if i < len(order) else i
|
||||
# realpath + containment guard before any filesystem access.
|
||||
seg_path = None
|
||||
for _sid in _seg_wav_candidates(job, lang, (seg_id, i)):
|
||||
cand = os.path.realpath(dub_seg_path(job_id, _sid))
|
||||
if cand.startswith(base + os.sep) and os.path.exists(cand):
|
||||
seg_path = cand
|
||||
break
|
||||
# Discovery returns only a non-symlink entry from the validated job root.
|
||||
seg_path = _existing_segment_artifact(
|
||||
job_id, _seg_wav_candidates(job, lang, (seg_id, i))
|
||||
)
|
||||
if seg_path:
|
||||
speaker = seg.get("speaker_id", "Speaker1").replace(" ", "")
|
||||
start_str = f"{seg['start']:.2f}"
|
||||
@@ -1563,32 +1748,38 @@ async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
|
||||
|
||||
@router.get("/dub/download-mp3/{job_id}")
|
||||
@router.get("/dub/download-mp3/{job_id}/{filename}")
|
||||
async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bool = Query(True), save_path: str = Query(""), bitrate: str = Query("192k")):
|
||||
async def dub_download_mp3(
|
||||
job_id: str,
|
||||
lang: str = Query(None),
|
||||
preserve_bg: bool = Query(True),
|
||||
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
|
||||
bitrate: str = Query("192k"),
|
||||
):
|
||||
job_dir = _existing_job_dir_or_404(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
|
||||
tracks = job.get("dubbed_tracks", {})
|
||||
if lang and lang in tracks:
|
||||
wav_path = tracks[lang]["path"]
|
||||
wav_path = _dub_artifact(tracks[lang].get("path"), job_id, missing_detail="Audio file not found")
|
||||
elif tracks:
|
||||
wav_path = list(tracks.values())[0]["path"]
|
||||
wav_path = _dub_artifact(list(tracks.values())[0].get("path"), job_id, missing_detail="Audio file not found")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
|
||||
|
||||
if not os.path.exists(wav_path):
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
lang_label = lang or list(tracks.keys())[0]
|
||||
_safe_lang_or_400(lang_label)
|
||||
ffmpeg = find_ffmpeg()
|
||||
stamp = _unique_stamp()
|
||||
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
|
||||
exports_dir = os.path.join(job_dir, "exports")
|
||||
os.makedirs(exports_dir, exist_ok=True)
|
||||
|
||||
source_path = wav_path
|
||||
bg_audio = job.get("no_vocals_path") if preserve_bg else None
|
||||
if bg_audio and os.path.exists(bg_audio):
|
||||
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{lang_label}_{stamp}.wav")
|
||||
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
|
||||
if bg_audio:
|
||||
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{stamp}.wav")
|
||||
cmd_mix = [
|
||||
ffmpeg, "-i", bg_audio, "-i", wav_path,
|
||||
"-filter_complex", bed_mix_filter("0:a", "1:a"),
|
||||
@@ -1601,7 +1792,7 @@ async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bo
|
||||
except Exception:
|
||||
logger.exception("Failed to mix audio for MP3")
|
||||
|
||||
mp3_path = os.path.join(exports_dir, f"dubbed_{lang_label}_{stamp}.mp3")
|
||||
mp3_path = os.path.join(exports_dir, f"dubbed_{stamp}.mp3")
|
||||
# Accept '128', '192k' etc. — normalize to ffmpeg's 'Nk' form and clamp
|
||||
# to a sensible range so a malformed value can't stall encoding.
|
||||
_br = str(bitrate or "192k").lower().rstrip("k") or "192"
|
||||
@@ -1629,11 +1820,12 @@ async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bo
|
||||
|
||||
if not os.path.exists(mp3_path) or os.path.getsize(mp3_path) == 0:
|
||||
raise HTTPException(status_code=500, detail="MP3 encoding produced no output file")
|
||||
logger.info("Dub MP3 encoded %s (%d bytes)", mp3_path, os.path.getsize(mp3_path))
|
||||
logger.info("Dub MP3 encoding completed")
|
||||
|
||||
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
|
||||
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
|
||||
dl_name = f"dubbed_{lang_label}_{safe_name}_{stamp}.mp3"
|
||||
save_path = _consume_native_save(save_authorization)
|
||||
if save_path:
|
||||
return _native_save(mp3_path, save_path, dl_name, media_type="audio/mpeg")
|
||||
return FileResponse(
|
||||
@@ -1644,6 +1836,8 @@ async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bo
|
||||
@router.get("/dub/export-stems/{job_id}")
|
||||
async def dub_export_stems(job_id: str, lang: str = Query(None)):
|
||||
import zipfile
|
||||
_job_dir_or_400(job_id)
|
||||
lang = _safe_lang_or_400(lang)
|
||||
job = _get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
@@ -1653,22 +1847,22 @@ async def dub_export_stems(job_id: str, lang: str = Query(None)):
|
||||
raise HTTPException(status_code=400, detail="No dubbed tracks generated yet")
|
||||
|
||||
if lang and lang in tracks:
|
||||
vocals_path = tracks[lang]["path"]
|
||||
vocals_path = _dub_artifact(tracks[lang].get("path"), job_id, missing_detail="Dubbed audio file not found")
|
||||
lang_label = lang
|
||||
elif tracks:
|
||||
first_key = list(tracks.keys())[0]
|
||||
vocals_path = tracks[first_key]["path"]
|
||||
_safe_lang_or_400(first_key)
|
||||
vocals_path = _dub_artifact(tracks[first_key].get("path"), job_id, missing_detail="Dubbed audio file not found")
|
||||
lang_label = first_key
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="No dubbed audio track")
|
||||
|
||||
bg_path = job.get("no_vocals_path")
|
||||
bg_path = _optional_dub_artifact(job.get("no_vocals_path"), job_id)
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if os.path.exists(vocals_path):
|
||||
zf.write(vocals_path, f"vocals_dubbed_{lang_label}.wav")
|
||||
if bg_path and os.path.exists(bg_path):
|
||||
zf.write(vocals_path, f"vocals_dubbed_{lang_label}.wav")
|
||||
if bg_path:
|
||||
zf.write(bg_path, "background_original.wav")
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import logging
|
||||
import time
|
||||
import asyncio
|
||||
import shutil
|
||||
import zipfile
|
||||
import torch
|
||||
import torchaudio
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -13,7 +15,8 @@ from core.config import DUB_DIR, VOICES_DIR, dub_seg_path
|
||||
from core.tasks import task_manager
|
||||
from schemas.requests import DubRequest
|
||||
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
|
||||
from services.tts_backend import resolve_generation_backend
|
||||
from services.tts_backend import resolve_generation_backend, active_backend_id
|
||||
from services import gpu_gateway
|
||||
from services.audio_dsp import apply_mastering, normalize_audio, apply_effects_chain, get_effect_chain
|
||||
from services.audio_io import atomic_save_wav, _safe_torchaudio_save
|
||||
from services.ffmpeg_utils import (
|
||||
@@ -29,6 +32,7 @@ from services.rvc import apply_rvc, is_enabled as rvc_is_enabled
|
||||
from services.incremental import segment_fingerprint, fit_fingerprint
|
||||
from services.fit_planner import UNDERRUN_TOLERANCE, FitParams, plan_fit
|
||||
from services.watermark import mark_synthetic
|
||||
from services.speaker_clone import auto_profile_id
|
||||
from api.routers.dub_core import _get_job, _save_job
|
||||
from omnivoice.utils.voice_design import heal_design_instruct
|
||||
|
||||
@@ -44,6 +48,38 @@ logger = logging.getLogger("omnivoice.dub")
|
||||
MAX_STRETCH_RATIO = 1.8
|
||||
|
||||
|
||||
def _prepare_oom_retry(error: Exception, *, execution_target: str) -> bool:
|
||||
"""Prepare one *local* low-step retry after a genuine device OOM.
|
||||
|
||||
The cache being flushed must belong to the device that raised the error.
|
||||
A remote worker owns its own recovery policy; flushing this process's CUDA
|
||||
cache after a remote failure both stalls the wrong GPU and can evict an
|
||||
unrelated local job. Keep this guard at the retry chokepoint so a future
|
||||
``dub_segments`` producer cannot accidentally inherit the old behaviour.
|
||||
|
||||
Returns ``False`` for non-OOM errors. Remote OOMs are deliberately raised
|
||||
unchanged: the worker may classify/retry them, but this process must not.
|
||||
"""
|
||||
is_oom = (
|
||||
isinstance(error, torch.cuda.OutOfMemoryError)
|
||||
or "out of memory" in str(error).lower()
|
||||
or "CUDA error" in str(error)
|
||||
)
|
||||
if not is_oom:
|
||||
return False
|
||||
if execution_target != "local":
|
||||
raise error
|
||||
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
return True
|
||||
|
||||
|
||||
def _underrun_min_rate() -> float:
|
||||
"""Floor for the underrun fill (audio slowed toward its slot, never below
|
||||
this rate). Default 0.85 stays natural-sounding; OMNIVOICE_UNDERRUN_MIN_RATE=1.0
|
||||
@@ -174,7 +210,7 @@ CONSISTENT_MIN_REF_S = 3.0
|
||||
def _speaker_key_matches(speaker_id: str, key: str) -> bool:
|
||||
"""Same matching rule the `auto:` branch has always used: the safe-name
|
||||
slug first (`auto_profile_id`), the raw speaker id as fallback."""
|
||||
return speaker_id.lower().replace(" ", "_") == key or speaker_id == key
|
||||
return auto_profile_id(speaker_id) == f"auto:{key}" or speaker_id == key
|
||||
|
||||
|
||||
def _find_speaker_clone(clones: dict, key: str):
|
||||
@@ -199,10 +235,76 @@ def _speaker_key_for_segment(job: dict, sid) -> str | None:
|
||||
for row in job.get("segments") or []:
|
||||
if isinstance(row, dict) and str(row.get("id", "")) == str(sid):
|
||||
spk = row.get("speaker_id") or "Speaker 1"
|
||||
return spk.lower().replace(" ", "_")
|
||||
return auto_profile_id(spk)[len("auto:"):]
|
||||
return None
|
||||
|
||||
|
||||
#: ``job_id -> {(segment, path)}`` already warned about, so a 300-segment dub
|
||||
#: whose clip directory was cleaned logs once per segment rather than once per
|
||||
#: retry.
|
||||
#:
|
||||
#: Keyed on the PATH as well as the segment, not the segment alone. The
|
||||
#: single-segment preview endpoint has no segment identity to pass — it is a
|
||||
#: "render this text" call — so every preview shared one key and only the first
|
||||
#: missing reference in a job was ever reported, silencing the rest (greptile).
|
||||
#: A distinct path is distinct information wherever it comes from, and on the
|
||||
#: render path this also means a segment whose binding was changed to a second
|
||||
#: missing clip is not mistaken for the one already reported.
|
||||
_MISSING_REF_WARNED: dict = {}
|
||||
|
||||
|
||||
def warn_if_ref_missing(ref_audio, *, job_id: str = "", seg_id="", where: str = "dub"):
|
||||
"""Say so when a clone reference points at a file that is gone (#1331).
|
||||
|
||||
Reported as: re-dubbing a single sentence loses the cloned voice, while
|
||||
re-dubbing everything keeps it.
|
||||
|
||||
Clone references are FILE PATHS into the job's extracted-clip directory,
|
||||
and the whole job dict — those paths included — is persisted to
|
||||
``dub_history.job_data`` so saved projects reopen after a restart. The job
|
||||
therefore outlives its clips. Reopen a saved dub once the clip directory
|
||||
has been cleaned, regenerate one line, and every resolution branch hands
|
||||
the engine a path that is no longer there. An engine given a missing
|
||||
reference renders *uncloned* rather than failing, so the line comes back in
|
||||
a default voice matching nothing else in the dub, with no error anywhere.
|
||||
Re-running the full dub re-extracts the clips — which is exactly why that
|
||||
appears to fix it, and is the workaround the reporter found unaided.
|
||||
|
||||
Deliberately DIAGNOSTIC ONLY: ``ref_audio`` is returned unchanged. Nulling
|
||||
it would not change what the user hears (the engine already falls back),
|
||||
and it would decide on the engine's behalf that a reference it cannot
|
||||
``stat`` is unusable — untrue for anything resolved inside a sidecar's own
|
||||
namespace. The defect here is the silence, not the fallback.
|
||||
"""
|
||||
if not ref_audio:
|
||||
return ref_audio
|
||||
try:
|
||||
if os.path.exists(ref_audio):
|
||||
return ref_audio
|
||||
except OSError: # unreadable path (permissions, dead mount) — same symptom
|
||||
pass
|
||||
seen = _MISSING_REF_WARNED.setdefault(str(job_id), set())
|
||||
key = (str(seg_id), str(ref_audio))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
logger.warning(
|
||||
"%s: the voice reference for segment %s is gone (%s), so this line "
|
||||
"will most likely render in a DEFAULT voice instead of the cloned "
|
||||
"one, with no error of its own. Clone clips are extracted per job "
|
||||
"and the job outlives them, so a saved dub regenerated after "
|
||||
"cleanup loses them — re-running the full dub re-extracts the "
|
||||
"clips, which is why that appears to fix it (#1331).",
|
||||
where, seg_id, ref_audio,
|
||||
)
|
||||
return ref_audio
|
||||
|
||||
|
||||
def forget_missing_ref_warnings(job_id: str) -> None:
|
||||
"""Drop the once-per-segment warning memo for a job (a fresh render should
|
||||
warn again if the clips are still gone)."""
|
||||
_MISSING_REF_WARNED.pop(str(job_id), None)
|
||||
|
||||
|
||||
def resolve_consistent_ref(job: dict, speaker_key: str, memo: dict | None = None):
|
||||
"""ONE clone reference for every segment of `speaker_key`.
|
||||
|
||||
@@ -250,6 +352,75 @@ def resolve_consistent_ref(job: dict, speaker_key: str, memo: dict | None = None
|
||||
return ref
|
||||
|
||||
|
||||
def _remote_voice(job: dict, profile_id: str | None, seg_id, voice_match: str,
|
||||
memo: dict) -> tuple[str | None, str | None, bool, str | None, int | None]:
|
||||
"""Resolve a dub binding without touching the TTS model."""
|
||||
ref_audio = ref_text = instruct = None
|
||||
seed = None
|
||||
single_use = False
|
||||
if profile_id and profile_id.startswith("auto-seg:"):
|
||||
sid = profile_id[len("auto-seg:"):]
|
||||
info = (job.get("segment_clones") or {}).get(sid)
|
||||
shared = False
|
||||
if voice_match == "consistent" and sid == str(seg_id):
|
||||
key = _speaker_key_for_segment(job, sid)
|
||||
alternate = resolve_consistent_ref(job, key, memo) if key else None
|
||||
if alternate:
|
||||
info = alternate
|
||||
shared = True
|
||||
if info:
|
||||
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
|
||||
single_use = not shared
|
||||
elif profile_id and profile_id.startswith("auto:"):
|
||||
key = profile_id[len("auto:"):]
|
||||
if voice_match == "consistent":
|
||||
info = resolve_consistent_ref(job, key, memo)
|
||||
else:
|
||||
info = ((job.get("segment_clones") or {}).get(str(seg_id))
|
||||
or _find_speaker_clone(job.get("speaker_clones") or {}, key))
|
||||
single_use = str(seg_id) in (job.get("segment_clones") or {})
|
||||
if info:
|
||||
ref_audio, ref_text = info.get("ref_audio"), info.get("ref_text")
|
||||
elif profile_id:
|
||||
with db_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
|
||||
if row:
|
||||
seed = row["seed"]
|
||||
if row["is_locked"] and row["locked_audio_path"]:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["locked_audio_path"])
|
||||
ref_text = row["ref_text"]
|
||||
elif row["instruct"] and not row["is_locked"]:
|
||||
try:
|
||||
vd_states = row["vd_states"]
|
||||
except (KeyError, IndexError):
|
||||
vd_states = None
|
||||
instruct = heal_design_instruct(row["instruct"], vd_states)
|
||||
else:
|
||||
ref_audio = os.path.join(VOICES_DIR, row["ref_audio_path"])
|
||||
ref_text = row["ref_text"]
|
||||
return ref_audio, ref_text, single_use, instruct, seed
|
||||
|
||||
|
||||
def _decode_remote_dub(result: gpu_gateway.RemoteResult) -> dict[int, str]:
|
||||
"""Extract the worker bundle into a task-scoped directory, path-safely."""
|
||||
target = os.path.join(DUB_DIR, ".remote", result.task_id)
|
||||
os.makedirs(target, exist_ok=True)
|
||||
paths: dict[int, str] = {}
|
||||
with zipfile.ZipFile(result.path) as archive:
|
||||
for member in archive.infolist():
|
||||
match = re.fullmatch(r"segments/(\d+)\.wav", member.filename)
|
||||
if not match:
|
||||
raise ValueError(f"unexpected dub artifact member: {member.filename}")
|
||||
index = int(match.group(1))
|
||||
destination = os.path.join(target, f"{index}.wav")
|
||||
partial = f"{destination}.part"
|
||||
with archive.open(member) as source, open(partial, "wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
os.replace(partial, destination)
|
||||
paths[index] = destination
|
||||
return paths
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/dub/generate/{job_id}")
|
||||
@@ -263,8 +434,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
)
|
||||
|
||||
# ── Engine resolution (issue #312 class) ────────────────────────────────
|
||||
# Dub used to hardcode OmniVoice via get_model() regardless of the engine
|
||||
# selected in Settings → Engines — a SILENT fallback. Every real dub
|
||||
# Dub used to hardcode VoiceStudio via get_model() regardless of the engine
|
||||
# selected in Model Catalogue → Engines — a SILENT fallback. Every real dub
|
||||
# segment's ref_audio resolves to either an auto:<speaker>/auto-seg:<id>
|
||||
# clone cut from the source video or a saved voice-profile row (see
|
||||
# `_gen` below), so require_cloning=True: an engine that can't clone
|
||||
@@ -421,6 +592,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# every segment of that speaker for the whole run.
|
||||
voice_match = (req.voice_match or "per_line").lower()
|
||||
_consistent_ref_memo: dict = {}
|
||||
remote_audio: dict[int, str] = {}
|
||||
# Strategy-transition guard: smart_fit re-mixes the *natural-rate*
|
||||
# per-segment WAVs from disk. If the previous run used strict_slot,
|
||||
# the on-disk WAVs are slot-squeezed ("slotted") — reusing them would
|
||||
@@ -461,6 +633,86 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
_t_cache = 0.0
|
||||
_t_tts = 0.0
|
||||
|
||||
# One coarse remote lease for every segment that actually needs fresh
|
||||
# synthesis. Assembly, fitting and the separately-pooled RVC pass stay
|
||||
# here; the worker returns a single verified bundle of segment WAVs.
|
||||
decision = gpu_gateway.decide("dub_segments")
|
||||
if decision.remote:
|
||||
remote_rows: list[dict] = []
|
||||
remote_refs: list[str | None] = []
|
||||
for i, seg in enumerate(req.segments):
|
||||
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
|
||||
if (regen_only is not None and seg_id not in regen_only) or not seg.text.strip():
|
||||
continue
|
||||
ref_audio, ref_text, ref_single_use, profile_instruct, seed = _remote_voice(
|
||||
job, seg.profile_id or None, seg_id, voice_match, _consistent_ref_memo
|
||||
)
|
||||
ref_audio = warn_if_ref_missing(
|
||||
ref_audio, job_id=job_id, seg_id=seg_id, where="remote dub render"
|
||||
)
|
||||
seg_instruct = seg.instruct or req.instruct or profile_instruct
|
||||
seg_speed = seg.speed if seg.speed is not None else req.speed
|
||||
if seg.direction and seg.direction.strip():
|
||||
try:
|
||||
from services.director import parse as _parse_direction
|
||||
direction = _parse_direction(seg.direction)
|
||||
extra = direction.instruct_prompt()
|
||||
if extra:
|
||||
seg_instruct = f"{seg_instruct}, {extra}" if seg_instruct else extra
|
||||
bias = direction.rate_bias()
|
||||
if bias and abs(bias - 1.0) > 0.01 and strategy == "strict_slot":
|
||||
seg_speed = (seg_speed or 1.0) * bias
|
||||
except Exception:
|
||||
logger.debug("direction parse skipped for remote segment %s", seg_id,
|
||||
exc_info=True)
|
||||
remote_rows.append({
|
||||
"index": i, "text": seg.text,
|
||||
"language": seg.target_lang or req.language,
|
||||
"ref_text": ref_text, "ref_single_use": ref_single_use,
|
||||
"instruct": seg_instruct,
|
||||
"duration": (seg.end - seg.start) if strategy == "strict_slot" else None,
|
||||
"num_step": 8 if req.preview else req.num_step,
|
||||
"guidance_scale": req.guidance_scale, "speed": seg_speed,
|
||||
"effect_preset": seg.effect_preset or "broadcast",
|
||||
"seed": seed,
|
||||
# RVC changes the waveform locally after TTS, so that path
|
||||
# is marked at the existing post-RVC chokepoint below.
|
||||
"watermark": not rvc_is_enabled(),
|
||||
})
|
||||
remote_refs.append(ref_audio)
|
||||
if remote_rows:
|
||||
states: asyncio.Queue = asyncio.Queue()
|
||||
call = gpu_gateway.RemoteCall(
|
||||
engine=active_backend_id(), operation="dub_segments",
|
||||
params={"segments": remote_rows, "ref_audio": remote_refs},
|
||||
decode=_decode_remote_dub,
|
||||
)
|
||||
dub_run = gpu_gateway.JobRun("dub_segments")
|
||||
run = asyncio.create_task(gpu_gateway.run(
|
||||
"dub_segments", local=gpu_gateway.LocalCall(fn=lambda: {}),
|
||||
remote=call, decision=decision, job=dub_run,
|
||||
on_state=states.put_nowait,
|
||||
))
|
||||
while not run.done():
|
||||
if task_manager.is_cancelled(task_id):
|
||||
run.cancel()
|
||||
try:
|
||||
await run
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': 0})}\n\n"
|
||||
return
|
||||
try:
|
||||
state = await asyncio.wait_for(states.get(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
fraction = float(state.get("progress") or 0.0)
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': round(fraction * total, 2), 'total': total, 'text': state.get('stage') or state.get('phase')})}\n\n"
|
||||
remote_audio = await run
|
||||
notice = dub_run.notice()
|
||||
if notice is not None:
|
||||
yield f"data: {json.dumps({'type': 'routing_notice', 'status': notice[0], 'reason': notice[1]})}\n\n"
|
||||
|
||||
for i, seg in enumerate(req.segments):
|
||||
seg_id = seg_ids[i] if i < len(seg_ids) else f"seg_{i}"
|
||||
|
||||
@@ -469,7 +721,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
yield f"data: {json.dumps({'type': 'cancelled', 'segments_processed': i})}\n\n"
|
||||
return
|
||||
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
|
||||
if not remote_audio:
|
||||
yield f"data: {json.dumps({'type': 'progress', 'current': i, 'total': total, 'text': seg.text[:50]})}\n\n"
|
||||
|
||||
seg_duration = seg.end - seg.start
|
||||
if seg_duration <= 0.05 or not seg.text.strip():
|
||||
@@ -545,7 +798,8 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
sync_scores.append(1.0)
|
||||
continue
|
||||
|
||||
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset):
|
||||
def _gen(text, lang, instruct_str, dur_s, nstep, cfg, spd, profile_id, effect_preset,
|
||||
*, execution_target="local"):
|
||||
# Normalize once at the segment's text→engine choke point
|
||||
# (covers the OOM-retry generate below too, which reuses this
|
||||
# closure's `text`). Pref-gated, idempotent, never raises.
|
||||
@@ -623,7 +877,19 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# editor's Voice dropdown can actually render ("From
|
||||
# Video → Speaker N"). `seg_id` is closed over from
|
||||
# the per-segment loop below.
|
||||
seg_ref = (job.get("segment_clones") or {}).get(str(seg_id))
|
||||
segment_speaker_key = _speaker_key_for_segment(job, seg_id)
|
||||
# Legacy jobs may not persist diarized segment rows.
|
||||
# Preserve their established per-line preference; only
|
||||
# suppress it when current metadata proves the user
|
||||
# explicitly selected a different speaker.
|
||||
selected_is_segment_speaker = (
|
||||
segment_speaker_key is None or segment_speaker_key == key
|
||||
)
|
||||
seg_ref = (
|
||||
(job.get("segment_clones") or {}).get(str(seg_id))
|
||||
if selected_is_segment_speaker
|
||||
else None
|
||||
)
|
||||
if seg_ref:
|
||||
ref_audio = seg_ref.get("ref_audio")
|
||||
ref_text = seg_ref.get("ref_text")
|
||||
@@ -632,6 +898,13 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
auto = _find_speaker_clone(
|
||||
job.get("speaker_clones") or {}, key
|
||||
)
|
||||
if auto is None:
|
||||
# Short lines may have no line-specific clip.
|
||||
# Reuse this speaker's best source instead of
|
||||
# silently reverting to the engine default.
|
||||
auto = resolve_consistent_ref(
|
||||
job, key, _consistent_ref_memo
|
||||
)
|
||||
if auto:
|
||||
ref_audio = auto.get("ref_audio")
|
||||
ref_text = auto.get("ref_text")
|
||||
@@ -662,6 +935,12 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
if used_seed is not None:
|
||||
torch.manual_seed(used_seed)
|
||||
|
||||
# Last gate before the engine: every resolution branch above
|
||||
# produces a PATH, and none of them can know it still exists.
|
||||
ref_audio = warn_if_ref_missing(
|
||||
ref_audio, job_id=job_id, seg_id=seg_id, where="dub render",
|
||||
)
|
||||
|
||||
try:
|
||||
audio_out = backend.generate(
|
||||
text=text, language=lang if lang != "Auto" else None,
|
||||
@@ -690,19 +969,7 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
)
|
||||
return normalize_audio(mastered_audio, target_dBFS=-2.0)
|
||||
except Exception as e:
|
||||
is_oom = (
|
||||
isinstance(e, torch.cuda.OutOfMemoryError)
|
||||
or "out of memory" in str(e).lower()
|
||||
or "CUDA error" in str(e)
|
||||
)
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
torch.mps.empty_cache()
|
||||
|
||||
if not is_oom:
|
||||
if not _prepare_oom_retry(e, execution_target=execution_target):
|
||||
raise
|
||||
|
||||
retry_steps = min(nstep, 8)
|
||||
@@ -808,14 +1075,24 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# Budget from the shared length-scaled helper (#1190): a long
|
||||
# dub segment used to die on the flat 300s even after v0.3.22.
|
||||
from services.model_manager import generate_timeout_s
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _gen(
|
||||
seg.text, seg_lang, seg_instruct, _dur_for_tts,
|
||||
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
|
||||
),
|
||||
what="Dub generate",
|
||||
timeout=generate_timeout_s(seg.text),
|
||||
)
|
||||
if i in remote_audio:
|
||||
audio_tensor, remote_sr = torchaudio.load(remote_audio[i])
|
||||
try:
|
||||
os.unlink(remote_audio[i])
|
||||
except OSError:
|
||||
pass
|
||||
if remote_sr != backend.sample_rate:
|
||||
import torchaudio.functional as AF
|
||||
audio_tensor = AF.resample(audio_tensor, remote_sr, backend.sample_rate)
|
||||
else:
|
||||
audio_tensor = await run_on_gpu_pool_guarded(
|
||||
lambda: _gen(
|
||||
seg.text, seg_lang, seg_instruct, _dur_for_tts,
|
||||
_num_step, req.guidance_scale, seg_speed, seg_profile, seg_effect_preset,
|
||||
),
|
||||
what="Dub generate",
|
||||
timeout=generate_timeout_s(seg.text),
|
||||
)
|
||||
_t_tts += time.perf_counter() - _t_tts_0
|
||||
|
||||
# Check abort immediately after GPU work completes
|
||||
@@ -904,8 +1181,9 @@ async def dub_generate(job_id: str, req: DubRequest):
|
||||
# no double-mark. Cached-reuse audio is already marked;
|
||||
# silence/zero slots carry no speech to mark, so neither is
|
||||
# re-watermarked.
|
||||
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
|
||||
context="dub_generate.segment")
|
||||
if i not in remote_audio or rvc_is_enabled():
|
||||
audio_tensor = mark_synthetic(audio_tensor, backend.sample_rate,
|
||||
context="dub_generate.segment")
|
||||
|
||||
seg_wav_path = _seg_lang_path(seg_id)
|
||||
try:
|
||||
@@ -1414,6 +1692,11 @@ import io
|
||||
|
||||
|
||||
class SegmentPreviewRequest(BaseModel):
|
||||
# Optional, and only used to label diagnostics: a preview is a "render this
|
||||
# text" call, so it has never needed segment identity. Supplying it makes a
|
||||
# missing-clone warning name the line the user was editing instead of a
|
||||
# bare "preview" (greptile).
|
||||
segment_id: Optional[str] = None
|
||||
text: str
|
||||
language: str = "Auto"
|
||||
instruct: Optional[str] = None
|
||||
@@ -1451,12 +1734,17 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
|
||||
pid = req.profile_id
|
||||
if pid and pid.startswith("auto:"):
|
||||
key = pid[len("auto:"):]
|
||||
clones = job.get("speaker_clones") or {}
|
||||
for spk, info in clones.items():
|
||||
if spk.lower().replace(" ", "_") == key or spk == key:
|
||||
ref_audio = info.get("ref_audio")
|
||||
ref_text = info.get("ref_text")
|
||||
break
|
||||
info = None
|
||||
if (
|
||||
req.segment_id is not None
|
||||
and _speaker_key_for_segment(job, req.segment_id) == key
|
||||
):
|
||||
info = (job.get("segment_clones") or {}).get(str(req.segment_id))
|
||||
if info is None:
|
||||
info = resolve_consistent_ref(job, key)
|
||||
if info:
|
||||
ref_audio = info.get("ref_audio")
|
||||
ref_text = info.get("ref_text")
|
||||
pid = None
|
||||
|
||||
instruct_str = req.instruct
|
||||
@@ -1475,6 +1763,10 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
|
||||
if not instruct_str and row["instruct"]:
|
||||
instruct_str = row["instruct"]
|
||||
|
||||
ref_audio = warn_if_ref_missing(
|
||||
ref_audio, job_id=job_id,
|
||||
seg_id=req.segment_id or "preview", where="dub preview",
|
||||
)
|
||||
lang = req.language if req.language != "Auto" else None
|
||||
# Same normalization as the full dub render above, so a preview
|
||||
# sounds exactly like the final segment. Pref-gated, never raises.
|
||||
@@ -1521,4 +1813,3 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
|
||||
"X-Audio-Duration": str(round(audio_tensor.shape[-1] / sr, 2)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -8,12 +8,23 @@ 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",
|
||||
@@ -287,9 +298,9 @@ async def dub_translate(req: TranslateRequest):
|
||||
|
||||
try:
|
||||
if _nllb_tokenizer is None:
|
||||
_nllb_tokenizer = AutoTokenizer.from_pretrained("facebook/nllb-200-distilled-600M")
|
||||
_nllb_tokenizer = _load_nllb_component(AutoTokenizer)
|
||||
if _nllb_model is None:
|
||||
_nllb_model = AutoModelForSeq2SeqLM.from_pretrained("facebook/nllb-200-distilled-600M")
|
||||
_nllb_model = _load_nllb_component(AutoModelForSeq2SeqLM)
|
||||
if target_device != "cpu":
|
||||
try:
|
||||
_nllb_model = _nllb_model.to(target_device)
|
||||
@@ -714,8 +725,16 @@ async def dub_translate(req: TranslateRequest):
|
||||
translated, req, src_lang, loop,
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback; traceback.print_exc()
|
||||
return JSONResponse(status_code=500, content={"error": str(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})
|
||||
|
||||
|
||||
def _stamp_duration_plan(rows, req) -> None:
|
||||
|
||||
@@ -15,21 +15,25 @@ 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_loopback
|
||||
from api.dependencies import require_admin, require_admin_action, require_desktop
|
||||
from core import prefs
|
||||
from services import tts_backend, asr_backend, llm_backend, translation_engines
|
||||
from services.audio_dsp import list_effect_presets
|
||||
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"),
|
||||
@@ -37,38 +41,48 @@ _FAMILIES = {
|
||||
"llm": (llm_backend, "llm_backend"),
|
||||
}
|
||||
|
||||
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": {
|
||||
"active": tts_backend.active_backend_id(),
|
||||
"backends": tts_backend.list_backends(),
|
||||
"backends": public_backends(tts_backend.list_backends()),
|
||||
},
|
||||
"asr": {
|
||||
"active": asr_backend.active_backend_id(),
|
||||
"backends": asr_backend.list_backends(),
|
||||
"backends": public_backends(asr_backend.list_backends()),
|
||||
},
|
||||
"llm": {
|
||||
"active": llm_backend.active_backend_id(),
|
||||
"backends": llm_backend.list_backends(),
|
||||
"backends": public_backends(llm_backend.list_backends()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engines/tts")
|
||||
def list_tts_backends():
|
||||
return {"active": tts_backend.active_backend_id(), "backends": tts_backend.list_backends()}
|
||||
return {"active": tts_backend.active_backend_id(), "backends": public_backends(tts_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/asr")
|
||||
def list_asr_backends():
|
||||
return {"active": asr_backend.active_backend_id(), "backends": asr_backend.list_backends()}
|
||||
return {"active": asr_backend.active_backend_id(), "backends": public_backends(asr_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/llm")
|
||||
def list_llm_backends():
|
||||
return {"active": llm_backend.active_backend_id(), "backends": llm_backend.list_backends()}
|
||||
return {"active": llm_backend.active_backend_id(), "backends": public_backends(llm_backend.list_backends())}
|
||||
|
||||
|
||||
@router.get("/engines/effects/presets", response_model=EffectPresetsResponse)
|
||||
@@ -91,12 +105,18 @@ def list_translation_engines():
|
||||
an engine whose Python dependency isn't importable yet.
|
||||
"""
|
||||
return {
|
||||
"engines": translation_engines.list_engines(),
|
||||
"engines": [
|
||||
{**entry, "availability_reason": public_unavailability(entry.get("availability_reason"))}
|
||||
for entry in translation_engines.list_engines()
|
||||
],
|
||||
"sandboxed": translation_engines.is_frozen(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/engines/translation/{engine_id}/install")
|
||||
@router.post(
|
||||
"/engines/translation/{engine_id}/install",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def install_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -132,7 +152,10 @@ async def install_translation_engine(engine_id: str):
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/engines/translation/{engine_id}")
|
||||
@router.delete(
|
||||
"/engines/translation/{engine_id}",
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
async def uninstall_translation_engine(engine_id: str):
|
||||
entry = translation_engines.get_engine(engine_id)
|
||||
if not entry:
|
||||
@@ -161,7 +184,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 Settings → Engines
|
||||
# background job, GET polls its step-by-step status (the Model Catalogue → Engines
|
||||
# Install button polls this), DELETE removes an app-managed install.
|
||||
#
|
||||
# Path namespace: /engines/sidecar/{engine_id}/… — NOT /engines/{engine_id}/…
|
||||
@@ -171,15 +194,16 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
# POST /engines/sonitranslate/install). Mirrors the
|
||||
# /engines/translation/{engine_id}/install namespace pattern.
|
||||
#
|
||||
# Loopback-gated: installing spawns subprocesses (git/uv) and writes to the
|
||||
# data directory — only the local desktop frontend may trigger it. The job
|
||||
# runs fine in packaged builds: the venv lives under the user data dir, not
|
||||
# inside the signed app bundle, and uv resolves via OMNIVOICE_BUNDLED_UV/PATH.
|
||||
# Desktop-only: installing spawns git/uv against mutable source and writes an
|
||||
# editable environment. An API key does not make that supply-chain path safe to
|
||||
# trigger remotely. The job runs fine in packaged builds: the venv lives under
|
||||
# the user data dir, not inside the signed app bundle, and uv resolves via
|
||||
# OMNIVOICE_BUNDLED_UV/PATH.
|
||||
|
||||
|
||||
@router.post(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin), Depends(require_desktop)],
|
||||
)
|
||||
def install_sidecar_engine(engine_id: str):
|
||||
"""Start (or report) the one-click install for a sidecar engine.
|
||||
@@ -205,7 +229,7 @@ def install_sidecar_engine(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/sidecar/{engine_id}/install/status",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def sidecar_install_status(engine_id: str):
|
||||
"""Step-by-step status of the sidecar install job (poll while running).
|
||||
@@ -226,7 +250,7 @@ def sidecar_install_status(engine_id: str):
|
||||
|
||||
@router.delete(
|
||||
"/engines/sidecar/{engine_id}/install",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def uninstall_sidecar_engine(engine_id: str):
|
||||
"""Remove an app-managed sidecar install (checkout + venv + weights) and
|
||||
@@ -257,29 +281,22 @@ def uninstall_sidecar_engine(engine_id: str):
|
||||
# frame. Result includes wall-clock latency so the UI can render
|
||||
# "1234 ms — pong" inline next to the button.
|
||||
#
|
||||
# Loopback-gated (T-02-13): only the local desktop frontend may trigger
|
||||
# a sidecar spawn through this endpoint.
|
||||
# Admin-gated (T-02-13): only the local desktop frontend or an authenticated
|
||||
# server-mode administrator may trigger a sidecar spawn through this endpoint.
|
||||
|
||||
# Engine instances cached for the lifetime of the FastAPI process so that
|
||||
# repeated health checks don't spawn a new SubprocessBackend (each spawn
|
||||
# allocates a sidecar venv probe + atexit hook). The cache is keyed by
|
||||
# class to survive registry-sandbox tests that rebind ids transiently.
|
||||
_ENGINE_INSTANCES: dict[type, object] = {}
|
||||
#
|
||||
# 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
|
||||
|
||||
|
||||
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
|
||||
_get_engine_instance = tts_backend.get_engine_instance
|
||||
|
||||
|
||||
def _resolve_engine_class(engine_id: str):
|
||||
@@ -301,7 +318,7 @@ def _resolve_engine_class(engine_id: str):
|
||||
|
||||
@router.get(
|
||||
"/engines/{engine_id}/health",
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
def engine_health(engine_id: str):
|
||||
"""Spawn-and-ping a SubprocessBackend; ``is_available()`` for the rest.
|
||||
@@ -309,10 +326,10 @@ def engine_health(engine_id: str):
|
||||
Returns:
|
||||
{ id, ok, message, latency_ms }
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
cls = _resolve_engine_class(engine_id)
|
||||
if cls is None:
|
||||
@@ -341,16 +358,17 @@ def engine_health(engine_id: str):
|
||||
except Exception as exc:
|
||||
ok, msg = False, f"{type(exc).__name__}: {exc}"
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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": _mask_hf_tokens(msg) if isinstance(msg, str) else str(msg),
|
||||
"message": public_engine_health(bool(ok), msg),
|
||||
"latency_ms": latency_ms,
|
||||
}
|
||||
|
||||
@@ -374,11 +392,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. Loopback-gated.
|
||||
# * Only ever on user click (POST) — never on Settings load. Admin-gated.
|
||||
|
||||
# Deliberately short + ASCII so the synth stays CPU-cheap and the phrase never
|
||||
# trips the no-hardcoded-CJK guard.
|
||||
_SELFTEST_PHRASE = "OmniVoice engine self test."
|
||||
_SELFTEST_PHRASE = "VoiceStudio engine self test."
|
||||
_SELFTEST_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -441,7 +459,7 @@ class SelfTestResponse(BaseModel):
|
||||
@router.post(
|
||||
"/engines/{engine_id}/selftest",
|
||||
response_model=SelfTestResponse,
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def engine_selftest(engine_id: str):
|
||||
"""Run a bounded, real synthesis on an available in-process TTS engine.
|
||||
@@ -540,7 +558,11 @@ class SelectEngineResponse(BaseModel):
|
||||
routing_reason: str | None = None
|
||||
|
||||
|
||||
@router.post("/engines/select", response_model=SelectEngineResponse)
|
||||
@router.post(
|
||||
"/engines/select",
|
||||
response_model=SelectEngineResponse,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def select_engine(req: SelectEngineRequest):
|
||||
"""Persist a family's engine pick to prefs.json. Refuses unknown backends,
|
||||
backends whose deps aren't installed, AND backends that cannot run on THIS
|
||||
@@ -571,7 +593,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 Settings → Models).
|
||||
# to Kokoro no matter what the user downloaded in Model Catalogue → 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
|
||||
@@ -579,11 +601,11 @@ 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 re.fullmatch(r"[\w.-]+/[\w.-]+", req.model_id):
|
||||
if req.model_id not in known_keys and not _is_hf_repo_id(req.model_id):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Unknown mlx-audio model: {req.model_id!r}. Expected one of "
|
||||
f"{sorted(known_keys)} or a HF repo id like 'owner/name'.",
|
||||
"Unknown mlx-audio model. Expected a curated model key or a "
|
||||
"Hugging Face repo ID like 'owner/name'.",
|
||||
)
|
||||
prefs.set_("mlx_audio_model_id", req.model_id)
|
||||
prefs.set_(pref_key, req.backend_id)
|
||||
|
||||
@@ -4,34 +4,28 @@ import time
|
||||
import shutil
|
||||
import subprocess
|
||||
import platform
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from api.dependencies import require_native_access
|
||||
from core.db import db_conn
|
||||
from core.config import OUTPUTS_DIR
|
||||
from core.config import DATA_DIR, 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 _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)
|
||||
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))
|
||||
parent = os.path.dirname(dest)
|
||||
if not parent or not os.path.isdir(parent):
|
||||
raise HTTPException(
|
||||
@@ -43,32 +37,32 @@ def _safe_destination(raw: str) -> str:
|
||||
|
||||
def _safe_source(filename: str) -> str:
|
||||
"""Resolve a source filename against OUTPUTS_DIR / dub outputs, blocking traversal."""
|
||||
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 (".", ".."):
|
||||
try:
|
||||
base = safe_filename(filename)
|
||||
except UnsafePath as exc:
|
||||
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")):
|
||||
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
|
||||
try:
|
||||
candidate = resolve_within(root, base)
|
||||
except UnsafePath:
|
||||
continue
|
||||
if candidate.is_file():
|
||||
return str(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")
|
||||
@router.post("/export", dependencies=[Depends(require_native_access)])
|
||||
def export_file(req: ExportRequest):
|
||||
src = _safe_source(req.source_filename)
|
||||
dest = _safe_destination(req.destination_path)
|
||||
dest = _authorized_destination(req.authorization)
|
||||
try:
|
||||
# Video exports: overlay OmniVoice logo if visible watermark is enabled
|
||||
# Video exports: overlay VoiceStudio 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")
|
||||
@@ -126,31 +120,38 @@ def get_export_history():
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/export/reveal")
|
||||
@router.post("/export/reveal", dependencies=[Depends(require_native_access)])
|
||||
def reveal_in_folder(req: RevealRequest):
|
||||
# Tauri/native dialog-provided path; subprocess uses list args (no shell interpolation).
|
||||
# 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.
|
||||
if not req.path or not req.path.strip():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No path was provided — nothing to reveal.",
|
||||
)
|
||||
target = os.path.realpath(os.path.expanduser(req.path))
|
||||
if not os.path.exists(target):
|
||||
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():
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="That file or folder is no longer on disk. It may have been moved or deleted.",
|
||||
)
|
||||
|
||||
folder = target if os.path.isdir(target) else os.path.dirname(target)
|
||||
target = str(target_path)
|
||||
folder = target if target_path.is_dir() else str(target_path.parent)
|
||||
system = platform.system()
|
||||
try:
|
||||
if system == "Darwin":
|
||||
if os.path.isfile(target):
|
||||
if target_path.is_file():
|
||||
subprocess.Popen(["open", "-R", target])
|
||||
else:
|
||||
subprocess.Popen(["open", folder])
|
||||
elif system == "Windows":
|
||||
if os.path.isfile(target):
|
||||
if target_path.is_file():
|
||||
subprocess.Popen(["explorer", "/select,", target.replace("/", "\\")])
|
||||
else:
|
||||
subprocess.Popen(["explorer", folder.replace("/", "\\")])
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.file_cleanup import FileCleanupError, unlink_if_present
|
||||
from services.ffmpeg_utils import spawn_subprocess
|
||||
|
||||
logger = logging.getLogger("omnivoice.gallery")
|
||||
@@ -139,11 +140,14 @@ def delete_voice(voice_id: str):
|
||||
raise HTTPException(status_code=404, detail="Voice not found")
|
||||
|
||||
audio_path = row["audio_path"]
|
||||
if audio_path and os.path.exists(audio_path):
|
||||
if audio_path:
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (voice_id,))
|
||||
return {"success": True}
|
||||
@@ -478,19 +482,22 @@ 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 and os.path.exists(audio_path):
|
||||
if audio_path:
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass
|
||||
unlink_if_present(audio_path)
|
||||
except FileCleanupError:
|
||||
logger.warning("Voice audio cleanup failed for a gallery item")
|
||||
failed += 1
|
||||
continue
|
||||
conn.execute("DELETE FROM voice_gallery WHERE id = ?", (vid,))
|
||||
deleted += 1
|
||||
return {"deleted": deleted}
|
||||
return {"deleted": deleted, "failed": failed}
|
||||
|
||||
|
||||
@router.post("/gallery/voices/{voice_id}/to-profile")
|
||||
@@ -526,4 +533,3 @@ def voice_to_profile(voice_id: str):
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
|
||||
return {"success": True, "profile_id": profile_id, "name": voice["name"]}
|
||||
|
||||
|
||||
+709
-150
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,8 @@ 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")
|
||||
|
||||
@@ -56,6 +58,26 @@ 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -109,18 +131,18 @@ def export_profile(profile_id: str):
|
||||
# Reference audio
|
||||
ref_path = profile.get("ref_audio_path")
|
||||
if ref_path:
|
||||
full_ref = os.path.join(VOICES_DIR, ref_path)
|
||||
if os.path.isfile(full_ref):
|
||||
full_ref = _voice_asset(ref_path)
|
||||
if full_ref and full_ref.is_file():
|
||||
ext = os.path.splitext(ref_path)[1] or ".wav"
|
||||
zf.write(full_ref, f"ref_audio{ext}")
|
||||
zf.write(str(full_ref), f"ref_audio{ext}")
|
||||
|
||||
# Locked audio (if profile is locked)
|
||||
locked_path = profile.get("locked_audio_path")
|
||||
if locked_path:
|
||||
full_locked = os.path.join(VOICES_DIR, locked_path)
|
||||
if os.path.isfile(full_locked):
|
||||
full_locked = _voice_asset(locked_path)
|
||||
if full_locked and full_locked.is_file():
|
||||
ext = os.path.splitext(locked_path)[1] or ".wav"
|
||||
zf.write(full_locked, f"locked_audio{ext}")
|
||||
zf.write(str(full_locked), f"locked_audio{ext}")
|
||||
|
||||
buf.seek(0)
|
||||
safe_name = "".join(
|
||||
@@ -255,7 +277,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
|
||||
OmniVoice instances on the same machine (or shared network drive)
|
||||
VoiceStudio instances on the same machine (or shared network drive)
|
||||
can discover and import it.
|
||||
"""
|
||||
with db_conn() as conn:
|
||||
@@ -270,7 +292,11 @@ 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 = MARKETPLACE_DIR / f"{safe_name}_{profile_id}.omnivoice"
|
||||
bundle_path = _contained_path(
|
||||
MARKETPLACE_DIR,
|
||||
f"{safe_name}_{profile_id}.omnivoice",
|
||||
detail="Invalid profile id",
|
||||
)
|
||||
|
||||
# Build the bundle
|
||||
with zipfile.ZipFile(str(bundle_path), "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
@@ -283,19 +309,19 @@ def publish_to_marketplace(
|
||||
|
||||
ref_path = profile.get("ref_audio_path")
|
||||
if ref_path:
|
||||
full_ref = os.path.join(VOICES_DIR, ref_path)
|
||||
if os.path.isfile(full_ref):
|
||||
full_ref = _voice_asset(ref_path)
|
||||
if full_ref and full_ref.is_file():
|
||||
ext = os.path.splitext(ref_path)[1] or ".wav"
|
||||
zf.write(full_ref, f"ref_audio{ext}")
|
||||
zf.write(str(full_ref), f"ref_audio{ext}")
|
||||
|
||||
locked_path = profile.get("locked_audio_path")
|
||||
if locked_path:
|
||||
full_locked = os.path.join(VOICES_DIR, locked_path)
|
||||
if os.path.isfile(full_locked):
|
||||
full_locked = _voice_asset(locked_path)
|
||||
if full_locked and full_locked.is_file():
|
||||
ext = os.path.splitext(locked_path)[1] or ".wav"
|
||||
zf.write(full_locked, f"locked_audio{ext}")
|
||||
zf.write(str(full_locked), f"locked_audio{ext}")
|
||||
|
||||
logger.info("Published voice %r to marketplace: %s", profile.get("name"), bundle_path)
|
||||
logger.info("Voice published to marketplace")
|
||||
return {
|
||||
"success": True,
|
||||
"profile_id": profile_id,
|
||||
@@ -345,7 +371,7 @@ def browse_marketplace(
|
||||
),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning("Skipping invalid bundle %s: %s", path.name, e)
|
||||
logger.warning("Skipping invalid bundle %s: %s", log_safe(path.name), log_safe(e))
|
||||
|
||||
return {"bundles": bundles, "total": len(bundles), "directory": str(MARKETPLACE_DIR)}
|
||||
|
||||
@@ -353,7 +379,13 @@ 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."""
|
||||
bundle_path = MARKETPLACE_DIR / filename
|
||||
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")
|
||||
if not bundle_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Bundle not found: {filename}")
|
||||
|
||||
@@ -426,7 +458,13 @@ async def install_from_marketplace(filename: str):
|
||||
@router.delete("/{filename}")
|
||||
def remove_from_marketplace(filename: str):
|
||||
"""Remove a bundle from the local marketplace directory."""
|
||||
bundle_path = MARKETPLACE_DIR / filename
|
||||
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")
|
||||
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_loopback
|
||||
from api.dependencies import require_admin
|
||||
from services import mcp_bindings
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/mcp",
|
||||
tags=["mcp"],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
router = APIRouter(dependencies=[Depends(require_loopback)])
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
class CustomPathRequest(BaseModel):
|
||||
path: str
|
||||
authorization: str
|
||||
|
||||
|
||||
def _svc():
|
||||
@@ -61,8 +61,13 @@ 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:
|
||||
return _svc().set_custom_path(tool, body.path)
|
||||
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
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
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 OmniVoice
|
||||
OpenAI protocol (Claude, Cursor, LangChain, litellm, etc.) can use VoiceStudio
|
||||
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 (OmniVoice extension)
|
||||
GET /v1/audio/voices → list available voices (VoiceStudio extension)
|
||||
|
||||
The router delegates to the active TTS/ASR backends via the same adapter
|
||||
protocol used by the rest of OmniVoice, so engine selection, GPU offloading,
|
||||
protocol used by the rest of VoiceStudio, so engine selection, GPU offloading,
|
||||
model loading, and invisible provenance watermarking (services.watermark,
|
||||
#1169) all work identically.
|
||||
|
||||
@@ -48,7 +48,7 @@ class SpeechRequest(BaseModel):
|
||||
model: str = Field(
|
||||
default="omnivoice",
|
||||
description=(
|
||||
"TTS model to use. Maps to OmniVoice engine IDs: "
|
||||
"TTS model to use. Maps to VoiceStudio 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 +61,7 @@ class SpeechRequest(BaseModel):
|
||||
voice: str = Field(
|
||||
default="default",
|
||||
description=(
|
||||
"Voice to use. For OmniVoice: pass a voice profile ID, 'default', "
|
||||
"Voice to use. For VoiceStudio: 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 +76,7 @@ class SpeechRequest(BaseModel):
|
||||
le=4.0,
|
||||
description="Speed of the generated audio (0.25 to 4.0).",
|
||||
)
|
||||
# OmniVoice extensions (not part of OpenAI spec, but accepted if sent)
|
||||
# VoiceStudio 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 +87,19 @@ class SpeechRequest(BaseModel):
|
||||
duration: Optional[float] = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="OmniVoice extension: target output duration in seconds.",
|
||||
description="VoiceStudio extension: target output duration in seconds.",
|
||||
)
|
||||
seed: Optional[int] = Field(
|
||||
default=None,
|
||||
description="OmniVoice extension: deterministic sampling seed.",
|
||||
description="VoiceStudio extension: deterministic sampling seed.",
|
||||
)
|
||||
denoise: bool = Field(
|
||||
default=True,
|
||||
description="OmniVoice extension: prepend denoise control when supported.",
|
||||
description="VoiceStudio extension: prepend denoise control when supported.",
|
||||
)
|
||||
preprocess_prompt: bool = Field(
|
||||
default=True,
|
||||
description="OmniVoice extension: trim/preprocess reference prompt when supported.",
|
||||
description="VoiceStudio extension: trim/preprocess reference prompt when supported.",
|
||||
)
|
||||
chunk_duration: Optional[float] = Field(
|
||||
default=None,
|
||||
@@ -120,13 +120,13 @@ class SpeechRequest(BaseModel):
|
||||
default=None,
|
||||
ge=1,
|
||||
le=128,
|
||||
description="OmniVoice extension: iterative unmasking steps (app default 16; 32 = the model's documented quality preset).",
|
||||
description="VoiceStudio 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="OmniVoice extension: classifier-free guidance scale (app default 2.0).",
|
||||
description="VoiceStudio extension: classifier-free guidance scale (app default 2.0).",
|
||||
)
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ class VerboseTranscriptionResponse(BaseModel):
|
||||
|
||||
# ── OpenAI voice name mapping ──────────────────────────────────────────────
|
||||
|
||||
# OpenAI's 6 named voices aren't real voices in OmniVoice. Map them to
|
||||
# OpenAI's 6 named voices aren't real voices in VoiceStudio. 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,7 +159,7 @@ _OPENAI_VOICE_ALIASES = {
|
||||
|
||||
|
||||
def _resolve_engine(model_id: str):
|
||||
"""Map an OpenAI model name to an OmniVoice backend."""
|
||||
"""Map an OpenAI model name to a VoiceStudio backend."""
|
||||
from services.tts_backend import get_backend_class, get_active_tts_backend
|
||||
|
||||
# Accept OpenAI model names as pass-through to the active engine.
|
||||
@@ -290,7 +290,7 @@ def _run_tts(backend, text: str, kw: dict):
|
||||
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 OmniVoice's 24 kHz clone
|
||||
# That chain's highpass + Compressor is tuned for VoiceStudio'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.
|
||||
@@ -385,6 +385,9 @@ 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).
|
||||
|
||||
# ── #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
|
||||
@@ -412,7 +415,7 @@ async def create_speech(req: SpeechRequest):
|
||||
detail=(
|
||||
f"TTS engine '{backend.id}' did not finish loading within its "
|
||||
f"model-load budget — on a first run this usually means the weight "
|
||||
f"download is slow or stalled (check Settings → Models for "
|
||||
f"download is slow or stalled (check Model Catalogue → Models for "
|
||||
f"progress), not that generation failed. Retry once the model "
|
||||
f"shows as installed."
|
||||
),
|
||||
@@ -492,7 +495,7 @@ async def create_transcription(
|
||||
default="whisper-1",
|
||||
description=(
|
||||
"ASR model. Accepts 'whisper-1' (maps to active engine), or an "
|
||||
"OmniVoice engine ID: whisperx, faster-whisper, mlx-whisper, pytorch-whisper."
|
||||
"VoiceStudio engine ID: whisperx, faster-whisper, mlx-whisper, pytorch-whisper."
|
||||
),
|
||||
),
|
||||
language: Optional[str] = Form(
|
||||
@@ -514,15 +517,16 @@ 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,
|
||||
get_active_asr_backend,
|
||||
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 OmniVoice-aware clients
|
||||
# (`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)
|
||||
@@ -543,18 +547,25 @@ 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"
|
||||
result = await run_transcribe_guarded(
|
||||
_gpu_pool,
|
||||
lambda: backend.transcribe(tmp_path, word_timestamps=word_ts),
|
||||
what="OpenAI",
|
||||
)
|
||||
|
||||
# `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")
|
||||
|
||||
# Extract the full text from segments
|
||||
segments = result.get("segments", [])
|
||||
@@ -622,6 +633,14 @@ 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)
|
||||
@@ -637,12 +656,12 @@ async def create_transcription(
|
||||
pass
|
||||
|
||||
|
||||
# ── Voices: GET /v1/audio/voices (OmniVoice extension) ─────────────────────
|
||||
# ── Voices: GET /v1/audio/voices (VoiceStudio extension) ─────────────────────
|
||||
|
||||
|
||||
@router.get("/voices")
|
||||
def list_voices():
|
||||
"""List available voices. OmniVoice extension to the OpenAI API."""
|
||||
"""List available voices. VoiceStudio extension to the OpenAI API."""
|
||||
from services.tts_backend import list_backends
|
||||
|
||||
backends = list_backends()
|
||||
@@ -654,7 +673,7 @@ def list_voices():
|
||||
"voice_id": name,
|
||||
"name": name.capitalize(),
|
||||
"type": "openai_alias",
|
||||
"description": f"OpenAI '{name}' voice — maps to the active OmniVoice engine's default voice.",
|
||||
"description": f"OpenAI '{name}' voice — maps to the active VoiceStudio engine's default voice.",
|
||||
})
|
||||
|
||||
# Include voice profiles from the database
|
||||
@@ -672,7 +691,7 @@ def list_voices():
|
||||
"language": row["language"],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Voice profiles could not be loaded; returning built-in aliases only")
|
||||
|
||||
return {"voices": voices, "engines": backends}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
|
||||
@@ -88,8 +89,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:
|
||||
logger.exception("persona export failed for %s", profile_id)
|
||||
except Exception as exc:
|
||||
logger.error("persona export failed for %s: %s", log_safe(profile_id), log_safe(exc))
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Could not build the persona bundle — see Settings → Logs.",
|
||||
@@ -236,15 +237,18 @@ async def import_persona(file: UploadFile = File(...)):
|
||||
_insert(profile_id)
|
||||
|
||||
except HTTPException:
|
||||
_cleanup(written)
|
||||
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.")
|
||||
raise
|
||||
except Exception:
|
||||
_cleanup(written)
|
||||
logger.exception("persona import failed")
|
||||
raise HTTPException(status_code=500, detail="Import failed; no files were kept.")
|
||||
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)
|
||||
|
||||
event_bus.emit("profiles", {"action": "created", "id": profile_id})
|
||||
logger.info("Imported persona %r as %s (verified=%s)", persona.get("name"), profile_id, verified)
|
||||
logger.info("Imported persona %s as %s (verified=%s)", log_safe(persona.get("name")), log_safe(profile_id), verified)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -260,13 +264,16 @@ async def import_persona(file: UploadFile = File(...)):
|
||||
}
|
||||
|
||||
|
||||
def _cleanup(paths: list[str]) -> None:
|
||||
def _cleanup(paths: list[str]) -> bool:
|
||||
complete = True
|
||||
for p in paths:
|
||||
try:
|
||||
if p and os.path.exists(p):
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
complete = False
|
||||
logger.warning("Persona import temporary-file cleanup did not complete")
|
||||
return complete
|
||||
|
||||
|
||||
def _rename_for_new_id(written: list[str], new_id: str) -> list[str]:
|
||||
|
||||
@@ -13,6 +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
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -377,13 +378,18 @@ 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")
|
||||
|
||||
src_path = os.path.join(OUTPUTS_DIR, history["audio_path"])
|
||||
if not os.path.exists(src_path):
|
||||
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():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found on disk")
|
||||
|
||||
locked_filename = f"{profile_id}_locked.wav"
|
||||
locked_path = os.path.join(VOICES_DIR, locked_filename)
|
||||
shutil.copy2(src_path, locked_path)
|
||||
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)
|
||||
|
||||
ref_text = history["text"][:100] if history["text"] else ""
|
||||
|
||||
@@ -405,8 +411,8 @@ async def unlock_profile(profile_id: str):
|
||||
)
|
||||
|
||||
if profile["locked_audio_path"]:
|
||||
locked_path = os.path.join(VOICES_DIR, profile["locked_audio_path"])
|
||||
if os.path.exists(locked_path):
|
||||
locked_path = _voices_path(profile["locked_audio_path"])
|
||||
if locked_path and os.path.exists(locked_path):
|
||||
os.remove(locked_path)
|
||||
|
||||
conn.execute(
|
||||
|
||||
@@ -7,7 +7,7 @@ CRUD for the DB-backed, per-language pronunciation dictionary the
|
||||
before synthesis (see ``services/pronunciation.apply_pronunciation`` and the
|
||||
generate path), so a saved entry actually changes the audio on every engine.
|
||||
|
||||
Endpoints (loopback-only, like the dictation router):
|
||||
Endpoints (admin-gated; loopback or authenticated server mode):
|
||||
GET /pronunciation → list every entry
|
||||
POST /pronunciation → create one entry
|
||||
PUT /pronunciation/{entry_id} → update an entry (partial)
|
||||
@@ -30,12 +30,12 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from api.dependencies import require_admin
|
||||
from core.db import db_conn
|
||||
from services.pronunciation import apply_pronunciation, entries_for_language
|
||||
|
||||
logger = logging.getLogger("omnivoice.pronunciation")
|
||||
router = APIRouter()
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
_VALID_TYPES = ("respelling", "ipa", "cmu")
|
||||
_ALL_LANG = "*"
|
||||
@@ -133,7 +133,7 @@ class PronImportRequest(BaseModel):
|
||||
# ── CRUD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
@router.get("/pronunciation")
|
||||
def list_entries():
|
||||
with db_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -143,7 +143,7 @@ def list_entries():
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/pronunciation", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation")
|
||||
def create_entry(entry: PronEntry):
|
||||
term = entry.term.strip()
|
||||
if not term:
|
||||
@@ -171,7 +171,7 @@ def create_entry(entry: PronEntry):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
@router.put("/pronunciation/{entry_id}")
|
||||
def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
with db_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -226,7 +226,7 @@ def update_entry(entry_id: str, patch: PronEntryUpdate):
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@router.delete("/pronunciation/{entry_id}", dependencies=[Depends(require_loopback)])
|
||||
@router.delete("/pronunciation/{entry_id}")
|
||||
def delete_entry(entry_id: str):
|
||||
with db_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM pronunciation_entries WHERE id = ?", (entry_id,))
|
||||
@@ -236,7 +236,7 @@ def delete_entry(entry_id: str):
|
||||
# ── Dry-run + import/export ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/pronunciation/test", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation/test")
|
||||
def test_substitution(req: PronTestRequest):
|
||||
"""Show the post-substitution text for ``req.text`` — no model call.
|
||||
|
||||
@@ -258,7 +258,7 @@ def test_substitution(req: PronTestRequest):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pronunciation/export", dependencies=[Depends(require_loopback)])
|
||||
@router.get("/pronunciation/export")
|
||||
def export_entries():
|
||||
"""Every entry as a JSON-serializable list (round-trips ``/import``)."""
|
||||
with db_conn() as conn:
|
||||
@@ -273,7 +273,7 @@ def export_entries():
|
||||
]}
|
||||
|
||||
|
||||
@router.post("/pronunciation/import", dependencies=[Depends(require_loopback)])
|
||||
@router.post("/pronunciation/import")
|
||||
def import_entries(req: PronImportRequest):
|
||||
"""Bulk-add entries. ``replace=true`` clears the table first.
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""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: 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.
|
||||
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.
|
||||
|
||||
The state endpoint duplicates `/system/hf-token/state` (which lives on
|
||||
`system.py` for legacy-router compatibility); both return the same shape.
|
||||
@@ -20,14 +19,15 @@ from dataclasses import asdict
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_loopback
|
||||
from core.logging_utils import log_safe
|
||||
from api.dependencies import require_admin, require_admin_action
|
||||
|
||||
logger = logging.getLogger("omnivoice.api.settings")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/settings",
|
||||
tags=["settings"],
|
||||
dependencies=[Depends(require_loopback)],
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
|
||||
|
||||
@@ -92,8 +92,8 @@ def get_hf_token_state(fresh: bool = Query(False)):
|
||||
|
||||
|
||||
# ── Performance settings (INST-12) ────────────────────────────────────────
|
||||
# Threat T-02-04: same loopback guard as the hf-token endpoints via the
|
||||
# router-level `require_loopback` dep.
|
||||
# Threat T-02-04: same admin guard as the hf-token endpoints via the
|
||||
# router-level `require_admin` dep.
|
||||
|
||||
|
||||
_TORCH_COMPILE_KEY = "perf.torch_compile_disabled"
|
||||
@@ -445,16 +445,46 @@ def test_llm_provider(provider_id: str):
|
||||
"reply": reply[:80],
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 — surface a clean, scrubbed error to the UI
|
||||
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)
|
||||
return {
|
||||
"ok": False,
|
||||
"kind": _classify_llm_error(e),
|
||||
"detail": _scrub_llm_detail(e, api_key),
|
||||
**failure,
|
||||
"latency_ms": int((_time.monotonic() - t0) * 1000),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/llm-providers/{provider_id}/models")
|
||||
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)],
|
||||
)
|
||||
def list_llm_provider_models(provider_id: str):
|
||||
"""List model ids the provider's key can access (OpenAI-compat /models).
|
||||
|
||||
@@ -480,10 +510,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,
|
||||
"kind": _classify_llm_error(e),
|
||||
"detail": _scrub_llm_detail(e, api_key),
|
||||
**provider_failure(_classify_llm_error(e)),
|
||||
"models": [],
|
||||
}
|
||||
|
||||
@@ -548,7 +578,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"})
|
||||
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3", "pockettts"})
|
||||
|
||||
|
||||
class _LicenseAcceptBody(BaseModel):
|
||||
@@ -577,8 +607,8 @@ def post_license_acceptance(body: _LicenseAcceptBody) -> dict:
|
||||
from services import settings_store
|
||||
try:
|
||||
settings_store.set_license_accepted(eid, body.accepted)
|
||||
except Exception:
|
||||
logger.exception("set_license_accepted failed for %s", eid)
|
||||
except Exception as exc:
|
||||
logger.error("set_license_accepted failed for %s: %s", log_safe(eid), log_safe(exc))
|
||||
raise HTTPException(status_code=500, detail="Failed to persist license acceptance")
|
||||
return {"ok": True, "engine_id": eid, "accepted": bool(body.accepted)}
|
||||
|
||||
@@ -603,8 +633,8 @@ def get_license_acceptance(engine_id: str) -> dict:
|
||||
from services import settings_store
|
||||
try:
|
||||
accepted = settings_store.get_license_accepted(eid)
|
||||
except Exception:
|
||||
logger.exception("get_license_accepted failed for %s", eid)
|
||||
except Exception as exc:
|
||||
logger.error("get_license_accepted failed for %s: %s", log_safe(eid), log_safe(exc))
|
||||
raise HTTPException(status_code=500, detail="Failed to read license acceptance")
|
||||
return {"engine_id": eid, "accepted": bool(accepted)}
|
||||
|
||||
@@ -636,7 +666,7 @@ def _effective_models_dir() -> str:
|
||||
|
||||
|
||||
class _ModelsDirBody(BaseModel):
|
||||
path: str = Field(default="", description="Absolute directory; empty clears → default cache")
|
||||
authorization: str = Field(description="One-shot native desktop authorization")
|
||||
|
||||
|
||||
@router.get("/storage/models-dir")
|
||||
@@ -665,17 +695,18 @@ def set_models_dir(body: _ModelsDirBody):
|
||||
saved. Returns restart_required=True.
|
||||
"""
|
||||
from core import user_env
|
||||
from core.path_authorization import PathAuthorizationError, consume
|
||||
|
||||
raw = (body.path or "").strip()
|
||||
try:
|
||||
raw = consume(body.authorization, "models_dir").strip()
|
||||
except PathAuthorizationError as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
if not raw:
|
||||
user_env.unset_user_env(_MODELS_DIR_ENV)
|
||||
return {"configured": None, "default": _default_models_dir(), "restart_required": True}
|
||||
|
||||
# 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).
|
||||
# Tauri already validates this before issuing the capability. Keep the
|
||||
# backend checks as defense in depth against a corrupt capability file.
|
||||
if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in raw):
|
||||
raise HTTPException(status_code=400, detail="Path contains invalid control characters")
|
||||
|
||||
@@ -736,7 +767,7 @@ async def get_storage_report(refresh: bool = Query(False)):
|
||||
|
||||
@router.post("/storage/temp/clear")
|
||||
async def clear_temp_files():
|
||||
"""Delete OmniVoice-owned temp files (Settings → Storage → Temporary files).
|
||||
"""Delete VoiceStudio-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
|
||||
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -20,6 +21,7 @@ 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
|
||||
@@ -68,6 +70,13 @@ 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()
|
||||
|
||||
|
||||
def _download_max_workers() -> int:
|
||||
"""Parallel-FILES worker count for snapshot_download (FDL-02). Default 8 —
|
||||
@@ -170,7 +179,7 @@ def _repo_cancelled(repo_id: str) -> bool:
|
||||
return repo_id in _cancelled
|
||||
|
||||
|
||||
def _segmented_snapshot(repo_id: str, *, endpoint: "str | None") -> str:
|
||||
def _segmented_snapshot(repo_id: str, *, endpoint: "str | None", revision: str) -> str:
|
||||
"""Fetch every file of a repo via the segmented downloader into the HF
|
||||
cache, mirroring hf_hub_download's blob+snapshot+refs layout so the result
|
||||
is indistinguishable from snapshot_download (FDL-09) — keeping /models
|
||||
@@ -187,10 +196,10 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None") -> str:
|
||||
|
||||
token = _resolve_token()
|
||||
api = HfApi(endpoint=endpoint, token=token)
|
||||
info = api.repo_info(repo_id, repo_type="model")
|
||||
info = api.repo_info(repo_id, repo_type="model", revision=revision)
|
||||
commit = info.sha
|
||||
files = [s.rfilename for s in (info.siblings or [])]
|
||||
if not commit or not files:
|
||||
if commit != revision 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"))
|
||||
@@ -222,11 +231,23 @@ def _segmented_snapshot(repo_id: str, *, endpoint: "str | None") -> 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(os.path.join(refs_dir, "main"), "w") as f:
|
||||
with open(ref_tmp, "w") as f:
|
||||
f.write(commit)
|
||||
except OSError:
|
||||
pass
|
||||
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
|
||||
return snap_dir
|
||||
|
||||
|
||||
@@ -275,17 +296,19 @@ 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 Settings → Models and install it again."
|
||||
"model in Model Catalogue → Models and install it again."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/setup/download-stream")
|
||||
async def setup_download_stream():
|
||||
async def setup_download_stream(target: str | None = None):
|
||||
"""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:
|
||||
@@ -319,6 +342,7 @@ async def setup_download_stream():
|
||||
|
||||
class InstallModelRequest(BaseModel):
|
||||
repo_id: str
|
||||
target: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -368,6 +392,21 @@ 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)
|
||||
@@ -381,10 +420,15 @@ async def install_model(req: InstallModelRequest):
|
||||
f"Retry in {remaining}s or check your network."
|
||||
),
|
||||
)
|
||||
with _active_installs_lock:
|
||||
if req.repo_id in _active_installs:
|
||||
return {"status": "already_running", "repo_id": req.repo_id}
|
||||
_active_installs.add(req.repo_id)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _do():
|
||||
token = hf_progress.current_repo_id.set(req.repo_id)
|
||||
target_token = hf_progress.current_target.set("local")
|
||||
_cancelled.discard(req.repo_id) # clear any stale cancel from a prior run
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
@@ -407,6 +451,7 @@ 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()
|
||||
@@ -447,11 +492,15 @@ 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, "dry_run": True}
|
||||
_preflight_kwargs = {
|
||||
"repo_id": req.repo_id,
|
||||
"revision": dl_kwargs["revision"],
|
||||
"dry_run": True,
|
||||
}
|
||||
if _endpoint:
|
||||
_preflight_kwargs["endpoint"] = _endpoint
|
||||
try:
|
||||
_plan = snapshot_download(**_preflight_kwargs)
|
||||
_plan = snapshot_download(**_preflight_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
_summary = compute_plan(_plan)
|
||||
# Disk-space guard (before a single byte flows): the preflight
|
||||
# gives an exact "to download" size, so reject an install that
|
||||
@@ -475,6 +524,7 @@ 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"]),
|
||||
)
|
||||
@@ -488,7 +538,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)
|
||||
download_aggregator.start(req.repo_id, target=target or "local")
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
"filename": req.repo_id,
|
||||
@@ -515,7 +565,11 @@ 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)
|
||||
_snapshot_path = _segmented_snapshot(
|
||||
req.repo_id,
|
||||
endpoint=_endpoint,
|
||||
revision=dl_kwargs["revision"],
|
||||
)
|
||||
except _InstallCancelled:
|
||||
raise
|
||||
except Exception as _seg_err:
|
||||
@@ -525,8 +579,11 @@ async def install_model(req: InstallModelRequest):
|
||||
)
|
||||
_snapshot_path = None
|
||||
if _snapshot_path is None:
|
||||
_snapshot_path = snapshot_download(**dl_kwargs)
|
||||
_snapshot_path = snapshot_download(**dl_kwargs) # nosec B615 -- immutable revision_for pin
|
||||
_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
|
||||
@@ -581,7 +638,7 @@ async def install_model(req: InstallModelRequest):
|
||||
# Flush the overall bar to 100% with the true byte total (FDL-06):
|
||||
# under Xet the per-file byte bars don't surface completion, so the
|
||||
# aggregator can sit below 100% even though every file landed.
|
||||
download_aggregator.complete(req.repo_id)
|
||||
download_aggregator.complete(req.repo_id, target=target or "local")
|
||||
logger.info("model install done: %s", req.repo_id)
|
||||
hf_progress.emit({
|
||||
"repo_id": req.repo_id,
|
||||
@@ -625,10 +682,20 @@ async def install_model(req: InstallModelRequest):
|
||||
})
|
||||
finally:
|
||||
_cancelled.discard(req.repo_id)
|
||||
download_aggregator.finish(req.repo_id)
|
||||
download_aggregator.finish(req.repo_id, target=target or "local")
|
||||
hf_progress.current_repo_id.reset(token)
|
||||
hf_progress.current_target.reset(target_token)
|
||||
with _active_installs_lock:
|
||||
_active_installs.discard(req.repo_id)
|
||||
|
||||
loop.create_task(asyncio.to_thread(_do))
|
||||
try:
|
||||
task = loop.create_task(asyncio.to_thread(_do))
|
||||
_install_tasks.add(task)
|
||||
task.add_done_callback(_install_tasks.discard)
|
||||
except Exception:
|
||||
with _active_installs_lock:
|
||||
_active_installs.discard(req.repo_id)
|
||||
raise
|
||||
return {"status": "install_started", "repo_id": req.repo_id}
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,36 @@ 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.
|
||||
|
||||
@@ -100,6 +130,25 @@ def _current_platform_tags() -> list[str]:
|
||||
``rocm`` (AMD HIP builds), and ``cpu`` (no GPU acceleration at all —
|
||||
Apple Silicon is NOT tagged cpu; it curates via ``darwin-arm64``).
|
||||
"""
|
||||
target = _target_host()
|
||||
if target is not None:
|
||||
target_os = {"windows": "win32", "darwin": "darwin"}.get(
|
||||
str(target.get("os") or "").lower(), "linux"
|
||||
)
|
||||
arch = str(target.get("arch") or "").lower()
|
||||
arch = {"amd64": "x86_64", "aarch64": "arm64"}.get(arch, arch)
|
||||
tags = [target_os, f"{target_os}-{arch}"]
|
||||
backend = ""
|
||||
if target.get("gpus"):
|
||||
backend = str(target["gpus"][0].get("backend") or "").lower()
|
||||
if backend:
|
||||
tags.append(backend)
|
||||
if backend == "rocm":
|
||||
tags.append("cuda")
|
||||
if not backend and not (target_os == "darwin" and arch == "arm64"):
|
||||
tags.append("cpu")
|
||||
return tags
|
||||
|
||||
tags = [sys.platform]
|
||||
arch = _platform.machine()
|
||||
tags.append(f"{sys.platform}-{arch}")
|
||||
@@ -238,7 +287,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. OmniVoice's Windows short cache) and
|
||||
``HF_HUB_CACHE``-is-set case, e.g. VoiceStudio'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).
|
||||
"""
|
||||
@@ -455,35 +504,52 @@ def list_models():
|
||||
Uses a 10 s response cache to avoid repeated ``scan_cache_dir()`` disk
|
||||
walks when the frontend polls.
|
||||
"""
|
||||
cached_response = _cached("models")
|
||||
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)
|
||||
if cached_response is not None:
|
||||
return cached_response
|
||||
|
||||
cached_by_repo: dict[str, dict] = {}
|
||||
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()
|
||||
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()
|
||||
|
||||
out = []
|
||||
host_tags = set(_current_platform_tags())
|
||||
host_tags = set(platform_tags)
|
||||
for m in KNOWN_MODELS:
|
||||
cached = cached_by_repo.get(m["repo_id"])
|
||||
on_disk = cached is not None and cached["size_on_disk"] > 0
|
||||
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
|
||||
)
|
||||
# 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 not cache_is_complete(m)
|
||||
incomplete = on_disk and remote_inventory is None and not cache_is_complete(m)
|
||||
out.append({
|
||||
**m,
|
||||
"installed": on_disk and not incomplete,
|
||||
@@ -498,14 +564,14 @@ def list_models():
|
||||
response = {
|
||||
"models": out,
|
||||
"total_installed_bytes": sum(m["size_on_disk_bytes"] for m in out),
|
||||
"hf_cache_dir": hf_cache_dir(),
|
||||
"hf_cache_dir": "" if remote_inventory is not None else 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": round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": _current_platform_tags(),
|
||||
"disk_free_gb": None if remote_inventory is not None else round(disk_free_bytes() / _GIB, 1),
|
||||
"platform_tags": platform_tags,
|
||||
}
|
||||
_set_cache("models", response)
|
||||
_set_cache(cache_key, response)
|
||||
return response
|
||||
|
||||
|
||||
@@ -518,18 +584,19 @@ def recommendations():
|
||||
TTS model is required; the ASR picks here are the optional "best for your
|
||||
system" set the wizard and Settings surface for on-demand install.
|
||||
"""
|
||||
is_mac_arm = sys.platform == "darwin" and _platform.machine() == "arm64"
|
||||
is_mac_intel = sys.platform == "darwin" and _platform.machine() == "x86_64"
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
is_windows = sys.platform == "win32"
|
||||
|
||||
tags = set(_current_platform_tags())
|
||||
target_os = "darwin" if "darwin" in tags else "win32" if "win32" in tags else "linux"
|
||||
target_arch = next((tag.split("-", 1)[1] for tag in tags if tag.startswith(target_os + "-")), _platform.machine())
|
||||
is_mac_arm = target_os == "darwin" and target_arch == "arm64"
|
||||
is_mac_intel = target_os == "darwin" and target_arch == "x86_64"
|
||||
is_linux = target_os == "linux"
|
||||
is_windows = target_os == "win32"
|
||||
has_cuda = "cuda" in tags and "rocm" not in tags
|
||||
has_rocm = "rocm" in tags
|
||||
|
||||
# Device label — used as the card title.
|
||||
if is_mac_arm:
|
||||
device_label = f"Apple Silicon ({_platform.machine()})"
|
||||
device_label = f"Apple Silicon ({target_arch})"
|
||||
elif is_mac_intel:
|
||||
device_label = "macOS Intel (x86_64)"
|
||||
elif is_windows:
|
||||
@@ -537,7 +604,7 @@ def recommendations():
|
||||
elif is_linux:
|
||||
device_label = "Linux x64" + (" + CUDA" if has_cuda else " + ROCm" if has_rocm else "")
|
||||
else:
|
||||
device_label = f"{sys.platform} / {_platform.machine()}"
|
||||
device_label = f"{target_os} / {target_arch}"
|
||||
|
||||
# Curated preset for this host, in catalog order (required entries lead).
|
||||
curated = [
|
||||
@@ -547,51 +614,57 @@ def recommendations():
|
||||
|
||||
if is_mac_arm:
|
||||
rationale = (
|
||||
"Apple Silicon preset: OmniVoice (required) covers multilingual TTS + "
|
||||
"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: OmniVoice (required) runs standalone. Optional ASR picks "
|
||||
"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: OmniVoice (required) runs standalone. CTranslate2 has "
|
||||
"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."
|
||||
)
|
||||
else:
|
||||
rationale = (
|
||||
"CPU preset: OmniVoice (required) runs standalone. Optional picks favour "
|
||||
"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."
|
||||
)
|
||||
|
||||
remote_inventory = _target_repo_inventory()
|
||||
cached_ids: set[str] = set()
|
||||
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())
|
||||
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())
|
||||
|
||||
entries = []
|
||||
for meta in curated:
|
||||
rid = meta["repo_id"]
|
||||
# Mirror /models: a truncated cache (weights missing) is not installed, so
|
||||
# the wizard counts it toward the remaining download instead of "all set".
|
||||
installed = rid in cached_ids and cache_is_complete(meta)
|
||||
installed = rid in cached_ids and (
|
||||
remote_inventory is not None or cache_is_complete(meta)
|
||||
)
|
||||
entries.append({
|
||||
"repo_id": rid,
|
||||
"label": meta.get("label", rid),
|
||||
@@ -607,8 +680,8 @@ def recommendations():
|
||||
|
||||
return {
|
||||
"device": {
|
||||
"os": sys.platform,
|
||||
"arch": _platform.machine(),
|
||||
"os": target_os,
|
||||
"arch": target_arch,
|
||||
"is_mac_arm": is_mac_arm,
|
||||
"is_mac_intel": is_mac_intel,
|
||||
"is_linux": is_linux,
|
||||
|
||||
@@ -183,7 +183,7 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
"""Host/port of the Hugging Face endpoint actually in effect.
|
||||
|
||||
Mirror-aware: restricted-network users (e.g. behind the Great Firewall)
|
||||
point HF_ENDPOINT at a mirror via Settings → Models → Hugging Face
|
||||
point HF_ENDPOINT at a mirror via Model Catalogue → Models → Hugging Face
|
||||
mirror. Probing hardcoded huggingface.co would fail them even when their
|
||||
configured mirror works fine.
|
||||
"""
|
||||
@@ -191,7 +191,8 @@ def _hf_endpoint_host() -> tuple[str, int]:
|
||||
from core.failure import configured_hf_mirror
|
||||
mirror = configured_hf_mirror()
|
||||
except Exception:
|
||||
mirror = ""
|
||||
logger.warning("Configured Hugging Face endpoint could not be read")
|
||||
return "", 0
|
||||
if mirror:
|
||||
try:
|
||||
from urllib.parse import urlsplit
|
||||
@@ -199,7 +200,10 @@ 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:
|
||||
pass
|
||||
logger.warning("Configured Hugging Face endpoint could not be parsed")
|
||||
return "", 0
|
||||
logger.warning("Configured Hugging Face endpoint has no host")
|
||||
return "", 0
|
||||
return "huggingface.co", 443
|
||||
|
||||
|
||||
@@ -272,6 +276,14 @@ 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":
|
||||
@@ -484,10 +496,10 @@ def preflight():
|
||||
elif _rs == "unavailable":
|
||||
r_status, r_detail, r_fix = "fail", (
|
||||
f"{_eng} can't run on this host: {_why or 'needs a GPU this machine lacks'}"), (
|
||||
"Select an engine with a CPU path in Settings → Engines.")
|
||||
"Select an engine with a CPU path in Model Catalogue → Engines.")
|
||||
else: # "none" / unknown
|
||||
r_status, r_detail, r_fix = "warn", "No active TTS engine resolved for routing.", (
|
||||
"Pick an engine in Settings → Engines.")
|
||||
"Pick an engine in Model Catalogue → Engines.")
|
||||
checks.append({
|
||||
"id": "gpu_routing", "label": "Active engine routing",
|
||||
"status": r_status, "detail": r_detail, "fix": r_fix,
|
||||
|
||||
@@ -5,10 +5,11 @@ SoniTranslate sidecar integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.dependencies import require_native_access
|
||||
from services import sonitranslate as soni
|
||||
|
||||
router = APIRouter(prefix="/engines/sonitranslate", tags=["SoniTranslate"])
|
||||
@@ -63,15 +64,15 @@ async def sonitranslate_stop():
|
||||
|
||||
|
||||
class DubRequest(BaseModel):
|
||||
video_path: str
|
||||
video_authorization: str
|
||||
target_language: str = "Spanish (es)"
|
||||
source_language: str = "Automatic detection"
|
||||
tts_voice: str = "es-ES-AlvaroNeural-Male"
|
||||
max_speakers: int = 1
|
||||
output_dir: Optional[str] = None
|
||||
output_authorization: str | None = None
|
||||
|
||||
|
||||
@router.post("/dub")
|
||||
@router.post("/dub", dependencies=[Depends(require_native_access)])
|
||||
async def sonitranslate_dub(body: DubRequest):
|
||||
"""Run full dubbing pipeline via SoniTranslate.
|
||||
|
||||
@@ -81,7 +82,7 @@ async def sonitranslate_dub(body: DubRequest):
|
||||
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. OmniVoice's tensor-stage
|
||||
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
|
||||
@@ -89,15 +90,28 @@ async def sonitranslate_dub(body: DubRequest):
|
||||
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=body.video_path,
|
||||
video_path=video_path,
|
||||
target_language=body.target_language,
|
||||
source_language=body.source_language,
|
||||
tts_voice=body.tts_voice,
|
||||
max_speakers=body.max_speakers,
|
||||
output_dir=body.output_dir,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("SoniTranslate dub failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -39,7 +39,7 @@ async def stories_encode(
|
||||
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 OmniVoice generations is already marked at its producing route, and
|
||||
from VoiceStudio generations is already marked at its producing route, and
|
||||
the AudioSeal mark survives the lossy encode here.
|
||||
"""
|
||||
fmt = (format or "mp3").lower()
|
||||
|
||||
+104
-58
@@ -11,27 +11,28 @@ 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 require_loopback
|
||||
from api.dependencies import is_loopback, require_admin, require_admin_action
|
||||
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 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
|
||||
# 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
|
||||
# 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.
|
||||
# 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)])
|
||||
# Native Tauri/dev callers remain loopback and need no credential.
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
logger = logging.getLogger("omnivoice.api")
|
||||
|
||||
# Cache device checks at module load — they don't change at runtime
|
||||
@@ -266,7 +267,7 @@ def system_info():
|
||||
"backend_port": network_share.backend_port(),
|
||||
"share_port_base": network_share.share_port_base(),
|
||||
"ui_port": _ui_port(),
|
||||
"error": str(e),
|
||||
"error": "System information is temporarily unavailable; check the backend log for details.",
|
||||
}
|
||||
|
||||
|
||||
@@ -288,7 +289,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/OmniVoice` falling
|
||||
`~/Library/Logs/OmniVoice` (macOS), `$XDG_STATE_HOME/VoiceStudio` falling
|
||||
back to `~/.local/state/OmniVoice` (Linux), and
|
||||
`%LOCALAPPDATA%\\OmniVoice\\Logs` (Windows). This is where uvicorn
|
||||
startup banners and hard-crash tracebacks land — keep all three OS
|
||||
@@ -299,7 +300,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, "OmniVoice Studio.log"),
|
||||
os.path.join(home, "Library/Logs", bid, "VoiceStudio.log"),
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend.log"),
|
||||
os.path.join(home, "Library/Logs/OmniVoice/backend_err.log"),
|
||||
]
|
||||
@@ -363,7 +364,14 @@ 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:
|
||||
return {"lines": [], "path": p, "exists": True, "error": str(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": None, "exists": False, "candidates": candidates}
|
||||
|
||||
|
||||
@@ -392,13 +400,18 @@ 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 = 0
|
||||
try:
|
||||
last_pos = os.path.getsize(path)
|
||||
except Exception:
|
||||
pass
|
||||
last_pos = initial_position
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
@@ -453,8 +466,12 @@ async def clear_system_logs():
|
||||
for key in ("crash_log_acked", "crash_log_acked_size"):
|
||||
try:
|
||||
prefs_delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
return {"cleared": cleared_any}
|
||||
|
||||
|
||||
@@ -468,14 +485,20 @@ 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 Exception:
|
||||
pass
|
||||
return {"cleared": cleared}
|
||||
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}
|
||||
|
||||
@router.get("/sysinfo", response_model=SysinfoResponse)
|
||||
def get_sys_info():
|
||||
@@ -521,9 +544,10 @@ async def flush_memory(unload_model: bool = False):
|
||||
if unload_model:
|
||||
import services.model_manager as mm
|
||||
async with mm._model_lock:
|
||||
if mm.model is not None:
|
||||
mm.model = None
|
||||
freed_model = True
|
||||
# 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()
|
||||
|
||||
# Multi-pass GC to break reference cycles
|
||||
gc.collect(generation=2)
|
||||
@@ -532,15 +556,25 @@ async def flush_memory(unload_model: bool = False):
|
||||
|
||||
free_vram()
|
||||
|
||||
# Snapshot after flush
|
||||
# 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.
|
||||
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
|
||||
|
||||
@@ -551,6 +585,7 @@ 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),
|
||||
}
|
||||
|
||||
|
||||
@@ -652,7 +687,7 @@ def system_notifications():
|
||||
"id": "disk-low",
|
||||
"level": "warn",
|
||||
"title": f"Low disk space ({free_gb:.1f} GB free)",
|
||||
"message": "OmniVoice needs disk space for models, audio, and temp files.",
|
||||
"message": "VoiceStudio needs disk space for models, audio, and temp files.",
|
||||
"action": None,
|
||||
})
|
||||
except Exception:
|
||||
@@ -705,7 +740,7 @@ def system_notifications():
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
@@ -728,7 +763,7 @@ def system_notifications():
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
logger.warning("Previous-session crash log could not be checked")
|
||||
|
||||
return {"notifications": notes, "count": len(notes)}
|
||||
|
||||
@@ -806,7 +841,6 @@ 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",
|
||||
@@ -843,7 +877,7 @@ async def set_env_var(body: dict):
|
||||
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_loopback)]` on
|
||||
at the router level via `dependencies=[Depends(require_admin)]` 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.
|
||||
"""
|
||||
@@ -858,23 +892,6 @@ 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.
|
||||
@@ -892,7 +909,7 @@ async def set_env_var(body: dict):
|
||||
detail=f"Invalid port for {key}: must be between 1024 and 65535.",
|
||||
)
|
||||
os.environ[key] = value
|
||||
logger.info("Set environment variable: %s (length=%d)", key, len(value))
|
||||
logger.info("Environment variable set (length=%d)", len(value))
|
||||
|
||||
# Capability 1 / issue #35: HF_TOKEN persists across restarts via
|
||||
# huggingface_hub.login() — writes the token to $HF_HOME/token so
|
||||
@@ -907,10 +924,10 @@ 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", e)
|
||||
logger.warning("Could not persist HF token to disk: %s", log_safe(e))
|
||||
else:
|
||||
os.environ.pop(key, None)
|
||||
logger.info("Cleared environment variable: %s", key)
|
||||
logger.info("Environment variable cleared")
|
||||
|
||||
# Mirror the persistence on clear — wipe the saved token file too.
|
||||
if key == "HF_TOKEN":
|
||||
@@ -985,18 +1002,26 @@ 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:
|
||||
await run_ffmpeg(
|
||||
rc, _, _ = await run_ffmpeg(
|
||||
[ffmpeg, "-y", "-i", clean_path, "-ar", "24000", "-ac", "1", final_path],
|
||||
timeout=120.0,
|
||||
)
|
||||
conversion_fallback = rc != 0
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if not os.path.exists(final_path):
|
||||
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):
|
||||
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={"X-Clean-Filename": clean_filename})
|
||||
headers=headers)
|
||||
|
||||
|
||||
@router.get("/system/asr-backends")
|
||||
@@ -1064,7 +1089,10 @@ async def diagnostic_bundle(network: bool = Query(False, description="Include th
|
||||
# ── Self-check diagnostics ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/system/diagnose")
|
||||
@router.get(
|
||||
"/system/diagnose",
|
||||
dependencies=[Depends(require_admin_action)],
|
||||
)
|
||||
async def system_diagnose(
|
||||
network: bool = Query(True, description="Include the HuggingFace hub reachability probe"),
|
||||
deep: bool = Query(False, description="Also load the active engine and synthesize a short utterance (may cold-load the model — minutes on first run)"),
|
||||
@@ -1101,12 +1129,21 @@ def quarantine_status():
|
||||
# ── Network sharing (loopback-only control surface) ──────────────────────────
|
||||
|
||||
@router.get("/system/network/state")
|
||||
async def network_state():
|
||||
async def network_state(request: Request):
|
||||
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,
|
||||
"pin": st.pin if may_reveal_pin else None,
|
||||
"pin_required": bool(st.pin),
|
||||
"lan_addresses": st.lan_addresses,
|
||||
}
|
||||
|
||||
@@ -1137,7 +1174,16 @@ async def tailscale_status():
|
||||
|
||||
@router.post("/system/tailscale/enable")
|
||||
async def tailscale_enable():
|
||||
return _tailscale.serve_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}
|
||||
|
||||
|
||||
@router.post("/system/tailscale/disable")
|
||||
|
||||
@@ -21,13 +21,16 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, Depends, 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()
|
||||
@@ -40,7 +43,7 @@ class ProbeReq(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
@router.post("/tools/probe")
|
||||
@router.post("/tools/probe", dependencies=[Depends(require_native_access)])
|
||||
async def probe(req: ProbeReq):
|
||||
target = os.path.realpath(os.path.expanduser(req.path))
|
||||
if not os.path.exists(target):
|
||||
@@ -174,18 +177,26 @@ 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 = os.path.join(DUB_DIR, job_id, "source.mp4")
|
||||
if not os.path.exists(video_path):
|
||||
video_path = job.get("video_path", "")
|
||||
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": {}}
|
||||
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
if not video_path.is_file():
|
||||
return {"error": "Source video not found", "segments": {}}
|
||||
|
||||
segments = job.get("segments") or []
|
||||
ctx = await analyse_video(video_path, segments)
|
||||
ctx = await analyse_video(str(video_path), segments)
|
||||
return ctx.to_dict()
|
||||
|
||||
@@ -62,6 +62,11 @@ 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
|
||||
@@ -83,6 +88,43 @@ async def ws_tts(websocket: WebSocket):
|
||||
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 (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Watermark detection API — upload audio, check if it was generated by OmniVoice.
|
||||
Watermark detection API — upload audio, check if it was generated by VoiceStudio.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
@@ -9,6 +9,7 @@ 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")
|
||||
|
||||
@@ -18,7 +19,7 @@ router = APIRouter()
|
||||
@router.post("/watermark/detect")
|
||||
async def detect_audio_watermark(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload an audio file and check whether it contains an OmniVoice watermark.
|
||||
Upload an audio file and check whether it contains a VoiceStudio watermark.
|
||||
|
||||
Returns confidence score, decoded message, and source attribution.
|
||||
"""
|
||||
@@ -49,8 +50,14 @@ async def detect_audio_watermark(file: UploadFile = File(...)):
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Watermark detection failed")
|
||||
raise HTTPException(status_code=500, detail=str(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
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
"""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 logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from api.dependencies import require_admin
|
||||
from worker import registry, routing, service
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
# A rejoin replaces a working enrollment. Keep enough to put it back:
|
||||
# pinning the new certificate overwrites the old one on disk, so a
|
||||
# failed rejoin would otherwise leave the machine unable to reconnect
|
||||
# to the control plane it was already serving.
|
||||
previous = worker_agent.snapshot_enrollment()
|
||||
await worker_agent.agent.stop()
|
||||
try:
|
||||
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()
|
||||
except Exception as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
await worker_agent.agent.stop()
|
||||
await worker_agent.restore_enrollment(previous)
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
worker_agent.agent.last_error = ""
|
||||
# Persisted only after the join actually worked: a machine that failed
|
||||
# to enrol must not come back up trying again forever.
|
||||
worker_agent.set_worker_mode_enabled(True)
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@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:
|
||||
if request.enabled:
|
||||
try:
|
||||
await worker_agent.agent.start()
|
||||
await worker_agent.agent.wait_until_registered()
|
||||
except Exception as exc:
|
||||
worker_agent.agent.last_error = str(exc)
|
||||
await worker_agent.agent.stop()
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
worker_agent.agent.last_error = ""
|
||||
worker_agent.set_worker_mode_enabled(True)
|
||||
else:
|
||||
await worker_agent.agent.stop()
|
||||
worker_agent.set_worker_mode_enabled(False)
|
||||
return worker_agent.agent.status()
|
||||
|
||||
|
||||
@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.",
|
||||
)
|
||||
token = service.control_plane.create_enrollment(
|
||||
endpoint=request.endpoint, label=request.label, ttl_seconds=request.ttl_seconds
|
||||
)
|
||||
return {
|
||||
"token": token.encode(),
|
||||
"endpoint": token.endpoint,
|
||||
"fingerprint": token.cert_fingerprint,
|
||||
"expires_at": token.expires_at,
|
||||
"shown_once": True,
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{worker_id}")
|
||||
def update_worker(worker_id: str, request: WorkerUpdate) -> dict:
|
||||
worker = registry.get(worker_id)
|
||||
if worker is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
if request.name is not None:
|
||||
registry.rename(worker_id, request.name)
|
||||
if request.enabled is not None:
|
||||
registry.set_enabled(worker_id, request.enabled)
|
||||
if request.priority is not None:
|
||||
registry.set_priority(worker_id, request.priority)
|
||||
updated = registry.get(worker_id)
|
||||
# Keep the live copy in step, so the scheduler and its logs do not go on
|
||||
# using the name or priority this worker had when it connected.
|
||||
if updated is not None and service.control_plane.running:
|
||||
service.control_plane.pool.refresh_record(updated)
|
||||
return updated.to_dict() if updated else {}
|
||||
|
||||
|
||||
@router.post("/{worker_id}/consent")
|
||||
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")
|
||||
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}")
|
||||
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.
|
||||
"""
|
||||
if registry.get(worker_id) is None:
|
||||
raise HTTPException(status_code=404, detail="No such worker.")
|
||||
registry.revoke(worker_id)
|
||||
if service.control_plane.running:
|
||||
service.control_plane.scheduler.on_disconnected(worker_id)
|
||||
service.control_plane.pool.breakers.forget_worker(worker_id)
|
||||
return {"ok": True, "revoked": worker_id}
|
||||
|
||||
|
||||
@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:
|
||||
task = scheduler.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,
|
||||
)
|
||||
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."
|
||||
),
|
||||
)
|
||||
if request.bind:
|
||||
inbound_service.set_bind_host(request.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
|
||||
|
||||
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."
|
||||
),
|
||||
)
|
||||
issued = inbound_service.node.keys.issue(request.label)
|
||||
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}")
|
||||
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 inbound_service.node.keys.revoke(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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
await inbound_service.outbound.remove(endpoint)
|
||||
return {"connections": inbound_service.outbound.snapshot()}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
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.
@@ -0,0 +1,3 @@
|
||||
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.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:16,560
|
||||
VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:13,200
|
||||
VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"version": "0.3.0",
|
||||
"rendered_by": "omnivoice engine + ffmpeg showwaves",
|
||||
"rendered_at": "2026-08-12T19:47:29Z",
|
||||
"license": "MIT (synthetic, no third-party IP)",
|
||||
"source": {
|
||||
"code": "en",
|
||||
"label": "English",
|
||||
"video": "source.mp4",
|
||||
"srt": "source.srt",
|
||||
"script": "VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating."
|
||||
},
|
||||
"dubbed": [
|
||||
{
|
||||
"code": "es",
|
||||
"label": "Español",
|
||||
"video": "dubbed_es.mp4",
|
||||
"srt": "dubbed_es.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio es una aplicación de escritorio para clonación de voz, doblaje de vídeo y diseño de voz. Funciona completamente en tu máquina. Sin cuentas, sin nube, sin claves de API. Solo abre la aplicación y comienza a crear."
|
||||
},
|
||||
{
|
||||
"code": "fr",
|
||||
"label": "Français",
|
||||
"video": "dubbed_fr.mp4",
|
||||
"srt": "dubbed_fr.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio est une application de bureau pour le clonage de voix, le doublage vidéo et la conception vocale. Elle fonctionne entièrement sur votre machine. Pas de compte, pas de cloud, pas de clé d'API. Ouvrez l'application et commencez à créer."
|
||||
},
|
||||
{
|
||||
"code": "zh",
|
||||
"label": "中文",
|
||||
"video": "dubbed_zh.mp4",
|
||||
"srt": "dubbed_zh.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudio 是一款桌面应用,用于语音克隆、视频配音和声音设计。它完全在你的电脑上运行。无需账户,无需云端,无需 API 密钥。打开应用即可开始创作。"
|
||||
},
|
||||
{
|
||||
"code": "ja",
|
||||
"label": "日本語",
|
||||
"video": "dubbed_ja.mp4",
|
||||
"srt": "dubbed_ja.srt",
|
||||
"dir": "ltr",
|
||||
"script": "VoiceStudioは、ボイスクローン、ビデオ吹き替え、ボイスデザインのためのデスクトップアプリです。すべてお使いのコンピュータ上で動作します。アカウント、クラウド、APIキーは不要です。アプリを開けば、すぐに制作を始められます。"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:11,400
|
||||
VoiceStudio is a desktop app for voice cloning, video dubbing, and voice design. It runs entirely on your machine. No accounts, no cloud, no API keys. Just open the app and start creating.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
# ── OmniVoice Studio — Model Catalog ─────────────────────────────────────
|
||||
# ── VoiceStudio — Model Catalog ─────────────────────────────────────
|
||||
#
|
||||
# This file is the source of truth for all known HuggingFace models.
|
||||
# The backend loads it at startup via `load_model_catalog()`.
|
||||
@@ -34,7 +34,7 @@ models:
|
||||
# ── Required ──────────────────────────────────────────────────────────
|
||||
|
||||
- repo_id: "k2-fsa/OmniVoice"
|
||||
label: "OmniVoice TTS (600+ languages, zero-shot)"
|
||||
label: "VoiceStudio TTS (k2-fsa/OmniVoice, 600+ languages, zero-shot)"
|
||||
role: TTS
|
||||
size_gb: 2.4
|
||||
required: true
|
||||
@@ -242,6 +242,24 @@ models:
|
||||
size_gb: 0.08
|
||||
curated_on: [all]
|
||||
|
||||
- repo_id: "openbmb/VoxCPM2"
|
||||
label: "VoxCPM2 (30 languages, voice cloning and design)"
|
||||
role: TTS
|
||||
size_gb: 5.0
|
||||
curated_on: [cuda]
|
||||
|
||||
- repo_id: "FunAudioLLM/Fun-CosyVoice3-0.5B-2512"
|
||||
label: "CosyVoice 3 0.5B (multilingual zero-shot)"
|
||||
role: TTS
|
||||
size_gb: 9.8
|
||||
curated_on: [cuda]
|
||||
|
||||
- repo_id: "lj1995/GPT-SoVITS"
|
||||
label: "GPT-SoVITS pretrained weights"
|
||||
role: TTS
|
||||
size_gb: 2.0
|
||||
curated_on: [cuda]
|
||||
|
||||
# ── mlx-audio engines (Apple Silicon only) ────────────────────────────
|
||||
|
||||
- repo_id: "mlx-community/Kokoro-82M-bf16"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Opt-in product analytics — hardened.
|
||||
|
||||
OmniVoice is local-first, so analytics here is held to a higher bar than the
|
||||
VoiceStudio is local-first, so analytics here is held to a higher bar than the
|
||||
usual SDK drop-in. Three rules, each enforced in code below and pinned by tests:
|
||||
|
||||
1. **Off unless the user says yes.** Two independent gates must BOTH be true:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Designed-voice archetype engine for the Voice Gallery.
|
||||
|
||||
This module produces a large catalog of ready-to-use *designed* voices — no
|
||||
real people, no cloning — built entirely from OmniVoice's own voice-design
|
||||
real people, no cloning — built entirely from VoiceStudio's own voice-design
|
||||
taxonomy. Each archetype carries an ``instruct`` string (e.g.
|
||||
``"female, middle-aged, low pitch, british accent"``) that flows straight into
|
||||
``OmniVoice.generate(instruct=...)``.
|
||||
``VoiceStudio.generate(instruct=...)``.
|
||||
|
||||
Two tiers (the "hybrid" gallery model):
|
||||
|
||||
@@ -311,7 +311,7 @@ def _make_featured():
|
||||
# ── Featured: multilingual designed voices ────────────────────────────────────
|
||||
# The voice-design *timbre* axes (gender/age/pitch) are language-independent, and
|
||||
# the spoken language of a designed voice is driven by the preview *text*, not by
|
||||
# the instruct — the same neutral instruct renders in any of OmniVoice's 646
|
||||
# the instruct — the same neutral instruct renders in any of VoiceStudio's 646
|
||||
# languages (the exact ``model.generate(text=…, language=…, instruct=…)`` call
|
||||
# the Generate tab already makes). So we ship a curated set in the major languages
|
||||
# the app already localizes its UI into, giving the gallery more than English +
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""cuDNN 8 side-load, and — the point of this module — a *probe* for it.
|
||||
|
||||
CTranslate2 (pinned at 4.4.0; the engine under WhisperX and faster-whisper)
|
||||
links against **cuDNN 8**, while PyTorch 2.8+ ships cuDNN 9. The two coexist:
|
||||
the Rust bootstrap side-loads ``nvidia-cudnn-cu12==8.9.7.29`` into a
|
||||
``cudnn8_compat/`` directory beside the venv's real site-packages (#827/#869),
|
||||
and we ``ctypes``-preload those libraries at startup so CTranslate2's own
|
||||
``LoadLibrary``/``dlopen`` finds them already resident in the process.
|
||||
|
||||
When that side-load is missing, CTranslate2 does not raise. It prints
|
||||
|
||||
Could not locate cudnn_ops_infer64_8.dll. Please make sure it is in your
|
||||
library path!
|
||||
|
||||
and calls ``__fastfail`` — killing the **whole backend process** with
|
||||
``0xC0000409`` (STATUS_STACK_BUFFER_OVERRUN, surfaced as exit code
|
||||
``-1073740791``). Python never gets a frame, so there is no traceback, no
|
||||
fallback, and nothing for the crash notice to classify. The desktop shell
|
||||
restarts the backend, the user retries, and it dies again — #1371.
|
||||
|
||||
The bug was never that the preload could fail. It is that we **computed the
|
||||
answer and threw it away**: the old inline preload in ``main.py`` silently
|
||||
``pass``-ed on a missing directory and on every ``OSError``, then handed
|
||||
control to a native library that treats the same condition as fatal. So this
|
||||
module keeps the outcome and exposes :func:`ctranslate2_cudnn_status`, letting
|
||||
``asr_backend`` route around a doomed engine *before* calling into it —
|
||||
exactly how the CTranslate2 exec-stack failure (#692) is already handled.
|
||||
|
||||
Deliberately stdlib-only: ``engines/_asr_sidecar/main.py`` runs in a child
|
||||
process with a clean import path and must not drag in the heavy ``services``
|
||||
package to get this.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The library CTranslate2 names in its own failure message. Probing the exact
|
||||
# one the error cites keeps the diagnosis honest — and because the probe runs
|
||||
# in the same process, through the same OS loader, a failure here is the same
|
||||
# failure CTranslate2 is about to hit.
|
||||
_SENTINEL_LIB = (
|
||||
"cudnn_ops_infer64_8.dll" if sys.platform == "win32" else "libcudnn_ops_infer.so.8"
|
||||
)
|
||||
_LIB_GLOB = "cudnn*64_8.dll" if sys.platform == "win32" else "libcudnn*.so.8"
|
||||
|
||||
_preloaded: bool = False
|
||||
_status: tuple[bool, str] | None = None
|
||||
# Handles from os.add_dll_directory (Windows). Held for the process lifetime —
|
||||
# dropping one un-registers its directory. See preload().
|
||||
_dll_dir_cookies: list = []
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
# backend/core/cudnn8.py → backend/core → backend → <project root>
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def compat_dirs() -> list[str]:
|
||||
"""Every plausible ``cudnn8_compat`` location, most specific first.
|
||||
|
||||
The old inline preload hardcoded ``<project root>/.venv``. That is right
|
||||
for the managed desktop install and wrong everywhere else — a differently
|
||||
named venv, ``uv run`` from a checkout, Docker, or a system Python all
|
||||
resolve to a directory that does not exist, so the preload silently did
|
||||
nothing and the process died later anyway. ``sys.prefix`` is where the
|
||||
*running* interpreter actually lives, so it is checked too.
|
||||
"""
|
||||
pyver = f"python{sys.version_info.major}.{sys.version_info.minor}"
|
||||
if sys.platform == "win32":
|
||||
tail = (("Lib", "site-packages"), ("nvidia", "cudnn", "bin"))
|
||||
else:
|
||||
tail = (("lib", pyver, "site-packages"), ("nvidia", "cudnn", "lib"))
|
||||
site_tail, lib_tail = tail
|
||||
|
||||
roots = [os.path.join(_project_root(), ".venv"), sys.prefix]
|
||||
out: list[str] = []
|
||||
for root in roots:
|
||||
path = os.path.join(root, *site_tail, "cudnn8_compat", *lib_tail)
|
||||
if path not in out:
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def preload() -> None:
|
||||
"""Load the side-loaded cuDNN 8 libraries into this process. Idempotent.
|
||||
|
||||
Best-effort by design — a host with no side-load is not an error here, it
|
||||
is a fact :func:`ctranslate2_cudnn_status` reports later.
|
||||
"""
|
||||
global _preloaded
|
||||
if _preloaded:
|
||||
return
|
||||
_preloaded = True
|
||||
if sys.platform == "darwin": # no CUDA on macOS
|
||||
return
|
||||
|
||||
import ctypes
|
||||
|
||||
mode = 0 if sys.platform == "win32" else ctypes.RTLD_GLOBAL
|
||||
for lib_dir in compat_dirs():
|
||||
if not os.path.isdir(lib_dir):
|
||||
continue
|
||||
if sys.platform == "win32":
|
||||
# Make the directory searchable by *name* as well, so a CTranslate2
|
||||
# LoadLibrary for a dependent library we did not preload explicitly
|
||||
# still resolves instead of aborting the process.
|
||||
#
|
||||
# The returned cookie must be KEPT: it is a context manager whose
|
||||
# close/__del__ removes the directory again, so discarding it makes
|
||||
# the call a silent no-op — the exact failure this module exists to
|
||||
# prevent, reintroduced one line lower (CodeRabbit, #1401). Hold it
|
||||
# for the life of the process.
|
||||
try:
|
||||
_dll_dir_cookies.append(os.add_dll_directory(lib_dir))
|
||||
except (OSError, AttributeError) as e:
|
||||
logger.debug("cuDNN 8 DLL directory not added (%s): %s", lib_dir, e)
|
||||
for so in sorted(glob.glob(os.path.join(lib_dir, _LIB_GLOB))):
|
||||
try:
|
||||
ctypes.CDLL(so, mode=mode)
|
||||
except OSError as e:
|
||||
logger.debug("cuDNN 8 preload skipped %s: %s", so, e)
|
||||
return # first directory that exists wins
|
||||
|
||||
|
||||
def _torch_wants_cudnn8() -> tuple[bool, str]:
|
||||
"""Whether CTranslate2 will reach for cuDNN 8 *on this host at all*.
|
||||
|
||||
cuDNN is a CUDA library: with no CUDA device, CTranslate2 runs on the CPU
|
||||
and never touches it, so a missing side-load is harmless and must not
|
||||
disqualify the engine. ROCm is excluded for the same reason and one more —
|
||||
``torch.cuda.is_available()`` is True on a HIP build, but CTranslate2 has
|
||||
no ROCm backend, so it is CPU-only there regardless. Without this branch a
|
||||
Windows/CUDA fix would silently downgrade every ROCm user's ASR engine.
|
||||
Mirrors ``bootstrap.rs::classify_cuda_probe``, which likewise declines to
|
||||
install cuDNN 8 on HIP hosts (#124).
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
except Exception as e: # noqa: BLE001 — no torch: nothing will run anyway
|
||||
return False, f"torch unavailable ({type(e).__name__})"
|
||||
if getattr(getattr(torch, "version", None), "hip", None):
|
||||
return False, "ROCm build — CTranslate2 has no ROCm backend, runs on CPU"
|
||||
try:
|
||||
if not torch.cuda.is_available():
|
||||
return False, "no CUDA device — CTranslate2 runs on CPU"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, f"CUDA probe failed ({type(e).__name__})"
|
||||
return True, "CUDA device present"
|
||||
|
||||
|
||||
def ctranslate2_cudnn_status() -> tuple[bool, str]:
|
||||
"""``(usable, reason)`` — can CTranslate2 load cuDNN 8 in this process?
|
||||
|
||||
Cached: the answer cannot change within a process, and it is consulted on
|
||||
every ASR engine selection.
|
||||
|
||||
Conservative on purpose. A false negative costs a user WhisperX's forced
|
||||
alignment (and so lip-sync accuracy), so this returns True whenever cuDNN 8
|
||||
is not actually required, and only reports False for the one condition it
|
||||
can prove: the library the failure message names will not load, here, now,
|
||||
through the same loader CTranslate2 is about to use.
|
||||
"""
|
||||
global _status
|
||||
if _status is not None:
|
||||
return _status
|
||||
_status = _compute_status()
|
||||
if not _status[0]:
|
||||
logger.warning("CTranslate2 ASR engines unavailable: %s", _status[1])
|
||||
return _status
|
||||
|
||||
|
||||
def _try_load(name: str) -> None:
|
||||
"""Load a shared library by *bare name*, raising OSError if it will not.
|
||||
|
||||
Deliberately its own seam: this runs in the same process and through the
|
||||
same OS loader CTranslate2 is about to use, which is what makes the probe
|
||||
faithful — and it gives tests a way to simulate a missing library without
|
||||
replacing ``ctypes.CDLL`` process-wide (which would break torch's own load).
|
||||
"""
|
||||
import ctypes
|
||||
|
||||
ctypes.CDLL(name)
|
||||
|
||||
|
||||
def _compute_status() -> tuple[bool, str]:
|
||||
# No separate macOS branch: there is no CUDA there, so `_torch_wants_cudnn8`
|
||||
# already answers "not required". One gate, testable on any platform.
|
||||
needed, why = _torch_wants_cudnn8()
|
||||
if not needed:
|
||||
return True, f"cuDNN 8 not required ({why})"
|
||||
|
||||
preload()
|
||||
try:
|
||||
_try_load(_SENTINEL_LIB)
|
||||
except OSError as e:
|
||||
return False, (
|
||||
f"CUDA is active but {_SENTINEL_LIB} cannot be loaded ({e}). "
|
||||
f"WhisperX and faster-whisper are CTranslate2, which requires "
|
||||
f"cuDNN 8; loading it is not optional and its absence aborts the "
|
||||
f"backend process outright rather than raising (#1371). Reinstall "
|
||||
f"the compat libraries with "
|
||||
f"`uv pip install --target <venv>/{'Lib/site-packages' if sys.platform == 'win32' else 'lib/pythonX.Y/site-packages'}/cudnn8_compat "
|
||||
f"nvidia-cudnn-cu12==8.9.7.29`, or pin a non-CTranslate2 engine "
|
||||
f"with OMNIVOICE_ASR_BACKEND=pytorch-whisper."
|
||||
)
|
||||
return True, "cuDNN 8 loadable"
|
||||
|
||||
|
||||
def reset_cache_for_tests() -> None:
|
||||
"""Clear the memoised probe. Tests only."""
|
||||
global _status, _preloaded
|
||||
_status = None
|
||||
_preloaded = False
|
||||
+104
-2
@@ -176,6 +176,108 @@ _BASE_SCHEMA = """
|
||||
created_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_pron_lang ON pronunciation_entries(language);
|
||||
|
||||
-- Remote GPU workers (docs/remote-workers.md). Opt-in: an install with no
|
||||
-- remote workers never writes a row here and behaves exactly as before.
|
||||
--
|
||||
-- `public_key` is the worker's identity — a server-assigned id is a name,
|
||||
-- not proof, so every reconnect is verified against this key. Revocation
|
||||
-- is a persisted fact (not in-memory state) precisely so a restart of the
|
||||
-- control plane cannot silently readmit a worker the user removed.
|
||||
CREATE TABLE IF NOT EXISTS remote_workers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
key_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
revoked_at REAL,
|
||||
priority INTEGER NOT NULL DEFAULT 50,
|
||||
endpoint TEXT NOT NULL DEFAULT '',
|
||||
host_json TEXT NOT NULL DEFAULT '{}',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||
max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
|
||||
-- Bumped on every successful (re)connect. Messages stamped with an
|
||||
-- older epoch are from a session we have already replaced.
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
consent_granted_at REAL,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_workers_key ON remote_workers(key_id);
|
||||
|
||||
-- Single-use join tokens. Only the hash is stored: the plaintext exists
|
||||
-- once, in the dialog that shows it.
|
||||
CREATE TABLE IF NOT EXISTS remote_worker_enrollments (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
secret_hash TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL DEFAULT '',
|
||||
cert_fingerprint TEXT NOT NULL DEFAULT '',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
created_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL,
|
||||
used_at REAL,
|
||||
used_by_worker TEXT
|
||||
);
|
||||
|
||||
-- Tasks dispatched to remote workers. Unlike the local `jobs` table (whose
|
||||
-- startup sweep marks anything in-flight as failed), these must SURVIVE a
|
||||
-- control-plane restart: the desktop app quits while a remote GPU keeps
|
||||
-- rendering, and the worker is the source of truth for what is still
|
||||
-- running. Reconciliation on reconnect rebuilds live state from here.
|
||||
CREATE TABLE IF NOT EXISTS remote_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
-- Client-supplied; deduplicates client retries before the worker
|
||||
-- protocol is involved at all.
|
||||
idempotency_key TEXT,
|
||||
operation TEXT NOT NULL,
|
||||
engine TEXT NOT NULL DEFAULT '',
|
||||
model_id TEXT NOT NULL DEFAULT '',
|
||||
params_json TEXT NOT NULL DEFAULT '{}',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
excluded_json TEXT NOT NULL DEFAULT '[]',
|
||||
error_json TEXT,
|
||||
-- Written BEFORE RESULT_ACK is sent. If the server dies between
|
||||
-- receiving a result and acknowledging it, the worker redelivers and
|
||||
-- this row is what makes the second delivery a no-op instead of a
|
||||
-- silently lost multi-minute render.
|
||||
result_ref TEXT,
|
||||
result_json TEXT,
|
||||
project_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
deadline_at REAL,
|
||||
-- Deliberate additive-reconcile exception to the alembic rule: remote
|
||||
-- task recovery must work in bundled installs where alembic may be
|
||||
-- unavailable, and this nullable affinity column is additive-only.
|
||||
pinned_worker_id TEXT,
|
||||
finished_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_tasks_state ON remote_tasks(state, priority, created_at);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_remote_tasks_idem ON remote_tasks(idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS remote_task_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
session_epoch INTEGER NOT NULL DEFAULT 0,
|
||||
attempt_number INTEGER NOT NULL DEFAULT 1,
|
||||
state TEXT NOT NULL DEFAULT 'assigned',
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
stage TEXT NOT NULL DEFAULT '',
|
||||
error_json TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
accepted_at REAL,
|
||||
started_at REAL,
|
||||
finished_at REAL,
|
||||
lease_expires_at REAL,
|
||||
grace_expires_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
|
||||
"""
|
||||
|
||||
# Only tables/columns this module is allowed to ALTER. Prevents SQL injection via
|
||||
@@ -470,12 +572,12 @@ def _run_alembic_upgrade() -> None:
|
||||
)
|
||||
msg = (
|
||||
f"Database migration failed while running: {exc}. "
|
||||
f"OmniVoice stopped instead of running on a partially migrated database, "
|
||||
f"VoiceStudio stopped instead of running on a partially migrated database, "
|
||||
f"and nothing was auto-restored (your database at {DB_PATH} was left "
|
||||
f"exactly as the failed migration left it). "
|
||||
f"{backup_note}. "
|
||||
"What to do: relaunch to retry; if it keeps failing, report it at "
|
||||
"https://github.com/debpalash/OmniVoice-Studio/issues (keep the backup file). "
|
||||
"https://github.com/debpalash/VoiceStudio/issues (keep the backup file). "
|
||||
"To roll back manually: quit the app, replace omnivoice.db with the backup "
|
||||
"file, and reinstall the previous version."
|
||||
)
|
||||
|
||||
+168
-1
@@ -30,6 +30,7 @@ from __future__ import annotations
|
||||
import functools
|
||||
import os
|
||||
import platform as _platform
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
@@ -111,6 +112,53 @@ def build_arch_list(torch) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
_CUDA_ARCH_TAG = re.compile(r"^(sm|compute)_(\d+)([a-z]?)$")
|
||||
|
||||
|
||||
def cuda_build_covers(arch_list, major: int, minor: int) -> bool:
|
||||
"""Can a torch build compiled for ``arch_list`` run on CC ``major.minor``?
|
||||
|
||||
NOT an exact-tag match, because NVIDIA's compatibility rules are not exact
|
||||
and PyTorch depends on that (#1285):
|
||||
|
||||
* **SASS (``sm_XY``) is binary-compatible upward within a major version** —
|
||||
a cubin built for 8.6 runs on any 8.x device with minor ≥ 6. This is why
|
||||
the official wheels ship ``sm_80``/``sm_86`` and **no ``sm_89``**: the
|
||||
8.6 kernels already cover Ada. An exact-match gate therefore declared
|
||||
every RTX 40-series card (4060…4090, all sm_89) unsupported and
|
||||
force-routed it to CPU, which is exactly what #1285 reported.
|
||||
* **PTX (``compute_XY``) JIT-compiles forward** to any newer architecture,
|
||||
so embedded PTX at or below the device's capability is a valid path.
|
||||
* **An ``a``/``f`` suffix (``sm_90a``) is architecture-SPECIFIC** — those
|
||||
cubins deliberately do not forward-run, so they only count on an exact
|
||||
capability match.
|
||||
|
||||
Unparseable entries are skipped rather than guessed at.
|
||||
"""
|
||||
device_cc = major * 10 + minor
|
||||
for entry in arch_list or ():
|
||||
m = _CUDA_ARCH_TAG.match(str(entry).strip())
|
||||
if not m:
|
||||
continue
|
||||
kind, digits, suffix = m.group(1), m.group(2), m.group(3)
|
||||
try:
|
||||
cc = int(digits)
|
||||
except ValueError:
|
||||
continue
|
||||
e_major, e_minor = divmod(cc, 10)
|
||||
if suffix:
|
||||
# Arch-specific: exact capability only, whatever the kind.
|
||||
if cc == device_cc:
|
||||
return True
|
||||
continue
|
||||
if kind == "sm":
|
||||
if e_major == major and e_minor <= minor:
|
||||
return True
|
||||
elif cc <= device_cc:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def gfx_for_hsa_override(value: str) -> str | None:
|
||||
"""``"11.0.0"`` → ``"gfx1100"``. The inverse of :func:`hsa_override_for`.
|
||||
|
||||
@@ -126,6 +174,110 @@ def gfx_for_hsa_override(value: str) -> str | None:
|
||||
return f"gfx{int(major)}{minor}{step}"
|
||||
|
||||
|
||||
#: The ROCm kernel driver interface. Its absence, or its presence without
|
||||
#: permission, are the two commonest reasons a ROCm host silently runs on CPU.
|
||||
_KFD_DEVICE = "/dev/kfd"
|
||||
|
||||
|
||||
def why_no_gpu(torch) -> tuple[str, ...]:
|
||||
"""Why ``torch.cuda.is_available()`` said no, as user-facing advisories.
|
||||
|
||||
This branch used to produce **nothing** (#1274/#1228). A host with a GPU
|
||||
the app could not use reported "Compute device: cpu / GPU active: no" and
|
||||
stopped there — true, useless, and indistinguishable from a machine that
|
||||
has no GPU at all. Two rounds of back-and-forth per report followed, and
|
||||
the reporter still ended up guessing (numeric ``--group-add`` values
|
||||
copied from another host, an ``HSA_OVERRIDE_GFX_VERSION`` that may or may
|
||||
not have been needed).
|
||||
|
||||
The distinctions worth making are cheap, and the probe already knows them:
|
||||
|
||||
* the wheel has no GPU support compiled in at all — no amount of
|
||||
device-passing or env vars will change that;
|
||||
* it is a ROCm wheel and ``/dev/kfd`` is absent — in a container that is a
|
||||
missing ``--device`` flag, not a driver problem;
|
||||
* ``/dev/kfd`` is there but this process cannot open it — a group
|
||||
membership problem, which is the one that bites hardest in Docker
|
||||
because the ``render``/``video`` GIDs differ between hosts and the
|
||||
numbers are usually copied from somewhere else;
|
||||
* everything is present and the runtime still enumerated nothing — the
|
||||
GPU is likely newer than this build's ROCm.
|
||||
|
||||
Never raises, and returns ``()`` rather than guessing when it cannot tell.
|
||||
"""
|
||||
# Metadata access itself can raise: `torch.version` is a module attribute
|
||||
# on a real torch, but a partially-initialised or shimmed torch-like object
|
||||
# can expose it as a property that throws. This function's contract is that
|
||||
# it never raises — it is called from the diagnostics path, where an
|
||||
# exception would take out the very report meant to explain the problem
|
||||
# (CodeRabbit, #1425).
|
||||
try:
|
||||
version = getattr(torch, "version", None)
|
||||
hip = getattr(version, "hip", None)
|
||||
cuda = getattr(version, "cuda", None)
|
||||
except Exception: # noqa: BLE001 - never raise from a diagnostic
|
||||
return ()
|
||||
|
||||
if not hip and not cuda:
|
||||
# A build with no GPU support compiled in. Deliberately silent: this
|
||||
# is also every macOS wheel (MPS is probed separately, below) and
|
||||
# every CPU Docker image, so a note here would fire on hosts that are
|
||||
# working exactly as intended. The situations worth explaining are the
|
||||
# ones where the build clearly meant to use a GPU and could not.
|
||||
return ()
|
||||
|
||||
if hip:
|
||||
# /dev/kfd only exists on Linux; on any other platform its absence
|
||||
# says nothing, so don't invent a reason.
|
||||
if sys.platform.startswith("linux"):
|
||||
if not os.path.exists(_KFD_DEVICE):
|
||||
return (
|
||||
f"ROCm {hip} is installed but {_KFD_DEVICE} is not "
|
||||
"present — the amdgpu kernel driver isn't loaded, or (in "
|
||||
"Docker) the container was started without "
|
||||
"--device /dev/kfd --device /dev/dri",
|
||||
)
|
||||
if not os.access(_KFD_DEVICE, os.R_OK | os.W_OK):
|
||||
return (
|
||||
f"ROCm {hip} is installed and {_KFD_DEVICE} exists, but "
|
||||
"this process cannot open it — add the groups that own "
|
||||
"it (`ls -l /dev/kfd /dev/dri/render*`; in Docker pass "
|
||||
"--group-add with THAT host's render/video GIDs, which "
|
||||
"differ between machines)",
|
||||
)
|
||||
override = (os.environ.get("HSA_OVERRIDE_GFX_VERSION") or "").strip()
|
||||
if override:
|
||||
# Checked BEFORE blaming the ROCm version, because it is the more
|
||||
# likely cause and the cheaper thing to test. An override remaps
|
||||
# the GPU onto a different architecture, and pointing a natively
|
||||
# supported card at one the runtime cannot match to the physical
|
||||
# agent can leave HSA with no usable agents at all — which is not
|
||||
# "a kernel failed" but "there is no device", exactly what the
|
||||
# #1274 reporter saw. Their card (gfx1151) is natively supported
|
||||
# by the ROCm this image ships, so the override they set is very
|
||||
# likely what hid it.
|
||||
return (
|
||||
f"ROCm {hip} is installed and the device nodes are reachable, "
|
||||
f"but no GPU was enumerated while HSA_OVERRIDE_GFX_VERSION="
|
||||
f"{override} is set. Try removing that override first — this "
|
||||
"ROCm supports most current cards natively, and remapping one "
|
||||
"it already supports can leave the runtime with no usable "
|
||||
"device. VoiceStudio sets the override itself when a card "
|
||||
"genuinely needs it",
|
||||
)
|
||||
return (
|
||||
f"ROCm {hip} is installed and the device nodes are reachable, but "
|
||||
"no GPU was enumerated — most often a card newer than this "
|
||||
"build's ROCm. Check `rocminfo` on the host",
|
||||
)
|
||||
|
||||
return (
|
||||
f"this is a CUDA {cuda} build but no CUDA device was found — the "
|
||||
"NVIDIA driver is missing or too old, or (in Docker) the container "
|
||||
"was started without --gpus all",
|
||||
)
|
||||
|
||||
|
||||
def arch_unsupported(torch) -> tuple[str, tuple[str, ...]] | None:
|
||||
"""``(device_arch, build_archs)`` when device 0's architecture is absent
|
||||
from this torch build's compiled arch list — i.e. kernels cannot launch
|
||||
@@ -186,7 +338,7 @@ def arch_unsupported(torch) -> tuple[str, tuple[str, ...]] | None:
|
||||
# ── CUDA: arch_list holds sm_/compute_ tags ──────────────────────
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
sm_tag = f"sm_{major}{minor}"
|
||||
if sm_tag in arch_list or f"compute_{major}{minor}" in arch_list:
|
||||
if cuda_build_covers(arch_list, major, minor):
|
||||
return None
|
||||
return sm_tag, tuple(arch_list)
|
||||
except Exception:
|
||||
@@ -248,9 +400,11 @@ def _probe() -> HostCaps:
|
||||
|
||||
# ── CUDA / ROCm (both present through torch.cuda) ────────────────────
|
||||
cuda_ok = False
|
||||
cuda_probe_failed = False
|
||||
try:
|
||||
cuda_ok = bool(torch.cuda.is_available())
|
||||
except Exception as exc: # broken CUDA init (forked process / driver crash)
|
||||
cuda_probe_failed = True
|
||||
notes.append(f"CUDA init raised: {type(exc).__name__}")
|
||||
|
||||
if cuda_ok:
|
||||
@@ -287,6 +441,19 @@ def _probe() -> HostCaps:
|
||||
f"build's archs ({', '.join(archs)}) — {KERNEL_RISK_MARKER}"
|
||||
)
|
||||
|
||||
elif not cuda_probe_failed:
|
||||
# A GPU-capable build that found nothing must say why (#1274/#1228).
|
||||
# Silence here is what made "Compute device: cpu" indistinguishable
|
||||
# from a machine with no GPU at all.
|
||||
#
|
||||
# Only when the probe actually completed, though. If
|
||||
# `torch.cuda.is_available()` RAISED we know nothing about the host's
|
||||
# devices, and `why_no_gpu()` would report its findings as fact —
|
||||
# "no CUDA device was found" beside "CUDA init raised", which reads as
|
||||
# a diagnosis when it is an unfinished probe. The exception note above
|
||||
# is the whole truth in that case (CodeRabbit, #1425).
|
||||
notes.extend(why_no_gpu(torch))
|
||||
|
||||
# ── Intel XPU via IPEX ───────────────────────────────────────────────
|
||||
try:
|
||||
import intel_extension_for_pytorch # noqa: F401
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user