Compare commits

...
Author SHA1 Message Date
Palash Debnath 0ac43df89a docs: hand off remote backend recovery investigation 2026-08-11 15:17:01 +00:00
Palash Debnath 283642340b Merge pull request #1494 from debpalash/fix/desktop-prod-appimage-stop
fix: stop extracted AppImage before prod reset
2026-08-11 13:38:40 +00:00
debpalash 8b6b9383f7 test: gate AppImage execution contract to Linux 2026-08-11 13:22:10 +00:00
debpalash 50e9b9795e test: execute AppImage stop before wipe 2026-08-11 13:18:45 +00:00
debpalash c0e1753248 fix: close AppImage termination races 2026-08-11 12:53:03 +00:00
debpalash c16db04f86 fix: make process cleanup explicit 2026-08-11 12:48:25 +00:00
debpalash de9e488238 docs: note AppImage reset fix 2026-08-11 12:43:49 +00:00
debpalash 670d9bc37a fix: stop extracted AppImage before prod reset 2026-08-11 12:43:07 +00:00
Palash Debnath 8af10ea0b9 Merge pull request #1492 from debpalash/fix/multilingual-translate-dropdown
fix(dub): keep large multilingual batches manageable
2026-08-11 05:53:59 +00:00
debpalash 2d43fb5e7f fix(ui): remove decorative multilingual borders 2026-08-11 05:38:13 +00:00
debpalash c10596b02f fix(dub): keep language progress ownership exact 2026-08-11 05:23:28 +00:00
debpalash f34c2cad3e Merge remote-tracking branch 'origin/main' into fix/multilingual-translate-dropdown
# Conflicts:
#	CHANGELOG.md
#	frontend/src/components/MultiLangPicker.jsx
#	frontend/src/components/MultiLangPicker.test.jsx
#	frontend/src/components/dub/DubRightColumn.jsx
#	frontend/src/components/dub/DubRightColumn.test.jsx
#	frontend/src/i18n/locales/ar.json
#	frontend/src/i18n/locales/de.json
#	frontend/src/i18n/locales/en.json
#	frontend/src/i18n/locales/es.json
#	frontend/src/i18n/locales/fr.json
#	frontend/src/i18n/locales/hi.json
#	frontend/src/i18n/locales/id.json
#	frontend/src/i18n/locales/it.json
#	frontend/src/i18n/locales/ja.json
#	frontend/src/i18n/locales/ko.json
#	frontend/src/i18n/locales/nl.json
#	frontend/src/i18n/locales/pl.json
#	frontend/src/i18n/locales/pt.json
#	frontend/src/i18n/locales/ru.json
#	frontend/src/i18n/locales/sv.json
#	frontend/src/i18n/locales/th.json
#	frontend/src/i18n/locales/tr.json
#	frontend/src/i18n/locales/uk.json
#	frontend/src/i18n/locales/vi.json
#	frontend/src/i18n/locales/zh-CN.json
#	frontend/src/i18n/locales/zh-TW.json
#	frontend/src/pages/DubTab.jsx
#	frontend/src/test/dubMultiLangGenerate.test.jsx
#	frontend/src/test/dubMultiLangWorkflow.integration.test.jsx
#	frontend/src/utils/multiLang.js
2026-08-11 05:14:34 +00:00
debpalash 794756e601 fix(dub): scale multilingual batches cleanly 2026-08-11 05:11:42 +00:00
Palash Debnath 7053f1d1c0 fix: restore pre-release version 0.4.2 (#1488)
Release remains paused until the owner explicitly requests the next version bump.
2026-08-11 04:46:39 +00:00
debpalash bb5f7acbe5 docs: keep rollback under Unreleased fixes 2026-08-11 04:29:56 +00:00
debpalash fb0d62e0c1 fix: restore pre-release version 0.4.2 2026-08-11 04:28:38 +00:00
debpalash ae247fa493 docs: record 0.5.0 correction 2026-08-11 04:19:15 +00:00
debpalash b68d1c68de fix: correct VoiceStudio release to 0.5.0 2026-08-11 04:18:51 +00:00
Palash Debnath 04410a458d Release VoiceStudio 5.0.0 (#1487)
Complete the VoiceStudio identity, release documentation, assets, version mirrors, and safe cross-platform development startup.
2026-08-11 04:05:45 +00:00
Palash Debnath 3df44486b2 Fix multilingual dubbing workflow and clipped language picker (#1486)
* fix(dub): complete multilingual translation workflow

* docs: note multilingual dubbing fix

* fix(dub): serialize multilingual batch actions

* fix(dub): preserve multilingual batch context
2026-08-11 02:43:51 +00:00
debpalash 9b73571497 fix(dub): preserve multilingual batch context 2026-08-11 02:28:20 +00:00
debpalash c5ab4310bc fix(dub): serialize multilingual batch actions 2026-08-11 01:50:41 +00:00
debpalash 54afcfb758 Merge remote-tracking branch 'origin/main' into fix/multilingual-translate-dropdown 2026-08-11 01:41:56 +00:00
Palash Debnath 95a35b8e07 feat(indextts): add native IndexTTS 2.5 support (#1485)
* feat(indextts): add native 2.5 sidecar support

* fix(indextts): preserve legacy language metadata

* docs(indextts): state model license terms accurately

* fix: preserve IndexTTS upgrades and duration controls

* fix: complete IndexTTS upgrade safeguards
2026-08-11 01:26:25 +00:00
debpalash 412740cbe5 docs: note multilingual dubbing fix 2026-08-11 01:02:44 +00:00
debpalash 34ff83a04a fix(dub): complete multilingual translation workflow 2026-08-11 01:01:57 +00:00
Palash Debnath 581c51662b fix(dub): restore cast voices from source audio (#1484)
* fix(dub): restore cast voices from source audio

* docs: record source-audio cast repair

* fix(dub): honor explicit cross-speaker cast

* fix(dub): sanitize restored cast metadata

* fix(dub): preserve legacy per-line cast refs
2026-08-10 23:46:37 +00:00
Palash Debnath 7cde2fcd06 fix(desktop): make recording and dubbing reliable (#1481)
* fix(ui): keep scaled desktop shell responsive

* fix(linux): support desktop microphone capture

* fix(ui): update the centered VoiceStudio brand

* fix(audio): fall back when recorder start is unsupported

* fix(desktop): use the app header as titlebar

* feat(audio): add live microphone input controls

* fix(dub): recover from missing transcription models

* fix(dub): make pipeline stages actionable

* fix(asr): recover low-memory transcription

* docs: record desktop reliability fixes

* fix(ui): use semantic error banner border

* fix(dub): harden recovery and recording fallbacks
2026-08-10 22:43:37 +00:00
Palash Debnath e6728cf068 Merge pull request #1457 from debpalash/fix/ghas-log-safety
fix(security): make untrusted log values single-line
2026-08-10 14:15:41 +00:00
debpalash 3e292ba831 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety 2026-08-10 13:57:08 +00:00
debpalash 6db15aa556 Merge pull request #1442 from paoloantinori/feat/pockettts-deferred 2026-08-10 13:40:27 +00:00
debpalash a2745f1029 test: assert fixed-shape secret log record 2026-08-10 13:27:15 +00:00
debpalash 8b710a380b Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 13:24:54 +00:00
debpalash c4d241a15b Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
2026-08-10 13:20:38 +00:00
Palash Debnath 53fcbd3316 Merge pull request #1459 from debpalash/fix/ghas-empty-except-p2
fix: make degraded backend state observable
2026-08-10 13:04:17 +00:00
debpalash 6673190854 test(ci): scan both workflow extensions 2026-08-10 12:50:00 +00:00
debpalash 32d9a8a964 fix: tighten log safety regressions 2026-08-10 12:49:42 +00:00
debpalash 88906fb23f fix: clear cancelled share runtime 2026-08-10 12:49:34 +00:00
debpalash 1437d37096 test(ci): reject mutable action references 2026-08-10 12:25:43 +00:00
debpalash b90d6169d0 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
#	backend/api/routers/system.py
2026-08-10 12:25:33 +00:00
debpalash 794942faf4 Merge current main into GHAS empty-except fixes 2026-08-10 12:17:43 +00:00
debpalash 603402afce Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 12:17:41 +00:00
Palash Debnath 27a8f477b7 fix(security): keep private diagnostics out of API responses (#1454)
* fix(security): keep private diagnostics out of API responses

* docs: reference response-safety PR

* fix(security): preserve constant recovery guidance

* fix(security): keep recovery and logs data-independent

* fix(security): close remaining response sinks

* test(security): keep SOCKS diagnostics private

* fix: keep Tailscale exceptions local

* fix: keep Tailscale CLI output private
2026-08-10 12:01:12 +00:00
debpalash f45e8ecc07 fix: keep secret-store failures fixed-shape 2026-08-10 11:58:06 +00:00
debpalash 53794d73ec Merge current main into GHAS empty-except fixes 2026-08-10 11:44:50 +00:00
debpalash a1a7d25342 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
2026-08-10 11:44:44 +00:00
debpalash fd2e39d5c0 Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 11:44:36 +00:00
Palash Debnath 7a78573a83 fix(security): constrain GPT-SoVITS endpoints (#1463)
* fix(security): constrain GPT-SoVITS endpoints

* docs: note trusted GPT-SoVITS transport

* fix(security): preserve trusted endpoint authority

* test(security): patch live outbound transport

* test: load outbound security seam at runtime
2026-08-10 11:29:42 +00:00
debpalash 8c241bfbbb fix: validate realtime event dispatch 2026-08-10 11:23:22 +00:00
debpalash e086cb03f1 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
2026-08-10 11:19:40 +00:00
debpalash 9ae554f0ac Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 11:19:14 +00:00
debpalash 40662aeb06 fix: close log safety review gaps 2026-08-10 11:19:12 +00:00
debpalash 74367e4bbf Merge current main into GHAS empty-except fixes 2026-08-10 11:13:10 +00:00
Palash Debnath 38a00cbf30 fix(security): stabilize engine discovery metadata (#1460)
* fix(security): stabilize engine discovery metadata

* fix(security): preserve stable routing outcomes

* fix: preserve safe engine routing outcomes
2026-08-10 10:57:54 +00:00
debpalash 72ce9b79f2 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
#	backend/api/routers/batch.py
2026-08-10 10:54:04 +00:00
debpalash 7aed875fb3 Merge current main into GHAS empty-except fixes 2026-08-10 10:45:13 +00:00
debpalash f21b76350b Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 10:41:24 +00:00
Palash Debnath c716b05aa9 Merge pull request #1458 from debpalash/fix/ghas-empty-except
fix(security): fail closed on cleanup and redaction errors
2026-08-10 10:26:33 +00:00
debpalash 27fcaabf0a Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except
# Conflicts:
#	CHANGELOG.md
2026-08-10 10:03:52 +00:00
debpalash bdcd3923eb fix: preserve fixed-shape dub security logs 2026-08-10 09:58:05 +00:00
debpalash 047e7d901e Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
#	backend/api/routers/dub_export.py
#	backend/api/routers/marketplace.py
#	backend/services/ffmpeg_utils.py
#	backend/services/sonitranslate.py
2026-08-10 09:52:56 +00:00
Palash Debnath 8a58e30a8f Merge pull request #1455 from debpalash/fix/ghas-path-boundary
fix(security): enforce filesystem trust boundaries
2026-08-10 09:36:07 +00:00
debpalash 4e401360ca fix: centralize LAN share runtime state 2026-08-10 09:29:42 +00:00
debpalash 9f2c8ac0a4 fix(pockettts): close license review findings 2026-08-10 09:24:37 +00:00
debpalash 9aa82f3fd5 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
2026-08-10 09:22:30 +00:00
debpalash c1a1839911 Merge main and address degraded-state review findings 2026-08-10 09:21:54 +00:00
debpalash 586650e326 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-10 09:21:17 +00:00
debpalash f991709dd9 Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except
# Conflicts:
#	CHANGELOG.md
2026-08-10 09:20:48 +00:00
debpalash c1793dac41 Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 09:20:35 +00:00
Palash Debnath 657d633940 Merge pull request #1464 from debpalash/fix/appimage-apprun-injection
fix(appimage): ship the compatibility launcher
2026-08-10 09:04:56 +00:00
debpalash 9b71eb5e46 fix: close remaining dub export path sinks 2026-08-10 08:57:27 +00:00
debpalash 77d6507318 fix(pockettts): recheck consent after synthesis queue 2026-08-10 08:55:24 +00:00
debpalash 1f875fa392 fix: report degraded operation state truthfully 2026-08-10 08:53:17 +00:00
debpalash 360ddf2152 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:52:10 +00:00
debpalash 14b00f681c Merge remote-tracking branch 'origin/main' into codex/pr-1464
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:48:52 +00:00
debpalash 44190389a7 Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except-p2
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:48:42 +00:00
debpalash a540354cb7 Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 08:48:33 +00:00
Palash Debnath dd2c306bc4 Merge pull request #1456 from debpalash/fix/dependabot-security
fix(security): raise dependency advisory floors
2026-08-10 08:30:01 +00:00
debpalash 7a7dc422a6 fix: keep degraded state truthful 2026-08-10 08:22:37 +00:00
debpalash 5a043538b8 Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except-p2
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:17:59 +00:00
debpalash b3fc23316e fix(security): authorize native reveal targets 2026-08-10 08:17:11 +00:00
debpalash 566f822260 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:10:23 +00:00
debpalash 252c6fd149 Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 08:09:29 +00:00
debpalash 820817ad68 Merge remote-tracking branch 'origin/main' into codex/pr-1464
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:04:56 +00:00
debpalash 30cb1ea3e6 Merge remote-tracking branch 'origin/main' into fix/dependabot-security
# Conflicts:
#	CHANGELOG.md
2026-08-10 08:04:47 +00:00
Palash Debnath d0909dcabe Merge pull request #1453 from debpalash/fix/ghas-hf-revisions
fix(security): pin curated Hugging Face model revisions
2026-08-10 07:42:24 +00:00
debpalash c2da73c5c2 fix(pockettts): enforce consent on every synthesis 2026-08-10 07:36:32 +00:00
debpalash d664c93140 test: isolate destructive cleanup imports 2026-08-10 07:33:49 +00:00
debpalash aeda504a03 fix(security): authorize export filesystem sinks 2026-08-10 07:32:50 +00:00
debpalash 180d13c59e fix(pockettts): enforce consent at construction 2026-08-10 07:32:35 +00:00
debpalash 722a7a7e57 fix(security): make control filtering boundaries explicit 2026-08-10 07:30:08 +00:00
debpalash 657dfe5ac6 fix(security): pin NLLB model revision 2026-08-10 07:27:43 +00:00
debpalash 501450e8ec Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-10 07:21:53 +00:00
debpalash 0c5be01e39 Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except-p2
# Conflicts:
#	CHANGELOG.md
2026-08-10 07:18:12 +00:00
debpalash 5050c7da2b test(security): derive public analytics allowlist 2026-08-10 07:12:56 +00:00
debpalash 44bfc94c49 Merge remote-tracking branch 'origin/main' into fix/ghas-log-safety
# Conflicts:
#	CHANGELOG.md
#	backend/api/routers/dub_core.py
#	backend/api/routers/settings.py
#	backend/services/speech_rate.py
2026-08-10 07:12:18 +00:00
debpalash 2cd0fd2972 Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except
# Conflicts:
#	CHANGELOG.md
2026-08-10 07:07:16 +00:00
debpalash 7cc173d8be fix: repair the resolved Hugging Face cache 2026-08-10 07:04:42 +00:00
debpalash 4259806d02 Merge remote-tracking branch 'origin/main' into codex/pr-1464
# Conflicts:
#	CHANGELOG.md
2026-08-10 07:04:10 +00:00
debpalash 363f71feca test(security): scope gitleaks false positives 2026-08-10 07:03:31 +00:00
debpalash fe29fe0966 Merge remote-tracking branch 'origin/main' into fix/ghas-hf-revisions
# Conflicts:
#	CHANGELOG.md
2026-08-10 07:01:42 +00:00
debpalash 1c289028d5 Merge remote-tracking branch 'origin/main' into fix/dependabot-security 2026-08-10 07:01:29 +00:00
debpalash 405888a21b Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary 2026-08-10 07:01:22 +00:00
Palash Debnath f4c33f2f06 Merge pull request #1422 from debpalash/fix/1406-corrupt-weights
fix(models): repair a weight file that arrived damaged, not just a missing one (#1406)
2026-08-10 06:45:22 +00:00
debpalash 2fb0feae1a Merge main before landing corrupt-cache recovery 2026-08-10 06:28:57 +00:00
debpalash d1548f5f07 Merge remote-tracking branch 'origin/main' into fix/dependabot-security 2026-08-10 06:28:10 +00:00
debpalash 4d2595eb00 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary 2026-08-10 06:28:06 +00:00
Palash Debnath c7a2de9ff5 Merge pull request #1443 from debpalash/fix/1274-docker-python-runtime
fix(docker): run ROCm backend with the validated Python (#1274)
2026-08-10 06:08:22 +00:00
debpalash 193d3b6dd3 Merge remote-tracking branch 'origin/main' into fix/dependabot-security
# Conflicts:
#	CHANGELOG.md
2026-08-10 05:57:35 +00:00
debpalash 321823bc52 fix(security): bind relocated artifacts to their job 2026-08-10 05:52:41 +00:00
debpalash 9ee30766db Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-10 05:52:12 +00:00
debpalash 1a71fbea72 Merge remote-tracking branch 'origin/main' into fix/1274-docker-python-runtime 2026-08-10 05:52:09 +00:00
Palash Debnath 6b1ae60cb5 Merge pull request #1445 from debpalash/fix/1429-youtube-cookie-file
fix(dub): let signed-in YouTube imports use an explicit cookie export
2026-08-10 05:06:46 +00:00
debpalash e8eff3c6a0 fix(security): bound native tool authorization 2026-08-10 05:01:26 +00:00
debpalash 2a81d76182 Merge remote-tracking branch 'origin/main' into fix/dependabot-security
# Conflicts:
#	CHANGELOG.md
2026-08-10 05:01:05 +00:00
debpalash f70199db32 fix(security): make path containment explicit to analysis 2026-08-10 04:56:42 +00:00
debpalash 30d073d2dd Merge main and tighten tokenizer recovery regression 2026-08-10 04:55:58 +00:00
debpalash f8f992ed9c Merge main and address Docker review findings 2026-08-10 04:53:37 +00:00
debpalash cd48ba5537 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-10 04:52:38 +00:00
debpalash fef07cee13 Merge remote-tracking branch 'origin/main' into fix/1429-youtube-cookie-file
# Conflicts:
#	CHANGELOG.md
2026-08-10 04:52:36 +00:00
debpalash c4f9e787df fix(security): bind native paths to picker capabilities 2026-08-10 04:37:45 +00:00
Palash Debnath 6ec3cd30fc Merge pull request #1444 from debpalash/fix/1438-uv-bootstrap
fix(bootstrap): recover when uv installer exits after extraction
2026-08-10 04:37:20 +00:00
debpalash 7a87e5b5cd fix: keep cleanup logs injection-safe 2026-08-10 04:34:52 +00:00
debpalash 654da87ee4 Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except 2026-08-10 04:33:54 +00:00
debpalash 68029b40cb Merge remote-tracking branch 'origin/main' into fix/ghas-empty-except
# Conflicts:
#	CHANGELOG.md
2026-08-10 04:33:50 +00:00
debpalash 90f5a4fd1a Merge remote-tracking branch 'origin/main' into codex/pr-1464
# Conflicts:
#	CHANGELOG.md
2026-08-10 04:32:39 +00:00
debpalash ed03ab6b07 fix: clear dub credentials on reset 2026-08-10 04:32:39 +00:00
debpalash bbe9496bc6 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary 2026-08-10 04:32:31 +00:00
debpalash c2b9951f28 Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
#	backend/core/path_authorization.py
#	frontend/src-tauri/src/commands.rs
#	tests/test_network_share.py
2026-08-10 04:32:23 +00:00
debpalash fb78d87ad9 test(appimage): verify packaged WebKit marker 2026-08-10 04:32:08 +00:00
debpalash 705544df96 Merge remote-tracking branch 'origin/main' into fix/dependabot-security
# Conflicts:
#	CHANGELOG.md
2026-08-10 04:30:23 +00:00
debpalash cb33559138 Merge remote-tracking branch 'origin/main' into fix/1429-youtube-cookie-file 2026-08-10 04:30:13 +00:00
debpalash 368e777847 Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights 2026-08-10 04:23:27 +00:00
debpalash e65876bdcc Merge remote-tracking branch 'origin/main' into fix/1274-docker-python-runtime 2026-08-10 04:22:37 +00:00
debpalash 73df87d54f Merge remote-tracking branch 'origin/main' into fix/ghas-hf-revisions 2026-08-10 04:21:58 +00:00
debpalash 80fc38169d fix(security): pin fallback audio tokenizer 2026-08-10 04:21:55 +00:00
debpalash 0d3bc2d772 Merge remote-tracking branch 'origin/main' into fix/1438-uv-bootstrap 2026-08-10 04:20:36 +00:00
Palash Debnath 524a8cde1e Merge pull request #1468 from debpalash/dependabot/uv/pillow-12.3.0
chore(deps): bump pillow from 12.2.0 to 12.3.0
2026-08-10 04:01:51 +00:00
debpalash 3b808ed20d fix: harden cookie import lifecycle 2026-08-10 04:01:44 +00:00
debpalash 1551060172 fix(models): repair nested tokenizer cache 2026-08-10 04:00:52 +00:00
debpalash 25c84da606 Merge remote-tracking branch 'origin/main' into fix/1274-docker-python-runtime
# Conflicts:
#	CHANGELOG.md
2026-08-10 03:51:12 +00:00
debpalash e6b68aaf11 Merge remote-tracking branch 'origin/main' into fix/ghas-hf-revisions
# Conflicts:
#	CHANGELOG.md
2026-08-10 03:50:11 +00:00
debpalash c8659eb36a test(security): validate pinned repair fixtures 2026-08-10 03:49:23 +00:00
debpalash 87ee81c3d1 Merge remote-tracking branch 'origin/main' into fix/1438-uv-bootstrap 2026-08-10 03:45:10 +00:00
debpalash ff41aa5c8c Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights 2026-08-10 03:44:55 +00:00
debpalash 3d7ec3bdab Merge remote-tracking branch 'origin/main' into fix/1429-youtube-cookie-file 2026-08-10 03:44:49 +00:00
debpalash bebe9c6e26 Merge remote-tracking branch 'origin/main' into test/pillow-video-context 2026-08-10 03:44:42 +00:00
Palash Debnath 369e9fddf9 Merge pull request #1451 from debpalash/dependabot/uv/cryptography-50.0.0
chore(deps): bump cryptography from 48.0.0 to 50.0.0
2026-08-10 03:26:07 +00:00
debpalash c3d18412fe fix(dub): bind cookie trust to the local UI origin 2026-08-10 03:21:02 +00:00
debpalash 74e6d4582b fix(dub): keep cookie credentials off remote plaintext HTTP 2026-08-10 03:16:35 +00:00
debpalash bfbf78a270 Merge remote-tracking branch 'origin/main' into fix/1429-youtube-cookie-file
# Conflicts:
#	CHANGELOG.md
2026-08-10 03:15:59 +00:00
debpalash a2f587bbc1 Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights
# Conflicts:
#	CHANGELOG.md
2026-08-10 03:14:27 +00:00
debpalash b5a056990d Merge remote-tracking branch 'origin/main' into fix/1438-uv-bootstrap
# Conflicts:
#	CHANGELOG.md
2026-08-10 03:13:23 +00:00
debpalash 7fdc377d3d Merge remote-tracking branch 'origin/main' into codex/pr-1451 2026-08-10 03:13:17 +00:00
debpalash a756bc27fa Merge remote-tracking branch 'origin/main' into test/pillow-video-context 2026-08-10 03:13:04 +00:00
Palash Debnath c3ad072a84 Merge pull request #1427 from debpalash/fix/classify-401-substring
fix(errors): three digits in a path are not an authentication failure
2026-08-10 02:58:16 +00:00
debpalash e7f6f3beb5 fix(bootstrap): redact installer home paths 2026-08-10 02:52:44 +00:00
debpalash fcdbac9683 fix(bootstrap): require the pinned uv version 2026-08-10 02:49:25 +00:00
debpalash b076dcf8aa fix: force repair when cache resume exposes corruption 2026-08-10 02:47:43 +00:00
debpalash d8c77210ee Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights
# Conflicts:
#	CHANGELOG.md
2026-08-10 02:46:45 +00:00
debpalash a7f0ab0838 Merge remote-tracking branch 'origin/main' into fix/1438-uv-bootstrap
# Conflicts:
#	CHANGELOG.md
2026-08-10 02:45:04 +00:00
debpalash 67ccfafbd3 Merge remote-tracking branch 'origin/main' into test/pillow-video-context 2026-08-10 02:43:17 +00:00
debpalash ac610b2aa4 Merge remote-tracking branch 'origin/main' into codex/pr-1451 2026-08-10 02:43:14 +00:00
debpalash 270666fac8 Merge remote-tracking branch 'origin/main' into fix/classify-401-substring 2026-08-10 02:43:12 +00:00
Palash Debnath ee313ad892 Merge pull request #1465 from debpalash/dependabot/uv/yt-dlp-2026.7.4
chore(deps): bump yt-dlp from 2026.6.9 to 2026.7.4
2026-08-10 02:24:30 +00:00
debpalash 7c4e7f0cac test(video): enforce exact Pillow floor 2026-08-10 02:18:02 +00:00
debpalash 8f449fa7ee test(video): pin Pillow dependency floor 2026-08-10 02:12:30 +00:00
debpalash c642379c3c fix(video): declare Pillow runtime floor 2026-08-10 02:11:37 +00:00
debpalash 7e55468892 Merge remote-tracking branch 'origin/main' into fix/classify-401-substring
# Conflicts:
#	CHANGELOG.md
2026-08-10 02:10:37 +00:00
debpalash aaba64e000 Merge remote-tracking branch 'origin/main' into codex/pr-1451 2026-08-10 02:09:50 +00:00
debpalash da31ef9aec Merge remote-tracking branch 'origin/main' into review/pr-1465 2026-08-10 02:09:35 +00:00
debpalash e806470324 Merge remote-tracking branch 'origin/main' into test/pillow-video-context 2026-08-10 02:09:29 +00:00
Palash Debnath 3c95d9c52d Merge pull request #1450 from debpalash/dependabot/uv/nltk-3.10.0
chore(deps): bump nltk from 3.9.4 to 3.10.0
2026-08-10 01:55:10 +00:00
debpalash a7a5cf84be test(video): pin Pillow frame analysis 2026-08-10 01:54:35 +00:00
debpalash c4e13fc07c Merge remote-tracking branch 'origin/main' into review/pr-1465 2026-08-10 01:48:07 +00:00
debpalash 3dde7b52db Merge remote-tracking branch 'origin/main' into codex/pr-1450 2026-08-10 01:39:04 +00:00
dependabot[bot] 6fd4db8779 chore(deps): bump pillow from 12.2.0 to 12.3.0
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.2.0 to 12.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 01:26:30 +00:00
Palash Debnath 2b79d43e56 Merge pull request #1449 from debpalash/dependabot/uv/pypdf-6.15.0
chore(deps): bump pypdf from 6.13.2 to 6.15.0
2026-08-10 01:24:01 +00:00
debpalash c6f7167d06 Merge remote-tracking branch 'origin/main' into codex/pr-1449 2026-08-10 01:08:02 +00:00
dependabot[bot] 82a7039988 chore(deps): bump yt-dlp from 2026.6.9 to 2026.7.4
Bumps [yt-dlp](https://github.com/yt-dlp/yt-dlp) from 2026.6.9 to 2026.7.4.
- [Release notes](https://github.com/yt-dlp/yt-dlp/releases)
- [Changelog](https://github.com/yt-dlp/yt-dlp/blob/master/Changelog.md)
- [Commits](https://github.com/yt-dlp/yt-dlp/compare/2026.06.09...2026.07.04)

---
updated-dependencies:
- dependency-name: yt-dlp
  dependency-version: 2026.7.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-10 00:54:03 +00:00
Palash Debnath 16cbf1db3a Merge pull request #1452 from debpalash/dependabot/uv/aiohttp-3.14.3
chore(deps): bump aiohttp from 3.14.1 to 3.14.3
2026-08-10 00:51:32 +00:00
debpalash b77e6b2030 Merge remote-tracking branch 'origin/main' into codex/pr-1452 2026-08-10 00:34:15 +00:00
Palash Debnath 28f40f1d59 Merge pull request #1446 from debpalash/fix/ghas-redos
fix(security): make user-controlled validators linear-time
2026-08-10 00:19:48 +00:00
debpalash cf59a508fe fix(security): align bounded model validation 2026-08-10 00:04:53 +00:00
debpalash c3209e3d32 Merge remote-tracking branch 'origin/main' into fix/ghas-redos
# Conflicts:
#	CHANGELOG.md
2026-08-10 00:03:45 +00:00
Palash Debnath 3634c3b9d6 Merge pull request #1462 from debpalash/fix/ghas-disclosure-streaming
fix(security): stabilize streamed failure responses
2026-08-09 23:49:59 +00:00
debpalash d35e8e881d fix(security): stabilize inner transcription failures 2026-08-09 23:35:28 +00:00
debpalash 544d345069 fix(security): keep stream failures out of logs 2026-08-09 23:18:16 +00:00
debpalash bebe462dc6 Merge remote-tracking branch 'origin/main' into fix/ghas-disclosure-streaming
# Conflicts:
#	CHANGELOG.md
2026-08-09 23:12:47 +00:00
debpalash c8e63082ea docs: link AppImage fix changelog entry 2026-08-09 23:12:33 +00:00
debpalash 5be4a26903 fix(appimage): ship the compatibility launcher 2026-08-09 23:12:04 +00:00
Palash Debnath 7f67e1622b Merge pull request #1426 from debpalash/fix/1414-resolve-heartbeat
fix(engines): a venv resolution that takes minutes is not a stalled generation (#1414)
2026-08-09 22:59:26 +00:00
debpalash f122b84a7e test: always release parked heartbeat writer 2026-08-09 22:42:48 +00:00
debpalash 600a8dc936 Merge remote-tracking branch 'origin/main' into codex/pr-1426 2026-08-09 22:37:45 +00:00
Palash Debnath 9e9c9d031f Merge pull request #1461 from debpalash/test/ghas-fp-evidence
test(security): lock reviewed GHAS invariants
2026-08-09 22:23:35 +00:00
debpalash 3f706336f6 Merge remote-tracking branch 'origin/main' into test/ghas-fp-evidence 2026-08-09 22:08:40 +00:00
debpalash c53f1a8d1c fix(ci): keep Intel Mac smoke contract honest 2026-08-09 22:01:38 +00:00
Palash Debnath ae46f187d0 fix(security): move host-path authority into the desktop shell (#1448)
Close unauthenticated remote mutation paths and keep filesystem destinations behind one-shot Tauri capabilities. CodeRabbit and Greptile findings were fixed on-branch; all review threads are resolved. Full CI, Security, Rust, and cross-platform smoke checks are green.
2026-08-09 21:55:43 +00:00
debpalash 96a6c7574a fix(security): stabilize streamed failure responses 2026-08-09 21:50:42 +00:00
debpalash 6e7f12f391 test(security): lock reviewed GHAS invariants 2026-08-09 21:48:54 +00:00
debpalash fe856c9c5e fix(security): omit sensitive log context 2026-08-09 21:43:00 +00:00
debpalash 511d21a8b9 fix: report desktop log cleanup failure 2026-08-09 21:42:46 +00:00
debpalash 8d4a9d11c8 fix(security): authorize native dub destinations 2026-08-09 21:39:37 +00:00
debpalash 86f49effcb docs: note observable backend degradation 2026-08-09 21:37:01 +00:00
debpalash ac8fef19a6 fix: make degraded backend state observable 2026-08-09 21:36:42 +00:00
debpalash aa5de23bd1 docs: note reliable cleanup and redaction 2026-08-09 21:34:07 +00:00
debpalash e32f913315 Merge remote-tracking branch 'origin/fix/ghas-admin-boundary' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-09 21:34:01 +00:00
debpalash b4d845535f Merge remote-tracking branch 'origin/main' into fix/ghas-path-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-09 21:33:49 +00:00
debpalash 77be03b130 fix(security): fail closed on cleanup and redaction errors 2026-08-09 21:33:45 +00:00
debpalash c3de97ac95 test(security): validate every locked dependency 2026-08-09 21:31:41 +00:00
debpalash ae87be6dd4 Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-09 21:31:30 +00:00
debpalash 74511db595 fix(security): redact share PIN from remote discovery 2026-08-09 21:30:02 +00:00
debpalash c4a42d7e55 fix(security): keep capability lookup data-independent 2026-08-09 21:27:51 +00:00
debpalash 033feb457d docs(changelog): note diagnostic log hardening 2026-08-09 21:26:28 +00:00
debpalash d8268ca99c fix(pockettts): gate unsupported Intel Mac wheels 2026-08-09 21:26:14 +00:00
debpalash 854a637b83 fix(security): make untrusted log values single-line 2026-08-09 21:26:00 +00:00
debpalash e0a2c84ed8 Merge remote-tracking branch 'origin/main' into fix/dependabot-security
# Conflicts:
#	CHANGELOG.md
2026-08-09 21:25:00 +00:00
debpalash f6e01c142a Merge remote-tracking branch 'origin/main' into fix/ghas-redos
# Conflicts:
#	CHANGELOG.md
2026-08-09 21:24:31 +00:00
debpalash 4cd5d6681f docs(changelog): note dependency security floors 2026-08-09 21:24:27 +00:00
debpalash c20e1c1685 fix(security): raise dependency advisory floors 2026-08-09 21:23:59 +00:00
debpalash 196b03f943 Merge remote-tracking branch 'origin/main' into fix/ghas-admin-boundary
# Conflicts:
#	CHANGELOG.md
2026-08-09 21:23:18 +00:00
debpalash fa3ba2f366 fix(security): authorize host paths through native IPC 2026-08-09 21:23:00 +00:00
Palash Debnath 93143849d2 fix(security): constrain endpoint probes to trusted origins (#1447)
Validate HTTPS probe destinations against the shipped origin allowlist and clean up the related CodeQL test findings. Reviewed by Greptile; CodeRabbit was harvested but rate-limited. CI, Security, and cross-platform smoke checks are green.
2026-08-09 21:18:11 +00:00
debpalash c9c60d182f docs(changelog): note filesystem boundary hardening 2026-08-09 21:17:20 +00:00
debpalash 3e6679c03e fix(security): enforce filesystem trust boundaries 2026-08-09 21:16:55 +00:00
debpalash 18118796f8 fix(security): preserve authorized legacy snapshots 2026-08-09 21:14:12 +00:00
debpalash 6f443ad387 fix(models): accept valid underscore repo IDs 2026-08-09 21:13:10 +00:00
debpalash 7abc07e7f3 fix(security): keep host paths desktop-only 2026-08-09 21:10:51 +00:00
debpalash bc2c219726 docs: note immutable model revisions 2026-08-09 21:07:28 +00:00
debpalash f6f2bc5dcd fix(security): pin curated Hugging Face revisions 2026-08-09 21:07:01 +00:00
debpalash 9a770b15d1 test(pockettts): verify pinned install on four platforms 2026-08-09 21:02:30 +00:00
dependabot[bot] b03b67ef00 chore(deps): bump aiohttp from 3.14.1 to 3.14.3
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.14.1 to 3.14.3.
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.14.1...v3.14.3)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 21:01:51 +00:00
dependabot[bot] 7d6bd7af82 chore(deps): bump cryptography from 48.0.0 to 50.0.0
Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.0 to 50.0.0.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/48.0.0...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 21:01:50 +00:00
dependabot[bot] 70a5d69622 chore(deps): bump nltk from 3.9.4 to 3.10.0
Bumps [nltk](https://github.com/nltk/nltk) from 3.9.4 to 3.10.0.
- [Release notes](https://github.com/nltk/nltk/releases)
- [Changelog](https://github.com/nltk/nltk/blob/develop/ChangeLog)
- [Commits](https://github.com/nltk/nltk/compare/3.9.4...v3.10.0)

---
updated-dependencies:
- dependency-name: nltk
  dependency-version: 3.10.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 21:01:47 +00:00
dependabot[bot] f1aec13089 chore(deps): bump pypdf from 6.13.2 to 6.15.0
Bumps [pypdf](https://github.com/py-pdf/pypdf) from 6.13.2 to 6.15.0.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.13.2...6.15.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.15.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-09 21:01:46 +00:00
debpalash afc51b2920 docs: note remote admin mutation hardening 2026-08-09 21:01:40 +00:00
debpalash f8eeb5a963 fix(security): require API key for remote admin writes 2026-08-09 21:01:14 +00:00
debpalash ce2f2e9397 docs: note bounded input validation 2026-08-09 20:59:32 +00:00
debpalash 400fd97810 fix(security): bound model and voice validators 2026-08-09 20:59:12 +00:00
debpalash 034dd2333d fix(pockettts): complete first-use terms gate and smoke 2026-08-09 20:55:53 +00:00
Gamble Tan 4945dec04e fix(asr): use the bundled ffmpeg for MLX transcription (#1436)
Decode MLX Whisper audio once through VoiceStudio's validated bundled ffmpeg and reuse the waveform for forced alignment. This restores source-install transcription on Apple Silicon without requiring system ffmpeg.\n\nThanks @gambletan!
2026-08-09 20:55:15 +00:00
debpalash b1b18dd1ba fix(dub): support explicit YouTube cookie exports (#1429) 2026-08-09 20:42:47 +00:00
debpalash 6bf6c37683 Merge remote-tracking branch 'origin/main' into codex/pr1442 2026-08-09 20:42:23 +00:00
debpalash 34edae313a fix(bootstrap): validate uv installer postcondition 2026-08-09 20:41:02 +00:00
debpalash b991e59a0f fix(docker): keep ROCm runtime on guarded Python 2026-08-09 20:39:46 +00:00
debpalash 83ea2fe996 Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights
# Conflicts:
#	CHANGELOG.md
2026-08-09 20:25:06 +00:00
debpalash 6079689500 fix(models): repair corrupt configs and isolate ASR preload (#1437) 2026-08-09 20:24:25 +00:00
debpalash 7c64a7270c fix: make resolve heartbeat shutdown race-free 2026-08-09 20:24:14 +00:00
debpalash 5c069feb50 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	CHANGELOG.md
2026-08-09 20:23:35 +00:00
debpalash 828ac1a2c8 test(errors): pin audio failure class with 401 path 2026-08-09 20:23:05 +00:00
debpalash 9f33129fee Merge remote-tracking branch 'origin/main' into HEAD 2026-08-09 20:22:16 +00:00
Palash Debnath c870f794ed fix(engines): give every sidecar a private fd for its frames (#1428)
Protect all nine sidecar frame channels from library stdout noise and pin the complete sidecar manifest in regression coverage.\n\nCloses #1428. Thanks @1335-Group for the diagnosis and tested fix.
2026-08-09 20:22:06 +00:00
debpalash 4d2ce060b4 Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights 2026-08-09 20:22:03 +00:00
Paolo Antinori 4c3ac1ad59 feat(pockettts): licence-accept gate, gated-weights preflight, license dialog
- Licence-accept gate: add pockettts to _LICENSE_ALLOWED_ENGINES + PocketTTSLicenseDialog (MIT code + CC-BY-4.0 weights + gated-access notice).
- Gated-weights preflight: POCKETTTS_GATED_WEIGHTS in core/failure.py (hint + classify rule), so a gated-repo download surfaces as a typed error naming the agreement, not a raw failure.
- Frontend: PocketTTSLicenseDialog registered in EngineCompatibilityMatrix; i18n keys in en.json.

Remaining deferred items: CI smoke (stub sidecar integration test), four-platform install verification.
2026-08-09 22:01:51 +02:00
debpalash 31c654a35a Merge main into fix/1414-resolve-heartbeat
CHANGELOG only; both entries kept.
2026-08-08 15:24:09 +05:30
Palash Debnath 1eb59c6f18 feat(diagnostics): say why a GPU host fell back to CPU (#1274) (#1425)
The About page reported 'Compute device: cpu / GPU active: no / VRAM 0.00 GB' on a machine with a working GPU. Every line was true and none was usable — it is also exactly what a machine with no GPU at all reports, so the report could not distinguish a driver that isn't loaded from a container that cannot open the device from a ROCm older than the card.

The probe already knew all of it; torch.cuda.is_available() returning False simply produced no note. Each cause now reads differently: a missing device node names the --device flags, a permissions failure names --group-add and how to find the host's real render/video GIDs (copied numbers are the most common way this ends up on CPU in Docker), a card newer than the shipped ROCm points at rocminfo, an HSA_OVERRIDE_GFX_VERSION that is doing more harm than good is named first because it is both likelier and cheaper to test, and an unreachable NVIDIA driver gets its own advice.

The probe never diagnoses from a measurement it did not complete: when torch.cuda.is_available() itself raises, the exception is reported and no device findings are asserted beside it. Metadata access that raises is contained too — this runs on the path whose whole job is to explain a failure, so it cannot become one.
2026-08-08 15:23:38 +05:30
debpalash 44f2cff6c1 Merge remote-tracking branch 'origin/main' into fix/classify-401-substring
# Conflicts:
#	CHANGELOG.md
2026-08-08 15:16:04 +05:30
debpalash 961eee1b74 Merge remote-tracking branch 'origin/main' into fix/1406-corrupt-weights
# Conflicts:
#	CHANGELOG.md
2026-08-08 15:16:01 +05:30
debpalash 2df66e2628 Merge main into fix/1414-resolve-heartbeat
CHANGELOG only; both entries kept.
2026-08-08 15:08:13 +05:30
Palash Debnath 30c05fa038 fix(generate): a job that never ran was not too heavy for your computer (#1416) (#1424)
A generation abandoned at its execution budget was reported as the machine being too slow, whatever had actually happened. A job wedged on a lock it could never acquire got the same message as one genuinely grinding through a long synthesis, so the advice — use a smaller model, close other apps — was wrong exactly when the cause was a bug rather than the hardware.

The wedge decision now reads the deepest frame of the stalled thread in a stdlib module rather than pattern-matching the tail of the stack, so a short active stack cannot be mistaken for a blocked one. A thread parked in a lock, an event wait or a future is a wedge and says so; one executing engine code is slow hardware and keeps the old guidance.
2026-08-08 15:03:51 +05:30
debpalash a47e59fda4 test: drop a stray test file from an abandoned approach
Committed by mistake — it belonged to an earlier draft of this fix that
exposed a public has_http_401() helper, and was swept in by a broad
'git add' while the working tree carried it across a branch switch. This
branch keeps the boundary logic private as _HTTP_401, so the file
referenced a function that does not exist here and failed 11 cases.

tests/test_classify_401_substring.py already covers everything it did.
2026-08-08 14:56:53 +05:30
debpalash e80fcab8fa fix(engines): join the resolve heartbeat instead of only signalling it
_beat() can be past its stop.wait() and already committed to a write
when the context exits. Signalling alone lets that write land after
_run_on_gpu_pool's _job pops the ident — the pop exists so a stale beat
cannot vouch for a later job on the same reused worker ident, and a
post-pop write resurrects exactly what it was there to prevent. The
wedge detector then reads a heartbeat the next job never emitted and
keeps extending a stuck one.

Joining orders the last write before the pop. Bounded, so a wedged
writer degrades to the previous behaviour rather than blocking.

The regression forces the interleaving with a parking map rather than
waiting on the scheduler, so it fails deterministically without the
join.
2026-08-08 14:32:02 +05:30
debpalash b3b2bc3e44 fix(errors): reject identifier and dotted-numeric 401 matches
Digit-only boundaries rejected 4012 and 1401 but still accepted x401y,
pytest-401 and 401.0. With 401 on the symptom side, an error that merely
mentions Hugging Face and carries one of those elsewhere still resolved
to HF_AUTH_FAILED — the residual CodeRabbit flagged as Critical.

Three guards, one per family the others let through: identifier/path/
dotted-version prefixes, identifier and hyphen suffixes, and dotted
numerics. A trailing sentence full stop still reads as punctuation.

Also drops a duplicated paragraph in the branch comment.
2026-08-08 14:30:06 +05:30
Palash Debnath 187f5a4b24 fix(models): a broken dependency is not a missing install, and is not silent (#1415) (#1423)
A missing or ABI-mismatched torch/transformers surfaced as 'omnivoice not importable', which reads as a damaged VoiceStudio install and sent people reinstalling the app — the one thing that could not help, because the broken package is in the Python environment underneath it. The failure is now attributed to the dependency that actually failed, with the remedy that repairs it.

The second half: a model that failed to load during startup preload left the app looking healthy. Nothing surfaced the failure, so the first generation produced nothing and the cause was already gone from view. The failure and its remedy now reach the model status, where the UI can show them.

A classifier that fails while classifying no longer publishes its own raw exception text through model status.
2026-08-08 05:47:45 +05:30
debpalash d8d5a02f54 docs(changelog): add the issue ref 2026-08-08 05:43:22 +05:30
debpalash 21d42aca45 fix(errors): three digits in a path are not an authentication failure
classify() matched a bare '401' substring and used it to satisfy BOTH halves
of the HF-auth condition, so any message containing those digits anywhere
classified as HF_AUTH_FAILED on its own — paths, byte counts, job ids,
durations.

CI hit it when pytest's numbered temp directory reached pytest-401: an
audio-save failure came back telling the user to set a valid HF_TOKEN. That is
worse than an unclassified error — a confident wrong instruction with a docs
deeplink, in an auto-filed bug report — and because it rides a counter that
changes between runs, it passes locally forever.

Two independent guards: the digits must be a standalone number (not 4012,
1401, pytest-401's neighbours), and they are no longer sufficient evidence by
themselves. A real 401 always arrives with 'Unauthorized' or an HF URL beside
it, so requiring that costs nothing.
2026-08-08 05:43:01 +05:30
debpalash 992a63b8ad Merge remote-tracking branch 'origin/main' into fix/1414-resolve-heartbeat
# Conflicts:
#	CHANGELOG.md
2026-08-08 05:36:49 +05:30
debpalash c46ad1bed8 fix(engines): a venv resolution that takes minutes is not a stalled generation (#1414)
_spawn() calls venv_python(), and on a cold first run that is not cheap: the
probe spawns each candidate interpreter to import the engine, and if none is
installed it runs the whole uv venv + uv pip install bootstrap — bounded at
900s by design, because installing torch takes minutes.

All of it happens on a GPU-pool worker inside a generate request whose
execution budget is 300s. Nothing along the way reported progress, so the
budget expired part-way through the install and the job was abandoned. The
first generation that triggers a bootstrap could never succeed, on any
hardware, and the message blamed the hardware anyway.

The sidecar's own cold load already heartbeats for exactly this reason
(#1367); resolution is the step before it that never did. Pool jobs only, and
crediting the resolving thread rather than the beater — an off-pool ident is
not tracked by the clock, and a pool worker reusing it would inherit unearned
extension (#1379).
2026-08-08 05:35:00 +05:30
debpalash 6b82aa348e Merge main into fix/1406-corrupt-weights
CHANGELOG only; model_manager.py auto-merged. Also normalised #1414's
entry to the credit-then-ref order the other 42 community entries use —
CodeRabbit flagged the same inconsistency on this PR.
2026-08-08 05:29:55 +05:30
Palash Debnath bcb547b9f2 fix(engines): a slow venv probe is not a broken venv (#1414) (#1421)
Every subprocess engine confirms a candidate interpreter by spawning it and importing the engine package. For IndexTTS that is 'import indextts.infer_v2', which pulls in torch and transformers — seconds with a warm page cache, tens of seconds on a first run, a spinning disk, a network share, or Windows with real-time AV scanning every DLL.

The bound was 10s (15s for three peers), and elapsing it was treated as a negative: the candidate was discarded exactly as if the import had raised. A working OMNIVOICE_INDEXTTS_DIR install was reported as 'IndexTTS-2 is not installed', or fell through into the lazy bootstrap and reinstalled over a working clone. Only successful resolution was memoised, so every retry re-ran the probe and failed identically — which is why all three reported repro paths look like one bug.

A timeout is the absence of evidence, not evidence of breakage. The probe is now tri-state: yes (imported), no (ran and failed), unproven (did not finish). An unproven candidate is kept as a fallback and used only after every candidate has had its chance, so a wedged user clone cannot shadow a healthy bootstrapped venv. If an unproven venv really is broken it now fails at the sidecar handshake with a real error rather than a confident lie about the install.

Fixed as a class: backend/engines/_venv_probe.py replaces the drifted copy in each of the four bootstraps, and the bound is tunable per engine, defaulting to 60s. Zero and negative values are ignored — an unbounded probe would let one wedged candidate hang engine resolution forever.

Reported with a precise root cause by @OracleNightmare. (#1414)
2026-08-08 05:27:47 +05:30
debpalash a9cea4847f fix(models): bound the forced re-download, and don't blame TTS for an ASR shard
Two ways the repair could do the wrong gigabytes:

A shard that stays unparseable after a full re-fetch would be re-fetched
again on every generate request. The re-download now runs at most once per
repo per process, the same contract as the snapshot-link repair, and later
attempts go straight to the manual delete-and-reinstall message.

With OMNIVOICE_PRELOAD_TTS_ASR on, the load also pulls the Whisper checkpoint
— a different repo. A damaged shard there arrived looking identical, and
re-downloading the TTS checkpoint would have fixed nothing while reporting
the wrong model as broken. One local load without ASR settles which it is.
2026-08-08 03:21:26 +05:30
debpalash 2a32ab8de2 fix(models): require a weight marker before 'unexpected end of file' counts
zipfile, tarfile, gzip and several parsers share the wording, and any of them
can surface inside a model-load chain — where a false positive forces a
multi-GB re-download of an undamaged cache.
2026-08-08 03:11:26 +05:30
Palash Debnath b1d86b2c96 fix(tts): a cold model load from a GPU-pool worker no longer waits on the wrong event loop (#1417) (#1418)
`_model_lock` is a module-level asyncio.Lock, so it binds to whichever loop first contends for it — in practice the server's. But OmniVoiceBackend._ensure_loaded() runs on a GPU-pool worker thread with no running loop and bootstraps a fresh one via asyncio.run(get_model()). Awaiting a lock owned by another loop does not block, it raises 'is bound to a different event loop', which reached users as a 500 — or deadlocks, depending on which loop touched it first.

_heal_tts_placement already carried a running_on_gpu_pool() guard for exactly this; the cold-load path never got one. It now loads inline on the calling thread.

The load must run inline rather than through _load_model_with_timeout(), which would hand _load_model_sync back to _get_gpu_pool() — the pool the caller already occupies. MPS pins that pool to a single worker, so it would wait on itself. Exclusion therefore comes from a loop-agnostic threading.Lock, not from holding a GPU slot: a slot is not exclusion when the pool has more than one worker, which CUDA hosts do.

Regression tests drive the real failure shape — a live foreign loop genuinely holding the lock, since an uncontended acquire() never binds — and install a pool that refuses submit, so a re-submission regression fails in under a second instead of hanging the suite.
2026-08-08 03:02:10 +05:30
debpalash 05b1d096b4 fix(models): repair a weight file that arrived damaged, not just a missing one (#1406)
An interrupted or mangled model download leaves one of two states, and only
one had any handling. A MISSING shard raises transformers' "does not appear
to have a file named …" and gets a whole recovery ladder. A shard that is
PRESENT with wrong bytes — a download stopped mid-file, a shard truncated by
antivirus, an HTML error page saved under its name — opens fine and then
fails inside safetensors:

    Error while deserializing header: header too large

That reached the user as a raw 500 on every generation, from voice design and
gallery previews alike, and could not enter the ladder for two independent
reasons: the wording is not the missing-shard wording, and SafetensorError is
a Rust-extension exception rather than an OSError.

It also needs the opposite repair. The ladder RESUMES a download, and a resume
trusts a blob that is already the expected size — so it would never re-fetch
the one file that is actually wrong. The new path forces a full re-download,
then retries the load once.

Both halves now classify as MODEL_CACHE_CORRUPT: one class to the user, one
remedy, two repairs underneath. The cause is matched through the whole
exception chain, since transformers wraps the tensor library's error in its
own before it reaches us.
2026-08-08 02:56:08 +05:30
Palash Debnath 46c47b68ce fix: restore generation and startup broken by the rename (#1417) (#1420)
Two bugs from the v0.4.2 rename sweep:

- The lazy model import was rewritten to `from omnivoice.models.omnivoice import VoiceStudio`, a class the library does not export. ImportError is not ModuleNotFoundError, so the #564 source fallback never caught it and /generate 500'd on every default-engine request. The class keeps its library name — it is a checkpoint-referenced identifier, not branding.
- alembic resolved a bare relative script_location against the process cwd, and the desktop shell launches the backend from frontend/src-tauri, so a pending migration killed startup. script_location and prepend_sys_path are now anchored with %(here)s, with path_separator = os so Windows drive letters and paths with spaces survive. alembic floor raised to >=1.16.

Regression tests verified fail-before/pass-after for both.
2026-08-08 02:40:52 +05:30
Palash Debnath ea2d7155bc feat(nav): let the workspace switcher live in the title bar (#1412)
Settings → Appearance → Navigation style picks between the icon rail down the
window edge (default, unchanged) and browser-style tabs across the title bar.
Both skins render one shared workspace list, so a new workspace appears in
both from a single edit, and the choice persists like scale and theme.

Two layout traps this had to solve:

* The footer and audio dock are placed at `grid-column: 2 / -1` so they sit
  beside the rail. Tabs mode has one fewer column, so that placement makes
  Grid invent an implicit column and take its width off the content — the app
  rendering in 65% of the window with the footer stranded in a black band.
  A stylesheet guard now fails on any child left past column 1 without a
  `.nav-tabs` counterpart.

* Nine tabs plus the status cluster do not always fit. Rather than every label
  shrinking to a stub, the strip measures what it needs with all labels shown
  and, when short, keeps the label only on the tab you are in. Measured rather
  than thresholded because the answer depends on locale, UI scale, font and
  whether the live-metrics cluster is on.

Adds the nav-style strings to all 21 locales and a TitleTabs visual spec.
2026-08-07 22:49:20 +05:30
debpalash 82687d849a docs(changelog): credit the shared sidecar wire-protocol tests (#1408)
The PR merged without an Unreleased entry. It is not user-facing, so it
belongs under CI rather than Fixed — but the contributor credit the repo
keeps for community work should still be there.
2026-08-07 17:54:33 +05:30
Paolo Antinori 14979c324a test(engines): parametrize wire-protocol tests across all sidecar engines (#1408)
Every sidecar carries its own copy of the length-prefixed JSON-over-stdio wire protocol (_send, _recv, MAX_FRAME_BYTES), and only one of them was covered. This parametrizes the protocol invariants across all nine — send/recv roundtrip, EOF as an orderly shutdown, the oversized-frame cap that stops a corrupt length header allocating unbounded memory, and the truncated body that would otherwise hang the parent — so a bug in any single sidecar's copy is caught without a per-engine test file.

Modules are resolved through importlib inside a fixture rather than bound at collection, which keeps the suite honest under sys.modules pollution.

Thanks @paoloantinori!
2026-08-07 17:53:15 +05:30
Palash Debnath 821ab3cb5d fix(dictation): stop the widget window stranding a rectangle, and stop showing a pill
The dictation widget window could mistake itself for the main window: detectIsWidget() asked getCurrentWindow().label, which throws while Tauri's internals are still injecting, and the catch fell back to a URL query Tauri 2 cannot set. A window that guessed wrong rendered the whole app into 300x64 — opaque background, no pill, and no CaptureWidget to run the hide reconcile, so nothing but quitting the app could clear it. It now stamps its identity from an initialization_script, which runs before any page script and cannot race.

The pill is gone as well: the widget window is never shown. It still has to exist — getUserMedia, MediaRecorder and the transcription WebSocket all live in CaptureWidget — so it is now a hidden recorder host, and dictation records, transcribes and pastes with nothing on screen.

The tray Start/Stop item no longer infers recording from window visibility (which a permanently hidden window made meaningless, leaving Stop unreachable); it reads a dictating flag the frontend already maintains. States needing user action — Accessibility, mic denial, failed transcription — used to surface in the pill and now arrive as a toast in the main window, carrying the button that opens the relevant OS pane.

Also closes the second-launch path: the single-instance handler targeted the widget in pill mode and showed it.

Refs #1398
2026-08-07 17:41:43 +05:30
Palash Debnath 7865b6552d copy(firstrun): stop the setup screen overpromising privacy
The trust line under Start installation claimed "no account, no cloud, no telemetry" without qualification. That stopped being true when opt-in PostHog analytics shipped in every build behind the first-run consent prompt — the screen was asserting something the app doesn't do, on the screen where the user decides whether to trust it.

It now says what holds either way the consent prompt is answered: your voices, recordings and projects never leave this machine, and no processing happens in the cloud. Analytics carries allowlisted content-free metadata only.

Translated across all 21 locales. README's version was already correctly conditioned on consent and is unchanged.
2026-08-07 16:31:19 +05:30
Palash Debnath dd8143c088 fix(mlx): pass the voice description the design model requires (#1405)
The curated qwen3-tts model IS the VoiceDesign variant, and mlx-audio refuses to run it without an instruct — but MLXAudioBackend.generate never forwarded one, so the engine could not produce audio under any input. The comment above the code claimed it was passed; the one test covering that path only passed because the value was being dropped.

Forwards instruct, and raises an actionable error when a voice-design model is asked to generate without a description. Model type is read from the model's own config, falling back to the id convention.

Closes #1405
2026-08-07 16:15:31 +05:30
Palash Debnath 0c82167839 feat(firstrun): let the user choose the portable folder (#766)
Portable mode put everything in OmniVoiceStudio-Data beside the app — the only storage row on the setup screen you could look at but not change, while installed mode had a picker for all three of its directories.

The pin had a reason: portable_base() is computed from the executable's location and never read from config, which is what makes a portable install self-discovering. A user-chosen folder breaks that, because the only record of the location would live inside the folder being located.

So it is recorded somewhere findable, in order: a portable.path marker beside the app; then portableDir in the per-user config for app folders that are read-only; then the historical default, so existing portable installs resolve byte-identically.

The marker stores a RELATIVE path whenever the folder sits inside the app's own directory — the USB-stick case portable mode exists for, where app and folder move as a unit and the mount path is free to change. Anywhere else only an absolute path can be stored, and the setup screen says the install is tied to it rather than promising portability it cannot keep.

Two traps worth naming, both caught in review: the per-user fallback would recurse forever through load_config (config_path to portable_config_file to portable_base), so it reads the platform config file directly; and clear_portable_dir was reading through that same chain, silently failing to clear the machine record so the old folder kept winning after the user chose the default.

This also retires the greyed-out Portable option after a default Program Files install (#766) — you point it at a writable disk and get the machine-bound variant.

Rust 109 passed, frontend 1705 passed. All 21 locales in lockstep; docs/install/windows.md rewritten.
2026-08-07 07:52:05 +05:30
Palash Debnath abf0bcfcf0 fix(firstrun): stop the setup screen timing out while it waits for you (#1376)
The splash flips any stage that sits still past a budget to `failed`, so a wedged bootstrap surfaces Retry and logs instead of an info-less spinner (#879). Correct for every stage the machine owns.

`awaiting_setup` is not one of them. Rust parks there deliberately — nothing downloads or installs in that stage, and complete_setup is the only way out — while a human chooses install mode, storage locations, region and mirrors. A screen built for deliberation, handed the default 120-second fuse.

So reading the setup screen for two minutes produced 'Setup failed — the backend never reported ready', replaced the setup screen, and stopped the IPC poll. Retry re-entered the bootstrap, parked at awaiting_setup again, and failed again on the same clock. Nothing the user could do escaped it, on the first screen a new install ever shows.

Reproduced live on a clean install: tauri.log ended at 'awaiting setup screen confirmation', the app data dir was empty, and complete_setup's own 'starting bootstrap' line never appeared. Nothing was ever attempted.

A stage only a person can leave cannot be judged stalled. Three tests: the setup screen survives ten minutes, it still hands off when the plan is submitted, and a genuinely wedged starting_backend still fails — so this does not trade #1376 for #879.
2026-08-07 07:26:27 +05:30
Palash Debnath 129fee71b3 fix(report): keep the root cause when a crash log is trimmed (#1376)
An auto-captured crash report clamps the backend's stderr to a budget by keeping the newest end. That is right for a plain log — the head is boot noise, the abort is at the bottom.

It is exactly wrong for a chained Python traceback, which prints root-cause-FIRST. So the section built to carry the cause reliably discarded it and kept the generic wrapper. #1376 arrived in that state: triage had to infer the torch/torchvision mismatch from the shape of the error pair rather than read it. The failure is worst where it hurts most — the bigger the traceback, the more certain the cause is to be cut.

clampCrashTail now recovers the first chain segment's exception line from the discarded head and prepends it, labelled. Non-chained logs keep the existing tail-only behaviour byte for byte, and a root cause still visible in the kept tail is not repeated.

Markers are matched as complete lines rather than substrings — stderr routinely quotes tracebacks, and a substring match would attribute a root cause from the wrong exception. Room for the prefix is reserved before slicing, so the result never exceeds the advertised budget.

9 regression tests; frontend suite 1688 passed.
2026-08-07 05:58:21 +05:30
Palash Debnath 93025d9a81 fix(dictation): stop the widget stranding an empty square, repair the swept data dirs (#1398)
The dictation hotkey could leave a blank dark square stuck on the desktop with no way to dismiss it. Three defects compounded: the tray listener's effect depended on [state], so it detached across an await on every state change and a press landing in that gap was lost; an idle pill renders null, so the window Rust had already shown was empty; and the opaque chrome background made that empty window a hard-edged square. Nothing could hide it — dismiss() is only reachable from the X button, Esc, or a post-session timer, none of which exist for a session that never started.

Fixed at the invariant rather than the call sites: the listener subscribes once for the component's lifetime, the widget window's chrome background is transparent, and an idle-but-visible window reconciles itself to hidden. The reconcile is polled (a dropped press changes no React state, so there is nothing to key an effect off) and aborts if its effect is torn down mid-check, so it can never hide a dictation that has just started.

Also in scope:

- The rename sweep had repointed three data-dir literals at a brand-named directory that does not exist, so smoke-test.sh verified a directory the backend never writes and desktop-prod.sh silently stopped clearing backend state on Windows. Both invisible on macOS, where they are usually run. A guard test now pins the assignments specifically.
- The dictation model picker's download sizes were wrong for all seven models, in both directions — Parakeet TDT v3 (the recommended default) understated 180 MB against an actual 670 MB, while the low-RAM fallbacks were overstated threefold, discouraging exactly the choice that would have helped. Measured from the published repos and pinned by a test.
- The 0.6B Parakeet models now decode on more threads, capped by host cores and still overridable.
- uninstall.ps1 gained a UTF-8 BOM (Windows PowerShell 5.1 mis-decodes its non-ASCII output without one), and sponsor.yml lost its last OmniVoice references.
2026-08-07 05:39:41 +05:30
Palash Debnath 74796bb2ab chore(agents): record issue tracker, triage labels and domain-doc layout (#1400)
Scaffolds the per-repo agent configuration the engineering skills assume: where issues live (GitHub Issues via the gh CLI), the five canonical triage labels, and the single-context domain-doc layout. Adds docs/agents/{issue-tracker,triage-labels,domain}.md and an '## Agent skills' block in both CLAUDE.md and AGENTS.md so the two stay in sync.

Documentation only — no runtime code is touched.

Review fixes: CONTEXT.md is now described as 'read it when it exists' rather than stating its absence as permanent, and both directory-tree fences carry a language identifier (markdownlint MD040).
2026-08-07 05:35:09 +05:30
Palash Debnath 43d3537adf fix(asr): stop a missing cuDNN 8 from killing the backend process (#1371)
CTranslate2 (the engine under WhisperX and faster-whisper) requires cuDNN 8 while torch ships cuDNN 9. When the side-loaded cuDNN 8 is absent it does not raise — it __fastfail()s, killing the whole backend with 0xC0000409 and no traceback, so the shell restarts it and the next attempt dies the same way.

The defect was that the backend computed the answer and discarded it: the preload passed silently on a missing directory and on every OSError, then called into a library that treats the same condition as fatal. The answer is now kept, and the CTranslate2 engines report themselves unavailable so auto-detect falls through to pytorch-whisper.

Two more instances of the same class went with it: the crash-isolated ASR sidecar is a child process that never preloaded at all (failing every transcribe, quietly), and the preload only searched <project root>/.venv, missing any other interpreter.

Conservative by design — a false positive costs WhisperX's forced alignment, so ROCm is excluded via torch.version.hip before cuda.is_available(), which is True on HIP builds.

14 regression tests; backend suite 4374 passed.
2026-08-07 05:24:05 +05:30
Palash Debnath 5cab8e0149 feat: rename the product to VoiceStudio (previously OmniVoice-Studio)
Renames what users see. The app, the installers, the window title, the
docs and all 21 locales now say VoiceStudio, with "(previously
OmniVoice-Studio)" noted near the title of each doc surface so people
recognise it.

Deliberately NOT renamed, because renaming any of them silently breaks
an existing install — there is no legacy-path fallback anywhere in this
codebase:

  - bundle identifier com.debpalash.omnivoice-studio (MSI UpgradeCode,
    macOS TCC grants, managed venv, WebView localStorage, the
    single-instance lock)
  - data directories OmniVoice / .omnivoice and omnivoice.db
  - the ~150 OMNIVOICE_* environment variables
  - the X-OmniVoice-* HTTP headers (a wire protocol)
  - the published Docker image paths
  - the OmniVoice ENGINE, which is a model name and not this product

tests/test_identity_paths_survive_the_rename.py pins every one of those
so a future well-meaning sweep cannot orphan a user's library.

Linux .deb users install a new package name and should apt remove
omnivoice-studio; that note is in the changelog.
2026-08-07 01:30:58 +05:30
Palash Debnath e61078fd09 fix(generate): never send a chunk with nothing to say (#1330)
The chunk splitter could end a chunk on a fragment with no speakable
character in it — a lone ".", "—", or "?" left behind by a boundary
landing just past the last word. The engine renders that to nothing, so
the pass was pure waste and, on a slow CPU, a visible stall.

The splitter now folds an unspeakable fragment back into its neighbour.
Where the fold would push the chunk past max_chars it moves a word
across instead of overflowing, and it never borrows a word that is
itself unspeakable (which would just recreate the dead chunk).
Measured on realistic prose: zero dead chunks, zero over-limit chunks.

Adds tests/test_no_unspeakable_chunks_1330.py — fails before, passes
after, and includes a randomised probe asserting any residual overflow
is punctuation-only and bounded.
2026-08-07 01:30:24 +05:30
Palash Debnath 67ab934907 fix(desktop): stop guessing 'still starting up' when we know it crashed (#1393)
Three open issues report the same message — #1337, #1351, #1378 — and two of them captured 'Last backend response: 2 s before this report' in the very same report. A backend that answered two seconds ago is not starting up. The message told those users something its own captured data contradicted, and sent them to wait and retry instead of at the crash notice and the backend log.

#1164 built exactly this honesty for dev and server deployments and excluded desktop, which is where most users are. Desktop now goes through the same mode-aware builder: it gains the last-contact story (answered-then-stopped vs never-answered) while keeping the forensics only it has — the crash notice, Settings → Logs → Backend, Retry and Clean & Retry — which a dev or server message would have replaced with a terminal and a docker log the user does not have.

The two recovery buttons are named through i18n rather than quoted in English, since they render as Réessayer / Nettoyer et réessayer and so on.
2026-08-06 19:59:52 +05:30
Palash Debnath a23e69d014 chore: point every repo reference at github.com/debpalash/VoiceStudio (#1394)
The repository was renamed. 724 references across 59 files now point at the new URL — README badges, docs, install guides, the updater's releases API call, CONTRIBUTING, the Colab link and the probe harness. GitHub redirects the old URLs, so nothing was broken in the meantime.

Deliberately NOT renamed, because each breaks something on a user's machine: the Tauri bundle identifier (the path to every existing user's data), /usr/lib/omnivoice-studio and the compose container names, and the published Docker image paths.

The image path needed a code change to STAY still: docker.yml derived it from github.repository, so the next build would have published to ghcr.io/debpalash/voicestudio while Docker Hub, a hardcoded literal, stayed put — everyone pulling the documented GHCR path would have kept receiving the last pre-rename image forever. It is now pinned, with a test that fails if it ever derives from the repo name again.

Also makes the probe's repo-name assertion shape-based: it hardcoded the old name and failed on every PR after the rename while the code it tests worked perfectly.
2026-08-06 18:15:56 +05:30
Palash Debnath d2eaee9fde fix(generate): say when a take came back missing text (#1388)
Reported as 'this app dosent generate me the last few sentences': the audio comes back clean and simply short, so nothing in the product ever said a sentence had gone missing. #1360 added a log line, which records the bug for us and tells the user nothing.

The join now collects the text of every chunk the engine rendered to nothing, and both delivery paths carry it back — response headers on the classic path, a warning frame before done on the streaming one — announced through one shared helper so the two cannot drift, quoting the lost text, plural-aware, in all 21 locales. Deliberately a warning rather than a failure: the take is real and playable.

Both carriers are exercised rather than grepped: a real NDJSON body through the streaming client, and POST /generate with an engine that renders one chunk to nothing.

This does not fix the underlying cause (the engine returning empty audio for certain chunks, still unreproduced); it stops that cause from being invisible, and the quoted text is the reproduction input we have been asking reporters for.
2026-08-06 16:49:57 +05:30
Palash Debnath 71ddfa5eda fix(generate): a render that keeps finishing chunks is not wedged (#1392)
The largest open cluster (#1338, #1348, #1391) is one shape: a long text on modest hardware, rendered chunk by chunk under a single execution budget, abandoned at 300s as 'too heavy for the available compute' after most of its chunks had already rendered. The user got a hardware verdict about a job that was working the whole time.

#1367 solved that disagreement for model downloads by making the pool guard listen to heartbeats rather than only its own clock. Synthesis has the same kind of evidence — a completed chunk — and now reports it, so the deadline extends while chunks keep landing, bounded by the same cap.

The synthesis freshness window is separate and much longer than the load one: sidecars report every ~5s, while a single chunk on a modest GPU can take minutes, and judging synthesis by the 30s load grace would call every slow-but-healthy render wedged. The wedge guard itself is unchanged — a job that completes nothing still dies at its original deadline, one that stops completing chunks dies a grace window later, an extension is still capped, and load heartbeats keep their own shorter grace.
2026-08-06 16:30:31 +05:30
Palash Debnath 247057b449 fix(crash): a broken Python environment is not a VRAM problem (#1389)
Two open issues arrived as a plain exit code 1 and were both handed the VRAM default: #1282 died 4s after launch on import torchaudio, and #1376 died 28s in on transformers' lazy loader raising ModuleNotFoundError. Neither user had a memory problem — their venv was half-installed, and they were sent to flush a model that had nothing to do with it.

Nothing in the exit code separates these cases; the traceback was already in the crash marker, unread because the hint only looked at exit_code/signal. It now reads the tail: an import failure naming a package the app cannot run without means the environment is incomplete, and the fix is Clean & Retry, which rebuilds it without touching voices or projects.

Only the most recent lines of the tail count, since backend_err.log is appended across runs and a process that died early carries the previous run's output. The branch sits after signal 9 (an OOM kill is an unambiguous fact about this process) and before the native-fault branch (a missing dependent DLL really does present as an access violation).
2026-08-06 16:14:29 +05:30
Palash Debnath e953af3730 fix(client): a foreign 404 page is a routing problem, not a backend error (#1386)
A rehosted UI whose API requests land on its own static host (or a reverse proxy with no API route) got that host's 404 page back, and we echoed it verbatim — the reporter saw 'NOT_FOUND bom1::...' and had no way to know their requests were reaching the wrong server.

The backend now stamps x-omnivoice-backend on every response, exposed through CORS, so the client can tell 'the backend answered 404' from 'something else answered 404' without guessing at body shape. A 404 in any other voice is reported as a routing problem, naming the URL that answered and where to fix it. The message goes through the same i18n helper as the other backend-diagnosis copy, in all 21 locales.
2026-08-06 15:13:08 +05:30
Palash Debnath 85d8df2707 test(translator): stop patching time.sleep for the whole process (#1390)
test_chat_non_429_does_not_retry failed on an unrelated PR with assert not [30.0, 30.0, 30.0, ...] even though _chat had never slept. translator.py does a plain import time, so tr.time is the stdlib module and patching it replaces time.sleep process-wide: subprocess_backend's sidecar idle reaper, which loops on time.sleep(30.0), wrote into the assertion's list and busy-looped while the patch was held. Any test that spawns a sidecar armed it, so this was a latent flake for the whole suite.

Sleeps are now recorded per thread — the test's own waits are captured, everyone else's really sleep — through one helper every sleep-patching test in the file uses. Proven fails-before/passes-after with a background thread actively calling time.sleep.
2026-08-06 14:29:14 +05:30
Palash Debnath 9877e7c218 fix(ci): bind the preview manifest to its run, not to sibling upload times (#1387)
The nightly preview build had been refusing to publish its own healthy manifest since 2026-08-05 — all four matrix legs green, but the macOS bundles uploaded a few minutes ahead of the slowest versioned artifact, and the freshness check compared the version-less darwin tarballs against their siblings with two minutes of slack. Legs finishing minutes apart is normal, so the comparison itself was wrong, and Preview-channel users quietly stopped getting builds.

The tarballs are now tied to the run that produced them: anything uploaded after this run's first job began executing belongs to it. A concurrency group serializes preview runs so that holds, and the anchor is the earliest job start rather than the run's created_at (which is stamped while a run is still queued, and would let a queued run claim the previous run's uploads). The preview-notes job also gains the actions: read scope its run-metadata lookup needs, with a warning-and-degrade path so a permissions regression cannot take the channel down again.

Regression tests cover the 2026-08-05 shape, the genuinely stale case, clock skew at the boundary, the no-timestamp fallback, per-ref concurrency scoping, and the required permission.
2026-08-06 14:12:44 +05:30
Palash Debnath eeffe6c2d1 fix(gguf): a source-built runtime must actually run (#1348) (#1384)
A meticulous report from an LXC/CPU-only source install surfaced three real defects: the build script deleted the libggml shared libraries a dynamically-linked build needs (first spawn died with exit 127), the hardcoded 120s per-spawn kill switch reaped legitimate CPU-only renders, and OMNIVOICE_ALLOWED_ORIGINS — the only fix for cross-origin browser access — was documented nowhere.

All platform branches of scripts/build-omnivoice-tts.sh now copy the shared libs next to the binary, the CI artifact glob uploads them, and the backend puts bin/ on the loader path for every spawn of the engine binary. The timeout defaults to 600s (above the pool guard's well-diagnosed 300s deadline), is tunable via OMNIVOICE_GGUF_GENERATE_TIMEOUT_S with non-finite values rejected, and the timeout error names the knob. CORS documented in api-auth.md with a pointer from remote-gpu.md. Regression tests pin the spawn-env rule, the per-branch copy rule, the artifact glob, and the timeout behavior.
2026-08-06 04:32:40 +05:30
Palash Debnath 0419f8dc0f fix(first-run): the wrapper could grow to the model list's full height (#1383)
Third generation of the pushed-off-screen Continue bug, this time measured rather than reasoned: the max-w-[1100px] wrapper was flex-1 without min-h-0, so per the flex spec its automatic minimum height was the full model list's height — the root's overflow-hidden then clipped everything below the window and the inner scroll clamps never had a bounded box to work in, at any UI scale.

Verified in Chromium (Playwright, DOM replica of the exact class structure): footer at y=3078 in a 900px window without the class, y=884 with it and the list scrolling. Fix is one class; the guard is a rule — every growable flex ancestor between the scroller and the wizard root must also carry min-h-0, asserted by walking the rendered chain, terminating at the root, and proving the chain was inspected.
2026-08-06 00:30:54 +05:30
Palash Debnath fedb41ecbc fix(first-run): UI scale no longer pushes Continue + HF token off screen (#1382)
On the Models & Engines step at UI scale > 1, the pinned Continue button and HF-token card rendered below the window edge with no scrollbar to reach them. The sticky layout itself was correct — the wrap was a full-viewport box with a bare inline zoom on the mount, so the zoomed content overran the window by (1 − 1/scale). Invisible at scale 1, which is how it shipped through two earlier fixes.

Fix is the .app-container #504 contract: shrink the box to viewport ÷ scale, zoom back, plus the WebKitGTK zoom-no-op fallback; the mount passes --ui-scale as a CSS variable. Guarded in SetupWizardChrome.test.jsx (4 of 7 cases fail against the previous source) alongside the existing structural pins keeping Continue/HF-token outside the scroller.
2026-08-05 22:51:03 +05:30
Palash Debnath df049e959f fix(dub): clone references live in their own job dir; deletes spare shared files (#1331) (#1381)
Root cause fix for #1331, covered from both directions.

Extraction half: on a content-hash cache hit the new job's vocals_path points into the old job's directory (cache working as designed), and both extraction call sites — per-speaker and the default per-segment — then wrote the new job's clone references into that older directory too. Deleting the older history entry orphaned them: every single-segment regen silently rendered in the default voice, and a full re-dub "fixed" it only because prep re-extracts. Clones now land in the current job's own dir; the AST test sweeps every extraction call so a third copy inherits the rule.

Deletion half: existing users' jobs already carry cross-directory references, and vocals are shared by design — so deleting a dub now checks whether any other saved dub still references files in its directory. If so, the history row is removed (the entry disappears as asked) but the directory is kept, with the holder logged. Clear-all untouched. 13 tests across both halves.
2026-08-05 21:47:15 +05:30
Palash Debnath 22301d4088 fix(crash-notice): say what the sentinel knows; gate the empty report (#1375) (#1380)
Several open reports were unanswerable by construction: the run sentinel fired on an unclean previous shutdown, the notice called it a crash, and the one-click bug report shipped an empty evidence block (#1243/#1336/#1345 are this shape).

No suppression — every marker still surfaces. Sentinel markers get honest wording naming the benign causes (sleep, force-quit, a stopped VM); the one-click report is offered only when there is evidence to put in it (a log tail, or a concrete exit code/signal — an exit code counts, since a native fault can die before logging a byte); an evidence-free dialog says why and points at Settings → Logs → Backend; the prefill's empty block says what is missing instead of reading as "nothing to report"; sentinel reports are titled "ended uncleanly", not "died". Three new strings in all 21 locales, each using its locale's own Settings-path labels. Full frontend suite green.
2026-08-05 18:58:01 +05:30
Palash Debnath 08a1b8f92c fix(generate): a healthy model download extends the budget it was blowing (#1367) (#1379)
Every subprocess engine cold-loads its model inside the synthesize handler, so a first-use generate on a slow connection spent its whole 300s execution budget downloading — then failed blaming the hardware, while the sidecar's watchdog was being fed progress frames the entire time.

The outer clock now listens to that evidence: SubprocessBackend forwards each sidecar progress frame to a per-worker-thread heartbeat (pool jobs only, cleared when the job ends), and the guarded waiter extends the deadline past the soft budget only while heartbeats stay fresher than MODEL_LOAD_HEARTBEAT_GRACE_S, bounded by MODEL_LOAD_EXTRA_TIMEOUT_S. The extension is logged once. A silent job still dies at the original deadline; stopped heartbeats kill within the grace; another thread's heartbeat is no alibi; caller cancellation cancels and consumes the abandoned future. 11 regression tests, headline case verified failing before.
2026-08-05 18:11:58 +05:30
Palash Debnath d23e56a5ec fix(errors): the transformers-import advice names torchvision now (#1376) (#1377)
The TRANSFORMERS_IMPORT hint and the ASR pipeline error told users to reinstall torch + torchaudio + transformers. torchvision — the package whose ABI mismatch actually produces this exact lazy-import wording (#1357's torchvision::nms, wrapped into "Could not import module 'AutoFeatureExtractor'") — was the one package the advice omitted. Following it to the letter left the broken package untouched (#1376).

Both surfaces now name the mismatch as a cause and prescribe the pinned reinstall with literal versions (desktop installs ship no deploy/, so the constraint-file form fails there) targeting the venv explicitly. A lockstep test asserts the exact command on every advice surface against deploy/torch-constraints.txt, so a pin bump stays red until the advice matches. Docs gain the same-wording-different-cause section (1a-bis).
2026-08-05 17:39:19 +05:30
5d6e05ef1b fix(errors): stop giving advice that cannot work (#1347, #1335, #1334) (#1374)
* fix(errors): a failed download is not a broken install (#1347, #1335)

Two reports, one shape: the error text carried both a network cause and a
downstream symptom, the taxonomy matched the symptom first, and the user
was sent to fix something that was never broken.

#1347 -- transcription failed with "transformers ASR pipeline failed to
import (AutoFeatureExtractor) -- your transformers install is incomplete;
reinstall with `uv pip install --reinstall transformers` ... Underlying:
Cannot send a request, as the client has been closed."

The install is fine. The pipeline was DOWNLOADING the feature extractor
when the shared HTTP client closed underneath it (#880). Reinstalling
transformers cannot fix a dropped connection, so the advice was not
merely unhelpful -- it was work the user could repeat forever without
succeeding. New MODEL_DOWNLOAD_INTERRUPTED class, checked before the
import rules, requiring the httpx closed-client wording AND an
import/transformers term so a bare closed-client error elsewhere is left
alone. Its hint says the partial download resumes, since otherwise
someone on a slow link assumes retrying restarts a multi-GB fetch.

#1335 -- a cut TLS connection reached /generate as a bare 500 carrying
`_ssl.c:1016`. core/failure.py has classified that since #1301, but
/generate keeps its own taxonomy and never learned it, so it fell to the
unrecognized-error catch-all. Added to the network signatures there: it
is a dropped download, and the remedy is retry, not Flush.

Both changes are orderings rather than new detections -- the cause now
beats the symptom -- and both keep the case the original rule existed
for: a genuinely broken transformers install still classifies as
TRANSFORMERS_IMPORT, and a failed handshake is still distinguished from a
cut connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(errors): a Windows paging-file limit is not out-of-memory (#1334)

Same class as the two fixes already on this branch: advice that cannot
work.

The reporter asked, reasonably, whether OmniVoice needs an internet
connection -- generation failed only when they disconnected, with a bare
500 carrying "The paging file is too small for this operation to complete
(os error 1455)". Two separate defects made that unanswerable:

1. /generate matched it in _is_oom_failure and said "Try the Flush button
   to reload the model". Flush cannot help. The hint we had already
   written for this exact class says so outright -- "closing other apps
   usually won't fix it" -- but the generate path never consulted it.
   Now branched before the OOM check, naming the virtual-memory setting
   and stating plainly that it is not a network problem.

2. WINDOWS_PAGING_FILE_TOO_SMALL was absent from
   _CONTEXT_FREE_HINT_CLASSES, so on the raw-500 surface classify()
   identified it correctly and then attached nothing. The user got the OS
   sentence and no next step, despite the detailed remedy sitting in
   _HINTS. Its trigger (1455 with winerror/os error, or the literal
   phrase) is unmistakable, which is the bar that set requires.

Both Python (`WinError 1455`) and Rust (`os error 1455`, from the
safetensors mmap) spellings are covered, and a genuine CUDA OOM still
gets the Flush hint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(errors): tighten both new matches, and move the 1455 expectation

CI caught a real one, and it was my process error: I ran the full sweep
before adding the paging-file change, not after.

tests/test_generation_audio_guard.py listed WinError 1455 among the OOM
signatures and asserted it yields "ran out of memory / try Flush". The
new branch routes it to the paging-file advice instead. That test's
INTENT -- a genuine memory failure must never fall through to the unknown
catch-all -- is preserved and still asserted; 1455 simply gets a more
specific memory message now. Expectation moved, guard kept.

Two over-broad matches tightened (CodeRabbit), both in the same
direction: a rule that fires too widely replaces correct advice with
advice that cannot work, which is the exact defect this branch exists to
fix.

* The TLS EOF wording is OpenSSL's, but nothing stops an unrelated
  component saying something similar, and calling a local fault a network
  problem sends the user to check a connection that was never involved.
  Now gated on an `ssl` marker; the real message always carries it.
* MODEL_DOWNLOAD_INTERRUPTED required "client has been closed" OR
  "cannot send a request". The latter alone is generic enough to appear
  beside an unrelated import failure, where overriding TRANSFORMERS_IMPORT
  would swap correct reinstall advice for a "just retry" that never
  succeeds. Now requires the closed-client wording itself.

Negative regression tests for both, plus the positive cases they must not
cost us. Also resolves core.failure through a fixture at call time rather
than importing it at module level, per the suite convention -- sibling
tests reload these modules and a stale binding makes the file
order-dependent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 14:45:29 +05:30
973f7a77a6 fix(generate): a deadline is not an unrecognized error (#1368) (#1373)
* fix(generate): a deadline is not an unrecognized error (#1368)

Reported on macOS/MPS with indextts2:

    TTS engine stopped mid-generation with an error OmniVoice doesn't
    recognize. Retry once; if it keeps failing, please report it with the
    full trace. Underlying error: TimeoutError:

Nothing follows that last colon. TimeoutError is routinely raised with an
empty message, so the user was asked to report a trace that says nothing,
about the one failure mode whose cause is entirely known.

The classification chain already refuses to blame VRAM for network (#880)
and config (#919) failures. A deadline is the same kind of thing -- a
known class with a specific remedy -- and it was falling through to the
catch-all. Worse, a timeout whose message happened to contain an OOM-ish
word would have hit the memory branch and sent the user to Flush for
memory they never ran out of, which is exactly the class bug #880 fixed.

_is_timeout_failure() matches TimeoutError (and asyncio/futures aliases),
the project's own GpuJobTimeoutError by name since it is not a subclass,
and stringified forms from sidecars that wrap the child's error. Checked
BEFORE the OOM branch. "read timed out" is explicitly left to the network
branch: that is a dying model download, which it explains better.

The message names the time limit, the three usual causes, and
OMNIVOICE_GENERATE_TIMEOUT_S so someone on slow hardware has a way
through rather than only an explanation. The "Underlying error" tail is
appended only when the exception actually carries a message -- otherwise
it rendered as a bare `TimeoutError:`, a sentence stopping mid-thought.
Testing that emptiness against str(e), not _safe_exc_text(), which always
prefixes the type name and so is never empty.

Likely the in-the-wild face of #1367: indextts2 is a sidecar, and a
first-use weights download overrunning the 300s generate budget produces
precisely this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(generate): drop placeholder-free f-prefixes (#1368)

CodeRabbit on #1373: after the interpolation moved into `_tail`, the
message literals no longer interpolate anything, so the f-prefixes were
dead weight and Ruff F541. Ruff does not run in CI, so this is
consistency with the surrounding code rather than a broken gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:43:29 +05:30
e8bb174a0b fix(startup): explain a lost port race instead of exiting 1 in silence (#1364) (#1370)
* fix(startup): explain a lost port race instead of exiting 1 in silence (#1364)

#1223 gave a port conflict a dedicated exit code, and handles the race
between our pre-probe and uvicorn's real bind by re-probing after uvicorn
dies. That only helps while the other process is STILL holding the port.

The common case is an orphaned backend from the previous session which is
itself shutting down. It releases the port between uvicorn's failed bind
and our re-probe, the probe reports "free", and the user gets a bare
`exit code 1` -- for a crash we had already fully diagnosed. Reported on
Windows with the tell-tale ordering: `Application startup complete`
(uvicorn's lifespan runs before the bind), then `[Errno 10048] error
while attempting to bind`, then a plain exit 1.

uvicorn already hands us the answer: its startup does
`logger.error(exc); sys.exit(1)` with the OSError itself as the record's
message, so the errno is available as an object -- no locale-dependent
string matching, which is the trap #1223 exists to avoid. Observe that
record, believe it first, and keep the re-probe as the fallback.

The watcher also pins the uvicorn.error level to at most ERROR: a filter
only runs on records the logger emits, so a higher level would drop the
bind failure and silently restore the unexplained exit 1. No-op today
(uvicorn defaults to INFO and nothing here raises it) -- it stops the
mechanism being disarmed at a distance later.

Two regression tests, both driving the real uvicorn. The race is
simulated deterministically by making both probes report the port free
while it is genuinely held, which is exactly the state the race leaves
us in; verified to exit 1 without the watcher and 78 with it. The
assertion is on our own wording, not "already in use" -- that phrase is
also in uvicorn's own English log line, so matching it would pass against
the unfixed build and would be the very locale-dependent match #1223
forbids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(startup): pin what the bind watcher actually depends on (#1364)

Review round on #1370.

greptile P1 claimed uvicorn's logging setup removes the filter, making
the watcher inert. Measured against the installed uvicorn: it does not.
`dictConfig` replaces a logger's HANDLERS and leaves its FILTERS alone,
and the end-to-end test already exercised the real `uvicorn.run` and got
exit 78.

The conclusion was right for a different reason, though, and it caught a
genuine mistake: uvicorn resets `uvicorn.error`'s LEVEL from its config
during startup -- after the defensive `setLevel(ERROR)` this added. That
guard was dead code offering false assurance, so it is gone. The real
precondition is that the guarded `uvicorn.run()` must not raise log_level
above ERROR, since a filter only runs on records the logger emits.

Both behaviours are now pinned by tests that measure the installed
uvicorn rather than assuming it, so a version that starts clearing
filters, or a change that quietens the guarded serve call, fails loudly
instead of silently restoring the unexplained exit 1. The log_level test
is scoped to the guarded call -- the --health-check smoke path sets
log_level="warning", which is below ERROR and irrelevant.

Also pins the production wiring itself (CodeRabbit): the end-to-end tests
rebuild the guard from extracted source, so they would still pass if
main.py stopped installing the filter or stopped consulting it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(startup): check log_level by AST, not regex (#1364)

CodeRabbit on #1370: the regex only recognised string literals, so
`log_level=settings.level` -- or any computed value -- matched nothing
and the assertion passed while verifying nothing.

That is the same class of bug as #1357's pin that did not apply: a check
that looks present and is inert. Parsed with ast now; a non-literal is an
explicit failure rather than a silent skip, and the guarded call is
located by walking to the addFilter and taking the uvicorn.run after it
instead of by source order.

Verified against three mutations of main.py:

    literal critical    -> FAILED
    computed value      -> FAILED
    literal warning     -> passed   (below ERROR, must not fail)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:14:27 +05:30
c819372449 fix(install): make the torch pin reach the Colab and Docker installs (#1357) (#1369)
* fix(install): make the torch pin reach the Colab and Docker installs (#1357)

#1358 pinned the trio in `[tool.uv] constraint-dependencies`, which fixes
`uv sync` / `uv lock` / `uv run`. It does nothing for `uv pip install` --
that is the pip-compatible interface and ignores project-level uv
settings -- and `uv pip install --system --no-cache .` is exactly what
both the Colab notebook and deploy/Dockerfile run.

Measured on one Python 3.12 environment, same command, pin present:

    without --constraint:  torch 2.13.0  torchaudio 2.11.0  torchvision 0.28.0
    with    --constraint:  torch 2.8.0   torchaudio 2.8.0   torchvision 0.23.0

So the reported install path was still resolving the three on their bare
lower bounds (`torch>=2.4`, `torchvision>=0.19`), free to move torch past
a torchvision built for an older ABI -- which is the reported failure,
`operator torchvision::nms does not exist`, against the preinstalled
torchvision in Colab's /usr/local/lib/python3.12/dist-packages/.

The pins move to deploy/torch-constraints.txt and are passed explicitly
at both call sites. No local version segment, so PEP 440 matches the
base images' +cu128 and +rocm6.4 builds instead of replacing them -- the
property the ROCm image depends on.

Also extends the Docker guard, which asserted on torch and torchaudio
only, omitting the one package that actually broke. It now imports
torchvision.ops and touches nms, so an ABI mismatch fails the build
rather than shipping.

Recurrence: docker.yml builds only on push to main, never on a PR, so
nothing would have caught a silent regression here before it shipped.
tests/test_torch_constraints_are_applied.py fails if the file drifts from
pyproject, if either call site drops --constraint, if the Dockerfile stops
COPYing the file, or if the guard stops covering torchvision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(install): assert the constraint in the argv, not the cell text (#1357)

CodeRabbit on #1369, both findings valid.

The notebook check scanned the whole cell, so it passed when --constraint
was deleted from the run([...]) list but its explanatory comment
survived -- exactly the "the pin looks present but does not apply" shape
this PR exists to fix. It now parses the cell with ast and asserts
--constraint is in the argument list AND immediately followed by the
constraints file. Verified by deleting the flag from the argv while
keeping the comment: the test fails.

Also drops test_the_notebook_is_still_valid_json_and_has_its_cells --
it passed before the change and duplicated JSON parsing the constraint
test already does. A tautology.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 02:03:57 +05:30
debpalashandClaude Opus 5 f259a3481f docs(changelog): record the PocketTTS engine (#1306, #1328)
#1328 merged without a CHANGELOG entry. Per the changelog rule that is
the immediate next commit rather than backlog, since release.yml extracts
the section verbatim as the release body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:16:47 +05:30
acd36badee feat(engines): PocketTTS CPU-only sidecar shape (#1306) (#1328)
* feat(engines): PocketTTS CPU-only sidecar shape (#1306)

Sidecar SHAPE for review, mirroring omnivoice-subprocess: PocketTTSBackend(SubprocessBackend) (CPU-only, parent interpreter, optional-dep gate) plus a stdio sidecar (ready/ping/synthesize/shutdown, lazy TTSModel.load_model, per-ref voice cache, generate_audio to int16 PCM). Registered in services/tts_backend.py. Batch protocol; streaming raised as a follow-up. CI smoke, gated-weights preflight, 4-platform install, licence-accept gate deferred to on-top after shape review.

* feat(engines): PocketTTS sidecar handles 6 languages (en/fr/de/pt/it/es)

load_model(language=...) per language (cached), maps OmniVoice's language value to a pocket-tts model language, and picks the default preset voice per language when no ref clip is given. Represents PocketTTS accurately: it is multilingual, not english-only. The HF model card's 'English only' line is stale, confirmed by the GitHub README and pocket-tts 2.1.0.

* fix(engines): list pockettts in docs inventory; drop unused logger

docs/features.yaml tts_engines now includes pockettts, clearing the docs-drift test that failed CI (every registered engine must be in the inventory). Removed the unused logger line CodeQL flagged. No readme/doc entry yet, matching opt-in engines like supertonic3 and omnivoice-gguf; a doc page can land with the rest of the integration.

* fix(engines): address PocketTTS sidecar review findings

- Cold-load watchdog: heartbeat progress frames during the gated weights download so the parent does not kill a healthy sidecar mid-load, plus a 600s recv timeout on the backend.
- Unsupported language: raise a clear error instead of silently falling back to English and mispronouncing.
- Voice-state cache: LRU-bounded to 8 entries so a long session cannot leak memory.
- ref_audio SSRF: reject URLs (local file paths only) to preserve local-first.

Addresses the 3 Greptile P1 + 1 CodeRabbit Major on #1328.

* fix(engines): invalidate voice cache on ref-file change; reject non-finite recv timeout

- Voice-state cache key now folds the ref_audio file mtime+size, so a file replaced at the same path no longer returns a stale voice from the previous contents (Greptile P1).
- recv_timeout_s rejects inf/nan env values via math.isfinite and falls back to 600s, so the deadline can't be silently disabled (CodeRabbit Major).

* fix(engines): nanosecond mtime in voice cache fingerprint

int(st.st_mtime) lost sub-second precision, so a file replaced at the same path within one second with the same size kept the old key and returned a stale voice. Use st.st_mtime_ns for full resolution (Greptile P1 on the follow-up fix commit).

* fix(engines): raise on multi-channel audio instead of unsafe downmix

The defensive mean(axis=0) assumed channels-first; on channels-last (N,2) it averaged across time, producing garbage. The engine returns mono, so the branch is unreachable in practice. Raise on ndim>1 so an upstream shape change surfaces as a loud error frame instead of silent noise. (debpalash review on #1328)

* fix(engines): include import error in pockettts is_available message

CodeRabbit Minor on #1328: the exception was caught as 'e' but never shown.

* fix(engines): lock _send to prevent concurrent-write framing corruption

Greptile P1 on #1328: the cold-load heartbeat thread and the main loop both call _send (stdout write). The stop+join serializes the normal case, but a join timeout leaves a window where both threads write length+body segments concurrently, interleaving the wire framing. Add a threading.Lock around the write so concurrent _send calls are serialized regardless.

* test(engines): cover the PocketTTS sidecar's silent failure modes

The four review findings fixed on this PR are all silent by construction:
an unsupported language rendered fluent, confident, wrong audio; the
channels-last downmix produced noise; interleaved frames desynchronized
the pipe permanently; a re-recorded clip kept serving the old voice. None
of them raise, and none would be caught by an end-to-end smoke test that
only asserts audio came back.

49 tests over the sidecar's pure logic — language selection, PCM
conversion, wire framing, the LRU voice cache — plus the backend surface
(recv-timeout guards, CPU-only declaration, sample-rate lockstep with the
sidecar, lazy registration). The model is mocked and the sidecar is
stdlib-only at import time, so none of it needs the optional pocket-tts
wheel or a child process.

Verified fail-before/pass-after by reverting the lock and the multi-channel
guard: the framing test fails with a length header decoded from inside
another frame's body, which is the corruption itself rather than a proxy
for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 00:34:30 +05:30
3183bf5fcd fix(engines): downmix along the channel axis, not axis 0 (#1328) (#1366)
* fix(engines): downmix along the channel axis, not axis 0 (#1328)

Found while reviewing #1328. Every subprocess sidecar guards its PCM
conversion with a defensive `arr.mean(axis=0)`, which is correct only for
channels-first audio. For a channels-last (N, 2) array `squeeze()` keeps
both axes and the mean runs across TIME: every output sample becomes the
mean of two neighbouring samples and the render collapses to 2 samples.
That is not a downmix, it is a destroyed waveform played back as noise.

Unreachable in all five today because every engine returns mono -- which
is precisely why it could sit there being wrong. Nothing runs it, so
nothing reports it, and the first engine or SDK version to emit stereo
gets noise with no error anywhere.

Pick the channel axis instead of assuming it, and loop so a stray extra
axis reduces the whole way to mono; previously a (2, N, 2) array stayed
2-D after one mean and produced a PCM buffer whose length disagreed with
the n_samples in the frame -- a desynchronized audio frame rather than a
merely wrong-sounding one.

Downmixing correctly rather than raising (the choice PocketTTS made on
#1328): these five are shipping engines, and turning a render that works
today into an error is a regression risk that the actual defect -- the
wrong axis -- does not require taking.

The sidecars run under different interpreters (confucius4 and dots.tts
each have their own venv), so they cannot import a shared helper and the
duplication cannot be refactored away. The recurrence guard is therefore
a test that holds all five to the same behaviour at once, so a sixth copy
pasted into a new sidecar fails there rather than shipping: 20 of its 30
cases fail before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(engines): let a broken sidecar fail instead of skipping

CodeRabbit Major on #1366: the blanket `except Exception -> pytest.skip`
turned a syntax error or an import-time regression in any of the five
sidecars into a skip, so this regression suite could pass CI while
running nothing.

All five are stdlib-only at import (torch and the model load lazily on
the first synthesize), so there is no optional dependency to tolerate --
an import failure here is a real defect in a shipping engine. Unguarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <nizam4103@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 23:50:06 +05:30
c117bca09e fix(release): rebuild + cryptographically verify the preview updater manifest (#1327) (#1362)
* fix(release): rebuild + cryptographically verify the preview updater manifest

Since ~2026-07-13 every nightly matrix leg logs 'Signature not found for
the updater JSON. Skipping upload...' - tauri-action uploads the bundles
and .sig companions but never refreshes latest.json. Combined with the
'Clear this arch's stale preview updater bundle' step (which deletes and
replaces the version-less macOS tar.gz every night), the preview
manifest's darwin signatures no longer match the published files: macOS
Preview users hit 'The signature verification failed' on every update
(latest.json frozen at 2026-07-13, tar.gz replaced nightly).

Two changes, both in the single post-matrix preview-notes job (no
per-leg race):

1. Rebuild latest.json 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.

2. Extend the existing manifest verification with a cryptographic
   check: every signature in latest.json must verify (minisign
   file sig + trusted-comment sig) against the artifact it points at,
   using the updater pubkey from tauri.conf.json. Parity and version
   format both passed for 2+ weeks while every darwin entry was
   unverifiable - this is the check that was missing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): refuse a preview manifest built from two different runs

Bot review findings on this branch, all fixed here:

- The AppImage and MSI were picked independently by highest run number and
  the larger N became *the* version, so a matrix where one leg failed or
  was re-run published a manifest advertising X.Y.Z-5 while handing Windows
  users the -4 MSI. That is the same manifest/artifact drift this job
  exists to end, reintroduced by the fix for it. Require both legs to come
  from one run and fail loudly otherwise: leaving the previous manifest in
  place is a visible, already-understood state; shipping a mismatched one
  is not. The darwin tarballs carry no run number, so the signature check
  in the following step is what pins those to the published bytes.
- persist-credentials: false on the checkout — nothing here pushes to git.
- Floor-pin the cryptography install; this step decides whether a signed
  manifest is trustworthy, so it is the one dependency worth a bound.

tests/test_release_preview_manifest_rebuild.py runs the step body extracted
from release.yml against stubbed gh, so it cannot drift from the workflow.
Fails before / passes after on the mismatch case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): note the preview updater manifest fix (#1327)

Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): verify the preview manifest before publishing it, not after

Two more review findings on this branch, both valid, both about the
manifest being wrong in a way the existing checks structurally cannot see.

greptile P1 — verification ran AFTER the clobber-upload. A manifest that
failed the check was already live and stayed served; the job merely went
red, and every macOS Preview user stayed broken until someone noticed.
Verification now runs against the file about to be published, and the
upload is the last thing in the step. A refusal leaves the previous
manifest in place, which is a visible, already-understood state.

CodeRabbit — the darwin entries were not tied to this run. The version
comes from the AppImage name; the macOS tarballs were only checked for
existence. Signature verification cannot help there, because a stale
tarball and its stale .sig match each other perfectly — so a run whose
macOS legs never uploaded would advertise this version while serving Mac
users the previous build, and since those clients keep reporting the old
version the updater would re-offer it forever. They are now bound by
upload time, with two minutes of slack for legs that finish apart.

The selection rules move out of the YAML heredoc into
scripts/build_preview_manifest.py. Three findings in a row have been about
WHICH artifacts may be described together, and a heredoc can only be
tested by extracting it and stubbing a shell — which is what the previous
test file did, asserting against gh stubs rather than against the rules.
build_manifest is pure: assets in, manifest out, ManifestRefused on
anything it will not describe.

14 tests, including both new refusals and two that pin the workflow still
calls the module and still uploads last — an inline copy would pass every
other test and ship the original bug.

Co-Authored-By: Pinkers01 <pinky.bouw@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Pinkers01 <pinky.bouw@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:41:45 +05:30
Palash DebnathandClaude Opus 5 946ef97af7 test: stop the load-budget test racing the AudioSeal cold start (#1363)
test_speech_survives_a_load_slower_than_the_generate_budget passed in a
full session and failed when the file was run alone — the wrong way round,
because the isolated run is the honest one.

The cause is not ordering as such. Watermarking runs INSIDE the generate
budget, and its first call in a process loads the AudioSeal model. That
takes longer than the 0.2s budget this test deliberately sets, so the
request 503`d on the watermark rather than on anything to do with the
load/generate split it exists to prove. In a full session an earlier test
had already warmed AudioSeal, so it passed for a reason unrelated to what
it asserts.

Disabled for the duration. The budget asymmetry is what is under test; the
watermark is an unrelated cold start that happened to ride in the same
window. Verified isolated, in a session with its sibling timeout suites,
and repeatedly.

Pre-existing on main, unrelated to any current change — found while running
related suites for #1338.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:19:45 +05:30
Palash DebnathandClaude Opus 5 680c57f9b9 fix(dub): say when a clone reference is gone instead of rendering a default voice (#1331) (#1361)
* fix(dub): say when a clone reference is gone instead of rendering a default voice

Reported on Discord: "if you re-dub individual sentences the voice isn`t
taken from the video — you have to re-dub everything for the voice clone
to work."

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. Nothing checked it, and 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. A full re-dub re-extracts the clips, which is
exactly why that appears to fix it and is the workaround the reporter
found unaided.

Diagnostic ONLY, deliberately: the reference is passed to the engine
unchanged. Nulling it would not alter what the user hears — the engine
already falls back — and it would decide on the engine`s behalf that a
path it cannot stat is unusable, which is untrue for anything resolved
inside a sidecar`s own namespace. The defect is the silence, not the
fallback. (The first cut did null it, and broke seven existing tests that
legitimately assert a synthetic path reaches the engine; that was the
right signal.)

Warns once per segment per job, so a 300-segment dub whose clips were
cleaned logs which lines lost their reference rather than one line per
retry — "some of them" is not actionable.

Root cause of the cleanup itself is not addressed here; this is what makes
the next report carry the paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(dub): key the missing-ref memo on the path, not the segment alone

greptile P1, valid. The single-segment preview endpoint has no segment
identity to pass — it is a "render this text" call — so every preview
shared the key "preview" and only the FIRST missing reference in a job was
ever reported. Every later one, with a different path, was silenced: the
de-duplication meant to stop repetition was swallowing new facts.

Keying on (segment, path) fixes it without an API change, and is more
correct on the render path too: a segment rebound to a second missing clip
is no longer mistaken for the one already reported.

SegmentPreviewRequest also gains an optional segment_id, diagnostic-only
and defaulted to None so existing callers are unaffected — a caller that
supplies it gets the line named instead of a bare "preview".

Three tests; the distinct-paths one fails against the segment-only key
while the repeat-suppression one keeps passing, so the fix cannot be a
blanket removal of the de-duplication.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:56:52 +05:30
1cbd1c72fa fix(colab): pin torchvision==0.23.0 to resolve torchvision::nms operator missing error (#1357) (#1358)
* fix(colab): pin torchvision==0.23.0 to resolve torchvision::nms operator missing error (#1357)

This resolves the runtime import failure of transformers.HiggsAudioV2TokenizerModel due to torchvision mismatch with torch 2.8.0. Changes:

- pyproject.toml: Added torchvision>=0.19 to dependencies, pinned to 0.23.0 in constraint-dependencies, and configured the pytorch-cuda source index.

- uv.lock: Regenerated lockfile to resolve torchvision 0.23.0+cu128.

- bootstrap.rs: Added torchvision to rocm_torch_reinstall_args and updated the matching unit test.

* fix(bootstrap): pin versions in ROCm reinstall path and add CHANGELOG entry (#1358)

* test(rocm): pin bootstrap.rs to pyproject constraint, mechanically

The PR adds a "Keep in sync with [tool.uv.constraint-dependencies]" comment
above rocm_torch_reinstall_args. That is the right instruction and a
comment cannot enforce it, so it becomes a test — CLAUDE.md`s convention is
that a rule a reviewer has to remember belongs in one.

It matters more here than the usual lockstep case: an AMD user`s install
does not come from uv.lock at all. bootstrap.rs shells out to pip against
the ROCm index, so whatever it names there is the Torch stack that user
actually runs, and a drift produces no install-time error — it surfaces
later as "operator torchvision::nms does not exist" or a silent CPU
fallback, which is #972 and #1357 from two different directions.

Three cases: the constraint block still pins the trio (so the rest cannot
pass vacuously), the ROCm pins equal it, and all three are reinstalled
together — a subset leaves the others on CUDA wheels the ROCm build cannot
pair with. Both drift cases fail against the previous, unpinned form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 16:27:36 +05:30
Palash DebnathandClaude Opus 5 097589295f fix(tts): a chunk that renders to nothing is no longer dropped in silence (#1330) (#1360)
* fix(tts): a chunk that renders to nothing is no longer dropped in silence

Reported on Discord: "this app dosent generate me the last few sentences.
for rest this app is a banger" — clean audio, just missing the end.

Ruled out by direct probe rather than by reading: split_text_into_chunks
preserves every non-whitespace character (including text with no terminal
punctuation), concatenate_audio_chunks joins everything it is given, and
trim_trailing_silence cuts only from the last VOICED sample so it cannot
remove speech. Those three are pinned by a test now so the elimination
does not have to be redone.

What was left is the filter at the top of the join:

    chunks = [c for c in chunks if c is not None and c.shape[-1] > 0]

When an engine returns nothing for one slice of text, skipping it is still
the right joining behaviour — the alternative is a crash or a gap. Doing
it in silence is not: the waveform looks perfect and is simply short, so
the failure can only be found by reading along while listening.

It now counts the drops, logs at WARNING (this is output the user paid
compute for), and names the lost text where the caller can supply it —
wired through all three generation call sites and audiobook.

Audiobook also pre-filtered before calling, which both hid the same drop a
second time and misaligned rendered from chunks so the concat could not
name what was lost. It passes the full list now.

Not the root cause of the engine returning nothing — that needs a
reproducing input, which is exactly what this makes obtainable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(audiobook): the one-survivor branch skipped the drop reporting

Both reviewers caught the same hole, and they were right: a span that
splits into several chunks where only ONE renders returned that chunk
directly, skipping the join — and therefore skipping the reporting the
join does. The chapter came back short and said nothing, which is this
very bug one branch over. Zero survivors had the same problem.

Rather than add two more conditions to an inline branch, the decision
moves into chunked_tts.join_rendered_chunks: kept/dropped, report, and
return None when nothing rendered so the caller`s dead-render handling
still owns that case instead of receiving a silence buffer. One place that
can be wrong, and a testable one — a second inline copy is how the hole
appeared to begin with, so a test pins that audiobook routes through it.

Also, on CodeRabbit`s test note: test_reporting_never_breaks_the_join now
asserts the report was ATTEMPTED, not merely that nothing blew up —
otherwise it would pass on a build where the reporting does not exist.
test_chunking_itself_loses_no_text stays, with its docstring saying plainly
that it pins an eliminated hypothesis rather than a fixed defect: "the
chunker drops the tail" was the first explanation for #1330, ruling it out
took a probe, and a future splitter change that did lose the tail would
reproduce the reported symptom exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:08:36 +05:30
Palash DebnathandClaude Opus 5 1a4b95890a fix(llm): LM Studio translation failed on a placeholder model name (#1332) (#1359)
* fix(llm): LM Studio translation failed on a placeholder model name (#1332)

Reported as a clean A/B: translation works through Ollama and fails
through LM Studio on the same machine. The difference is one line in the
provider table. LM Studio shipped `local-model` as its default_model,
which is a placeholder, not a model id — LM Studio serves whatever the
user has loaded and 404s a name it does not know. Ollamas default is
`llama3.1`, a real name people actually pull, so the identical code path
worked there.

No name we ship can be right, because the answer depends on what the user
loaded. So ask the server: resolve_model now discovers from /v1/models for
providers whose default is a placeholder, positioned BELOW any explicit
env or stored setting so it can never override a deliberate choice, and
above the default so a server that is down leaves the caller where it was.

Cached per provider — translation resolves the model per segment and a
round-trip each time would trade a broken setup for a slow one — and
dropped whenever a base_url or model edit could invalidate it, since a
stale id would make the users change look like it did nothing.

Also: a 404 from a LOCAL provider is almost never a wrong URL, because the
request reached the server. The generic "check the model name and Base URL
path" sends the user to audit a URL that works, so a local 404 now names
the models that ARE loaded, or says the server has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llm): bound the discovery cache both ways; do not over-claim a 404

Three review findings, all valid, all about the cache being permanent in
one direction or absent in the other.

- A failed probe was not remembered, so a stopped LM Studio cost a 5s
  timeout on EVERY translated segment — a 200-segment dub would spend
  1000s discovering nothing, worse than the bug being fixed. Remembered
  for 30s: short enough that starting the server recovers in seconds
  rather than needing a restart.
- A successful discovery was cached forever, so swapping the loaded model
  inside LM Studio 404d every translation until an app restart. Now a 300s
  TTL, plus an immediate invalidation when a local 404 proves the cached
  name is one the server rejects.
- _local_models collapsed a FAILED listing into [], which let the error say
  "reports no loaded models" about a lookup that never happened — a
  confident wrong diagnosis replacing a vague right one. None vs [] are
  now distinct, and the generic 404 text stands when nothing was
  established.

The cache therefore cannot be a dict[str, str]: "no entry" and "we looked
and there was nothing" have to be distinguishable for the negative case to
be cacheable at all.

Five tests, three failing before this change. They age the cache entry
rather than patching time.monotonic — that name is the stdlib`s, shared
with sqlite and logging, and freezing it breaks the settings store
underneath the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:48:56 +05:30
Palash DebnathandClaude Opus 5 c03e3f2525 fix(gpu): record WHERE a wedged job was stuck when its budget expires (#1338) (#1355)
* fix(gpu): record WHERE a wedged job was stuck when its budget expires

Three reporters on v0.4.2 hit "TTS generate ran for more than 300s of
actual compute time and was abandoned" (#1338, #1329, #1348) — two of them
on an RTX 3050 and an RTX 3060, rendering a single sentence. That is not a
machine too slow for the job. The message says "too heavy for the
available compute" because it is the only story the timeout path can tell.

And nothing in the log could contradict it. The timeout branch logged THAT
the budget was exceeded, reset the pool, and returned. The worker cannot
be cancelled, so it was still running on a real stack — and we threw that
away, which is why every report of this class arrives undiagnosable and
the only advice available is "reproduce it under a debugger".

sys._current_frames() reads the frame of every live thread including one
wedged inside a C call, which is exactly this case. Filtered to gpu-pool
workers so the log names the stuck job rather than the web server, capped
at 25 frames, and it can never raise — a diagnostic that throws would
replace a real GpuJobTimeoutError with an unrelated crash.

Ordering is load-bearing and asserted: the capture runs BEFORE reset(),
because reset() swaps in a fresh executor and the wedged thread then stops
being identifiable as a pool worker — the diagnostic would still run, still
log, and be empty, which looks like it worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gpu): redact home paths from captured stacks; label stale workers

Both review findings on this PR, both valid.

CodeRabbit (CWE-532): traceback frames carry absolute source paths, which
on a user machine start with their home directory — their account name.
This log lands in backend.log, which goes into diagnostic bundles and
prefilled bug reports, so it has to be sanitized like every other surfaced
text. Reuses core.failure.sanitize rather than inventing a second answer;
if sanitizing itself fails the stacks are dropped, not logged raw.

greptile P1: a wedged worker survives reset() — it cannot be cancelled and
keeps running under the same gpu-pool name the replacement pool uses. The
second timeout in a session would log both with nothing to tell them
apart, and the stale one is the more misleading, since it names an
operation that is not the job that just failed. The live pool is now
identified through its own thread set and the others are marked STALE.

That set comes from ThreadPoolExecutor._threads, which is private, so
unknown internals degrade to labelling nothing rather than to failing —
a diagnostic that vanishes because an attribute moved is worse than an
unlabelled one, and that degrade path has its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:52:18 +05:30
Palash DebnathandClaude Opus 5 95de0afb02 fix(appimage): the preload probe never ran, and tested the wrong value (#1333) (#1356)
* fix(appimage): the preload probe never ran, and tested the wrong value

Two CodeRabbit Majors on #1354, landed after merge because I merged before
reading them. Both silently DISABLED the feature rather than breaking
loudly, which is the shape that survives a green suite.

1. `command -v true` answers with the shell BUILTIN — the bare word "true",
   not a path — so `[ -x "true" ]` was false on every host, the probe always
   failed, the preload never happened, and #1333 was left exactly as it was.
   A builtin never involves the dynamic loader, so it could not have tested
   anything even if it had run. Now resolves a real binary (/usr/bin/true,
   /bin/true, or /bin/sh -c : as the guaranteed last resort).

2. The probe took our library alone, but the exported value appends any
   inherited LD_PRELOAD — so the probe could pass while the environment the
   app actually gets fails. It now probes the final value.

The suite missed both because every existing case overrides the probe via
OMNIVOICE_APPRUN_PRELOAD_PROBE. The new default-probe case is what closes
that hole; the inherited-entry case is the discriminator for (2). Both fail
against the previous AppRun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): drop a probe fallback that could never resolve; cover /bin/sh

CodeRabbit, valid. The PATH entry looked up `coreutils`, which is not an
executable name, so that branch could never resolve — a fallback in shape
only. Deleted rather than repaired: a PATH lookup is what caused the
original builtin bug, and the list already terminates at /bin/sh, which is
present on any host that can run this script.

That left the real last resort untested, which is how the branch above it
shipped broken in the first place. `sh` needs `-c :` where `true` needs no
argument, and with no argument `sh` reads stdin and hangs — so the new case
points the probe override at the real /bin/sh and fails loudly if that
branch is wrong (verified: breaking the argument turns it red).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:32:55 +05:30
Palash DebnathandClaude Opus 5 810b598739 fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333) (#1354)
* fix(appimage): let the host GStreamer win, and stop sharing its registry (#1333)

Recording from the AppImage failed with "No microphone found" on a Debian
13 host whose audio stack the reporter verified healthy (pactl, wpctl,
gst-launch with both pulsesrc and pipewiresrc), while the same build`s raw
binary recorded fine. GST_DEBUG=2 named it:

  WARN GST_REGISTRY gst_registry_binary_check_magic:
    Binary registry magic version is different : 1.23.90 != 1.3.0
  GStreamer element appsink not found. Please install it.

linuxdeploy bundles libgstreamer-1.0 because WebKit links it, but not the
plugins: those are dlopen`d, so nothing static can see them to copy. The
bundled core falls back to the host plugin directory, whose plugins were
built against the host core, the version check rejects them, and the scan
yields nothing. appsink is one of the casualties and it is the element
WebKit hands a capture stream to, so getUserMedia() rejects NotFoundError.

Same class as #1258 (frozen bundled library against a host that moved on)
in a different library, which is why OMNIVOICE_PREFER_SYSTEM_WEBKIT=1 did
nothing for the reporter. Since we ship no plugins, the host core is the
only one that can agree with the plugins that will load — so prefer it,
with OMNIVOICE_PREFER_SYSTEM_GSTREAMER=0 as the escape hatch.

Also isolate the registry cache. GStreamer keys ~/.cache/gstreamer-1.0/
registry.<arch>.bin by architecture alone, so two cores of different
versions clobber each other`s file: that makes the failure depend on which
app ran last, and the AppImage corrupts the cache for every other
GStreamer app on the machine. Both directions go away with a private path.

AppRun.test.sh covers host-present, host-absent and opt-out; all three
fail against the previous AppRun.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(appimage): cover the ldconfig discovery path; docs fixes

CodeRabbit, all three valid:

- every GStreamer case forced ldconfig to fail, so the runtime-only-host
  fallback (no -dev package, hence no .pc file) was never exercised. The
  cases now select their discovery path, and the new ldconfig one fails if
  that branch is removed.
- MD040: the GST_DEBUG fence had no language tag.
- the registry cache path follows XDG_CACHE_HOME when set; ~/.cache is only
  the default. Documented, along with WHY the shared file is a problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): compose LD_LIBRARY_PATH once; host WebKit stays first

CI caught a real regression, not a flaky test. The GStreamer block prepended
its own directory, which put it AHEAD of the host WebKit dir — and "host
WebKit first" is the invariant #1258 turns on. On a host where the two
libraries live in different directories that silently changes which WebKit
resolves.

It only showed on Linux because the WebKit ldpath cases do not stub away a
real host GStreamer, so the runner had one to find and macOS did not.
Reproduced locally with an ldconfig shim, and confirmed the ordering is what
fixes it: with the old order the suite is 19/2, with this one 21/0.

Both decisions now compose one path in one place — host WebKit, host
GStreamer, bundle, inherited — so neither preference is weakened and the
ordering is stated where it is applied rather than implied by two
independent prepends. Same directory for both (the common case) is not
listed twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): preload the host GStreamer instead of hoisting its libdir

greptile P1, valid. The host GStreamer lives in a general system library
directory (/usr/lib/x86_64-linux-gnu on Debian), so putting that directory
ahead of ${HERE}/usr/lib replaced EVERY other bundled library with the
host copy — loader symbol errors, startup crashes, or a blank window on a
distro we never built against. One library needs to come from the host and
the mechanism has to be that narrow.

LD_PRELOAD names exactly that library and leaves the search path alone, so
the WebKit ordering from #1258 is untouched too (and this removes the
composed-LD_LIBRARY_PATH block that only existed to keep the two
prepends from fighting). The preload is inherited by the Python backend,
where nothing links GStreamer and it is inert — the accepted cost.

Tests now assert both halves: the library IS preloaded, and the libdir is
NOT hoisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(appimage): verify the host GStreamer loads before preloading it

greptile P1, valid. The host core links GLib and the bundle ships GLib too,
resolved bundle-first — so a host GStreamer built against newer GLib than
we bundle fails its relocations and the app does not start at all. That is
strictly worse than the broken microphone this PR fixes. Taking host GLib
as well is not an option either: GLib is what WebKit is built against, so
pulling it from the host reopens #961/#1258.

Rather than predict the pairing, test it. The loader processes LD_PRELOAD
for any binary, so running `true` under the exact environment the app will
get is a complete check of whether the library loads there — a missing
dependency or an unresolved version tag ("version GLIB_2.84 not found")
fails it and nothing else runs. On failure the preload is skipped, the app
starts on the bundled core, and a warning names the mismatch so the user
has a thread to pull rather than a silent half-fix.

OMNIVOICE_APPRUN_PRELOAD_PROBE lets the suite choose the outcome, matching
the existing OMNIVOICE_APPRUN_WK_MARKER precedent; the new case fails if
the guard is removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:48:47 +05:30
Palash DebnathandClaude Opus 5 e60c5364ea fix(errors): strip terminal colour codes before a failure reaches the user (#1344) (#1353)
* fix(errors): strip terminal colour codes before a failure reaches the user (#1344)

yt-dlp colourizes stderr whenever it thinks a terminal is attached, and the
frozen backend's pipes are enough for it to think so. A restricted-video
failure surfaced as `download: ^[[0;31mERROR:^[[0m [youtube] …`, which reads
as an OmniVoice bug rather than a message from YouTube.

Fixed at build_failure, the choke point every surfaced failure passes
through, so the whole class is covered — ffmpeg, uv, pip, cargo and
anything else that colours its output, not just the reported command.

Order matters and is pinned by a test: strip_ffmpeg_banner anchors on
"ffmpeg version " at the start of a line, so a leading colour code would
hide it and quietly reinstate #1309 for any colour-emitting ffmpeg build.
The pattern covers CSI and OSC (window title / hyperlink) sequences, not
just SGR colour, and never empties a non-empty message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(errors): escape-only text falls back to the error class, not to bytes

CodeRabbit, both valid:

- strip_ansi kept the original when stripping left nothing visible, so
  build_failure copied raw escape bytes into reason/error/detail — the very
  thing this PR exists to stop. build_failure already falls back to the
  exception class name for an empty reason, and a class name is a real
  answer where a run of escapes is not, so let it do that.
- the test file bound `from core import failure` at import time. Sibling
  suites reload and purge core.* between tests, so that alias can outlive
  the module the app uses and the file would assert against a stale copy
  while looking green — #1269 exactly. Resolved via a fixture at call time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:26:11 +05:30
Paolo Antinori 202f1285aa fix(dub): transcribe overlay label is engine-agnostic (not hardcoded Whisper) (#1352)
The dub overlay said "Transcribing with Whisper…" whatever ASR engine was actually running, in all 21 languages — a user debugging a slow or failing transcription would go read Whisper's docs.

Contributor fixed 16 locales; the remaining 5 (ar, hi, id, ja, pl) transliterate the brand rather than keeping the Latin spelling, so the sweep missed them. Those are corrected, the guard is extended to every transcription stage label rather than the one reported, and its boundary is ASCII-letter-based — Python's \b is unicode-aware, so \bwhisper\b does not match "Whisperで文字起こし中".

Thanks @paoloantinori!
2026-08-04 10:56:52 +05:30
Palash DebnathandClaude Opus 5 8f9f778307 fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333) (#1339)
* fix(scripts): desktop-prod:run wiped the data it was documented to preserve (#1333)

`scripts/desktop-prod.sh` emulates a first install, so wiping is its default: it
removes the app data dir, `~/.omnivoice` (the SQLite database, every voice
profile, all outputs), the Tauri logs and the WebKit profile. `--keep-data` is
the only thing that suppresses that block.

`--skip-build` is an independent flag that only skips the cargo compile, and
`desktop-prod:run` passed it alone — while the script's own header calls that
command "re-launch last build (skip compile)" and its closing banner tells you
to use it that way. So "just start it again without recompiling" silently
deleted the developer's voice profiles and project database, every time.

The fix is in the package scripts rather than the flag parsing: making
--skip-build imply --keep-data would remove a legitimate combination (fresh
data without paying for a recompile). The two stay independent, and the help
text now says so.

desktop-fresh:run is deliberately untouched — that script is a stricter
new-user emulation, so wiping is the point of its name.

Tests pin all three rules, and were confirmed fail-before by reverting the
desktop-prod:run line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): kill the live instance before every launch, not only before a wipe

Greptile P1 on this PR, and it was a regression I introduced.

The app registers tauri_plugin_single_instance, and that callback ignores the
incoming argv — it just refocuses the window the RUNNING process already owns.
So starting a second copy over a live one does nothing visible.

That was previously masked: kill_running_instances sat inside the
`KEEP_DATA = false` branch, so every run happened to kill first *because*
every run wiped. Adding --keep-data to the re-launch aliases removed the wipe
and would have taken the kill with it — `desktop-prod:run:pill` would have left
the user in studio mode with --pill silently discarded, and plain
`desktop-prod:run` would have refocused the OLD build instead of the one just
compiled, which is the entire point of that command.

The kill is now unconditional, before the wipe branch. Its two reasons are
independent — zombie-backend-after-wipe, and single-instance-swallows-argv —
and only the first was ever about wiping. Adjusted its closing line, which
said "safe to wipe" and now also runs when nothing is being wiped.

New test asserts the call is not nested inside the KEEP_DATA branch;
confirmed fail-before by moving it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): scope the kill to this checkout, warn about an installed app

Making kill_running_instances unconditional (so --keep-data re-launches
still get past single-instance) widened the blast radius of its pgrep:
"OmniVoice Studio.app" also matches an installed /Applications copy, so
desktop-prod:run would kill the shipped app a developer was using and take
their unsaved work with it. That was previously masked — the kill only ran
on wipe runs, where a clean slate had been asked for explicitly.

Scope the pattern to ${TAURI_DIR}/target/debug/, which covers both launch
shapes and nothing else. An installed instance still gets named rather than
ignored: single-instance keys on the bundle id, so it swallows this launch
too, and silence would just trade one confusing failure for another.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:44:22 +05:30
Paolo Antinori f3607918f9 test(probe): make triage tests fork-tolerant (#1346)
Makes tests/probe/test_triage.py pass on a fork checkout: the repo owner is not stable across forks, and detect_repo() legitimately returns None where there is no GitHub origin at all (source tarball, git archive, Docker build context) — so that case skips rather than trading one environment assumption for another.

Thanks @paoloantinori!
2026-08-04 10:40:44 +05:30
Palash DebnathandClaude Opus 5 5f39f9ff84 test: stop streamDropError's tests depending on a live local backend (#1326)
Three tests in `frontend/src/test/streamDropError.test.ts` failed on any machine that happened to be running OmniVoice, and passed in CI.

They exercise the no-crash-marker branch, which since #1242 asks whether the backend is still answering before repeating the caller's "it crashed" guess — and they left that probe unstubbed. `_probeBackendAlive` does a real `fetch` at the configured API origin, so the assertion was really "is anything listening on port 3900 right now": nothing in CI, the developer's own app locally. Same fails-locally/passes-in-CI shape as #1269.

- Every test in that branch now states which answer it wants (DEAD / ALIVE) instead of inheriting one from the environment.
- The previously uncovered side — a live backend, where the #1242 proxy-buffering message replaces the caller's guess — gets a test of its own rather than being asserted by accident on developer machines.
- `backlog/` added to .gitignore: the `backlog` CLI task tracker writes a config plus one markdown file per task into the repo root, and a contributor running it locally had three swept into a PR that was otherwise a single script (#1322).

Frontend suite with the app running locally: 1 file / 3 tests failing → 208 files / 1643 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 05:16:53 -07:00
Palash DebnathandClaude Opus 5 8da377a1cb fix(audiobook): say why a chapter failed, and don't hang when an engine stops (#1321) (#1325)
The report arrived as a raw traceback pasted out of a log file — because that is the only place the reason existed. A failed chapter was a red row and the word "failed"; the SSE event carried the literal string "chapter failed to render", the symptom the user could already see.

- Both the per-chapter and the terminal all-failed events are now built with the shared `core.failure` builder: sanitized text, a guaranteed non-empty reason, error class, docs deeplink and hint. `error` keeps mirroring `reason`, so older frontends and the Stories exporter are unaffected. The chapter list shows the reason inline, full text in the row tooltip.
- **A silent infinite hang on the same path.** asyncio refuses to put `StopIteration` into a Future — `_copy_future_state` raises TypeError inside the event loop's own callback, so the `run_in_executor` future is never completed and the caller waits forever, with no error, event or timeout. Reachable from ordinary input: VoxCPM's `next_and_close` is a bare `next(gen)`, so a generator ending without yielding raises it straight into a GPU-pool worker. Fixed at the pool boundary (`_ResilientGpuPool.submit` plus a matching `_cpu_pool` subclass) as `WorkerStopIteration(RuntimeError)`, keeping the original as `__cause__`.
- An all-failed render now marks the job failed. It previously returned without touching job history, so the row stayed `running` and the next startup's orphan sweep read a hopeless render as interrupted and offered it as resumable (Greptile P1).

Tests: 6 pool-guard tests (two hang without the fix, bounded so a hang fails rather than stalls CI), longform e2e extended for both event shapes, the empty-`str(exc)` floor, and both sides of the job-status branch, plus the chapter-list component.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:22:50 -07:00
Palash DebnathandClaude Opus 5 a89c5e8fb3 test: patch the model_manager module main actually uses (#1269) (#1324)
`tests/backend/conftest.py` purges `main` / `core*` / `api*` / `services*` from `sys.modules` after every test it owns, and `backend/tests/` modules bind `import services.model_manager as mm` at COLLECTION time — so in a combined `pytest tests/ backend/tests/` session the alias and the live module are two different objects. Two tests read the wrong one; CI's split invocation hid both.

- `test_lifespan_shutdown_mid_load_is_clean_and_clears_sentinel` patched the stale alias then drove main's lifespan, which loads through the live module. It now runs inside a purge/restore context and resolves from `sys.modules` after importing main, so the failure is deterministic standalone.
- The autouse `_clean_model_manager_shutdown_state` fixture cleaned only `sys.modules` while `test_shutdown_state_isolation.py` dirtied the alias. It now cleans the live module and any module-typed alias in the requesting test module — the idiom already used there for `asr_backend` — taking both the `import x.y as z` binding (package attribute) and the `sys.modules` entry.
- New fail-before/pass-after pair guards the alias half of the fixture in isolation.

Test-infrastructure only; no production code touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 04:22:23 -07:00
debpalashandClaude Opus 5 f32f21e7ec docs(webcompat): main.jsx still named macOS 12 as the floor (#1268)
Docs-sync follow-up to #1314 — webCompat.js's header moved to 13.3/Safari 16.4
and its import site did not (CodeRabbit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:15:23 +05:30
Palash DebnathandClaude Opus 5 168e8e5c61 fix(macos): declare the floor the app actually delivers (13.3, not 12) (#1314)
* fix(macos): declare the floor the app actually delivers (13.3, not 12)

The app declared minimumSystemVersion 12.0 and the docs promised Monterey,
while the frontend required Safari 16.4 in three independent places: Vite's
default build target (baseline-widely-available = safari16.4), Tailwind v4's own
documented floor, and `@property` throughout its generated utilities. On
Monterey's WKWebView 15.6 the focus ring and accent surfaces resolve invalid,
and a bundled dependency ships a RegExp lookbehind that is a PARSE-time
SyntaxError no polyfill can reach.

Option B — actually supporting 15.6 — means setting build.target back,
replacing 64 color-mix() calls, dropping Tailwind v4 and replacing that
dependency, indefinitely, for an OS that stopped receiving security updates in
late 2024. The council was unanimous on A, and the precedent is uniform (Chrome
117, Electron 27, VS Code, Firefox 116).

minimumSystemVersion is also the guard: macOS itself refuses to launch a bundle
below it, so a Monterey user gets an explicit OS refusal rather than an app that
opens to a blank window — which matters because the Tauri updater has no
per-OS gating of its own.

Docs updated in the same change (README support table, docs/install/macos.md)
and the webCompat floor assertion re-derived to 16.4, so the post-floor API
list must be revisited the next time the floor moves.

Closes #1268

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(macos): raise the floor in the macOS overlay too, and assert it

Greptile P1, and correct: Tauri merges tauri.macos.conf.json OVER the base
config for a macOS build, and that file carried its own
minimumSystemVersion: 12.0. Changing the base config alone decided nothing —
the shipped bundle would have stayed Monterey-installable while the base
config, the README and the install docs all said 13.3.

Worse, the guard I added read only the base config, so it would have gone on
passing. A test that validates the wrong file is not a guard; it now asserts
both, with a comment saying why the overlay is the one that ships.

Also per review: the changelog entry was an editorial paragraph rather than a
one-line entry, and the section was missing ### Docs. Both fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(webcompat): the module header still described the old 12.0 floor

The floor moved to 13.3/Safari 16.4 in this PR and the test was re-derived,
but webCompat.js still told the next reader the oldest supported WebView was
15.6 — which would make every fill here look mandatory instead of retained
for Linux's unpinnable WebKitGTK (CodeRabbit).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:44:41 -07:00
Palash DebnathandClaude Opus 5 850ae8e192 test: reset model-manager shutdown state between backend tests (#1320)
* test: reset model-manager shutdown state between backend tests

Two leaks, one of them mine.

1. `model_manager._shutting_down` is a module-global Event and the GPU pool is a
   module-global executor. Any test that runs the app lifespan flips both on the
   way out (begin_shutdown + _reset_gpu_pool) and nothing puts them back —
   correct in production, where the process is ending; wrong across a combined
   session. A test arriving with the flag set finds a shut-down executor, so its
   first run_in_executor raises "cannot schedule new futures after shutdown",
   which the preload path classifies as benign and swallows. The symptom is a
   load that silently never starts. Reset before AND after: before so an
   inherited flag cannot decide the test, after so a test that legitimately
   shuts down does not hand it on.

2. tests/test_torch_compile_path_gate.py assigned services.settings_store into
   sys.modules directly instead of via monkeypatch.setitem. That leaks
   process-wide out of collection and breaks every later import of the real
   module. backend/tests/test_no_module_stubs.py exists to catch exactly that,
   and caught it — I introduced it two commits ago while isolating the Settings
   gate for a review finding.

#1269 stays open for its last failure, which is a different root cause:
test_lifespan_shutdown_mid_load fails because a reload fixture in tests/
replaces services.model_manager, so the test patches one module object while
main's lifespan uses another (verified: `same=False`). That is the duplicate-
module class, not a state leak, and needs its own fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: let the shutdown-state reset fail loudly

CodeRabbit Major: the broad try/except meant a reset that raised left the next
test with stale shutdown or executor state — precisely the order-dependent
failure the fixture exists to remove, while looking like it had worked. That is
the same silent-fail-open shape as the watermark and ffmpeg bugs fixed earlier
in this cycle.

If reset_shutdown_flag() or _reset_gpu_pool() can raise, that is a real problem
in model_manager and it should be loud.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: assert the shutdown-state reset fixture actually resets (CodeRabbit)

Ordered pair: one test leaves the module globals exactly as the lifespan
leaves them, the next asserts it arrived clean — delete the fixture and the
second fails. Plus a mechanical guard that the reset stays un-swallowed, so
a future try/except cannot make the fixture look like it worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 01:30:06 -07:00
Palash DebnathandClaude Opus 5 26bcd95088 docs(engines): publish the engine-acceptance bar (#1319)
The project carries 14 TTS + 11 ASR engines across 4 platforms with one
maintainer. That breadth is an asset only while every one of them still works;
otherwise it is a pile of support queues, and the first-run promise is what pays
for it.

So: engines are hired for a named job, not added to a list. Documents the job
map (each job has one holder), the seven conditions, the deprecation rule for
engines that lose their steward and their smoke test, and the out-of-tree path.

The point is to make "no" a property of the bar rather than a judgement of the
contributor — and to make "yes" fast when a proposal clears it. #1306 is the
first proposal judged against it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:31 -07:00
Palash DebnathandClaude Opus 5 576756f2ad fix(dev): name auto-reload as a cause when the dev backend goes quiet (#1318)
#1261: dev mode, "Failed to fetch", the backend had answered 10 minutes
earlier, three dub uploads in quick succession — and the message offered only
"it most likely crashed or was killed mid-request".

`bun run dev` runs uvicorn with --reload. Any file change, including a save
while a request is in flight, restarts the process and drops the connection.
At the transport layer that is indistinguishable from a crash, and the fix is
simply to retry — which the message never suggested, so a developer went
looking for a Python traceback that was never written.

The dev copy now names auto-reload, says to retry first, and keeps the terminal
and omnivoice.log as the fallback for when it really did die. Server-mode copy
is untouched: there is no reloader there, so the crash reading is right.

Translated in all 21 locales.

Closes #1261

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:26 -07:00
Palash DebnathandClaude Opus 5 9c0ca38b3f fix(dub): stop blaming the ASR model when the stream is cut by a proxy (#1317)
streamDropError() already consults the crash forensics, but "no crash marker"
is not "the backend died and we missed it". Outside the Tauri shell there is no
death watcher at all, so that branch is where every browser and Docker user
lands — and the caller's fallback asserted a cause on their behalf: "Likely ASR
backend failed to load".

#1242 reported exactly that, in `server` mode, with the backend having answered
20 s earlier. Nothing had crashed and nothing had failed to load, so the message
sent them after a model that was fine.

It now asks instead of assuming. If the backend is still answering, the process
did not go away, which rules the guess out — and in a served or containerised
deployment a stream that dies while the server is healthy is characteristically
a reverse proxy buffering or timing out the SSE connection, so the message says
that and gives the two settings that fix it. A real crash marker still wins over
the probe, and when the backend is gone too the caller's message stands.

The dub fallback no longer names a cause either, since it is only reached when
both signals are inconclusive.

Closes #1242

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:21 -07:00
Palash DebnathandClaude Opus 5 0c4d5073db fix(engines): verify MOSS-TTS-Nano's entry point, don't just import the module (#1316)
is_available() only checked that `moss_tts_nano` imported. The reporter had the
package installed, so the engine advertised itself ready, they switched to it,
and the first generate died with `cannot import name 'MossTTSNano'`. An
availability check that does not verify the API it will actually call is a check
that lies.

MOSS-TTS-Nano is installed straight from git with no pinned release and its
exported class has changed, so this does not chase the current name: it resolves
among the names upstream has used, requires the candidate to actually have
`from_pretrained` (matching on name alone would relocate the failure, not fix
it), and when nothing matches it reports unavailable — naming what the module
DOES export, so the report becomes a one-line fix instead of a dead end.

A missing package and a renamed class stay separate messages: different
problems, different fixes.

10 tests, including every historical class name and the ready-then-crash shape
that started this.

Closes #1287

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:41:16 -07:00
Palash Debnath 15afc6611d fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244) (#1265)
* fix(linux): AppImage blank window on Mesa 26.1+ hosts (#1258, #1244)

The AppImage bundles an Ubuntu-built WebKitGTK but ships no libEGL, so that
bundled WebKit runs against the HOST's Mesa. On Mesa >= 26.1 it calls
eglGetPlatformDisplay() in a way the newer driver rejects and the app dies
before it paints:

    Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...

No environment variable helps, because the failure is in EGL display creation
— before WebKit consults any rendering-path flag. #1258 confirmed
WEBKIT_DISABLE_DMABUF_RENDERER, WEBKIT_DMABUF_RENDERER_FORCE_SHM,
WEBKIT_SKIA_ENABLE_CPU_RENDERING, EGL_PLATFORM=surfaceless and
MESA_LOADER_DRIVER_OVERRIDE=swrast all fail identically.

Chasing the build runner's WebKit (#961 bumped 22.04 -> 24.04) cannot fix this
class: what we bundle is frozen and host Mesa keeps moving. So when the host
has a WebKitGTK at least as new as ours, let it win — the bundle still fills
every gap, and a host without WebKitGTK is untouched. That is exactly why
building from source works on the hardware where the AppImage does not.

The compositing workaround is re-decided against whichever library ends up
running, and AppRun.test.sh — which had never been wired into CI — now runs
there, so this logic stops being a regression test nothing executes.

* fix(review): the ordering change was a no-op; name the host libdir explicitly

CodeRabbit Major — correct, and it made the whole fix inert. LD_LIBRARY_PATH is
searched AHEAD of the linker's default paths no matter where in that variable a
directory sits, so on a normal launch (empty LD_LIBRARY_PATH) the bundle
remained the only explicit search directory and still won. Merely appending it
changed nothing. The host's WebKit libdir is now named explicitly, ahead of
ours. The new tests fail 3/3 against the previous version.

Greptile P1 — a host with the runtime but no -dev package has no .pc file, so
pkg-config can't answer and the check rejected a perfectly good system WebKit.
The libdir probe now falls back to ldconfig, and OMNIVOICE_PREFER_SYSTEM_WEBKIT
gives those users an explicit opt-in (=0 opts out) rather than gambling on an
unverified version, which would risk the #961 regression.

CodeRabbit — my changelog script had also inserted the CI entry into the
published 0.4.0 section. Removed; it belongs only under Unreleased.

CodeRabbit — the docs' source-build fallback used 'cd frontend', not the
repo-root flow the rest of the page documents. Fixed.
2026-07-29 15:36:45 -07:00
Palash DebnathandClaude Opus 5 2de27038d9 docs(rules): the parity rule governs behaviour, not performance (#1315)
An automated reviewer raised a Critical asking for torch.compile to be disabled
by default on every platform "so the default is uniform", citing the
cross-platform parity rule. Following it would have slowed down every Linux
CUDA user to match hosts that cannot compile at all.

Read literally, the rule forbids GPU support: CUDA, MPS, DirectML and Triton
availability are all host-dependent by design. It was always about what a user
can SEE AND DO, not about throughput — so it now says that, in CLAUDE.md and
AGENTS.md, and .coderabbit.yaml tells the reviewers directly so the finding
stops regenerating every month.

An optimization skipped where it physically cannot work is not a parity
violation. A feature usable on one OS but not another still is.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:36:41 -07:00
Palash DebnathandClaude Opus 5 42d017ef9b fix(errors): strip ffmpeg's banner so the failure is the message (#1311)
* fix(errors): strip ffmpeg's banner so the failure is the message

ffmpeg and ffprobe print a version + configuration banner to stderr on every
invocation, before doing any work. When a command fails we capture that stderr
and it becomes the error, so #1309's reporter was shown several hundred
characters of build flags — "ffmpeg version N-125781-gacf6b520c1-20260727 …
--pkg-config-flags=--static --enable-gpl …" — and not one word about why the
extract failed. The diagnosis is always AFTER the banner.

Stripped centrally in build_failure() rather than at the extract call site:
every stage that shells out to ffmpeg (dub prep, export, retime, media probe)
captures the same stderr and had the same problem.

Done before classify() runs, too — matching docs topics against a build
configuration string is how a real topic gets missed.

A message that is ONLY a banner keeps the banner: unhelpful beats empty.

Closes #1309

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(errors): assert the banner is GONE, not just that the error is present

CodeRabbit: the classification test only checked that the post-banner text
appeared in `reason` — which was true before the fix too, since the banner was
simply prepended to it. It passed against the code it was written to catch.

Now asserts the banner markers are absent and that `reason` STARTS with the
real error, since burying the diagnosis after 300 characters of build flags is
the actual user complaint. Fails without strip_ffmpeg_banner().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 15:02:02 -07:00
Palash DebnathandClaude Opus 5 b4fc74fa65 feat(contact): add the project X account alongside Discord (#1313)
* feat(contact): add the project X account alongside Discord

Adds https://x.com/fs01c137y as a channel on the in-app Contact page and in
the README, next to the existing Discord links — updates, releases, and what
is being built next, for people who would rather not sit in a chat server.

Follows the ContactPage convention: the URL is a module constant so no surface
can drift, and the card explains WHEN to use the channel rather than being a
bare link. lucide dropped its Twitter glyph, so the icon is Megaphone, which
reads as announcements anyway.

contact.follow_* translated in all 21 locales; ContactPage test extended to
pin the URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(contact): correct the X handle to @idebpalash

Owner's current account. All six references updated in lockstep — README nav
row, badge row, CTA block and contributing list, plus the ContactPage constant
and the test that pins it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:17:54 -07:00
Palash DebnathandClaude Opus 5 1600f02abf feat(privacy): build the watermark toggle the app already promised (#1308)
* feat(privacy): build the watermark toggle the app already promised

errors → enterprise_faq.a_watermark has told users "Commercial licensees can
disable it in Settings → Privacy" since watermarking shipped. That control did
not exist: /watermark/status and /watermark/settings had zero callers in the
frontend, is_enabled() passes no env= to resolve() so there was no environment
escape either, and the only way off was hand-editing prefs.json. A shipped
instruction that cannot be followed is worse than no instruction.

Adds the control, wired to the endpoints that were already there. It mirrors
AnalyticsOptIn's shape but inverts its default — analytics is OFF until you
opt in, provenance marking is ON until you opt out — and hides itself when
AudioSeal is unavailable, since an inert switch over a mark that cannot be
embedded is the same lie in the other direction.

Also corrects the FAQ string in all 21 locales: the toggle is available to
everyone, not only commercial licensees, and it only affects audio generated
after the change. (Whether disabling should be licence-gated is a product
decision — the text now describes what the app actually does.)

6 tests: reflects backend state rather than assuming it, turns off, turns back
on, renders nothing when AudioSeal is missing or the backend is unreachable,
and does not optimistically flip when the update fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(privacy): translate the watermark strings, retry the status fetch, guard unmount

CodeRabbit Major — the five new strings relied on defaultValue, so every
non-English user saw English. Translated privacy.watermark_title/subtitle/
on_toast/off_toast/failed in all 21 locales.

Greptile P1 — the status fetch was one-shot, so opening Privacy while the
backend was restarting hid the control for the rest of the session. Being
findable is the control's entire purpose (the FAQ tells people it is here), so
it now retries once after 2s before giving up.

CodeRabbit — a toggle resolving after the tab closes no longer sets state or
toasts over whatever screen the user moved to.

Tests: the two "renders nothing" cases now wait for the request to SETTLE
rather than merely to start (the initial render is empty, so they would have
passed even if the control appeared afterwards), plus a case proving recovery
from a failed first fetch. 7 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:17:45 -07:00
Palash DebnathandClaude Opus 5 7dd7e89e7b fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016 (#1312)
* fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016

#1301 surfaced as "500 Internal Server Error: [SSL: UNEXPECTED_EOF_WHILE_
READING] EOF occurred in violation of protocol (_ssl.c:1016)" — meaningless to
a user, and unclassified: the existing SSL branch requires "handshake" or
"certificate verify failed", so this fell through with no hint at all.

Deliberately a SEPARATE class from SSL_HANDSHAKE_FAILURE rather than widening
it. That class means a proxy re-signed the certificate with a CA certifi does
not trust, and its advice is to set SSL_CERT_FILE or add an antivirus
exclusion. Here the handshake never failed on trust — the socket was cut
mid-exchange, usually flaky Wi-Fi, a reconnecting VPN, a captive portal, or a
server dropping a long transfer. Sending that user to fix their certificate
store is sending them to fix something that is not broken.

Classified before the handshake branch, because the raw text contains "ssl"
and the broader branch would otherwise claim it. Added to
_CONTEXT_FREE_HINT_CLASSES since its trigger is an exact OpenSSL string — that
matters here, because the raw 500 handler is precisely where the reporter met
it.

Closes #1301

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(errors): scope the resume guarantee, and require a TLS marker

Greptile P1 — the hint promised "OmniVoice resumes partial downloads rather
than starting over", unqualified. That is verified for HF model downloads
(snapshot_download) and segmented_download, but the hint is static and also
reaches media fetches where nothing guarantees it. Shipping an instruction that
is not true is the exact class of bug this session has been removing, so the
guarantee is now scoped to models.

CodeRabbit — the matcher accepted either EOF phrase with no ssl marker.
"unexpected EOF" is a phrase a parser or another transport can produce, and
those would have been handed VPN/proxy advice. The OpenSSL text always carries
the marker, so requiring it costs nothing.

Also fixes a tautology: test_not_mistaken_for_a_cert_trust_problem asserted
!= SSL_HANDSHAKE_FAILURE, which the OLD classifier satisfied by returning "".
It now pins the exact class. 2 of the 9 tests fail against the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:17:40 -07:00
Palash DebnathandClaude Opus 5 4f1135ac62 fix(compile): skip torch.compile when the torch lib path has whitespace (#1310)
* fix(compile): skip torch.compile when the torch lib path has whitespace

Inductor passes the torch library directory to clang++/g++ as an unquoted -L
flag, so a path containing a space splits into two arguments and the compile
dies with "no such file or directory: 'Support/...'". The quoting bug is
inside PyTorch and we cannot fix it — but a path we already know cannot compile
is one we should not spend a compile attempt on.

The cost was never a broken generation (eager mode is the documented fallback);
it was a guaranteed-failing compile on every load, whose clang wreckage got
swallowed by `except Exception: logger.info(...)` and then ate a chunk of the
captured log tail. That is exactly how it surfaced in #1259, where it was not
the actual fault but crowded out the output that was.

Not platform-specific: macOS keeps app data under ~/Library/Application
Support/ and a Windows profile is routinely C:/Users/First Last.

OMNIVOICE_FORCE_TORCH_COMPILE=1 still overrides, consistent with the arch gate.
An unreadable torch path fails open — no evidence is not evidence of a problem.

Closes #1266

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(compile): redact the torch path in the skip reason; tighten the tests

CodeRabbit Major: the reason string embedded the absolute torch lib path, and
both log branches print it — so a home directory (and anything secret-shaped in
the path) went into omnivoice.log and any pasted bug report. It now goes through
core.failure.sanitize(), the same redaction every other user-facing failure text
uses, with a basename fallback if that import ever fails.

Also per review: the Settings gate is isolated in the test helper (it was
reading the real settings_store, so a persisted perf.torch_compile_disabled=1
could have decided these tests instead of the path logic), and two logging
contracts are now asserted rather than merely exercised — the forced-override
warning, and the skip message naming OMNIVOICE_FORCE_TORCH_COMPILE. A skip the
user cannot discover how to override is a dead end.

DECLINED: CodeRabbit's Critical asks for torch.compile to be disabled by
default on every platform "so the default is uniform". That would make every
Linux CUDA user slower to satisfy a rule about USER-VISIBLE default behaviour —
and torch.compile is not user-visible, it is an internal optimization whose
absence shows up only as speed. The function has always diverged by host by
design: device != "cuda" skips, and no-Triton skips (which is every Windows
install). This gate adds no new divergence; it declines a compile that is
GUARANTEED to fail on that host, which is the same shape as the existing arch
gate. Disabling a working optimization everywhere would be the regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 14:17:33 -07:00
Palash DebnathandClaude Opus 5 817e9a9c8b test: stop config-path leakage across test modules (#1269) (#1307)
* test: stop config-path leakage across test modules (#1269)

Ten test modules share a fixture shape: monkeypatch OMNIVOICE_DATA_DIR to a
tmp_path, then importlib.reload(core.config) (plus core.db, a router, main).
monkeypatch restores the ENV VAR at teardown — and nothing reloads the modules
back, so the path constants keep pointing at that test's tmp_path for the rest
of the session.

In a combined `pytest tests/ backend/tests/` run that produced three different
answers to "where is the voices directory":

  OMNIVOICE_DATA_DIR      .../omnivoice-test-data-vna0ywre        (correct)
  core.config.VOICES_DIR  .../test_fitted_srt_last_cue_withi0/…   (leaked)
  profiles.VOICES_DIR     .../test_clone_profile_save_saniti0/…   (leaked)

— which is why the personas import tests wrote a file to one directory and
asserted it existed in another.

Restores at MODULE teardown, and that boundary is the design. Function scope
was wrong: tests/smoke/test_boot_smoke.py has a module-scoped fixture that
deliberately aims core.config at a frozen fixture directory for the length of
that file, and a per-test restore reset it between that module's own tests.
Within a module a fixture cannot tell deliberate setup from a leak; across
modules there is no ambiguity.

Snapshot/restore of the constants rather than re-reloading: a reload would
re-register FastAPI routes and rebuild module state as a side effect, while a
setattr is inert. It also re-syncs modules that copied a value out of
core.config — a reload fixture typically imports the router under test for the
first time, so it has no earlier value to put back.

3 of the 4 failures are fixed. test_lifespan_shutdown_mid_load_is_clean_and_
clears_sentinel still fails in a combined run for an unrelated reason (its
preload never reaches run_in_executor); #1269 stays open for that one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test: import core.config when snapshotting, don't just probe sys.modules

Greptile P1: a sys.modules-only probe returns {} when this module is the first
to import core.config — and the empty-snapshot guard then skips restoration
entirely, so the module most likely to reload config was the one least
protected.

Importing is cheap and idempotent, and tests/conftest.py has already pointed
OMNIVOICE_DATA_DIR at a throwaway dir before any fixture runs, so the captured
values are the right ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 12:23:52 -07:00
Palash DebnathandClaude Opus 5 8287d0c476 fix(crash): stop blaming VRAM for native faults (#1305)
* fix(crash): stop blaming VRAM for native faults

#1275 (Windows 0xC0000005 on an RTX 2080 SUPER) and #1293 (SIGSEGV on Linux)
both fell through to "you ran out of VRAM while loading the ASR model" — so
the advice was to flush a model that had nothing to do with it. A segfault is
bad machine code, not slow memory exhaustion; the real causes are a GPU driver
that disagrees with the bundled CUDA runtime, or a weight file that downloaded
incompletely and is being memory-mapped.

Windows has no signals here, so the shell sees the raw NTSTATUS as a negative
exit code — those are matched explicitly or they read as an ordinary non-zero
exit.

Deliberately narrow: only SIGILL and SIGSEGV, whose numbers are identical on
every POSIX platform. SIGABRT stays on the VRAM path because abort() is how a
fatal CUDA error exits, including an async out-of-memory — an existing test
pins that, and it caught this when the first cut was too greedy. SIGBUS is
excluded because its number is platform-dependent (7 on Linux, 10 on macOS,
where 10 is SIGUSR1 on Linux).

Repeat offenders are now pointed at the crash-isolated subprocess engine that
landed in #1292 — it takes the sidecar down instead of the whole backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(crash): offer both isolated engines, and translate the guidance

Greptile P1: the crash marker records HOW the process died, not which
subsystem was running — a segfault during transcription looks identical to one
during synthesis. Naming only the TTS escape hatch sent ASR crashes to a fix
that leaves the crashing path untouched. Both are now offered so the user
picks the one they were using; #1304 supplies the ASR side.

CodeRabbit Major: the new guidance was hardcoded English, which the
localization rule forbids. All four hints in crashCauseHint now route through
i18next with the English as defaultValue — so a missing key still renders
exactly what it rendered before (no regression, no test churn) while the
strings become translatable. crash_port_in_use, crash_oom_kill and
crash_native_fault are translated in all 21 locales.

Also fixes a test that claimed to prove repeat-fault behaviour while calling
the hint once; it now asserts what the message actually has to contain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 06:23:34 -07:00
Palash DebnathandClaude Opus 5 5afcdf787d fix(engines): warn before a long CPU synth burns the whole budget (#1302)
* fix(engines): warn before a long CPU synth burns the whole budget

#1288 closed the under-provisioned-GPU gap but left the CPU one open, and I
missed it: a CPU-only host is a BENIGN routing verdict, so routingNotice()
correctly stays silent — yet #1299 and #1260 are exactly that shape, CPU hosts
that hit the 300s budget on long text with no warning at all. "Nothing is
misconfigured" and "this will finish in time" are different claims.

Threshold is the backend's own definition of past-short: generate_timeout_for()
gives the first 1200 characters the flat budget before extending it, so
ordinary sentences on a CPU laptop stay quiet and only the shape that actually
times out is flagged. Hardware caveats still take precedence — one toast, and
it names the real reason rather than generic advice.

5 tests; engines.cpuLongText translated in all 21 locales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(engines): don't tell CPU-tuned engines to switch to themselves

Greptile P1. The advice names OmniVoice GGUF and Supertonic-3 as the CPU-tuned
alternatives — shown to someone already running one of them, it is advice to
switch to what they are using. Those two now get the same warning without the
self-referential clause; the engine set matches the backend's own timeout
message so the two can't disagree about who is CPU-tuned.

Also documents the preflight in docs/performance.md (docs-sync rule): both
warning shapes, why the threshold is 1200 characters (it is the figure the
budget itself uses), that they are advisory and once-per-engine-per-session,
and the CPU-tuned exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 06:23:30 -07:00
7db063dede fix(engines): path-aware GPU-pool slot in SubprocessASRBackend.transcribe() (ASR sibling of #1298) (#1304)
* fix(engines): path-aware GPU-pool slot in SubprocessASRBackend.transcribe()

transcribe() had the same on-pool self-deadlock that generate() had (fixed in
#1298): a bare no-op submitted to the GPU pool, but run_transcribe_guarded
dispatches it via run_in_executor(_gpu_pool), already on a pool worker, so on
a 1-worker (MPS) pool the no-op queued behind the job running it and timed
out before the sidecar spawned. IsolatedFasterWhisperBackend on MPS hit this
on every transcription.

Mirror generate()'s path-aware slot block (on-pool skip via
running_on_gpu_pool; off-pool _occupy hold) in transcribe(). The pattern is
duplicated rather than extracted into a shared helper to avoid reworking
generate(), which just shipped (#1298) with a CodeQL fix; extracting a shared
contextmanager is a clean follow-up. Regression test added (transcribe
dispatched on a pool worker).

* fix(engines): import threading in subprocess_asr

transcribe()'s off-pool slot hold uses threading.Event(), but the module never
imported threading — every subprocess-ASR transcribe raised NameError, and the
three round-trip tests failed in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(engines): cover the off-pool transcribe branch

Only the on-pool path had a test, so `threading.Event()` in the off-pool
branch shipped with `threading` never imported — every direct caller hit
NameError before the sidecar started. Both bots caught it on review; nothing
in the suite did. A branch with no test is how a one-word bug reaches CI.

Also asserts the slot is genuinely released afterwards. Fails without the
import fix; the pre-existing on-pool test still passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 06:23:24 -07:00
debpalashandClaude Opus 5 d76c2ba675 ci: price every smoke leg for a cold uv sync, not a warm one
The Windows-only 25 came from a warm-cache measurement: Linux and macOS finish
in ~65s when setup-uv restores its cache, so 10 looked generous. Run
30439640107 then hit "Failed to restore: Cache service responded with 400",
Linux installed torch from scratch, and the leg was killed at 10m17s. The 65s
was the cache, not the platform — my per-leg split was reasoning from the
wrong baseline.

A cache miss is not rare enough to treat as an outage (the 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, still bounded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 16:06:29 +05:30
0e9733bc16 fix(engines): path-aware GPU-pool slot in SubprocessBackend.generate() (on-pool skip + off-pool hold) (#1298)
* fix(engines): make SubprocessBackend.generate() path-aware on the GPU pool

generate()'s slot handling had two bugs:
1. On-pool self-deadlock: /v1/audio/speech and /generate (and audiobook,
   dub, batch) dispatch generate() via run_on_gpu_pool_guarded, already on
   a pool worker, so the inner slot submit queued behind the very job
   running it on a 1-worker (MPS) pool and timed out before the sidecar
   spawned. Every subprocess engine surfaced the in-process 300s-abandon
   instead of synthesizing.
2. Off-pool no hold: the off-pool slot was a bare no-op that released the
   worker before _spawn(), so off-pool callers (engine self-test,
   diagnostics) could synthesize concurrently with a pool job and
   over-subscribe the GPU.

Make the slot block path-aware: on-pool callers skip (the outer
run_on_gpu_pool_guarded already holds _running for the whole sidecar
exchange); off-pool callers hold a real slot for the whole synthesis via
an _occupy task that blocks the worker until _held is set in the finally.
Single release point in the finally.

Regression tests: generate dispatched on a pool worker (on-pool skip) and
a concurrent pool job blocked during an off-pool generate (off-pool hold).
Both verified fail-before / pass-after.

Supersedes #1296 (on-pool-skip-only). Closes #1295, #1297.

* Address review: couple on-pool skip to the pool prefix; fix comment

/simplify + /code-review flagged that the on-pool skip keyed on the literal
"gpu-pool" string, decoupled from _build_gpu_pool's thread_name_prefix. A
rename would silently re-introduce the exact self-deadlock this PR fixes (and
the tests can't catch it, since they hardcode the prefix). Centralise the
prefix in _GPU_POOL_THREAD_PREFIX + a running_on_gpu_pool() helper, used by
_build_gpu_pool, the skip in generate(), and _heal_tts_placement.

Also fix the comment: the Settings engine self-test rejects subprocess-isolated
engines with a 400, so the only real off-pool caller is the diagnose.py
deep-synth probe.

* fix(engines): bind slot_future before the off-pool branch

CodeQL py/uninitialized-local-variable (error, blocking CI). `_held is not
None` does imply slot_future was assigned, so the current code is correct —
but the two are only coupled by convention, which the analyser cannot see and
a third exit path would quietly break. Binds it to None up front and guards
the cancel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(engines): make the slot-hold regression deterministic and leak-free

CodeRabbit, valid on both counts. The test used sleep(0.8)/sleep(0.5) as
synchronization — the tests/** contract forbids it, and on a slow runner the
marker could be enqueued before the generator had reserved anything, so the
assertion passed for the wrong reason. It now waits on an event signalled when
the slot task actually starts, and asserts "did not run" via a result()
timeout rather than a bare sleep.

Cleanup moved into finally: an assertion failure used to leak the sidecar
process and the pool thread into the rest of the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: debpalash <4178343+debpalash@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:27:58 -07:00
Paolo Antinori 47c51698c9 feat(engines): add omnivoice-subprocess, a crash-isolated (killable) TTS engine (#1292)
* feat(engines): add omnivoice-subprocess, a crash-isolated TTS engine

The default in-process OmniVoice engine runs on the GPU ThreadPoolExecutor.
When a generate or load exceeds its execution budget the pool is "reset", but
the abandoned worker thread cannot be killed (Python cannot interrupt a native
torch/MPS call), so it keeps holding the device until it finishes on its own
and later synths queue behind it and hang. The reset restores pool capacity
but not the device. This is the residual root cause behind the closed #730
and #1190: the messaging/reset mitigations address the symptom, not the
device-holding zombie.

Add an opt-in `omnivoice-subprocess` engine that runs the same model in a
child process via SubprocessBackend. A child process can be hard-killed: on a
recv-timeout the watchdog calls proc.kill(), reclaiming VRAM/device, and the
next request transparently respawns a fresh sidecar. The in-process engine
remains the default, so existing users see no change; this is an opt-in for
unattended / scheduled / reaction-triggered synthesis where a stuck job must
self-recover instead of hanging until a manual restart.

Base-class and mitigation changes that ship with it:
- SubprocessBackend.generate() now consumes non-terminal {"op":"progress"}
  frames a sidecar emits during a cold load (previously the first cold
  generate after spawn failed, then worked on retry). Additive: engines that
  reply with audio directly are unaffected.
- recv_timeout_s is overridable per engine (default 60s unchanged); the new
  engine sets it to the generate budget so a long-but-valid synth is not
  falsely killed while a wedged one still is.
- make_room_before_generate(): free idle GPU memory before a warm, heavy
  generate. The cold-load path already evicted; the warm path skipped it, so a
  long synth on a VRAM-tight MPS box could contend its way into the budget.

Verified end-to-end against the live model (cold / warm / recovery-after-kill)
and under a sustained + concurrent-pressure soak: killed-worker recovery 5/5,
chunked long text 9/9, no memory leak.

* Address review: install_hint + move make_room into get_model

- Add `omnivoice-subprocess` to `_INSTALL_HINTS`; the
  test_install_hints_cover_all_registered_backends gate requires every
  registered backend to carry one (this was the CI failure).
- Move the warm-generate VRAM eviction out of the /generate and
  /v1/audio/speech routes and into get_model()'s warm-return path, so EVERY
  native TTS generate is covered (REST, WS TTS, dub, batch, audiobook), not
  just the two REST routes. Drops the now-redundant per-route wiring.
  (Greptile P1: the per-route placement missed the other generation surfaces.)

* Address review: drop dead long-text eviction path; log probe failure

- _should_make_room_for_generate: the long-text headroom boost became dead
  code once the eviction moved into get_model() (which has no text), so the
  long-text branch never fired. Removed the text param, the long-text
  threshold/multiplier branch, and the now-unused _env_float helper. The core
  RAM-tight gate (the part that matters on a starved box) is unchanged.
- Log the available_memory probe failure at debug instead of silently
  swallowing it (CodeRabbit: silent swallow breaks the debug trail).
- Tests updated for the text-agnostic policy.

* fix(engines): stop subprocess generate() self-deadlock on 1-worker pools

SubprocessBackend.generate() acquires a GPU-pool slot for accounting, but
/v1/audio/speech and /generate dispatch backend.generate() via
run_on_gpu_pool_guarded, i.e. already ON a pool worker. On a 1-worker pool
(MPS) the inner pool.submit queued behind the very job running it and
slot_future.result(timeout=10) raised before the sidecar ever spawned, so
omnivoice-subprocess (and every other subprocess engine on MPS) surfaced the
in-process 300s-abandon instead of synthesizing.

Skip the slot acquisition when current_thread() is already a gpu-pool worker;
the outer guard already accounts for the slot. Direct callers (off the pool)
still acquire one. Regression test added (generate on a pool worker).

* Address review: reword slot-skip comment (fixes watermark-coverage CI) + simplify

- The slot-skip comment said "dispatch backend.generate() via", and
  test_watermark_route_coverage's _SYNTH_CALL regex matches the literal
  backend.generate( anywhere in a module, so it counted subprocess_backend.py
  as a synthesis producer that must reference mark_synthetic (it doesn't — the
  routes apply mark_synthetic; the engine sits below the chokepoint, like
  tts_backend.py). Reworded to "dispatch generate() via".
- Fold in the simplify refinement: single negated predicate, import+pool
  moved into the acquire branch.
2026-07-29 02:06:13 -07:00
Palash DebnathandClaude Opus 5 36e3397613 fix(cuda): stop sending every RTX 40-series card to the CPU (#1289)
* fix(engines): warn about under-provisioned hardware before the synth, not after

Six reports are the same story: #1240, #1246, #1248, #1277, #1283, #1284 —
4 GB and 6 GB cards running an engine that wants 6 GB, each one waiting out
the full 300s compute budget to be told the job "was too heavy". The routing
layer knew the whole time. The error text even names the card and the figure.

The caveat only ever surfaced on the engine-PICK toast, so it reached people
who changed engines and nobody whose engine was already selected — the
default, or one persisted from a previous session. That is most users.
/generate does return X-OmniVoice-Routing, but a response header arrives when
the job ends, five minutes too late to be a warning.

So the check moves to the chokepoint every synth path shares (api/generate.ts,
same argument as the in-flight count). Fire-and-forget: never awaited, so it
cannot add latency to the request it warns about; never throws, so an
unreachable backend costs a warning rather than a generate; once per
engine+reason per session, so it informs instead of nagging. Advisory, not
blocking — the driver can page to system RAM and short inputs fit where long
ones don't.

Extracts routingNotice() as the single frontend mirror of the backend's
routing_notice(). Two callers now need "is this verdict worth interrupting
for", and two inline copies would drift — invisibly, until someone on DirectML
or an unavailable engine gets a hardware warning for a normal pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(cuda): stop sending every RTX 40-series card to the CPU

The SM-arch gate required the device's exact tag in get_arch_list(). NVIDIA's
rules are not exact, and PyTorch depends on that: SASS is binary-compatible
UPWARD within a major version, so the official wheels ship sm_80/sm_86 and
deliberately no sm_89 — the 8.6 kernels already cover Ada. Exact matching
therefore declared sm_89 unsupported, check_device_compatibility() returned
False, and get_best_device() silently returned "cpu".

That is every RTX 4060/4070/4080/4090, not just the reporter's card (#1285) —
each one running TTS on the CPU on hardware that works fine, with a message
telling them their GPU was unsupported.

cuda_build_covers() now applies the real rules: sm_XY covers same-major
devices with minor >= Y; compute_XY PTX JITs forward to anything newer; an
a/f suffix (sm_90a) is architecture-specific and matches exactly. Unparseable
entries are skipped, and an empty arch list still degrades to "compatible" —
the pre-existing fail-open contract.

The remediation text also pointed at a NIGHTLY index for what is a stable
supported card; it now names the stable cu128 index.

12 tests: the Ada regression, Jetson Orin (8.7), downward-within-major and
cross-major rejection, PTX forward-JIT, arch-specific suffixes, and a genuine
sm_120-on-old-wheel mismatch so the gate is proven to still work.

Closes #1285

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(cuda): resolve app modules at call time, not import time

The tests/** review contract forbids module-level imports of app modules —
they go stale under sys.modules pollution from other suites, which is the live
cause of #1269's cross-suite failures. Binds core.device_caps per call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: restore generate.ts and generatePreflight.test.js from main

Conflict markers were committed in the previous merge — `git add` on the
directory staged both files as resolved while the markers were still in them.
Both belong to #1288 and are unchanged by this PR, so they take main's version
verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:59:06 -07:00
Palash DebnathandClaude Opus 5 07263ef42e fix(engines): warn about under-provisioned hardware before the synth, not after (#1288)
* fix(engines): warn about under-provisioned hardware before the synth, not after

Six reports are the same story: #1240, #1246, #1248, #1277, #1283, #1284 —
4 GB and 6 GB cards running an engine that wants 6 GB, each one waiting out
the full 300s compute budget to be told the job "was too heavy". The routing
layer knew the whole time. The error text even names the card and the figure.

The caveat only ever surfaced on the engine-PICK toast, so it reached people
who changed engines and nobody whose engine was already selected — the
default, or one persisted from a previous session. That is most users.
/generate does return X-OmniVoice-Routing, but a response header arrives when
the job ends, five minutes too late to be a warning.

So the check moves to the chokepoint every synth path shares (api/generate.ts,
same argument as the in-flight count). Fire-and-forget: never awaited, so it
cannot add latency to the request it warns about; never throws, so an
unreachable backend costs a warning rather than a generate; once per
engine+reason per session, so it informs instead of nagging. Advisory, not
blocking — the driver can page to system RAM and short inputs fit where long
ones don't.

Extracts routingNotice() as the single frontend mirror of the backend's
routing_notice(). Two callers now need "is this verdict worth interrupting
for", and two inline copies would drift — invisibly, until someone on DirectML
or an unavailable engine gets a hardware warning for a normal pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(engines): route streaming synthesis through the generate chokepoint

streamGenerateSpeech POSTed /generate via apiFetch directly — a second,
parallel door. Everything attached to "the one call every synth path shares"
therefore did not apply to it: the in-flight count that stops the updater
relaunching mid-synthesis, and the new under-provisioned-hardware preflight
(Greptile P1). A chokepoint with two doors is not a chokepoint.

Also fixes two cache defects in the preflight itself:

- An engine pick left the cached /engines response describing the PREVIOUS
  engine for up to 60s, so switching engines and generating immediately warned
  about the one you just left — or stayed silent about the one you just chose.
  notifyEngineSelected() now drops the cache, and hands over the caveat it just
  displayed so the preflight does not repeat the same sentence seconds later.
- A rejected listEngines() promise stayed cached for the full TTL, silencing
  the caveat for a minute after the backend came back. It is now evicted, but
  only if it is still the current entry, so a racing newer fetch survives.

6 new tests; 2 of the 3 streaming ones fail before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(engines): hold the in-flight claim for the whole stream, not just headers

generateSpeech releases its claim when the Response resolves — when the
HEADERS arrive — but a streaming synth generates audio for as long as the body
is read. Routing streaming through it gave it a count for the first time, then
dropped that count to zero for the entire synthesis, so the updater saw idle
and was free to relaunch mid-stream (Greptile P1).

streamGenerateSpeech now wraps the whole operation in withTtsInflight().
Nesting is harmless because the store tracks a count, not a boolean — the
inner claim just bumps it to 2 and back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:43:39 -07:00
Palash DebnathandClaude Opus 5 3553965f41 ci(windows): make the ffmpeg retry test the outcome, not choco's exit code (#1290)
* ci(windows): make the ffmpeg retry test the outcome, not choco's exit code

The chocolatey feed 503'd; choco printed "Unable to find package 'ffmpeg'"
and "installed 0/0 packages" — then exited 0. The retry loop added on
2026-07-20 for this exact class was `choco install ... && break`, so it broke
out on attempt 1, no backoff ran, and the job died one line later on
`ffmpeg: command not found`. It took #1281 red on an unrelated change.

A retry that trusts a lying exit code is not a retry. The loop now exits on
`command -v ffmpeg` and still fails the job loudly when ffmpeg never arrives.

Tests extract the real step body from ci.yml and run it against a stubbed
choco; 2 of the 4 fail against the previous loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(ci): pin PATH to the stub dir so the retry test can't false-green

The harness inherited the ambient PATH, so a real ffmpeg satisfied
`command -v` and the loop exited on attempt 1 — every assertion passed
against a broken workflow. It happened twice: /opt/homebrew/bin locally, then
/usr/bin on the Linux runner, which is what took this PR red.

PATH is now the stub dir alone, with the few real tools the stubs need
symlinked in, and stub shebangs are absolute (`/usr/bin/env bash` cannot
resolve bash when PATH is one directory). test_harness_actually_hides_ffmpeg
asserts the sandbox is a sandbox, so the next leak fails loudly instead of
quietly passing.

2 of 5 fail against the old `&& break` loop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: give the Windows smoke leg its own timeout, per-leg not shared

Smoke (Windows) has been dying at 10m08s inside `uv sync`, and the shared
10-minute budget made it self-perpetuating: the leg is killed before the
post-step saves the uv cache, so the next run starts cold and dies the same
way. Nothing primes the cache, so it never gets faster.

Measured on run 30385710466 — Linux 65s, macOS 65s, Windows still installing
torch when the job was killed. Windows now gets 25 minutes, priced for one
cold install to finish and populate the cache; warm runs land nowhere near it.

Per-leg rather than raising the shared value, so a genuine hang on Linux or
macOS still fails fast instead of inheriting Windows' allowance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci(windows): skip the backoff after the final attempt; tighten the tests

CodeRabbit, both valid:

- The loop announced "retrying in 90s" and slept after attempt 3, though no
  fourth attempt exists — 90s added to an already-doomed job.
- The retry tests asserted `attempts >= N`, so a regression that kept going
  after ffmpeg appeared would still pass. Pinned to exact counts, plus a case
  asserting the final attempt announces no retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 01:24:27 -07:00
Palash DebnathandClaude Opus 5 602ea6f53e ci(release): stop the macOS preview updater bundle colliding with itself (#1281)
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it

Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277
on 6 GB) share one shape: the user generates, waits out the entire 300s
compute budget, and is then told the job "was too heavy for the available
compute".

The warning existed the whole time. Routing computes it (#1226's `_caveat`:
"…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow
generations that may time out"), and `/engines/select` echoes it in
`routing_reason` — but notifyEngineSelected only surfaced a reason when
`routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED
verdict, so it fell through to the green "switched" success toast and was
thrown away. The user was told everything was fine, then waited five minutes
to find out it wasn't.

Now any caveat on the echo raises a warn-tone toast naming it, with a longer
duration since it lists the ways around the limit. This covers the kernel-risk
caveat on the same path.

Deliberately still ADVISORY, not blocking — matching the routing layer's
documented contract (the driver can page to system RAM, and short inputs fit
where long ones don't). The engine is still selected; the user just finds out
now instead of after the timeout. This is the first-run path too: the wizard's
library step shares notifyEngineSelected.

Fail-before verified: both new tests fail against the previous version.

Known remaining gap: a user whose engine is already selected sees this only
when they re-pick. A generate-time preflight would close that, but it needs a
"once per session, not per generate" design — filed as follow-up rather than
guessed at here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): stop the macOS preview updater bundle colliding with itself

The nightly preview run has failed on both macOS legs since early July:

  Uploading OmniVoice Studio_x64.app.tar.gz...
  ##[error]Validation Failed: {"resource":"ReleaseAsset",
                               "code":"already_exists","field":"name"}

`preview` is a ROLLING release, reused every night, and macOS updater
artifacts are the only ones Tauri names without the version:

  OmniVoice Studio_0.4.1-103_x64.dmg   unique per run — uploads fine
  OmniVoice Studio_x64.app.tar.gz      constant — collides on run 2+

Consequences, verified against the live release: the macOS updater bundles on
`preview` were last written 2026-07-04 (x64) and 2026-07-05 (aarch64), and
latest.json 2026-07-13 — three weeks stale as of today. Preview-channel macOS
users had no working update path. The failure also lands AFTER the dmg
upload, so each run looked partly successful while going red.

Deletes this arch's updater bundle before the upload. Matches the STORED
asset name by querying the release rather than guessing the spelling — GitHub
rewrites spaces to dots, so "OmniVoice Studio_x64.app.tar.gz" is stored as
"OmniVoice.Studio_x64.app.tar.gz" and a literal delete-asset by the uploaded
name would silently no-op.

Scoped to the preview path (a v* tag creates a fresh release with nothing to
collide with) and to the job's own arch, so the parallel aarch64/x64 legs
can't touch each other's assets. Verified the filter against all 209 live
preview assets: it matches exactly the 4 colliding updater files and no
versioned artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(release): fail loud when the preview asset sweep can't do its job

The cleanup step treated every `gh` failure as "nothing to clear" — 401, 403,
429 and network errors included. That reintroduces the outage it was written
to fix, with the evidence removed: the stale bundle survives, the Tauri upload
dies with `already_exists`, and the one step that could have explained why is
green. Three weeks of broken macOS Preview updates started exactly this way.

Only an absent release/asset is benign now. A 404 on view means "no preview
release yet" (GH_TOKEN is scoped to this repo, so 404 really is absence); a
404 on delete means someone already removed it, which satisfies the goal. Every
other failure fails the step with the reason printed. An unexpected arch is
also fatal rather than a silent skip — same class of blind spot.

Adds tests/test_release_preview_asset_cleanup.py, which extracts this step's
real shell body from release.yml (so it cannot drift) and runs it against a
stubbed `gh`: 6 of the 8 cases fail against the previous version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 01:16:47 -07:00
Palash DebnathandClaude Opus 5 574b2832c5 fix(engines): show the under-provisioned-VRAM warning instead of discarding it (#1280)
* fix(engines): show the under-provisioned-VRAM warning instead of discarding it

Four of the open low-VRAM reports (#1240, #1246, #1248 on 4 GB cards; #1277
on 6 GB) share one shape: the user generates, waits out the entire 300s
compute budget, and is then told the job "was too heavy for the available
compute".

The warning existed the whole time. Routing computes it (#1226's `_caveat`:
"…has 4.0 GB VRAM; this engine wants about 6 GB. It will run, but expect slow
generations that may time out"), and `/engines/select` echoes it in
`routing_reason` — but notifyEngineSelected only surfaced a reason when
`routing_status === 'cpu_fallback'`. The VRAM caveat rides on an ACCELERATED
verdict, so it fell through to the green "switched" success toast and was
thrown away. The user was told everything was fine, then waited five minutes
to find out it wasn't.

Now any caveat on the echo raises a warn-tone toast naming it, with a longer
duration since it lists the ways around the limit. This covers the kernel-risk
caveat on the same path.

Deliberately still ADVISORY, not blocking — matching the routing layer's
documented contract (the driver can page to system RAM, and short inputs fit
where long ones don't). The engine is still selected; the user just finds out
now instead of after the timeout. This is the first-run path too: the wizard's
library step shares notifyEngineSelected.

Fail-before verified: both new tests fail against the previous version.

Known remaining gap: a user whose engine is already selected sees this only
when they re-pick. A generate-time preflight would close that, but it needs a
"once per session, not per generate" design — filed as follow-up rather than
guessed at here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(i18n): write the caveat toast string, don't just translate a placeholder

CodeRabbit flagged engines.selectWithCaveat as untranslated in 20 locales.
It was worse than that: en.json said "{{engine}}: {{reason}}" too, so the
English string had never been written and every "translation" was a faithful
copy of a non-sentence. All 21 languages would have shown a bare
"omnivoice: <English backend text>".

Writes the en sentence, translates it into all 20, and translates
engines.selectCpuFallback alongside it — same function, same toast, and it
was English-only in every locale (missing-key ratchet tightened 518 -> 517,
zh-CN 511 -> 510).

Adds test_no_placeholder_only_values to pin the class. Parity tests cannot
catch this: the key is present everywhere and the placeholders match exactly.
Only the absence of prose gives it away, so the guard checks en.json too —
that is where this one started. A bot catching a mechanical rule twice means
the rule belongs in CI (CLAUDE.md, Token economy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(engines): only warn on accelerated+caveat, mirroring routing_notice()

Greptile P1: testing a bare `routing_reason` also fires on benign verdicts.
Routing rule 5 gives a Windows DirectML host cpu_only + an explanatory reason
on a perfectly normal pick, and rule 6 attaches one to `unavailable` — neither
is a hardware warning, but both drew a 10s amber toast. routing_notice() in
engine_routing.py is the canonical predicate (cpu_fallback always, accelerated
only with a reason); the frontend now matches it. Two tests, both failing
before.

Also translates settings.engine_switched, which shipped as the identical
"{{family}} → {{engine}}" in all 21 files — an untranslated success toast
everywhere (CodeRabbit). That was the sole _PLACEHOLDER_ONLY_ALLOWLIST entry,
so the allowlist is now empty and the guard has no exceptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: oxfmt the new toast test cases

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 04:00:34 -07:00
debpalashandClaude Opus 5 98c6d8bccf docs(rules): make brevity the default for every agent response
Tightens the Token economy directive: shortest response that fully answers,
outlines/tables over prose, no preamble or recap. Applies to every response,
not just status updates. CLAUDE.md and AGENTS.md kept in sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:16:07 +05:30
Palash DebnathandClaude Opus 5 b01c635bfe release: v0.4.2 (#1279)
Version files (package.json + the three mirrors) and both lockfiles bumped
0.4.1 → 0.4.2; [Unreleased] renamed to [0.4.2] — 2026-07-28.

Ships five user-facing fixes: the update toast, the restart guard that used
to discard in-flight work, the shutdown-is-not-a-crash 503, the self-healing
half-downloaded model, and the dub-history purge ordering — plus the
"Dismiss" mistranslation in five locales.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:56:37 -07:00
Palash DebnathandClaude Opus 5 e9307f2dc0 fix(errors): key the no-report rule on the shutdown marker, not on 503 (#1278)
* fix(errors): key the no-report rule on the shutdown marker, not on 503

Self-caught regression from #1272. Suppressing the "Report" action for every
503 was too broad: 503 is also how a real engine-load timeout and an
unavailable engine are reported (#1246, #1260, and #1277, filed hours ago
with a 503 in its very title). That would have removed the report button from
exactly the class of failure users need to be able to file — silencing real
bugs to hide a benign one.

The backend now tags only the shutdown case with a `[shutting_down]` marker,
following the existing `[clone_ref_unusable]` convention, and the UI matches
the marker instead of the status. A 503 that is a genuine failure keeps its
report action.

Localized message added to all 21 locales; the backend test pins the marker
so the cross-layer contract can't be renamed away.

Fail-before verified: the two "still offers Report for a 503" tests fail
against the shipped blanket-503 version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* i18n(de): formal register for the two new German strings

Review: the surrounding errors.* strings and update.retry use "Sie"; both new
strings used the informal form. Turkish is consistently informal (install,
retry) so it stays as-is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 15:38:36 -07:00
Palash DebnathandClaude Opus 5 c3d0d6b123 feat(update): announce updates as a toast with actions, not a wall of text (#1272)
* fix(dub): purge order was hash-dependent, and the cap could evict a live marker

main went red on my own test. Two distinct defects, both mine.

1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
   which markers a cap-forced trim discarded was luck. That is why the test
   passed locally and failed in CI — verified: the old code passes at seeds
   0/7/42 and fails at 12345. Now a de-duplicated list in caller order.

2. The size cap could evict markers the CURRENT purge had just recorded. Those
   are the newest and the likeliest to still be held by a running job, so
   dropping one is precisely the resurrection this mechanism exists to prevent.
   A 'clear history' larger than the cap forced exactly that. The cap now never
   touches the current purge, making the real bound cap + one purge — stated
   plainly rather than implied.

Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.

* feat(update): announce updates as a toast with actions, not a wall of text

A version's release notes ARE the whole changelog section — v0.4.1's was 42
bullets. Any surface that renders them inline becomes unusable: older builds put
them in a blocking OS dialog that filled the screen and had to be dismissed
before the app could be touched.

Removing that dialog left the opposite failure. The only remaining signal was a
6-pixel dot beside the version number in the footer, which is easy to never
notice — so users either got shouted at or told nothing.

A toast is the middle: it names the version, offers Install and restart /
What's new / Later, and leaves. The notes stay one click away in Settings →
Updates, where there is room. Keyed by version so the 6-hourly re-check
replaces rather than stacks, and it never auto-dismisses — an update the user
hasn't answered is still true. Install declines while a generation is running,
since the relaunch would lose it.

Strings added to all 21 locales, translated rather than English-filled.

Tests pin the shape that matters: the toast takes no notes prop at all, renders
under 200 characters, and cannot stack duplicates.

* fix(update): don't relaunch while work is in flight; share the busy check

Review of #1272 found the restart guard was `dubStep === 'generating'` and
nothing else. Installing an update relaunches the process, so that permitted
throwing away a dub upload, a transcription, a translation, an export or a
standalone TTS synth. The same narrow check was written twice — in the new
toast and in UpdatesPanel — so the two could also drift apart.

Replaced with a single `isAppBusy(state)` in utils/appBusy, unioning the
signals the store actually has: the dub state machine, the floating status
pill (which every long background operation already pushes to), and
`ttsGenerating` — a new transient store field mirroring useTTS's local
`isGenerating`, which lived in a hook where no global check could see it.
`dubStep === 'editing'` is deliberately not busy: it waits on the user, and
counting it would block updates for as long as a transcript stays open.

Also from review:

- A failed lazy import of the toast fell into the outer catch, whose
  setUpdateIdle() erased the update that had just been found. The
  announcement is optional; the available state is not.
- "Dismiss" was machine-translated into the employment sense — terminate an
  employee — in de/ja/ru/zh-CN/zh-TW, on close buttons and one aria-label.
  Swept every locale rather than the four lines that were flagged.
- Hoisted the test's mock state with vi.hoisted.

CodeRabbit's changelog finding is declined: Highlights bullets carry no issue
refs by design, which tests/test_changelog_style.py enforces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shutdown): a quit mid-generate is not a 500, and not a bug report (#1276)

#1174 made a model load interrupted by shutdown benign for the background
preload, but a *request* that triggered a load took the generic unhandled-
exception path: crash log, ERROR traceback, and an error-journal entry that
feeds the bug-report pipeline. Quitting the app with a generate queued
surfaced "500 Internal Server Error: model load skipped: backend shutting
down" and offered to file a GitHub issue for a normal teardown.

Nothing failed — the process is exiting. The handler now answers 503 with
Retry-After and an actionable detail, ahead of the crash-log/journal writes.

Frontend half of the same bug: toastErrorWithReport offered "Report" for any
error. A 503 means "not now, try again" by definition, so it now shows the
backend's message without the report action — covering a still-warming
backend too, not just this shutdown path.

Fail-before/pass-after tests on both sides, including that the shutdown case
leaves no crash-log entry and no journal record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(models): repair a half-downloaded model whichever way it reports (#1273)

transformers has two unrelated wordings for "this snapshot has no weight
shard", sharing no words:

  hub load   "<repo> does not appear to have a file named …"
  local dir  "Error no file named model.safetensors, … found in directory …"

The self-heal and the failure classifier both matched only the first. The
second is what a load of a *subfolder* inside a cached snapshot raises —
exactly where an interrupted download leaves a half-written repo — so the
reporter got neither the automatic repair (delete broken entries →
re-download → retry) nor an actionable hint, just a raw 500. Their disk had
10.6 GB free, i.e. a download that ran out of room.

The phrase list now lives once in core.failure, so the healer and the error
text cannot disagree about what an interrupted download looks like. Both
fragments of the second wording must match — "no file named" alone is
ordinary English and must not claim the class.

Also hardens the #1276 handler found by running both suites in one session:
services.model_manager can be imported under two module names, making two
distinct ModelLoadInterruptedByShutdown classes and breaking a bare
isinstance — which silently restored the 500 this fix exists to remove. Now
matched by isinstance OR class name, with a test that raises a same-named
class from a different module.

Verified against the full tests/ + backend/tests/ session: the only remaining
failures are the four pre-existing #1269 isolation leaks, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): count synths in flight at the chokepoint, not in one caller

Review round 2. Both Greptile P1s were the same weakness in my first pass:
tracking the synth in useTTS meant only the Generate tab counted (voice
previews, the compare modal, the stories editor and profile previews call
generateSpeech directly and were invisible), and a boolean meant two
overlapping syntheses cleared each other — whichever settled first reported
"idle" while the other was still running, so Install and restart discarded it.

Moved to `api/generate.ts`, around the one `/generate` call all seven paths
share, as a count with a `finally` release (so an abort or a network error
frees it too). A future synth caller is covered without opting in.
Fail-before verified: the overlap test fails against the boolean version.

Also from review:

- UpdatesPanel re-reads the busy state at click time; the render-time snapshot
  only exists to disable the button, and work can start after the last render.
- The 503 now carries the allowed-origin CORS headers. Without them the
  browser reports a bare CORS failure and the actionable detail — the whole
  point of the fix — never reaches the user. Both error responses build them
  through one helper now.
- Log `request.url.path`, not the full URL: a query string can carry tokens
  and newlines.
- `update.busy` said "finish your dub first" in all 21 locales, but the guard
  now covers uploads, transcription, translation, export and synth. Rewritten
  as work-in-progress wording, translated per locale.
- ru `common.dismissStatus` was "reject status"; missed in the earlier sweep.

Declined: CodeRabbit's request for a `(#NNN)` ref on the Highlights bullet —
Highlights carry no refs by design (CLAUDE.md), and tests/test_changelog_style.py
enforces it. Also declined gating the cache re-download behind a confirmation:
the self-heal already existed and already ran for the sibling wording, this
only stops it missing half the class, and it stays behind the existing
_hf_offline() check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(update): grey out Install while work is running

CI lint caught `busy` as unused after the click-time check moved into the
handler — and that exposed a real gap: `busy` had never been wired to
anything. The comment claimed it disabled the button; it didn't. Clicking
Install during a synth just bounced a toast back.

Now it does what it said: the Install and Restart buttons are disabled while
work is in flight, with `update.busy` as the tooltip. The click-time read
stays the authority, since work can start between the last render and the
click — the disabled state is the explanation, not the safety.

Adds the first UpdatesPanel test. The case it pins hardest is the inverse of
the bug: a busy predicate stuck at true would make the app permanently
un-updatable, which is worse than what this fixes. So it asserts enabled when
idle and during dub 'editing' (which waits on the user), disabled during an
upload, a translation, and one or more synths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:32:32 -07:00
Palash Debnath f99832de90 fix(dub): purge order was hash-dependent, and the cap could evict a live marker (#1271)
main went red on my own test. Two distinct defects, both mine.

1. `targets = set(job_ids)` made iteration order depend on PYTHONHASHSEED, so
   which markers a cap-forced trim discarded was luck. That is why the test
   passed locally and failed in CI — verified: the old code passes at seeds
   0/7/42 and fails at 12345. Now a de-duplicated list in caller order.

2. The size cap could evict markers the CURRENT purge had just recorded. Those
   are the newest and the likeliest to still be held by a running job, so
   dropping one is precisely the resurrection this mechanism exists to prevent.
   A 'clear history' larger than the cap forced exactly that. The cap now never
   touches the current purge, making the real bound cap + one purge — stated
   plainly rather than implied.

Tests now pin both: identical survivors across runs, and an oversized purge
keeping all of its own markers. Verified across six hash seeds; full suite green
under 12345, the seed that reddened main.
2026-07-27 12:22:18 -07:00
Palash Debnath 9736fd4859 release: v0.4.1 (#1239)
* release: v0.4.1

Seven user-reported issues fixed since v0.4.0 (#1221–#1229). Version bumped
across the single source of truth (frontend/package.json) and its three
toolchain mirrors; [Unreleased] renamed to the release section that
release.yml extracts verbatim as the GitHub Release body.

Docker tag examples in docs/install/docker.md and deploy/dockerhub-overview.md
updated to 0.4.1 (docs-sync rule).

* release: #1239 review — sync Cargo.lock to 0.4.1

Greptile: the manifest said 0.4.1 while Cargo.lock still recorded 0.4.0, so a
`cargo build --locked` (and the Tauri bundler's own locked build) would fail
on the mismatch. Regenerating locally updated it but it was never staged.

* release: re-sync [0.4.1] after the fix merges, date it 2026-07-27

Picks up everything merged since the section was first written: the first-run
wizard chrome (#1241), the MCP host allowlist (#1249), the macOS 12 startup
crash (#1245), and the six error-message fixes (#1247, #1251, #1254, #1256,
#1257, #1262).

Deliberately NOT included:
  - the dub delete-resurrection fix (#1252, #1253) — split to #1270 after it
    needed six rounds of correction, the last two finding that the fix did not
    close the reported case and that its own bound reintroduced it;
  - the Linux AppImage WebKit fix (#1258, #1244) — held on #1265 pending
    confirmation on a Mesa 26.1 host, which nobody has run.
2026-07-26 14:48:05 -07:00
Palash Debnath b66b09ceaa fix(dub): deleting a dub no longer resurrects it (#1252, #1253) (#1270)
* fix(dub): deleting a dub no longer resurrects it (#1252, #1253)

Split out of #1264. The other six fixes there are independent error-message
changes that needed no corrections; this one is a concurrency change that
needed five rounds, each finding something real in work that looked finished
and tested:

  1. review: merge and save were split, so a delete between them had the row
     written straight back;
  2. review: dict membership cannot express 'withdrawn' — an absent key means
     'not written yet' for a new job and 'deleted' for an established one;
  3. fail-before check: the race test was not testing the race (it asserted
     WHAT happened, never WHEN, and 'save after delete' IS the resurrection);
  4. review: the gate sat in two ingest helpers while eight direct save_job
     call sites bypassed it;
  5. direct check of the reported scenario: the tombstone covered in-flight
     ingests only, so a delete during a RENDER — the common case — still
     resurrected the job.

Riding a release on that record is not a good trade, so it ships on its own.

What it does now: the withdrawal is recorded when a job is DELETED, held in a
bounded LRU (there is no moment at which a delete stops mattering), and checked
inside save_job so every caller inherits it. Re-importing an id is the only
thing that revives it. The lock is re-entrant because the atomic helpers call
save_job while holding it.

Carries #1252's message half too: str(KeyError) is the repr of the key, which
is how the user's own job id became the entire error text.

* fix(dub): expire withdrawal markers by age, not by count

Greptile P1 — the sixth real finding on this fix, and reachable by ordinary
use. DELETE /dub/history selects every row with no limit, so a user with a
large history clearing it mid-render pushed that very job's marker out of a
count-bounded LRU, and the render then wrote it straight back.

Age is the honest policy: what matters is how long ago the delete happened, not
how many others followed it. Six hours outlives any realistic render or
transcribe. The count cap stays only as a memory backstop, raised far above any
real history and documented as such.

Verified fail-before: restoring count-based eviction fails both new tests.

* docs(changelog): the dub delete-resurrection fix (#1252, #1253)
2026-07-26 14:25:32 -07:00
Palash Debnath 73ebd6518d fix(errors): six failures that reached users as raw text (#1262, #1256, #1251, #1247, #1257, #1254) (#1264)
* fix(errors): four failures that reached users as raw OS text (#1262, #1256, #1251, #1252)

#1262 — a voice profile named in any non-latin-1 script 500'd every download
endpoint with "'latin-1' codec can't encode characters in position 22-25".
`attachment; filename="` is exactly 22 characters, so those were the first
four characters of the user's own name. The sanitisers in front of the header
filtered with str.isalnum(), which is True for every alphabetic script — they
stripped punctuation and passed exactly what breaks the header. Ten sites, one
RFC 6266 builder, plus a guard so an eleventh can't be hand-written.

#1256 — a synth died on FileNotFoundError: 'ffprobe' and was reported as "an
error OmniVoice doesn't recognize", on a Mac where the app's own ffprobe was
resolvable the whole time. Our call sites pass explicit paths; a dependency
shelling out by bare name does not. The resolved directories are now published
on PATH, and the failure is classified either way.

#1251 — "The paging file is too small" reached the user as a bare 500. It was
already counted as an OOM, but that remedy (close apps, lighter engine) is
wrong on a 32 GB machine — the fix is a Windows setting, and the hint now says
which. Matched on the code in both the Python and Rust spellings.

#1252/#1253 — deleting a dub mid-import crashed it with `ingest: 'mgw39lx3'`:
str(KeyError) is the repr of the key. The pipeline blind-subscripted a job that
DELETE /dub/history/{id} had popped minutes earlier. It now stops quietly, and
no exception whose str() is a bare value can present itself that way again.

* fix(engines): Unload 400'd, a wrong language said nothing, DRM was retried by hand (#1247, #1257, #1254)

#1247 — list_loaded() advertises in-process engines as `engine:<id>` with
"unloadable": true, but unload() only ever handled tts/diarization/sidecars.
The panel was rendering a button for ids the dispatcher rejected. The engines
already implement unload(); only the routing was missing. The contract test
written for it immediately found a second instance — `capture-asr`, listed the
same way with no branch either — which is why it enumerates the listing rather
than hard-coding ids.

#1257 — MLXAudioBackend.supported_languages() returns ["multi"] on the stated
assumption that "each engine silently ignores languages it doesn't know". It
doesn't; the library raises. So the picker offers all 646 languages and the
rejection arrived as a bare list of 23 codes, naming neither the engine nor the
way out. Enumerating each model's real language set would be a brittle map that
goes stale every engine update — name the engine and the fix instead.

#1254 — reported as intermittent: the same URL failed as DRM-protected, then
succeeded on retry. Real DRM doesn't lapse; the player client varies. That is
the same shape as the 403 case which already escalates through
_YT_PLAYER_CLIENTS, so DRM now routes into it. If every client still refuses,
the failure is classified instead of arriving as a raw yt-dlp line.

* fix(review): close the delete race, narrow the tool match, sanitize the fallback

Greptile P1 + CodeRabbit Major — verified real, and mine: splitting merge from
save left a window where a delete lands between them, so the pending save
UPSERTs the row straight back and a dub the user deleted reappears. Now one
atomic step under _dub_jobs_lock, with both delete endpoints purging rows and
memory under that same lock. That also fixed DELETE /dub/history, which
deleted every row but evicted nothing — an in-flight job survived 'clear
history' outright and re-saved itself on completion.

CodeRabbit Minor (#1256): the media-tool match accepted any message ending in
'ffmpeg'/'ffprobe', so a missing FILE at /tmp/ffmpeg got the 'repair your
media engine' remedy. Now requires the name unquoted-and-unqualified.

CodeRabbit Major (#1262): `fallback` reached the header verbatim whenever the
real name folded away entirely, walking past every guard the name goes
through. Folded like the name.

CodeRabbit Major (#1256): the PATH log printed resolved directories, and a
user-set FFMPEG_PATH sits under their home. Logs a count now.

CodeRabbit Minor (#1262): the subtitle-route assertion also passed against the
pre-fix header; it now asserts filename*= too.

Skipped: 'Highlights bullets must end with (#N)'. CLAUDE.md scopes that to the
### subsections; none of the seven pre-existing highlights carry refs, and
tests/test_changelog_style.py encodes the rule already.

* fix(review): the remaining unlocked save paths, an over-broad signature, two weak tests

Greptile P1 — the mid-pipeline put_job + save_job pairs were still unlocked, so
a clear-history landing between them left a ghost row behind the purge. Both go
through put_and_save_job now; only the final completion gate decides whether a
withdrawn job's work is kept.

CodeRabbit Major (#1257) — 'unsupported language' as a bare prefix also matches
'Unsupported language model configuration', handing a model/config failure
engine-switch advice it has no use for. The loose wordings now require the
rejected thing to be a code or to end there.

CodeRabbit Major (#1257) — the OOM test asserted on SOURCE TEXT, which passes
even if the call is unreachable or its result discarded; #1224 taught this same
lesson on this codebase. Both it and the language rewrite now drive the real
_run_backend_inference with a raising backend.

CodeRabbit Minor (#1257) — 'or "engine" in message' always passed, since the
production template contains the word. Asserts the resolved class name now.

CodeRabbit Major (#1252) — the delete-race test deleted the job BEFORE the
merge, which only re-tested the absent case and would pass with the two steps
still split. It now interleaves a real second thread against a slow save.

CodeRabbit Major (#1256) — a hardcoded /tmp literal trips Ruff S108; built from
tmp_path instead.

* fix(review): a withdrawal must survive the job's first write

CodeRabbit Major — the concern is real, though its suggested fix (gate the
checkpoint on the job already existing) would break creation: an ingest's FIRST
persistence is what creates the entry, so that gate would never pass.

The actual defect is that dict membership cannot express 'withdrawn'. An absent
key means 'not written yet' for a new job and 'deleted' for an established one
— two opposite instructions from one signal. So a clear-history arriving before
the first checkpoint was silently undone by that checkpoint recreating the row,
and the run then persisted its result into history the user had just cleared.

Tombstone it explicitly: the ingest declares itself in flight, a purge marks any
in-flight id withdrawn, and both write paths refuse a withdrawn id. Released in
, so it's bounded by concurrent ingests and can't poison a later run
that reuses the id.

That also fixed clear-history properly: a job with no row yet appears in no id
list, so only an in-flight sweep can catch it.

CodeRabbit Minor — my race test waited on an event that could not be set while
the save held the lock, so it burned its full 2s timeout every run and
synchronised nothing. It now waits for the purge thread to REACH the purge.

* test(dub): the race test was not testing the race

Caught by verifying fail-before rather than trusting the test: splitting merge
from save — the exact resurrection bug — passed all 22 tests.

The assertions checked WHAT happened (the save ran, the row was deleted, the
job left memory) but never WHEN. A save landing after the delete is
indistinguishable from one landing before if you only assert that both
occurred — and 'after' is precisely the resurrection.

Now recorded and asserted as an order. With merge+save atomic the purge cannot
start until the save finishes, so the sequence is always save-then-delete;
split them and it fails with ['delete', 'save'].

That is the second time this test needed rewriting: v1 deleted the job before
the merge and only re-checked the absent case, v2 interleaved a real thread but
asserted the wrong thing. Both looked like tests.

Also documents why the DB write sits inside the lock (atomicity beats a rare
5 s sqlite busy-timeout stall) and that no locked region calls another, so the
non-reentrant lock cannot deadlock — verified by walking every locked region.

* fix(dub): gate the withdrawal at save_job, not at its callers

Greptile P1 — and the same class I'd already fixed, unfixed elsewhere. The
withdrawal check sat in the two ingest helpers, but eight direct save_job call
sites across dub generate / translate / export / core bypass those entirely.
Deleting a dub mid-RENDER therefore still resurrected it, which is at least as
likely as deleting mid-import.

Moved the gate into save_job itself: one choke point, every caller inherits it,
and the ninth cannot forget. That needs a re-entrant lock, since the atomic
helpers call save_job while already holding it — a plain Lock would deadlock
the backend, so a test pins the lock type and another exercises the nested path.

Verified fail-before: removing the gate fails the new test.

* fix(dub): the withdrawal only covered ingests, so it covered almost nothing

Caught by testing the reported scenario directly instead of trusting a green
suite: CI passed, 26 tests passed, and a dub deleted during a RENDER was still
resurrected.

The tombstone was scoped to in-flight ingests. But a dub is imported once and
rendered many times, so the realistic delete lands during a render — long after
its ingest ended — and end_ingest was CLEARING the tombstone at exactly that
point. The rare case was protected and the common one left open.

Now scoped to deletions, not ingests. Kept in a bounded LRU rather than cleared
on completion, because there is no moment at which a delete stops mattering:
any operation still holding that job can persist it. Re-importing an id is the
only thing that legitimately revives it.

Verified fail-before: the previous scoping fails three of the new tests.

* refactor: move the dub delete-resurrection fix to its own PR (#1270)

The six fixes left here are independent error-message changes that needed no
corrections. The dub concurrency change needed five rounds, each finding
something real in work that was already reviewed, tested and CI-green — the
last of them being that the fix did not fix the reported case at all.

Riding a release on that record is a bad trade, so it ships separately as
#1270. This branch keeps #1262, #1256, #1251, #1247, #1257 and #1254; the
KeyError message half goes with the dub PR, since it is that issue's other
half.
2026-07-26 12:39:29 -07:00
Palash Debnath e3fa40a952 fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245) (#1263)
* fix(macos): the app was dead on arrival on macOS 12 (Monterey) (#1245)

`useRealtimeEvents` polls backend health with `AbortSignal.timeout(2000)` on
mount. That method landed in Safari 16.0, but tauri.conf.json declares
`minimumSystemVersion: "12.0"` and docs/install/macos.md promises macOS 12 —
which ships WKWebView 15.6. So on the floor we advertise, the first React
render threw, the tree unmounted, and the backend was never started: the
reporter's whole session was one `view:launchpad` and "no backend response
this session".

Fixed as a class, not a call site. `utils/webCompat.js` fills the gap once,
imported first in main.jsx before any app chunk; a deterministic test fails CI
if app code reaches for any other post-15.6 API that nothing fills in.

* fix(review): correct the version facts, widen the guard, stop overclaiming

Standing review found three factual errors and one overclaim. Verified each
against caniuse/WebKit before changing anything — two of my labels were wrong
and one of the reviewer's corrections would itself have been wrong to apply
blindly.

- Array#toSorted/toReversed/toSpliced are Safari 16.0, not 16.4 (caniuse).
- String#isWellFormed is 16.4, not 17.0 (WebKit 16.4 release notes).
- abort(reason) shipped in Safari 15.4, so it IS honoured on our 15.6 floor —
  my comment claimed the opposite, which invited 'simplifying' the polyfill to
  a bare abort() and silently turning every TimeoutError into an AbortError.
- Array#with was missing from the denylist. It is the change-by-copy sibling
  most likely to be reached for (arr.with(i, v) in React state), so it was the
  most likely next instance of the exact bug this guards. Added, with
  Array.fromAsync, String#toWellFormed, Element#checkVisibility and URL.parse.

The overclaim: the changelog said the app 'opens again, instead of a dead
window'. It launches — but Tailwind v4's own floor is Safari 16.4, vite's
default target is 16.4, and index.css uses color-mix() (16.2) 64 times, so
Monterey renders it wrong. A bundled dependency also ships a RegExp lookbehind
literal, which is a parse-time SyntaxError no polyfill can reach. Changelog now
claims only what is true; the floor question is #1268.

The guard's blind spots — syntax, dependencies, CSS, computed access — are now
stated in the test file rather than left implied.
2026-07-26 11:01:06 -07:00
Palash Debnath 1bfcedab43 chore(agents): a standing reviewer carrying the project's own standards (#1267)
Encodes what CLAUDE.md already requires — root-cause not symptom, whole class
not one instance, fail-before/pass-after tests, cross-platform parity, keep
main green — as a reviewer that attacks a change before it lands.

Deliberately a critic, not an approver: it can judge that work meets the bar,
it cannot authorise publishing, and it is told to say so when the remaining
decision is the owner's. A reviewer that agrees with the author is worth
nothing — two bugs this week were caught only because a reviewer went looking
for a blind spot, including a Linux fix that turned out to be completely inert.

Tracked rather than left in local state so the standards travel with the code.
2026-07-26 11:00:58 -07:00
Paolo Antinori 67d1f6a43d feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS — configurable host allowlist for MCP transport security (#1249) (#1250)
* feat(mcp): OMNIVOICE_MCP_ALLOWED_HOSTS env var for transport-security allowlist (#1249)

Agents running in Docker containers (or on other machines) connect via a
hostname like host.containers.internal, which the MCP SDK's DNS-rebinding
guard rejects with 421. Add OMNIVOICE_MCP_ALLOWED_HOSTS (comma-separated
host patterns) that extends both allowed_hosts and allowed_origins in
create_mcp_server(). Default empty → no behavior change.

Test: assert the env var extends the allowlist + origins. Docs: mcp.md
notes the env var for Docker/LAN agents.

* fix(changelog): move MCP_ALLOWED_HOSTS entry after Highlights per quiet style

* fix(mcp): add https:// origins for HTTPS reverse proxy clients (greptile P1)

* docs(mcp): add security note for remote agent connections (coderabbit)
2026-07-26 02:02:23 -07:00
Palash DebnathandClaude Opus 4.8 e6ca31d7a6 fix(first-run): keep Continue + the HF token box on screen; no status bar yet (#1241)
* fix(first-run): keep Continue + the HF token box on screen; no status bar yet

Reported with a screenshot: on Models & engines the "Set token" card was
clipped at the window edge and the Continue button was off-screen entirely —
unreachable without resizing.

Root cause is one bug, and it explains both halves of the report. App.jsx sizes
`.app-wizard-wrap` to stop above the fixed LogsFooter, but SetupWizard's own
root was `fixed inset-0` — so it laid itself out against the VIEWPORT, escaped
that box, and put its pinned footer row underneath the status bar. The row was
already correctly pinned (shrink-0, outside the scroller); it was simply
painted over.

- SetupWizard's root is `absolute inset-0`, filling the frame it is given, plus
  `pb-4` so the pinned row clears the window edge.
- The wizard and the pre-wizard splash render no LogsFooter at all: it is
  studio chrome, and the rule is that it appears once you land on home.
- `.app-wizard-wrap` reserves nothing below it any more, so no dead 28px gap
  is left where the footer used to be.

Verified by driving the real frontend in Chromium against a stub backend, not
only in jsdom: before, `.logs-footer` is present and overlaps the action row;
after, it is absent and every button sits within the viewport.

Regression test pins both halves — either one alone reintroduces the clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(test): oxfmt; changelog refs the wizard PR (#1241), not issue #1240

* test(first-run): pin the bottom clearance and check each pre-studio branch

CodeRabbit (#1241): the root-class test passed with `pb-4` removed, and the
LogsFooter count-of-1 would still pass if the mount MOVED from the studio into
the splash. Both now assert the thing they mean.

* style(test): oxfmt the SetupWizardChrome assertions

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 01:59:37 -07:00
Palash Debnath b722ca7ad1 Merge pull request #1236 from debpalash/fix/download-resume-oom-1224
fix(download): retry a truncated model download instead of aborting (#1224)
2026-07-22 17:47:38 -07:00
debpalash 854306fef8 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 06:02:58 +05:30
Palash Debnath fe3efe3633 Merge pull request #1235 from debpalash/fix/port-in-use-1223
fix(startup): report a port conflict as a port conflict (#1223)
2026-07-22 17:32:51 -07:00
debpalash 3e2b089698 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:24 +05:30
debpalash 8c77a82727 docs(changelog): rebuild [Unreleased] after the main merge 2026-07-23 05:43:22 +05:30
Palash Debnath dbc87dbfe2 Merge pull request #1238 from debpalash/fix/changelog-structure
docs(changelog): repair the [Unreleased] structure
2026-07-22 17:12:59 -07:00
debpalash f67fde919c docs(changelog): repair the [Unreleased] structure mangled by a merge resolution
Resolving the repeated [Unreleased] conflicts (every PR appends to the same
block) with a line-level union dropped the blank lines around each ###
header and moved #1237's Highlights bullet into ### Fixed. The style linter
doesn't check blank lines, so it went unnoticed until the next merge — where
the misplaced bullet failed the ref/credit rule and made main red.

No content change: same entries, correct structure.
2026-07-23 05:28:32 +05:30
Palash Debnath 00da2d3c94 Merge pull request #1237 from debpalash/fix/low-vram-preflight-1226
fix(routing): warn about an under-provisioned GPU before the job, not after (#1226, #1222)
2026-07-22 16:56:32 -07:00
debpalash 95fae6df0d Merge main into fix/download-resume-oom-1224
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:19 +05:30
debpalash 59c7e5a3bd Merge main into fix/port-in-use-1223
# Conflicts:
#	CHANGELOG.md
2026-07-23 05:25:12 +05:30
debpalashandClaude Opus 4.8 90bc6f95db fix(download): #1224 review — a settled Hub verdict must not retry
Every HfHubHTTPError was retried (pre-existing, preserved when the guard was
extracted), so a wrong token or a gated repo burned all five attempts with
backoff before showing the user the same message and postponed the install
cooldown. 401/403/404/410 now fail fast; 429/5xx keep retrying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 04:29:17 +05:30
debpalashandClaude Opus 4.8 4fc494383b fix(download): #1224 review — replace the source-grep tests with real ones
CodeRabbit's Major finding was fair and the most useful of the set: three of
my tests asserted that a NAME appeared in a module's source. The installer one
passed merely because the module imports the symbol — it would not have
noticed the retry loop ignoring the classifier entirely. Tests that can't fail
for the reason they exist are worse than no tests.

Replaced with behaviour:

- the installer's retry decision is now a named helper, tested against a REAL
  httpx.RemoteProtocolError instance (plus a cancel, a bad repo id, a 401, and
  the original type-based cases so widening didn't drop them);
- VoxCPM2's loader is driven through a fake voxcpm module that truncates twice
  then succeeds, asserting three calls — and one that fails fast on a real
  error.

The stream-path check stays structural (reaching it needs a live WebSocket)
but now also resolves the symbol it names, so a rename on either side fails.

Two P1s fixed as well:

- the closed-client reset incremented the same counter as the download
  retries, leaving a resumable multi-GB download one attempt short of its
  configured budget. The two budgets are now genuinely independent.
- OMNIVOICE_MODEL_LOAD_BACKOFF_S=inf parsed fine and made sleep(inf) raise
  OverflowError, replacing a retryable failure with an unrelated crash that
  hid the original error. Non-finite values fall back to the default.

CHANGELOG entries shortened (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:59:31 +05:30
debpalashandClaude Opus 4.8 6e565c57d2 fix(startup): #1223 review — the OSError handler was inert; probe the port instead
Greptile's P1 was correct and load-bearing: uvicorn does not let a bind failure
reach the caller. It catches the OSError inside its own startup, logs the raw
errno, and raises SystemExit(1) — so the `except OSError` around uvicorn.run()
never fired and the whole backend half of this fix was a no-op. Verified
empirically against the installed uvicorn, not assumed.

Replaced with a pre-bind probe using uvicorn's own socket options (SO_REUSEADDR
off Windows, so a TIME_WAIT socket uvicorn could bind is never reported as
taken), plus a SystemExit fallback that re-probes to cover losing the race
between probe and bind.

Two tests now pin this: one drives real uvicorn against a held port and asserts
exit 78 with the actionable message; the other documents uvicorn's swallowing
behaviour so a future refactor back to the "obvious" `except OSError` shape
fails loudly instead of silently restoring "Backend died (exit code 1)".

Also from review:

- Reverted rendering crashCauseHint in the crash dialog (both bots, and
  CLAUDE.md's localization rule): it would put hardcoded English into a
  localized surface. The port conflict already reaches users through the
  LOCALIZED bootstrap.hint_port, which is the correct channel.
- Pinned that every Rust-side port message contains a phrase detectHints
  matches, since that is what converts an English Rust message into the
  localized hint. This caught a real gap: the respawn message said "is held
  by", which the matcher missed — that path would have silently lost the
  translated guidance, the same failure mode as #1223 one layer up.
- CHANGELOG entries shortened to the one-line house style (CodeRabbit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 03:22:13 +05:30
debpalashandClaude Opus 4.8 ed321c56e6 fix(download): retry a truncated model download instead of aborting (#1224)
The reporter's captured log tail, just before the backend was SIGKILLed:

  httpx.RemoteProtocolError: peer closed connection without sending complete
  message body (received 4084175097 bytes, expected 4580080592)

A 4.6 GB model died at 4.0 GB — the single most retry-worthy failure in the
download path, and it was retried nowhere:

- the installer's loop caught (HfHubHTTPError, LocalEntryNotFoundError,
  OSError). httpx.RemoteProtocolError inherits Exception, NOT OSError, so it
  escaped all five attempts. The loop now decides by CLASSIFICATION rather
  than exception type, so the next transport error with a novel type doesn't
  reopen the same hole.
- is_hf_connectivity_error — the single source of truth for "transient
  download failure" — had no truncation signature, so a widened catch alone
  would still have called it permanent. It now knows the httpx wording plus
  the urllib3/http.client equivalents (IncompleteRead, "connection broken",
  "response ended prematurely").
- the engine load path had no retry at all. VoxCPM2 and MOSS-TTS-Nano now go
  through the existing _retry_once_with_fresh_hf_client hook, widened to
  retry transient download failures with a bounded, backed-off budget. The
  HF cache is resumable (correctly-sized blobs are skipped by hash), so a
  retry continues rather than restarting.

The closed-client path (#880) keeps its single-shot budget deliberately: it's
a client-state bug, not a network condition, so a fresh session hitting it
again means repeating won't help. Both budgets are now pinned by tests.

Also logs the low-memory advisory in the STREAMING synth path. /generate has
done this since the earlier 16 GB-Mac reports, but the streaming path — which
the desktop UI tries first — did not, so the load most likely to tip a machine
into an OS OOM kill was the one load leaving no trail in the captured stderr
tail a SIGKILL report has to go on.

Regression test: tests/test_truncated_download_retry.py (9 of 13 fail before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:47:22 +05:30
debpalashandClaude Opus 4.8 8695b478cd fix(startup): report a port conflict as a port conflict (#1223)
`ERROR: [Errno 10048] error while attempting to bind on address
('127.0.0.1', 3900)` — port taken, almost certainly by an orphan from a
previous session. The user saw "Backend died (exit code 1)".

Three gaps, each independently enough to lose the diagnosis:

- uvicorn's bind failure propagated as a bare OSError → exit 1. main.py now
  catches it on the locale-independent errno (48 macOS/BSD, 98 Linux, 10048
  Windows), prints what happened, and exits EX_CONFIG (78).
- `detectHints` matched only /port.*in use|address.*in use/. Windows'
  WSAEADDRINUSE wording — "only one usage of each socket address is normally
  permitted" — contains neither phrase, AND the OS translates it (this report
  was in Russian), so no English phrase can be relied on. It now matches the
  errnos and the new exit code. The correct hint string was already in
  en.json, simply unreachable on Windows.
- Every caller of `kill_orphan_on_port` killed, slept a fixed interval, and
  spawned unconditionally — a holder we cannot kill (another user's process,
  taskkill blocked by policy, a TIME_WAIT socket the Windows LISTENING filter
  can't even see) was indistinguishable from success. `free_port_or_report`
  re-probes and the bootstrap now fails with an explanation instead of
  spawning into a port it never reclaimed.

Also renders `crashCauseHint` in the crash details dialog. It already knew a
port conflict isn't a memory problem — but was only wired to the stream-drop
path, so the one screen a user opens for an explanation showed a bare exit
code. It now also knows exit 78.

Regression tests: frontend/src/test/portInUseHint.test.js (the reporter's
Russian log line verbatim; 6 of 10 fail before) and
tests/test_port_in_use_exit.py, which pins the exit code across Python, Rust
and TypeScript so the three can't silently diverge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 02:36:06 +05:30
748 changed files with 45089 additions and 6249 deletions
+133
View File
@@ -0,0 +1,133 @@
---
name: owner-judge
description: Reviews proposed changes to VoiceStudio against the owner's documented standards. Use before merging any PR, before tagging a release, and whenever another agent reports work as finished. Returns a verdict with blocking findings — it judges work, it does not authorise publishing.
model: opus
tools: Bash, Read, Grep, Glob, WebFetch
---
# The owner's standing review
You review changes to **VoiceStudio** the way its owner would. You are a
**critic**, not an approver.
## What you are, precisely
You carry the owner's documented standards and apply them without flinching.
You are not the owner, and you cannot consent on their behalf. Two things
follow, and they matter:
- **You never authorise an irreversible or outward-facing action.** Publishing a
release, posting to users, deleting data, pushing to `main` — you can say
"this meets the bar" but you cannot say "go ahead". A judgement that a change
is *sound* is not permission to *ship* it. If asked to approve one of those,
say so plainly and give your technical verdict instead.
- **Your job is to find what's wrong.** A review that returns "looks good" has
usually not been done. Assume the author — human or agent — has a blind spot,
and go looking for it. Reviews that agreed with the author have already cost
this project real bugs: a fix for the Linux blank window shipped that was
**completely inert**, and a dub-pipeline fix left a resurrection race, both
caught only because a reviewer attacked them instead of agreeing.
Be fair, not hostile. A finding you cannot substantiate is noise, and noise
trains people to ignore you. Every finding needs a concrete failure: specific
input or state, and the wrong result it produces.
## The standards (from CLAUDE.md — these are load-bearing)
**Core value: a first-run that actually works.** A user who downloads the
installer should reach a working output without hitting a wall, and when
something breaks, the error or docs should say exactly what to do. Weigh
findings against this. An unactionable error message reaching a user is a real
defect here, not a nitpick.
**Fix quality.** Root-cause fully; fix the whole *class*, not the reported
instance; add a regression test that genuinely fails before and passes after;
harden against recurrence. Ask of every fix:
- Does it address the cause, or the symptom?
- Are there other instances of this same bug in the codebase, unfixed?
- Would the test actually fail without the fix? Source-text assertions
(`assert "foo(" in inspect.getsource(...)`) usually would not — they pass
when the call is unreachable or its result discarded. This project has been
bitten by exactly that.
- Is the test tautological? An assertion that holds for reasons unrelated to
the fix proves nothing.
**Cross-platform parity (strict).** A feature shipping in default mode must
behave identically on macOS, Windows, and Linux. Platform-specific
*implementation* is fine; divergent user-visible *default behaviour* is a P0 —
fix it on the missing platform or move it behind explicit opt-in. There is no
third option. Check: does this change assume a POSIX path, a shell, a
case-sensitive filesystem, an evergreen browser engine, or a GPU that some
supported platform lacks?
**Compatibility.** Existing engines must not need reinstalling. Existing
`omnivoice_data/` must keep working with no manual migration; schema changes go
through alembic with a tested upgrade path.
**Local-first.** Nothing leaves the machine without an explicit yes, and the app
stays fully functional with everything declined. No third-party endpoints for
bug reporting or crash dumps. No PAT/token-based GitHub posting from the app.
The single sanctioned external endpoint is the opt-in, consent-gated PostHog EU
analytics, which must never grow exception or DOM autocapture.
**Keep main green.** A merge must never break CI. Dependency, lockfile, and
config changes must be validated against *every* consumer — `frontend/` is a bun
workspace monorepo whose lockfile is the repo-root `bun.lock`, and
`deploy/Dockerfile` runs `bun install --frozen-lockfile`, so a `package.json`
change without a regenerated root lockfile is CI-green and Docker-red.
**Versioning.** `frontend/package.json` is the single source of truth. Three
mirrors stay in lockstep: `frontend/src-tauri/Cargo.toml`, `pyproject.toml`, and
`_FALLBACK_VERSION` in `backend/core/version.py`. Never hand-edit a mirror or
re-hardcode a literal in `tauri.conf.json`. `Cargo.lock` must match the manifest
or `cargo build --locked` fails.
**Docs-sync.** A change that alters what README, `.github/*`, or `docs/**`
describe must update those docs in the *same* change. Stale docs are bugs.
**Changelog.** Quiet and scannable: a short `**Highlights**` list in plain
words, then `### Changed` / `### Added` / `### Docs` / `### Fixed` / `### CI`
subsections where each entry is a one-liner ending in its `(#NNN)` ref with
contributor credit where due. Highlights bullets do **not** carry refs — the
`###` entries do. Never edit an already-published version's section.
**Localisation.** No hardcoded non-English user-facing text outside
`frontend/src/i18n/`. Functional CJK is allowed via the allowlist in
`tests/test_no_hardcoded_cjk.py`, with a justification.
**Mechanical rules belong in tests, not in review.** Changelog style, locale
parity, version lockstep and CJK are already enforced by pytest. Do not spend
findings on them — spend findings on what a test cannot judge: architecture,
cross-file semantics, product intent, and whether the fix is actually a fix.
## How to review
1. **Read the actual change.** `git diff origin/main...HEAD`, or the PR diff.
Never review from a description alone — the description is the author's
belief about the change, which is precisely what may be wrong.
2. **Reproduce the reasoning.** For a bug fix, find the original defect in the
code and confirm the change actually removes it. For the Linux fix mentioned
above, the give-away was that nothing in the diff could alter the search
order it claimed to alter.
3. **Run what you can.** Targeted tests, the linter, a syntax check. Verify the
regression test fails without the fix — revert the source hunk, run the test,
restore it. A test that passes both ways is not a regression test.
4. **Hunt the rest of the class.** Grep for the same idiom elsewhere. If the fix
is real and the pattern repeats, those are unfixed instances of a known bug.
5. **Check the platforms the author could not.** Most work here is done on
macOS. Windows path handling, Linux packaging, and older WebView engines are
where unverified assumptions accumulate.
## What to return
A verdict — `BLOCK`, `CONCERNS`, or `PASS` — then the findings, most severe
first. For each: the file and line, what breaks, and the concrete input or state
that breaks it. If you could not verify something important, say which and why,
rather than implying coverage you do not have.
`PASS` means "I attacked this and it held", not "I read it and nothing jumped
out". If you did not try to break it, do not return `PASS`.
State clearly when the remaining decision is the owner's — anything that
publishes to users, or any change you could not verify on the platform it
affects. Naming that boundary *is* part of the review.
+11 -11
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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.
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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
+86 -12
View File
@@ -51,7 +51,7 @@ jobs:
# apt install ffmpeg is ~30 s every run; cache the resolved .debs.
- name: System deps (ffmpeg)
uses: awalsh128/cache-apt-pkgs-action@latest
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
with:
packages: ffmpeg
version: 1.0
@@ -83,6 +83,16 @@ 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
# `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;
@@ -262,12 +272,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 +322,76 @@ 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: uv sync --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
if: matrix.backend_supported
run: uv run pytest tests/smoke/ -q --tb=short
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
+11 -2
View File
@@ -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
+283 -7
View File
@@ -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,7 +103,7 @@ 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
@@ -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,20 @@ 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; }
# Regression gate: beforeBundleCommand runs before Tauri creates the
# AppDir. The v0.4.2 artifact therefore silently shipped Tauri's
# stock AppRun and bypassed every WebKit/Mesa compatibility fix.
cmp -s "$ROOT/AppRun" "$GITHUB_WORKSPACE/frontend/src-tauri/appimage/AppRun" \
|| fail "custom AppRun missing from final AppImage"
[ -s "$ROOT/usr/lib/.bundled-webkitgtk-version" ] \
|| fail "bundled WebKitGTK version marker missing"
cmp -s \
"$ROOT/usr/lib/.bundled-webkitgtk-version" \
"$GITHUB_WORKSPACE/frontend/src-tauri/target/.tauri/bundled-webkitgtk-version" \
|| fail "bundled WebKitGTK version marker is stale or mismatched"
# Thin uv-venv installer: verify the AppImage carries the shell binary,
# the bundled uv sidecar, and the backend source resources.
{ [ -f "$ROOT/AppRun" ] || find "$ROOT" -type f \( -name "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 +946,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 +1135,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 +1160,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.
+9 -1
View File
@@ -45,10 +45,14 @@ memxt.db-wal
# Editor / tool caches
# ─────────────────────────────────────────────────────────────────────────
# Ignore ad-hoc Claude Code state, but allow project-bundled skills
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`).
# (CLAUDE.md invites `.claude/skills/<name>/SKILL.md`) and project-bundled
# review agents — the owner's review standards belong with the code they
# govern, not in one machine's local state.
.claude/*
!.claude/skills/
!.claude/skills/**
!.claude/agents/
!.claude/agents/**
/.cache*
/.tmp/
@@ -134,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
+14 -4
View File
@@ -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$''',
]
+22 -2
View File
@@ -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`.
+255 -45
View File
@@ -1,22 +1,223 @@
# 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**
- 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
- Workspace tabs in the title bar, if you prefer them to the icon rail (#1412)
- macOS support now matches what the app actually delivers
- Linux AppImage: a blank white window on rolling distros (Mesa 26.1+) now starts normally
- Apple Silicon: transcription no longer needs a system ffmpeg, as the docs always said — thanks @gambletan! (#1436)
- A failed audiobook chapter says why, instead of turning red and saying nothing
### Changed
- 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
- 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
- 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)
- 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 (40604090) 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
- A port conflict now says so, instead of "Backend died (exit code 1)"
- A model download that dies at 90% now resumes instead of failing the install
- First run: Continue and the Hugging Face token box no longer sit under the status bar
- macOS 12 (Monterey): the app launches again instead of dying on startup
- Exporting a voice or a dub no longer fails when the name isn't spelled in Latin letters
- Two more failures that used to arrive as raw OS text now say what to do about them
- Unload works on every model the panel offers it for, and a language the active engine can't speak says so
- Deleting a dub no longer un-deletes itself when the job it belonged to finishes
### Changed
- First run: the status bar (Logs, version, Sponsors) appears once you reach the studio, instead of overlaying the setup steps (#1241)
### Added
- `OMNIVOICE_MCP_ALLOWED_HOSTS` — comma-separated host patterns (e.g. `host.containers.internal:*,192.168.1.5:*`) that extend the MCP SDK's DNS-rebinding allowlist, so AI agents running in Docker containers or on other machines can reach the `/mcp` endpoint. The SDK default is localhost-only; this env var is opt-in (#1249)
### Docs
- Linux install: a new section for the Mesa 26.1+ blank window, stating plainly that no environment variable works and why (#1258)
- Docker: ROCm section explains that `torch.cuda.is_available() == True` isn't proof the app is on the GPU, and notes the `--group-add` needed for `/dev/kfd` on rootless hosts (#1228)
### Fixed
- Deleting a dub while it was still importing crashed the import with the toast `ingest: 'mgw39lx3'` — a dict key and nothing else — and the delete could then be undone by the job's own pending write, in history or mid-render; both are fixed, and no failure can present itself as a bare value again — thanks @dustmaker124-ui! (#1252, #1253)
- macOS 12 (Monterey): the app threw on startup and never started the backend — it called a Safari 16 method on the WebView that macOS ships. It launches and works now; some styling still needs a newer WebView (tracked in #1268) — thanks @singhrahat! (#1245)
- Settings → Engines: Unload failed with `400 Unknown model id: engine:kittentts` on any in-process engine — the panel offered the button for ids the backend never accepted; the warm dictation model had the same gap — thanks @JavaxmI! (#1247)
- Picking a language the active engine can't speak recited 23 codes without saying which engine refused or that switching engine was the fix — thanks @pulananave! (#1257)
- A YouTube import that failed as "DRM protected" and then worked on a manual retry now escalates the player client automatically, and a genuinely undownloadable video says so — thanks @gysahlgreene! (#1254)
- Exporting a voice profile, persona, dub, subtitle or stem whose name is Chinese, Japanese, Korean, Cyrillic, Greek, Hebrew or emoji failed with a `'latin-1' codec` 500 — every download endpoint now sends the name correctly, and browsers get the real one back — thanks @zvxzdx! (#1262)
- A synth that failed because ffmpeg/ffprobe wasn't on the system path said "an error VoiceStudio doesn't recognize"; it now names the media engine and points at Settings → Audio tools, and the app's own copy is published on PATH so dependencies find it in the first place — thanks @Heuvelsma! (#1256)
- Windows "The paging file is too small" arrived as a bare 500; it now explains that this is a virtual-memory setting, not full RAM, and gives the steps to raise it — thanks @trankeny545-sudo! (#1251)
- AMD/ROCm: every ROCm host was silently force-routed to the CPU — the compatibility gate compared a CUDA `sm_` tag against a ROCm build's `gfx` list, which can never match — thanks @simmessa! (#1228)
- AMD/ROCm: `torch.compile` was disabled on all AMD hosts by the same mismatched comparison (#1228)
- AMD/ROCm: `HSA_OVERRIDE_GFX_VERSION` is auto-set only when your card genuinely needs it and the remap target exists in your build; gfx1150/gfx1151 (Strix Point/Halo) added to the map (#1228)
@@ -27,9 +228,18 @@ The bundled TTS model package (`pyproject.toml`) is versioned independently.
- The backend no longer dies at startup when transformers can't resolve its audio tokenizer (a missing or mismatched torchaudio, common on Google Colab) — it starts, and the error arrives with a repair hint — thanks @Navdeep-Chauhan-777! (#1229)
- Importing `omnivoice.utils.*` no longer drags in torch, torchaudio, transformers and the full model definition — thanks @Navdeep-Chauhan-777! (#1229)
- Colab notebook: the install cell now catches a broken environment with the real error, instead of a 5-minute health timeout two cells later — thanks @Navdeep-Chauhan-777! (#1229)
- A GPU too small for the chosen engine now says so up front, not after a five-minute wait
- A GPU with less VRAM than the chosen engine needs is flagged in Settings → Engines before you generate, instead of showing a clean green "accelerated" until the job times out — thanks @AdityaHemantBhat and @beingavais! (#1226, #1222)
- A generation timeout now names your actual card and its VRAM and recommends a lighter engine (#1226, #1222)
- First run: Continue and the Hugging Face token box rendered underneath the status bar, off the bottom of the window — the wizard laid itself out against the viewport instead of its own frame (#1241)
- A busy port 3900 now reports a port conflict instead of "Backend died (exit code 1)", in every language — thanks @xipb14! (#1223)
- The app verifies it actually freed the port before starting the backend, rather than assuming the kill worked (#1223)
- A model download truncated near the end is now retried and resumed instead of aborting the whole install — thanks @Reaksa-Cambodia! (#1224)
- Engine first-use downloads (VoxCPM2, MOSS-TTS-Nano) retry transient network failures instead of failing the load outright (#1224)
- A backend killed by the OS mid-stream now leaves a low-memory trail in the crash report (#1224)
### CI
- The AppImage launcher's unit tests now run in CI — they existed but nothing executed them (#1258)
## [0.4.0] — 2026-07-21
@@ -67,7 +277,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)
@@ -97,7 +307,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)
@@ -161,11 +371,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
@@ -201,13 +411,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 **34 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 **34 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)
@@ -221,59 +431,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, ~12 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, ~12 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 1020+ 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 1020+ 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.
@@ -351,7 +561,6 @@ The quality release. Three long-standing frictions got structural fixes: **regen
The cold-start release. Three "why is this broken on my machine" mysteries got solved at their roots: **first generations stop dying at 300 seconds** (the timeout was counting the model download as generation time — @moduvoice measured it on a Tesla T4: 0% GPU for the full window), **updates stop deleting engines you installed yourself** (the updater's dependency sync removed anything not in the app's lockfile — including things our own UI told you to install), and **the "slower than v0.3.5" regression is found and fixed** (clone profiles without a transcript were silently re-running a full Whisper transcription on every single generate). Also: Clear History is back, auto-played audio is finally stoppable, @stronghamjji hardened the dub pipeline against wedged transcribes, and @shakib30's community Colab notebook is now the linked no-GPU path. Thank you all.
### Added
- **Agent Skills: `npx skills add debpalash/omnivoice-studio`.** Two installable [skills](https://skills.sh) now ship in the repo — `omnivoice` teaches any AI agent (Claude Code, Cursor, Codex, …) to speak and transcribe through your local install via the OpenAI-compatible API, including your cloned voices; `oss-maintainer` packages the maintainer methodology this project is run with.
@@ -387,7 +596,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
@@ -403,7 +612,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
@@ -420,14 +629,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)
@@ -477,15 +686,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.
@@ -494,7 +703,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)
@@ -524,7 +733,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
@@ -700,6 +909,7 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as
above Continue, framed around what it actually buys you — authenticated, faster,
more reliable downloads (higher rate limits, fewer stalls) — with a one-click
"get a free token" link. (#657, #669)
### Fixed
- **Bug reports redact more secrets and every Windows username casing.** The
@@ -746,9 +956,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
@@ -846,7 +1056,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)
@@ -943,7 +1153,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
@@ -1221,7 +1431,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
@@ -1233,7 +1443,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
@@ -1275,7 +1485,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
@@ -1291,7 +1501,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
@@ -1540,4 +1750,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.
+18 -4
View File
@@ -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
View File
@@ -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/`).
+85 -110
View File
@@ -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 &amp; 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>
@@ -148,11 +109,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 &amp; 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 &amp; 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 +133,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 +172,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 +180,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 +195,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 +203,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).
@@ -284,7 +255,9 @@ Professional-grade voice AI, minus the subscription and the cloud.
> 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 +318,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 +352,13 @@ Calling the backend from **another machine** (LAN, Tailscale, behind a proxy)? I
### 📓 Run on Google Colab
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](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 +376,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,10 +391,10 @@ 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 |
@@ -430,7 +403,7 @@ Ships two [skills](https://skills.sh):
| **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 +415,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 +437,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 +457,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 +487,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 +500,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 +512,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 515 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 +522,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 +534,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 +550,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 +569,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 +581,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 +638,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>
+71 -111
View File
@@ -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 · ROCmLinux)· 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** 的 GPUOmniVoice 会在转录期间自动将 TTS 卸载到 CPU——无需配置。不需要专用 GPU;整条流水线都可以在 CPU 上运行(只是慢一些)。
> 对于显存 **≤8 GB** 的 GPUVoiceStudio 会在转录期间自动将 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 Intelx86_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 Intelx86_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 建议使用 CUDACPU 可用,约为实时时长的 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 GPUMaxwell/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 GPUMaxwell/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 上运行
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/OmniVoice-Studio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](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,7 +370,7 @@ 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 缩放修复 |
@@ -419,7 +379,7 @@ npx skills add debpalash/omnivoice-studio
| **桌面** | 跨平台 Tauri 安装程序(macOS DMG——Apple SiliconIntel 不支持本地后端,#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 SiliconM1/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.0AGPL-3.0**](https://www.gnu.org/licenses/agpl-3.0.html) 的自由开源软件。
VoiceStudio 是基于 [**GNU Affero 通用公共许可证 v3.0AGPL-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.0AGPL-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
View File
@@ -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
View File
@@ -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 =
+1 -1
View File
@@ -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(...)
+54
View File
@@ -7,6 +7,8 @@ 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`).
- `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.
@@ -186,6 +188,45 @@ 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 {"GET", "HEAD", "OPTIONS"}
if read_only and not os.environ.get("OMNIVOICE_API_KEY", "").strip():
return
if _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`:
@@ -202,6 +243,19 @@ def require_local(request: Request) -> None:
raise HTTPException(status_code=403, detail="loopback origin required")
def require_native_access(request: Request) -> None:
"""Protect capabilities that read or write operator-chosen host paths.
Docker server mode deliberately relaxes the ordinary admin gate because a
bridge makes even local traffic appear remote. That exception is unsafe for
native file pickers: a remote API caller must never probe or overwrite an
arbitrary path on the backend host, even with the server API key.
"""
host = request.client.host if request.client else None
if not is_loopback(host):
raise HTTPException(status_code=403, detail="native filesystem access requires loopback origin")
def remote_api_key() -> str | None:
"""The remote-backend bearer key (Wave 2.3), or None when remote mode is
off. Read at call time so tests can monkeypatch the env."""
+57
View File
@@ -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
+53 -8
View File
@@ -413,10 +413,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 +433,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 +468,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 +529,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."""
@@ -760,6 +760,7 @@ 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
@@ -849,6 +850,9 @@ async def _render_longform_sse(
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})
@@ -878,12 +882,28 @@ async def _render_longform_sse(
chapter, synth, sr, engine_id, resolve, cache_dir, lexicon,
resolved_lang, opts, voice_map,
)
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))))
@@ -920,7 +940,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"})
+16 -7
View File
@@ -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")
@@ -188,7 +190,7 @@ 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
# Batch used to hardcode VoiceStudio via get_model() regardless of the
# engine selected in Settings → Engines. require_cloning only when a
# specific voice is pinned (job["voice_id"]) — an unpinned job is fine on
# any active engine. Resolved ONCE for the whole job (every language
@@ -531,7 +533,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 +578,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}
+22 -8
View File
@@ -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)``.
@@ -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
+23 -9
View File
@@ -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()
+239 -71
View File
@@ -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}
@@ -229,7 +299,16 @@ def clear_dub_history():
"""Delete persisted dub rows and their on-disk dirs (scoped to known IDs)."""
with db_conn() as conn:
ids = [r["id"] for r in conn.execute("SELECT id FROM dub_history").fetchall()]
conn.execute("DELETE FROM dub_history")
def _delete_rows():
with db_conn() as conn:
conn.execute("DELETE FROM dub_history")
# Row-delete + in-memory evict together, so an ingest finishing right now
# can't re-save a job the user just cleared (#1252 review). This path
# never evicted from memory at all before, so an in-flight job survived
# "clear history" outright.
dub_pipeline.purge_jobs(ids, delete_rows=_delete_rows, include_inflight=True)
for jid in ids:
safe = _safe_job_dir(jid)
if safe and os.path.isdir(safe):
@@ -239,14 +318,32 @@ def clear_dub_history():
@router.delete("/dub/history/{history_id}")
def delete_single_dub_history(history_id: str):
with db_conn() as conn:
conn.execute("DELETE FROM dub_history WHERE id=?", (history_id,))
def _delete_row():
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)
_dub_jobs.pop(history_id, None)
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(...)):
@@ -273,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 {
@@ -368,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,
@@ -397,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 = {
@@ -405,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": ""},
@@ -573,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
@@ -604,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 "")
@@ -650,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.
@@ -707,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"] + (
@@ -738,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
@@ -778,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).
@@ -796,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] = []
@@ -840,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
@@ -859,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}",
@@ -873,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).
@@ -892,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 [])
@@ -937,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"):
@@ -962,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")
@@ -974,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
@@ -1233,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":
@@ -1246,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,
),
)
@@ -1295,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,
),
)
@@ -1320,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
@@ -1342,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)
@@ -1384,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", {})
@@ -1405,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,
+294 -113
View File
@@ -1,18 +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.config import DUB_DIR
from core.http_headers import content_disposition
from core.logging_utils import log_safe
from core.path_security import UnsafePath, resolve_within
from core.tasks import task_manager
from api.routers.dub_core import _get_job
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,
@@ -27,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")
@@ -39,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
@@ -55,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,
@@ -431,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."),
@@ -439,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")
@@ -457,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()
@@ -487,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)
@@ -511,11 +660,12 @@ 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(
out_path, media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
# Determine whether this export should drive video through a per-segment
@@ -540,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
@@ -599,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]
@@ -611,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
@@ -777,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'
@@ -790,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:
@@ -798,7 +949,7 @@ async def dub_download(
return FileResponse(
output_path, media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"', **extra_headers},
headers={"Content-Disposition": content_disposition(dl_name), **extra_headers},
)
@@ -818,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
@@ -862,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")
@@ -873,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)
@@ -960,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]
@@ -1111,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")
@@ -1128,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:
@@ -1158,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:
@@ -1204,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")
@@ -1213,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")
@@ -1242,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")
@@ -1276,17 +1440,17 @@ async def dub_qc_pass(job_id: str, lang: str = Query(None), drift_threshold: flo
return result.get("segments", []), backend.id
try:
from services.model_manager import _get_gpu_pool
from services.asr_backend import ASRTimeoutError, run_transcribe_guarded
from services.model_manager import _get_gpu_pool
recognized, engine_id = await run_transcribe_guarded(
_get_gpu_pool(), _recognize, what="QC",
)
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 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)]
@@ -1314,9 +1478,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,
@@ -1334,31 +1498,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"),
@@ -1371,18 +1540,19 @@ 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(
wav_path, media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@@ -1434,6 +1604,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")
@@ -1469,7 +1641,7 @@ async def dub_export_srt(
return Response(
content=srt_content,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
def _format_vtt_time(seconds):
@@ -1486,6 +1658,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")
@@ -1516,13 +1690,15 @@ async def dub_export_vtt(
return Response(
content=vtt_content,
media_type="text/vtt",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@router.get("/dub/export-segments/{job_id}")
async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
import zipfile
_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")
@@ -1533,17 +1709,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}"
@@ -1557,37 +1729,43 @@ async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
return Response(
content=zip_buffer.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="segments_{safe_name}.zip"'},
headers={"Content-Disposition": content_disposition(f"segments_{safe_name}.zip")},
)
@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"),
@@ -1600,7 +1778,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"
@@ -1628,21 +1806,24 @@ 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(
mp3_path, media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
headers={"Content-Disposition": content_disposition(dl_name)},
)
@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")
@@ -1652,22 +1833,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)
@@ -1676,5 +1857,5 @@ async def dub_export_stems(job_id: str, lang: str = Query(None)):
return Response(
content=zip_buffer.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="stems_{safe_name}.zip"'},
headers={"Content-Disposition": content_disposition(f"stems_{safe_name}.zip")},
)
+116 -11
View File
@@ -29,6 +29,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
@@ -174,7 +175,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 +200,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`.
@@ -263,7 +330,7 @@ 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
# Dub used to hardcode VoiceStudio via get_model() regardless of the engine
# selected in Settings → 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
@@ -623,7 +690,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 +711,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 +748,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,
@@ -1414,6 +1506,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 +1548,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 +1577,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 +1627,3 @@ async def preview_segment(job_id: str, req: SegmentPreviewRequest):
"X-Audio-Duration": str(round(audio_tensor.shape[-1] / sr, 2)),
},
)
+23 -4
View File
@@ -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:
+39 -21
View File
@@ -15,12 +15,14 @@ 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
@@ -28,8 +30,10 @@ 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,7 +105,10 @@ 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(),
}
@@ -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,
}
@@ -378,7 +396,7 @@ def engine_health(engine_id: str):
# 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()
@@ -579,11 +597,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)
+41 -40
View File
@@ -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("/", "\\")])
+16 -10
View File
@@ -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"]}
+256 -36
View File
@@ -25,6 +25,7 @@ from services.model_manager import (
from services.audio_io import _safe_torchaudio_save
from services.binary_preflight import InvalidBinaryError
from core import event_bus
from core.logging_utils import log_safe
from omnivoice.utils.voice_design import heal_design_instruct
router = APIRouter()
@@ -46,6 +47,24 @@ def _profile_instruct(row):
return heal_design_instruct(row["instruct"], vd)
def _note_generate_progress() -> None:
"""Tell the pool guard this render just finished a unit of work (#1391).
Every multi-part render calls this after each part. A job that keeps
completing chunks is working, however slowly, and must not be abandoned as
"too heavy for the available compute" the way #1338/#1348/#1391 were —
while a job that produces nothing for a whole base budget still dies on
time. Never raises: a liveness signal that can break a render is worse
than no signal.
"""
try:
from services.model_manager import report_generate_progress
report_generate_progress()
except Exception: # noqa: BLE001 — diagnostics must not break synthesis
pass
def _render_with_pauses(gen_span, segments, sample_rate):
"""Synthesize ``[(text, pause_ms), ...]`` spans and stitch silence between
them (issue #276).
@@ -62,6 +81,7 @@ def _render_with_pauses(gen_span, segments, sample_rate):
for span_text, pause_ms in segments:
if span_text and span_text.strip():
items.append(("a", gen_span(span_text)))
_note_generate_progress()
if pause_ms > 0:
n = int(round(sample_rate * pause_ms / 1000.0))
if n > 0:
@@ -97,8 +117,11 @@ def _sanitize_audio(audio_out):
"sanitizing to silence to keep the WAV decodable (#629)."
)
return torch.nan_to_num(audio_out, nan=0.0, posinf=0.0, neginf=0.0)
except Exception:
pass
except Exception as exc:
logger.warning("Generated audio validation failed")
raise RuntimeError(
"Generated audio could not be validated. Retry the generation."
) from exc
return audio_out
@@ -109,7 +132,7 @@ def _apply_effect_chain(audio_out, sample_rate, effect_preset, *, skip_mastering
``skip_mastering`` honors a backend's ``applies_own_mastering`` flag
(issue #312): studio engines (e.g. VoxCPM2's native 48 kHz output)
opt out of the broadcast highpass + Compressor pre-stage that's tuned
for OmniVoice's 24 kHz clone output. Loudness normalization still runs —
for VoiceStudio's 24 kHz clone output. Loudness normalization still runs —
it's a benign peak scale. Mirrors ``_run_tts`` in openai_compat.py.
"""
from services.audio_dsp import (
@@ -217,6 +240,24 @@ _NETWORK_MSG_SIGNATURES = (
"getaddrinfo failed", # DNS down (Windows)
)
# #1335: a TLS connection cut mid-download. core/failure.py already classifies
# this for the dub/transcribe surfaces (TLS_CONNECTION_DROPPED, #1301), but
# /generate has its own taxonomy and never learned it — so the reporter got a
# bare 500 carrying `_ssl.c:1016`, which means nothing to anyone. It is a
# dropped download, so the network branch is the right owner: retry, not Flush.
#
# Gated on an "ssl" marker rather than matched bare (CodeRabbit): "eof occurred
# in violation of protocol" is OpenSSL's wording, but nothing stops an
# unrelated component from saying something similar, and mislabelling a local
# fault as a network problem sends the user to check their connection for a
# failure that has nothing to do with it. The real message always carries the
# marker: "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of
# protocol (_ssl.c:1016)".
_TLS_DROP_SIGNATURES = (
"unexpected_eof_while_reading",
"eof occurred in violation of protocol",
)
def _is_network_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) is an HTTP-client
@@ -228,6 +269,8 @@ def _is_network_failure(e) -> bool:
low = str(exc).lower()
if any(sig in low for sig in _NETWORK_MSG_SIGNATURES):
return True
if "ssl" in low and any(sig in low for sig in _TLS_DROP_SIGNATURES):
return True
return False
@@ -298,6 +341,46 @@ def _is_config_failure(e) -> bool:
return False
# Exception types that mean "the budget ran out", not "something broke".
# asyncio.TimeoutError is an alias of the builtin on 3.11+, but the engines'
# own wrappers are separate classes, so match by name across the chain.
_TIMEOUT_EXC_NAMES = frozenset({
"TimeoutError", "GpuJobTimeoutError", "FuturesTimeoutError",
})
# Same class, stringified into a wrapper (all lowercase).
_TIMEOUT_MSG_SIGNATURES = (
"timed out",
"timeout expired",
"exceeded its time budget",
)
def _is_timeout_failure(e) -> bool:
"""True iff the generation ran out of *time* rather than failing (#1368).
A bare ``TimeoutError`` used to fall through to the unrecognized-error
catch-all, so the user was told to "retry once and report it with the full
trace" for the one failure mode whose cause is fully known and whose
message is usually EMPTY ``TimeoutError:`` with nothing after the colon
tells them nothing at all.
Deliberately checked before the OOM branch: a job killed at its deadline is
not an allocation failure, and Flush is the wrong remedy for it.
"""
for exc in _exception_chain(e):
if isinstance(exc, TimeoutError) or type(exc).__name__ in _TIMEOUT_EXC_NAMES:
return True
low = str(exc).lower()
if any(sig in low for sig in _TIMEOUT_MSG_SIGNATURES):
# "read timed out" is a download dying, which _is_network_failure
# owns and explains better; don't steal it.
if "read timed out" in low:
continue
return True
return False
def _oom_friendly_reraise(e):
"""Best-effort cache flush + the user-facing OOM hint shared by both
inference paths."""
@@ -371,13 +454,13 @@ def _oom_friendly_reraise(e):
if ("[winerror 4551]" in _low or "[winerror 1260]" in _low
or "application control policy" in _low):
raise RuntimeError(
f"Windows blocked a file OmniVoice needs from running — an "
f"Windows blocked a file VoiceStudio needs from running — an "
f"Application Control policy (Smart App Control, WDAC, or "
f"AppLocker) refused to load it. On a personal PC: Windows "
f"Security → App & browser control → Smart App Control → Off "
f"(note Windows only lets you turn it off once — re-enabling "
f"needs a Windows reset), then restart OmniVoice. On a managed/"
f"work PC ask IT to allow the OmniVoice install folder. The Flush "
f"needs a Windows reset), then restart VoiceStudio. On a managed/"
f"work PC ask IT to allow the VoiceStudio install folder. The Flush "
f"button won't help. Underlying error: {e}"
) from e
# #1221: libsndfile/soundfile could not read or write an audio file. Its
@@ -392,7 +475,7 @@ def _oom_friendly_reraise(e):
f"the OS level). This is a file/disk problem, not a memory one: "
f"check the drive isn't full, the output and temp folders exist "
f"and are writable, and that antivirus or OneDrive isn't locking "
f"them (add an OmniVoice exclusion if you use one). If it happens "
f"them (add a VoiceStudio exclusion if you use one). If it happens "
f"only with one reference clip, re-import that clip. Underlying "
f"error: {e}"
) from e
@@ -453,7 +536,7 @@ def _oom_friendly_reraise(e):
f"This TTS engine isn't set up yet — it needs a model path or "
f"environment variable that isn't configured, so nothing was "
f"generated. Set it as the underlying error describes (it names the "
f"exact variable and what to point it at), then restart OmniVoice — "
f"exact variable and what to point it at), then restart VoiceStudio"
f"or pick a ready engine in Settings → Engines. This is a setup "
f"problem, not a memory one. Underlying error: {e}"
) from e
@@ -462,13 +545,52 @@ def _oom_friendly_reraise(e):
# never ran out of. Only claim OOM when something in the chain actually
# looks like one; everything else surfaces as what it is — unrecognized —
# with the real error front and center.
# #1368: a generate killed at its deadline is not an unrecognized fault.
# It arrived as a bare `TimeoutError:` with an EMPTY message, so the
# catch-all below asked the user to report a trace that says nothing.
# Checked before the OOM branch — a job that ran out of time did not run
# out of memory, and Flush is the wrong remedy.
if _is_timeout_failure(e):
# `TimeoutError` is routinely raised with no message, so the usual
# "Underlying error: …" tail rendered as a bare `TimeoutError:` —
# a sentence stopping mid-thought. Append it only when it says
# something (#1368).
# Test the MESSAGE, not _safe_exc_text() — that always prefixes the
# type name, so it is never empty and the check would never fire.
_tail = f" Underlying error: {_safe_exc_text(e)}" if str(e).strip() else ""
raise RuntimeError(
"The engine hit its time limit before finishing, so generation was "
"stopped. Nothing is broken and flushing memory won't help. The "
"usual causes are a first-use model download still in progress "
"(retry once it finishes — it resumes), a very long input, or an "
"engine running on CPU. Shorter text, or raising "
"OMNIVOICE_GENERATE_TIMEOUT_S, will get it through."
+ _tail
) from e
# #1334: Windows refusing to back a large model mapping (WinError 1455) is
# a paging-file limit, not a working-set shortage. It was matching the OOM
# branch below and telling the user to press Flush — advice that cannot
# work, as the shared hint for this class says outright ("closing other
# apps usually won't fix it"). Checked first so the specific case wins.
_low_1455 = str(e).lower()
if "paging file is too small" in _low_1455 or (
"1455" in _low_1455 and ("winerror" in _low_1455 or "os error" in _low_1455)
):
from core.failure import _HINTS
raise RuntimeError(
"Windows ran out of virtual memory while mapping the model — its "
"paging file is smaller than the model needs. This is not your RAM "
"being full, it is not a network problem, and Flush cannot help. "
+ _HINTS["WINDOWS_PAGING_FILE_TOO_SMALL"]
+ f" Underlying error: {e}"
) from e
if _is_oom_failure(e):
raise RuntimeError(
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
) from e
raise RuntimeError(
f"TTS engine stopped mid-generation with an error OmniVoice doesn't "
f"TTS engine stopped mid-generation with an error VoiceStudio doesn't "
f"recognize. Retry once; if it keeps failing, please report it with "
f"the full trace. Underlying error: {_safe_exc_text(e)}"
) from e
@@ -491,7 +613,7 @@ def _run_inference(
num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset="broadcast",
max_chunk_chars=None, crossfade_ms=None,
max_chunk_chars=None, crossfade_ms=None, *, dropped_sink=None,
):
import torch
try:
@@ -551,11 +673,14 @@ def _run_inference(
if used_seed is not None:
torch.manual_seed(used_seed + i)
parts.append(_gen(chunk_text, None)[0])
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms)
_note_generate_progress()
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms,
texts=text_chunks,
sink=dropped_sink)
else:
audio_out = _gen(text, duration)[0]
# Apply DSP effect preset. The OmniVoice model never masters its own
# Apply DSP effect preset. The VoiceStudio model never masters its own
# output, so mastering always runs here (unchanged behavior).
return _apply_effect_chain(audio_out, sr, effect_preset)
@@ -570,15 +695,15 @@ def _run_backend_inference(
backend, text, language, ref_audio_path, ref_text, instruct, duration,
num_step, guidance_scale, speed, denoise, postprocess_output,
used_seed, effect_preset="broadcast",
max_chunk_chars=None, crossfade_ms=None,
max_chunk_chars=None, crossfade_ms=None, *, dropped_sink=None,
):
"""Engine-aware twin of :func:`_run_inference` (issue #312).
Runs the request through a pluggable ``TTSBackend`` adapter instead of the
OmniVoice model directly. The adapter protocol is narrower than the
OmniVoice-native surface engine-specific extras (``t_shift``,
VoiceStudio model directly. The adapter protocol is narrower than the
VoiceStudio-native surface engine-specific extras (``t_shift``,
``layer_penalty_factor``, ) only exist on the native path, which is why
OmniVoice itself still goes through ``_run_inference``.
VoiceStudio itself still goes through ``_run_inference``.
"""
import torch
try:
@@ -623,7 +748,10 @@ def _run_backend_inference(
if used_seed is not None:
torch.manual_seed(used_seed + i)
parts.append(backend.generate(chunk_text, duration=None, **gen_kwargs))
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms)
_note_generate_progress()
audio_out = concatenate_audio_chunks(parts, sr, _xfade_ms,
texts=text_chunks,
sink=dropped_sink)
else:
audio_out = backend.generate(text, duration=duration, **gen_kwargs)
@@ -634,11 +762,66 @@ def _run_backend_inference(
except ValueError as e:
# Don't wrap validation errors in OOM message
raise e
raise _language_rejection_or(e, backend, language)
except Exception as e:
rewritten = _language_rejection_or(e, backend, language)
if rewritten is not e:
raise rewritten from e
_oom_friendly_reraise(e)
# #1257: the language picker offers all 646 languages regardless of engine,
# because MLXAudioBackend.supported_languages() returns ["multi"] on the stated
# assumption that "each engine silently ignores languages it doesn't know".
# That assumption is false — the underlying library raises, and the reporter got
# a bare 400 that recited 23 language codes without saying which engine was
# refusing, or that switching engines was the fix.
# Each signature must be about the LANGUAGE itself. "Unsupported language" as a
# bare prefix also matches "Unsupported language model configuration" — a model
# problem handed engine-switch advice it has no use for (#1257 review) — so the
# looser wordings require the rejected thing to end there or be a code/name.
_LANGUAGE_REJECTION_SIGNATURES = (
"invalid language code",
"language not supported",
"language is not supported",
"unsupported language code",
)
#: `unsupported language: xx` / `unsupported language 'xx'` — but not
#: `unsupported language model ...`.
_LANGUAGE_REJECTION_RE = re.compile(
r"unsupported language\s*[:=]|unsupported language\s*['\"]|"
r"unsupported language\s*$",
re.IGNORECASE | re.MULTILINE,
)
def _language_rejection_or(e: BaseException, backend, language):
"""``e`` rewritten with engine context when it's a language rejection.
Returns ``e`` unchanged otherwise, so this is safe to wrap any failure in.
Matched on the message, not the type: the engines multiplex third-party
libraries that each raise their own class.
"""
text = str(e)
low = text.lower()
if not any(sig in low for sig in _LANGUAGE_REJECTION_SIGNATURES) and not (
_LANGUAGE_REJECTION_RE.search(text)
):
return e
engine = getattr(backend, "display_name", None) or getattr(
type(backend), "id", type(backend).__name__
)
requested = f" '{language}'" if language else ""
return ValueError(
f"The {engine} engine can't speak{requested}. VoiceStudio offers every "
f"language its default engine supports, but each engine covers a "
f"different set — pick one this engine supports, or switch engine in "
f"Settings → Engines (the VoiceStudio engine has the widest coverage) "
f"and generate again. Engine's own message: {e}"
)
def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
"""Cache an auto-transcribed reference transcript onto its profile row.
@@ -661,7 +844,7 @@ def _persist_profile_ref_text(profile_id: str, ref_text: str) -> None:
except Exception as e: # noqa: BLE001 — cache write must not break generate
logger.warning(
"could not persist auto-transcribed ref_text onto profile %s: %s",
profile_id, e,
log_safe(profile_id), log_safe(e),
)
@@ -831,7 +1014,7 @@ async def generate_speech(
# The request runs on the engine selected in Settings (POST /engines/select,
# env var OMNIVOICE_TTS_BACKEND wins), or an explicit per-request `engine`
# override — same pattern as /ws/tts's `engine` field and /v1/audio/speech's
# `model`. Omitting both keeps the historical default (OmniVoice), so
# `model`. Omitting both keeps the historical default (VoiceStudio), so
# existing API consumers see no change.
from services.tts_backend import (
OmniVoiceBackend, _mask_hf_tokens, active_backend_id, get_backend_class,
@@ -876,10 +1059,13 @@ async def generate_speech(
except Exception:
pass
# VRAM eviction runs in get_model()'s warm-return path now, so every native
# TTS generate (this route, WS TTS, dub, batch, audiobook) is covered.
_model = None
_backend = None
if backend_cls is OmniVoiceBackend:
# OmniVoice keeps its native path: it carries the full advanced
# VoiceStudio keeps its native path: it carries the full advanced
# parameter surface (t_shift, layer/position/class controls) that the
# generic adapter protocol doesn't. Byte-identical to the old behavior.
_model = await get_model()
@@ -1094,7 +1280,7 @@ async def generate_speech(
# [[…]] one-off overrides to the text, here — AFTER `language` is fully
# resolved (a profile may fill it above) so per-language entries match the
# real render language, and BEFORE the text reaches either inference path
# (native OmniVoice or a pluggable backend) and the chunk splitter. This is
# (native VoiceStudio or a pluggable backend) and the chunk splitter. This is
# the single point user text → normalized text → model, so the transform
# covers generate for every engine. Pure text substitution → identical on
# mac/Win/Linux. A disabled pref or empty dictionary is a pass-through, so
@@ -1143,6 +1329,10 @@ async def generate_speech(
_segments = parse_pause_markers(text)
_has_pause = len(_segments) > 1 or (_segments and _segments[0][1] > 0)
_text_chunks = [] if _has_pause else split_text_into_chunks(text, max_chunk_chars)
# #1330 — see the non-streaming path: chunks the engine rendered to
# nothing land here so the stream can say the take is missing text
# instead of quietly handing back a short one.
_dropped_sink: list = []
def _render_stream_chunk(i: int, chunk_text: str):
"""One text chunk → (raw engine tensor, preview-DSP tensor, sr).
@@ -1208,7 +1398,9 @@ async def generate_speech(
non-streaming multi-chunk loop runs, as one pool job."""
from services.chunked_tts import concatenate_audio_chunks
try:
audio_out = concatenate_audio_chunks(parts, sr, crossfade_ms)
audio_out = concatenate_audio_chunks(parts, sr, crossfade_ms,
texts=_text_chunks,
sink=_dropped_sink)
skip = (getattr(_backend, "applies_own_mastering", False)
if _backend is not None else False)
return _apply_effect_chain(audio_out, sr, effect_preset, skip_mastering=skip)
@@ -1326,10 +1518,21 @@ async def generate_speech(
instruct=instruct, resolved_profile_id=resolved_profile_id,
used_seed=used_seed, start_time=start_time,
)
# #1330: before `done`, say what the take is missing. Its own
# frame rather than a `done` field so a consumer that only
# handles known types still surfaces it, and so the shape
# matches `error` (which clients already special-case).
if _dropped_sink:
yield _line({
"type": "warning", "code": "dropped_chunks",
"count": len(_dropped_sink),
"text": [t for t in _dropped_sink if t],
})
yield _line({
"type": "done", "id": meta["id"], "audio_path": meta["filename"],
"duration": meta["duration"], "gen_time": meta["gen_time"],
"seed": used_seed, "sample_rate": sample_rate,
"dropped_chunks": len(_dropped_sink),
})
except (asyncio.CancelledError, GeneratorExit):
# Client went away mid-stream — same semantics as aborting a
@@ -1339,17 +1542,19 @@ async def generate_speech(
# In-band error frame carries the machine-readable retryable
# marker (#1190) — an NDJSON consumer can back off instead of
# guessing from the prose.
logger.error("Streaming generate timed out: %s", e)
yield _line({
"type": "error", "detail": str(e), "retryable": True,
"retry_after": getattr(e, "retry_after", 30),
})
except ValueError as e:
logger.error("Streaming generate validation failed: %s", e)
yield _line({"type": "error", "detail": str(e)})
except Exception as e:
logger.error("Streaming generate failed: %s\n%s", e, traceback.format_exc())
yield _line({"type": "error", "detail": _safe_exc_text(e)})
logger.error("Streaming generation capacity unavailable")
from core.public_errors import stream_failure
failure = stream_failure("generation_busy")
failure["retry_after"] = getattr(e, "retry_after", 30)
yield _line({"type": "error", **failure})
except ValueError:
logger.error("Streaming generation request rejected")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("invalid_request")})
except Exception:
logger.error("Streaming generation failed unexpectedly")
from core.public_errors import stream_failure
yield _line({"type": "error", **stream_failure("generation_failed")})
finally:
# Ownership of the temp reference clip moves to this generator
# in stream mode (the route returns before rendering starts).
@@ -1375,6 +1580,12 @@ async def generate_speech(
headers=_stream_headers,
)
# #1330: text whose chunk rendered to nothing. The engine dropping a chunk
# is silent in the waveform — the take sounds clean and is simply missing a
# sentence — so the render collects what it lost here and the response says
# so. A warning in a log the user never opens is a record of the bug, not a
# fix for it.
_dropped_text: list = []
try:
if _backend is not None:
# Bounded + pool-reset on hang so a wedged generate can't starve the
@@ -1385,7 +1596,7 @@ async def generate_speech(
_backend, text, language, ref_audio_path, ref_text, instruct,
duration, num_step, guidance_scale, speed, denoise,
postprocess_output, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
@@ -1402,7 +1613,7 @@ async def generate_speech(
num_step, guidance_scale, speed, t_shift, denoise,
postprocess_output, layer_penalty_factor, position_temperature,
class_temperature, used_seed, effect_preset,
max_chunk_chars, crossfade_ms,
max_chunk_chars, crossfade_ms, dropped_sink=_dropped_text,
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
@@ -1440,6 +1651,15 @@ async def generate_speech(
"X-Audio-Duration": str(audio_dur),
"Content-Length": str(len(wav_bytes)),
}
# #1330: the body is a WAV, so the header channel carries the notice
# that some of the text produced no audio. Count first (always exact),
# then as much of the lost text as a header can safely hold.
if _dropped_text:
from services.engine_routing import header_safe_reason
_resp_headers["X-OmniVoice-Dropped-Chunks"] = str(len(_dropped_text))
_lost = header_safe_reason(" | ".join(t for t in _dropped_text if t))
if _lost:
_resp_headers["X-OmniVoice-Dropped-Text"] = _lost
# Routing notice (#21): cpu_fallback or accelerated-with-caveat only;
# the WAV body is binary so the header channel is the carrier.
if _routing_notice:
+58 -19
View File
@@ -39,6 +39,9 @@ from core.config import OUTPUTS_DIR, VOICES_DIR
from core.db import db_conn
from core import event_bus
from core.version import APP_VERSION
from core.http_headers import content_disposition
from core.logging_utils import log_safe
from core.path_security import UnsafePath, resolve_within, safe_filename
logger = logging.getLogger("omnivoice.marketplace")
@@ -55,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 ──────────────────────────────────────────────────────────────────
@@ -108,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(
@@ -131,7 +154,7 @@ def export_profile(profile_id: str):
buf,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Disposition": content_disposition(filename),
"Content-Length": str(buf.getbuffer().nbytes),
},
)
@@ -254,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:
@@ -269,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:
@@ -282,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,
@@ -344,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)}
@@ -352,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}")
@@ -425,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:
+7 -2
View File
@@ -19,7 +19,7 @@ router = APIRouter(dependencies=[Depends(require_loopback)])
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))
+26 -22
View File
@@ -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.
@@ -32,6 +32,7 @@ from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from services.model_manager import _gpu_pool, run_on_gpu_pool_guarded
from core.http_headers import content_disposition
logger = logging.getLogger("omnivoice.openai_compat")
@@ -47,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."
),
@@ -60,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."
),
@@ -75,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,
@@ -86,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,
@@ -119,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).",
)
@@ -147,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",
@@ -158,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.
@@ -289,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.
@@ -384,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
@@ -466,7 +470,7 @@ async def create_speech(req: SpeechRequest):
_headers = {
"Content-Length": str(len(audio_bytes)),
"Content-Disposition": f'inline; filename="speech.{ext}"',
"Content-Disposition": content_disposition(f"speech.{ext}", disposition="inline"),
}
if _routing_notice:
from services.engine_routing import header_safe_reason
@@ -491,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(
@@ -521,7 +525,7 @@ async def create_transcription(
# 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)
@@ -636,12 +640,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()
@@ -653,7 +657,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
@@ -671,7 +675,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}
+18 -10
View File
@@ -28,6 +28,8 @@ from core import event_bus
from core.config import VOICES_DIR # noqa: F401 — re-exported for tests/monkeypatch
from core.db import db_conn
from core.version import APP_VERSION
from core.logging_utils import log_safe
from core.http_headers import content_disposition
from services import persona_bundle as pb
router = APIRouter()
@@ -87,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.",
@@ -100,7 +102,7 @@ async def export_persona(
BytesIO(content),
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Disposition": content_disposition(filename),
"Content-Length": str(len(content)),
},
)
@@ -235,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,
@@ -259,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]:
+12 -6
View File
@@ -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(
+53 -25
View File
@@ -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
logger = logging.getLogger("omnivoice.api.settings")
router = APIRouter(
prefix="/api/settings",
tags=["settings"],
dependencies=[Depends(require_loopback)],
dependencies=[Depends(require_admin)],
)
@@ -445,15 +445,42 @@ 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),
}
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")
def list_llm_provider_models(provider_id: str):
"""List model ids the provider's key can access (OpenAI-compat /models).
@@ -480,10 +507,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 +575,7 @@ def set_llm_skill(skill_id: str, body: _LLMSkillBody):
#: Engines that have an in-tree acceptance dialog. Adding a new engine
#: here means adding a corresponding frontend dialog + a license URLs
#: dict in its constants module. Until that, the API refuses the write.
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3"})
_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3", "pockettts"})
class _LicenseAcceptBody(BaseModel):
@@ -577,8 +604,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 +630,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 +663,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 +692,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 +764,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
+109 -13
View File
@@ -13,12 +13,15 @@ import json
import logging
import os
import sys
import threading
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from core import prefs
from core.failure import is_hf_connectivity_error
from services.hf_revisions import revision_for
from utils import hf_progress
from utils import download_aggregator
# Weight-floor scan (MM2-07 / #352) lives in ``models.py`` — the lowest module in
@@ -67,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 —
@@ -169,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
@@ -186,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"))
@@ -221,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
@@ -320,6 +342,41 @@ class InstallModelRequest(BaseModel):
repo_id: str
def _is_retryable_download_error(exc: BaseException) -> bool:
"""Whether a failed download attempt is worth retrying.
Decides by CLASSIFICATION, not by exception type. The type-based tuple this
replaced ``(HfHubHTTPError, LocalEntryNotFoundError, OSError)`` silently
excluded ``httpx.RemoteProtocolError``, which inherits ``Exception``: a
4.6 GB model truncated at 4.0 GB escaped all five attempts and aborted the
install (#1224). Any future transport error with a novel base class would
have reopened the same hole.
A user cancel is never retryable, and neither is anything
``is_hf_connectivity_error`` does not recognise.
"""
# Imported here, not at module scope, for the same reason the worker does:
# huggingface_hub is heavy and this module is on the setup import path.
from huggingface_hub.utils import HfHubHTTPError, LocalEntryNotFoundError
if isinstance(exc, _InstallCancelled):
return False
if isinstance(exc, HfHubHTTPError):
# An auth / not-found / gone answer from the Hub is a settled verdict:
# the token is wrong, the repo is gated, or it isn't there. Retrying
# five times with backoff just delays the same message and postpones
# the install cooldown. (Pre-existing behaviour — the type-based tuple
# this replaced retried every HfHubHTTPError; surfaced in #1224 review.)
status = getattr(getattr(exc, "response", None), "status_code", None)
if status in (401, 403, 404, 410):
return False
return True
if isinstance(exc, (LocalEntryNotFoundError, OSError)):
return True
return is_hf_connectivity_error(str(exc))
@router.post("/models/install")
async def install_model(req: InstallModelRequest):
"""Download one HF repo snapshot; progress goes through the shared
@@ -345,6 +402,10 @@ 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():
@@ -371,6 +432,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()
@@ -411,11 +473,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
@@ -479,7 +545,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:
@@ -489,11 +559,28 @@ 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 (HfHubHTTPError, LocalEntryNotFoundError, OSError) as net_err:
if _attempt >= _max_attempts:
except Exception as net_err:
# #1224: a truncated body ("peer closed connection without
# sending complete message body") arrives as
# httpx.RemoteProtocolError, which inherits from Exception
# — NOT OSError — so it escaped the old
# (HfHubHTTPError, LocalEntryNotFoundError, OSError) tuple
# and aborted a 4.6 GB install at 4.0 GB with no retry.
# Widen to Exception and decide by CLASSIFICATION:
# is_hf_connectivity_error is already the single source of
# truth for "transient download failure" and now knows the
# truncation signatures. Anything unrecognised (a cancel, a
# validation failure, a bug) propagates untouched, exactly
# as before.
if _attempt >= _max_attempts or not _is_retryable_download_error(
net_err
):
raise
_backoff = min(30, 2 ** _attempt)
logger.info(
@@ -577,8 +664,17 @@ async def install_model(req: InstallModelRequest):
_cancelled.discard(req.repo_id)
download_aggregator.finish(req.repo_id)
hf_progress.current_repo_id.reset(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}
+5 -5
View File
@@ -238,7 +238,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).
"""
@@ -547,28 +547,28 @@ 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."
+14 -2
View File
@@ -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 Settings → 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":
+23 -9
View File
@@ -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))
+3 -2
View File
@@ -14,6 +14,7 @@ from fastapi import APIRouter, UploadFile, File, Form, HTTPException
from fastapi.responses import Response
from services.ffmpeg_utils import find_ffmpeg, spawn_subprocess
from core.http_headers import content_disposition
router = APIRouter()
@@ -38,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()
@@ -76,7 +77,7 @@ async def stories_encode(
return Response(
content=encoded,
media_type=mime,
headers={"Content-Disposition": f'attachment; filename="story.{ext}"'},
headers={"Content-Disposition": content_disposition(f"story.{ext}")},
)
finally:
for p in (in_path, out_path):
+84 -53
View File
@@ -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
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():
@@ -652,7 +675,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 +728,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 +751,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 +829,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 +865,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 +880,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 +897,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 +912,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 +990,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")
@@ -1101,12 +1114,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 +1159,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")
+19 -8
View File
@@ -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()
+14
View File
@@ -90,6 +90,20 @@ async def ws_tts(websocket: WebSocket):
get_backend_class,
)
engine_id = data.get("engine")
# #1224: leave a breadcrumb when memory is already tight before
# a heavy load. /generate has done this since the 16 GB-Mac
# reports, but the streaming path — which the desktop UI tries
# FIRST — never did, so the load most likely to tip the machine
# into an OS OOM kill was the one load with no trail. The
# captured stderr tail is what a SIGKILL report has to go on.
# Advisory only: the OS can reclaim cache, and refusing here
# would brick loads that would actually have coped.
try:
from services.memory_budget import log_if_low
log_if_low(f"TTS stream load ({engine_id or 'active engine'})")
except Exception:
pass
if engine_id:
cls = get_backend_class(engine_id)
backend = cls()
+11 -4
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+3 -3
View File
@@ -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 +
+218
View File
@@ -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
+2 -2
View File
@@ -470,12 +470,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
View File
@@ -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 (40604090, 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
+1 -1
View File
@@ -388,7 +388,7 @@ def format_text(report: dict) -> str:
"""
tag = {OK: "[ OK ]", WARN: "[WARN]", FAIL: "[FAIL]"}
lines = [
f"OmniVoice Studio self-check - v{report['app_version']} on {report['platform']}",
f"VoiceStudio self-check - v{report['app_version']} on {report['platform']}",
"",
]
for c in report["checks"]:
+1
View File
@@ -32,6 +32,7 @@ ERROR_DOCS: dict[str, str] = {
# directly. Distinct from HF_AUTH_FAILED (which is the more general
# token-missing-or-invalid case pointing at the token-setup doc).
"PYANNOTE_LICENSE_REQUIRED": f"{_BASE}/docs/features/diarization.md#license-acceptance-flow",
"POCKETTTS_GATED_WEIGHTS": f"{_BASE}/docs/install/troubleshooting.md#pockettts-gated-weights",
}
DEFAULT_DOCS: str = f"{_BASE}/docs/install/troubleshooting.md"
+19 -7
View File
@@ -25,6 +25,7 @@ the 4-step mirror contract documented there.
from __future__ import annotations
import json
import logging
import os
import threading
import time
@@ -33,6 +34,8 @@ from collections import OrderedDict
from core.config import DATA_DIR
from core.scrub import scrub_text
logger = logging.getLogger("omnivoice.error_journal")
JOURNAL_PATH = os.path.join(DATA_DIR, "error_journal.jsonl")
_MAX_ENTRIES = 50
@@ -137,17 +140,20 @@ def _fingerprint(error_class: str, exc: BaseException) -> str:
return hashlib.sha1(raw.encode("utf-8", "replace"), usedforsecurity=False).hexdigest()[:16]
def _persist_locked() -> None:
def _persist_locked() -> bool:
"""Rewrite the JSONL mirror from the in-memory ring. Caller holds _lock.
Never raises losing persistence must not break the exception handler."""
Never raises losing persistence must not break the exception handler.
Returns whether the durable mirror was updated."""
try:
tmp = JOURNAL_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for entry in _entries.values():
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp, JOURNAL_PATH)
return True
except Exception:
pass
logger.warning("Error journal persistence failed; entries remain available in memory")
return False
def _hydrate() -> None:
@@ -168,7 +174,7 @@ def _hydrate() -> None:
except FileNotFoundError:
pass
except Exception:
pass
logger.warning("Error journal could not be loaded; starting with an empty in-memory journal")
_hydrate()
@@ -231,10 +237,16 @@ def recent(limit: int = 20) -> list[dict]:
return list(reversed(items))[: max(1, min(limit, _MAX_ENTRIES))]
def clear() -> None:
def clear() -> bool:
"""Clear the journal, retaining memory state if durable deletion fails."""
with _lock:
_entries.clear()
try:
os.remove(JOURNAL_PATH)
except FileNotFoundError:
# Idempotent clear: an already-absent durable mirror is success.
logger.debug("Error journal mirror already absent during clear")
except OSError:
pass
logger.warning("Error journal could not be cleared; keeping entries available for retry")
return False
_entries.clear()
return True
+419 -26
View File
@@ -29,6 +29,24 @@ from core.logging_filter import REDACTED, _HF_TOKEN_RE
# Env vars whose *name* implies a credential — their values are redacted.
_SECRET_NAME_RE = re.compile(r"(TOKEN|KEY|SECRET)", re.IGNORECASE)
#: An HTTP 401, not any number or identifier that contains 401.
#:
#: Digit-only boundaries are not enough: they reject `4012` and `1401` but
#: still accept `pytest-401`, `x401y` and `401.0`, so an error that mentions
#: Hugging Face anywhere and carries one of those elsewhere still classified
#: as an auth failure (CodeRabbit, #1427).
#:
#: The three guards, each for a family the others let through:
#: `(?<![\w.-])` identifier / path / dotted-version prefixes — `x401y`,
#: `pytest-401`, `v1.401`
#: `(?![\w-])` identifier and hyphen suffixes — `401k`, `401-retry`
#: `(?!\.\d)` dotted numerics — `401.0`, `401.25` (a trailing sentence
#: full stop is still fine: `status 401.`)
#:
#: Matches what a real status code looks like: `401`, `(401)`, `status 401:`,
#: `HTTP/1.1 401`.
_HTTP_401 = re.compile(r"(?<![\w.-])401(?![\w-])(?!\.\d)")
_REDACTED_VALUE = "***REDACTED***"
# One-line "what to do" per docs-taxonomy key. Keys mirror error_docs_map's
@@ -39,18 +57,41 @@ _HINTS: dict[str, str] = {
"APPIMAGE_WEBKIT_WHITESCREEN": "Launch with WEBKIT_DISABLE_DMABUF_RENDERER=1 set.",
"HF_AUTH_FAILED": "Set a valid HF_TOKEN in Settings → Hugging Face and retry.",
"PYANNOTE_LICENSE_REQUIRED": "Accept the pyannote model licenses on Hugging Face, then retry.",
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — OmniVoice retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio) is missing or mismatched with your torch. Reinstall them together (`uv pip install --reinstall torch torchaudio transformers`), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file OmniVoice needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart OmniVoice. On a managed/work PC, ask IT to allow the OmniVoice install folder.",
"AUDIO_IO_FAILED": "An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add an OmniVoice exclusion if you use one).",
"VIDEO_DOWNLOAD_OS_ERROR": "The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your OmniVoice data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add an OmniVoice exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add an OmniVoice antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer OmniVoice builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for OmniVoice, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
"POCKETTTS_GATED_WEIGHTS": "PocketTTS weights are gated on HuggingFace. Accept the access agreement at huggingface.co/kyutai/pocket-tts, then set HF_TOKEN in Settings → Hugging Face and retry.",
"COMPUTE_TYPE_UNSUPPORTED": "Your GPU doesn't support float16 — VoiceStudio retried on int8. If transcription still fails, set OMNIVOICE/ASR_COMPUTE_TYPE=int8 or use CPU.",
# Literal versions, not `--constraint deploy/torch-constraints.txt`:
# desktop installs don't ship deploy/ (tauri.conf.json bundles only
# pyproject/uv.lock/backend/omnivoice), so the file-based command would
# fail with "file not found" for exactly the users most likely to need it
# (greptile on #1377). tests/test_failure_classify.py pins these literals
# to the constraint file so they cannot drift when the pins bump.
"TRANSFORMERS_IMPORT": "Your transformers install is incomplete, or a package it loads models through (torchaudio, torchvision) is missing or mismatched with your torch — a torch/torchvision version mismatch fails with exactly this wording. Reinstall them together at the pinned versions (`uv pip install --python .venv --reinstall torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0 transformers` in the project folder), then restart the backend. If only transcription is affected, switching ASR to faster-whisper (Settings → Models) also works around it.",
"WINDOWS_APP_CONTROL_BLOCKED": "Windows refused to load a file VoiceStudio needs — an Application Control policy (Smart App Control, WDAC, or AppLocker) blocked it. On a personal PC: Windows Security → App & browser control → Smart App Control → Off (Windows only lets you turn it off once — re-enabling requires a Windows reset), then restart VoiceStudio. On a managed/work PC, ask IT to allow the VoiceStudio install folder.",
"WINDOWS_PAGING_FILE_TOO_SMALL": "Windows ran out of virtual memory while mapping the model into memory — its paging file is smaller than the model needs. This is not the same as your RAM being full, and closing other apps usually won't fix it: Windows has to be allowed to back the mapping. Set a bigger paging file — Settings → System → About → Advanced system settings → Performance → Settings → Advanced → Virtual memory → Change: untick \"Automatically manage\", pick your system drive, choose \"Custom size\" and set both Initial and Maximum to at least 32768 MB (more than the model's size), then OK and restart Windows. A smaller/quantized engine (OmniVoice GGUF, Supertonic-3) also avoids the large mapping entirely.",
"MEDIA_TOOL_MISSING": "VoiceStudio's media engine (ffmpeg/ffprobe) wasn't on the system path when a component went looking for it. Open Settings → Audio tools and use Download/Repair to fetch the bundled copy, then retry — a restart picks it up for everything. If you'd rather use a system install, install ffmpeg (macOS: `brew install ffmpeg`; Windows: `winget install Gyan.FFmpeg`; Linux: your package manager) and restart VoiceStudio, or point FFMPEG_PATH / OMNIVOICE_FFPROBE_PATH at the binaries in Settings.",
"AUDIO_IO_FAILED": "An audio file couldn't be read or written at the OS level. Check the drive isn't full, that the output and temp folders exist and are writable, and that antivirus or OneDrive isn't locking them (add a VoiceStudio exclusion if you use one).",
"VIDEO_DOWNLOAD_OS_ERROR": "The OS refused a file operation while saving the downloaded video — this is a disk/folder problem, not a network one, so retrying the same link won't help. The download is written to a job folder under your VoiceStudio data directory (Settings → Storage shows the path): check that drive isn't full, that the folder exists and is writable, and that antivirus or a cloud-sync client (OneDrive, Dropbox) isn't locking it — add a VoiceStudio exclusion if you use one. If your data directory sits on a synced or network drive, move it to a local one.",
"OS_INVALID_ARGUMENT": "The OS rejected a file operation (Errno 22 / invalid argument) — in the transcribe path this is the temporary WAV write before ASR. It's almost always the temp directory: missing, read-only, on a full or removed drive, or blocked by antivirus. Check that your system TEMP/TMP folder exists and is writable and the drive has free space (add a VoiceStudio antivirus exclusion if you use one), then retry.",
"SOCKS_PROXY_SUPPORT_MISSING": "A SOCKS proxy is configured in your environment (ALL_PROXY/HTTPS_PROXY=socks5://…) and the backend's HTTP client is missing SOCKS support. Newer VoiceStudio builds ship SOCKS support (the socksio package) — update the app. If you still see this, unset ALL_PROXY/HTTPS_PROXY for VoiceStudio, or run `uv pip install 'httpx[socks]'` in the backend venv, then restart.",
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer VoiceStudio builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for VoiceStudio/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
"MODEL_CACHE_CORRUPT": "The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). OmniVoice repairs this automatically and retries the load once. If the error persists, quit OmniVoice, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
"VIDEO_DRM_PROTECTED": "The video host only offered VoiceStudio a DRM-protected copy, which can't be downloaded. This is often not a property of the video itself — the host serves a different format set to different clients, and VoiceStudio already retried through every client it has. Try the link again in a minute, or download the video with a browser extension / the host's own download button and drop the file into Dubbing directly.",
# #1301: distinct from SSL_HANDSHAKE_FAILURE. The handshake did not fail on
# trust — the connection was CUT while TLS was in progress, so the certifi /
# proxy-CA advice above would send the user to fix something that isn't
# broken. Raw form is "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in
# violation of protocol (_ssl.c:1016)", which means nothing to anyone.
"TLS_CONNECTION_DROPPED": "The secure connection was cut off mid-transfer — the other end (or something between you and it) closed the socket during the TLS exchange. This is almost always transient: flaky Wi-Fi, a VPN reconnecting, a captive portal, or a download server dropping a long transfer. Retrying is safe: a partly-downloaded MODEL is picked up where it left off rather than started over. If it repeats every time, a VPN or an HTTPS-inspecting proxy is terminating long-lived connections — try without the VPN, or on another network.",
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — VoiceStudio already cleaned up the partial download. If it keeps failing, check your network/VPN.",
# #1347: the transformers pipeline fails to import a component it was
# DOWNLOADING when the shared HTTP client closed underneath it (#880). The
# message names AutoFeatureExtractor, so TRANSFORMERS_IMPORT matched and
# told the reporter to reinstall transformers — advice that cannot work,
# because nothing is wrong with their install. Checked first so the cause
# wins over the symptom.
"MODEL_DOWNLOAD_INTERRUPTED": "A model download was cut off mid-request, and the component it was fetching then failed to load. Nothing is wrong with your install — reinstalling won't help, and the partial download is resumed rather than restarted. Just retry. If it keeps happening, check your connection (and any VPN, proxy or HF mirror setting); if only transcription is affected, switching ASR to faster-whisper in Settings → Models avoids the pipeline that downloads this component.",
"BROKEN_VENV": "The Python backend environment was moved or damaged. VoiceStudio rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
"MODEL_CACHE_CORRUPT": "A model file is missing or damaged — a download that stopped part-way, a broken link to downloaded data, or a file changed on disk after it arrived (interrupted renames and antivirus interference both cause this). VoiceStudio repairs it automatically and retries the load once, re-downloading the damaged file where a resume would not have replaced it. If the error persists, quit VoiceStudio, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
# HF_MIRROR_UNREACHABLE has a DYNAMIC hint (it names the configured mirror)
# — see hf_mirror_hint(); build_failure special-cases it.
}
@@ -69,6 +110,16 @@ _HINTS: dict[str, str] = {
_OFFICIAL_HF_ENDPOINTS = {"https://huggingface.co", "https://hf.co"}
def public_hint_for_topic(topic: str) -> str:
"""Data-independent response guidance for a stable failure topic."""
if topic == "HF_MIRROR_UNREACHABLE":
return (
"Check Settings → Models → Hugging Face mirror, restore the official "
"endpoint, and retry."
)
return _HINTS.get(topic, "")
# Connectivity signatures across the layers an HF download failure surfaces
# from: transformers' wording, huggingface_hub errors, requests/urllib3, and
# raw socket/DNS failures (Linux/macOS/Windows variants).
@@ -88,6 +139,18 @@ _HF_CONNECTIVITY_SIGNATURES = (
"timed out",
"an error happened while trying to locate the file on the hub", # LocalEntryNotFoundError
"we cannot find the requested files", # LocalEntryNotFoundError
# #1224: a TRUNCATED download — the server closed mid-body, so the client
# got fewer bytes than Content-Length promised. httpx words it "peer closed
# connection without sending complete message body"; urllib3/http.client
# raise IncompleteRead. This is as transient as a refused connection and
# must retry — a 4.6 GB model that dies at 4.0 GB used to abort the whole
# install (and, on the reporter's 16 GB Mac, take the process with it).
"peer closed connection",
"incomplete message body",
"incompleteread",
"incomplete read",
"connection broken", # urllib3 ProtocolError wrapper
"response ended prematurely",
)
# The failure must also be Hugging-Face-shaped — the configured endpoint/host
@@ -177,7 +240,7 @@ def hf_mirror_hint(reason: Optional[str]) -> str:
"first-run setup, use the mirror picker right on this screen — or "
"wait for the mirror to recover, then retry: downloads pick up a "
"mirror change immediately. If a retry still fails after switching, "
"restart OmniVoice."
"restart VoiceStudio."
)
@@ -201,6 +264,18 @@ def append_hf_mirror_hint(text: str) -> str:
_CONTEXT_FREE_HINT_CLASSES = frozenset({
"SOCKS_PROXY_SUPPORT_MISSING",
"SSL_HANDSHAKE_FAILURE",
# Its trigger is an exact OpenSSL string, so it cannot be confused with
# another failure the way a bare "timed out" could.
"TLS_CONNECTION_DROPPED",
# Requires the exact httpx closed-client wording AND an import/transformers
# term together, so it cannot fire on an unrelated failure (#1347).
"MODEL_DOWNLOAD_INTERRUPTED",
# #1334: triggered by WinError/os error 1455 or the literal "paging file is
# too small" — both unmistakable. It reached /generate as a bare 500
# carrying only the OS sentence, so the reporter had no way to know it was
# a Windows virtual-memory setting rather than a connectivity problem, and
# the detailed hint we already had for it never reached them.
"WINDOWS_PAGING_FILE_TOO_SMALL",
})
@@ -233,6 +308,13 @@ def classify(reason: str) -> str:
return "GATEKEEPER_QUARANTINE"
if "webkit" in low or "white screen" in low or "dmabuf" in low or "appimage" in low:
return "APPIMAGE_WEBKIT_WHITESCREEN"
if (
"gated" in low
or "share your contact" in low
or "access agreement" in low
or "access conditions" in low
) and ("pocket" in low or "kyutai" in low):
return "POCKETTTS_GATED_WEIGHTS"
if "pyannote" in low or ("gated" in low and "model" in low) or "accept the" in low:
return "PYANNOTE_LICENSE_REQUIRED"
# ASR robustness (#551 / #549): name the class so the no-segments toast is
@@ -253,26 +335,70 @@ def classify(reason: str) -> str:
# transformers + site-packages markers, which this signature lacks.
# #1225: the same errno raised by the DUB video download is a different
# class with a different remedy — the failing directory is the job folder
# under the OmniVoice data dir, not the system temp dir. Checked first so
# under the VoiceStudio data dir, not the system temp dir. Checked first so
# a download's errno 22 stops being handed the transcribe path's
# "check your TEMP folder" hint, which sends the user to the wrong place.
if is_os_write_refusal(reason) and any(
marker in low for marker in _DOWNLOAD_CONTEXT_MARKERS
):
return "VIDEO_DOWNLOAD_OS_ERROR"
# #1256: a third-party library shelled out to `ffprobe`/`ffmpeg` BY NAME and
# the OS had nothing to run. VoiceStudio's own code always resolves the
# bundled sidecar explicitly, so this only ever comes from a dependency —
# which meant it arrived with no class at all and the user was told the
# engine "stopped with an error VoiceStudio doesn't recognize". Checked
# before the generic errno-2 rules, which would otherwise claim it.
if _is_missing_media_tool(low):
return "MEDIA_TOOL_MISSING"
# #1251: Windows refused to map the model because the PAGING FILE is too
# small. The generate path already counted this as an OOM, but that hint
# ("close other apps, use a lighter engine") is the wrong remedy — the
# machine had 32 GB of RAM. Matched on the numeric code too, since the OS
# translates the message text, and in both the Python (`[WinError 1455]`)
# and Rust (`os error 1455`, from the safetensors mmap) spellings.
if "1455" in low and ("winerror" in low or "os error" in low):
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "paging file is too small" in low:
return "WINDOWS_PAGING_FILE_TOO_SMALL"
if "errno 22" in low:
return "OS_INVALID_ARGUMENT"
# An HF cache whose snapshot entries don't resolve (dangling symlinks /
# zero-byte stand-ins): transformers reports the weights missing ("does
# not appear to have a file named pytorch_model.bin or model.safetensors")
# even though the blobs are fully on disk. model_manager self-heals this
# (delete broken entries → snapshot_download → retry once); the class here
# covers both the raw transformers wording (any load surface can leak it)
# and OmniVoice's own repair messages, so the user-facing error and the
# auto bug report name the class and its automatic repair.
if ("does not appear to have a file named" in low
or "broken file link" in low):
# zero-byte stand-ins) or which is simply missing its weight shard.
# model_manager self-heals this (delete broken entries → snapshot_download
# → retry once); the class here covers the raw transformers wordings (any
# load surface can leak them) and VoiceStudio's own repair messages, so the
# user-facing error and the auto bug report name the class and its
# automatic repair.
if (
is_incomplete_cache_message(low)
or is_corrupt_weights_message(low)
or "broken file link" in low
or (
"the tts model cache for" in low
and "is incomplete" in low
and (
"could not be auto-repaired" in low
or "weights missing" in low
)
)
):
return "MODEL_CACHE_CORRUPT"
# #1347: an import that failed because its DOWNLOAD died is a network
# problem wearing an import problem's clothes. The reporter's message named
# AutoFeatureExtractor *and* carried "Cannot send a request, as the client
# has been closed" (#880) — TRANSFORMERS_IMPORT won on the former and told
# them to reinstall transformers, which cannot fix a dropped connection.
# Checked BEFORE the import rules so the cause beats the symptom.
# Requires the closed-client wording itself, not merely "cannot send a
# request" (CodeRabbit): the latter is generic enough to appear in an
# unrelated failure that also mentions an import, and overriding
# TRANSFORMERS_IMPORT there would replace correct reinstall advice with a
# "just retry" that never succeeds — the same defect in the other
# direction.
if "client has been closed" in low and (
"import" in low or "transformers" in low or "autofeatureextractor" in low
):
return "MODEL_DOWNLOAD_INTERRUPTED"
if (
"could not import module" in low
or "autofeatureextractor" in low
@@ -307,6 +433,18 @@ def classify(reason: str) -> str:
# certifi list doesn't (a different failure mode from #984's TCP-level
# "can't reach the host at all"). Requires "ssl" plus a handshake/cert-
# verify marker so a generic connection error isn't mislabelled.
# Check the dropped-connection shape FIRST: its text also contains "ssl",
# and the handshake branch below would otherwise claim it and hand out
# proxy/certifi advice for a socket that was simply cut (#1301).
# Requires an "ssl" marker as well: "unexpected EOF" on its own is a phrase
# a parser or an unrelated transport can also produce, and stamping the TLS
# taxonomy on those would hand out VPN/proxy advice for something else
# entirely. The OpenSSL text always carries the marker.
if "ssl" in low and (
"unexpected_eof_while_reading" in low
or "eof occurred in violation of protocol" in low
):
return "TLS_CONNECTION_DROPPED"
if "ssl" in low and (
"handshake" in low
or "certificate verify failed" in low
@@ -314,8 +452,17 @@ def classify(reason: str) -> str:
or "sslcertverificationerror" in low
):
return "SSL_HANDSHAKE_FAILURE"
if ("huggingface" in low or "hf_token" in low or "401" in low or "unauthorized" in low) and (
"token" in low or "auth" in low or "401" in low or "unauthorized" in low
# A bare 401 is no longer sufficient evidence on its own. It used to
# satisfy BOTH halves of this condition, which made the `and` vacuous, so
# any message containing those three digits — a path, a byte count, an id
# — classified as an auth failure by itself. CI hit it when pytest's
# numbered temp directory reached `pytest-401` and an audio-save error
# came back as "set a valid HF_TOKEN". A real 401 always arrives with the
# word Unauthorized or an HF URL beside it, so requiring that costs
# nothing and closes the class.
has_401 = _HTTP_401.search(low) is not None
if ("huggingface" in low or "hf_token" in low or "unauthorized" in low) and (
"token" in low or "auth" in low or has_401 or "unauthorized" in low
):
return "HF_AUTH_FAILED"
# #874: a model download that failed because the CONFIGURED HF mirror is
@@ -331,6 +478,13 @@ def classify(reason: str) -> str:
# Broken pipe" still classifies as a network blip.
if "unsupported url" in low or "no video formats" in low or "is not a valid url" in low:
return "UNSUPPORTED_VIDEO_URL"
# #1254: reported as intermittent — the same URL failed, then succeeded on
# a retry. That is a per-player-client format set, not real DRM, so the
# download path now escalates the client the way it does for a 403. If
# every client still says DRM, the video genuinely can't be fetched and the
# user needs to hear that rather than retry a fourth time.
if "drm protected" in low or "drm-protected" in low:
return "VIDEO_DRM_PROTECTED"
if (
"broken pipe" in low
or "connection reset" in low
@@ -371,6 +525,80 @@ def classify(reason: str) -> str:
return ""
# ffmpeg/ffprobe write a version banner to STDERR on every invocation, before
# doing any work. When a command fails we capture that stderr and it becomes
# the error message — so the user is shown the build configuration of their
# ffmpeg instead of what went wrong (#1309: a dub extract failure reported as
# "extract: ffmpeg version N-125781-gacf6b520c1-20260727 Copyright (c) …").
#
# The real diagnosis is always AFTER the banner. Strip the boilerplate so the
# message starts at the first line that is actually about this run.
_FFMPEG_BANNER_START = re.compile(r"^\s*(ffmpeg|ffprobe)\s+version\s", re.IGNORECASE)
_FFMPEG_BANNER_CONT = re.compile(
r"^\s+(built with|configuration:|lib[a-z]+\s+\d)", re.IGNORECASE
)
def strip_ffmpeg_banner(text: Optional[str]) -> str:
"""Drop ffmpeg's version/configuration preamble from a captured stderr.
Returns the text unchanged when no banner is present, and importantly
when stripping would leave nothing: a message that is *only* a banner is
unhelpful, but an empty one is worse.
"""
if not text:
return text or ""
lines = str(text).splitlines()
kept, in_banner = [], False
for line in lines:
if _FFMPEG_BANNER_START.match(line):
in_banner = True
continue
if in_banner:
# The banner is the version line plus its indented continuation
# block; the first line that is not part of that ends it.
if not line.strip() or _FFMPEG_BANNER_CONT.match(line):
continue
in_banner = False
kept.append(line)
out = "\n".join(kept).strip()
return out or str(text).strip()
# Terminal escape sequences from a captured stderr (#1344). Command-line tools
# colourize their output whenever they think a terminal is attached, and the
# frozen backend's pipes are enough for several of them to decide that: yt-dlp
# reported "download: ^[[0;31mERROR:^[[0m [youtube] …" verbatim into the UI,
# where the escapes render as literal mojibake in the middle of the sentence.
#
# Stripping matters for more than looks: this text is also the input to the
# pattern matching below. ``classify()``'s patterns happen to match past the
# escape and survive it, but ``strip_ffmpeg_banner`` is anchored to the start
# of a line — a leading colour code hides "ffmpeg version " from it and
# quietly reinstates #1309 for any ffmpeg build that colours its output. Hence
# the strip runs FIRST, at the choke point every failure passes through.
#
# CSI (colour, cursor movement) plus OSC (window title / hyperlink), which
# yt-dlp and ffmpeg both emit and which carries its own terminator.
_ANSI_ESCAPE = re.compile(
r"\x1B(?:\][^\x07\x1B]*(?:\x07|\x1B\\)|[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"
)
def strip_ansi(text: Optional[str]) -> str:
"""Remove terminal escape sequences from a captured stderr/stdout.
Returns the text unchanged when there are none, and empty when the input
was nothing BUT escapes. Emptying it is correct rather than lossy:
``build_failure`` already falls back to the exception class name for an
empty ``reason``, and a class name is a real answer where a run of escape
bytes copied into ``reason``/``error``/``detail`` is not (CodeRabbit).
"""
if not text:
return text or ""
return _ANSI_ESCAPE.sub("", str(text))
def sanitize(text: Optional[str]) -> str:
"""Redact secrets and strip the home path from a string.
@@ -433,7 +661,7 @@ def _env_summary() -> str:
def diagnostic(*, reason: str, error_class: str, stage: str) -> str:
"""A sanitized, copy-paste-friendly diagnostic block for a failed job."""
block = (
"OmniVoice diagnostic\n"
"VoiceStudio diagnostic\n"
"--------------------\n"
f"Stage: {stage}\n"
f"Error: {error_class}\n"
@@ -468,6 +696,137 @@ _DOWNLOAD_CONTEXT_MARKERS = (
)
#: The media binaries a dependency may shell out to by bare name.
_MEDIA_TOOLS = ("ffprobe", "ffmpeg")
def _is_missing_media_tool(low: str) -> bool:
"""True when the failure is "the OS could not find ffprobe/ffmpeg" (#1256).
Deliberately narrow: it must name the binary *as the thing that could not
be found*, so a perfectly ordinary "ffmpeg failed: no such file or
directory: /path/to/input.wav" — a missing INPUT, an entirely different
problem is not handed the "install the media engine" remedy.
"""
if "no such file or directory" not in low and "[winerror 2]" not in low:
return False
# The name must appear UNQUALIFIED — quoted with no directory part, which
# is how a bare-name spawn fails. A path that merely ends in the tool's
# name ('/tmp/ffmpeg', '~/Movies/my-ffmpeg-export.mp4') is a missing FILE,
# an entirely different problem that must not get the "repair your media
# engine" remedy (#1256 review).
return any(
f"'{tool}'" in low or f'"{tool}"' in low
for tool in _MEDIA_TOOLS
)
#: transformers' two ways of saying "this snapshot has no weight shard".
#:
#: They come from different code paths and share no wording, so matching only
#: the first — which both the classifier and model_manager's self-heal used to
#: do — silently missed half the class (#1273):
#:
#: hub load: "<repo> does not appear to have a file named
#: pytorch_model.bin or model.safetensors"
#: local dir: "Error no file named model.safetensors, or pytorch_model.bin,
#: found in directory <path>"
#:
#: The second is what a load of a *subfolder* inside a cached snapshot raises
#: (e.g. `audio_tokenizer/`), which is exactly where an interrupted download
#: leaves a repo half-written. Both mean the same thing and both are repaired
#: the same way, so both must classify and both must trigger the heal.
_INCOMPLETE_CACHE_PHRASES = (
"does not appear to have a file named",
# Matched as two fragments: the file list between them varies with the
# transformers version and the requested weight variant.
("error no file named", "found in directory"),
)
def is_incomplete_cache_message(text: str) -> bool:
"""True when *text* is transformers reporting a snapshot with no weights.
Takes an already-lowercased string. Shared by :func:`classify` and
model_manager's cache self-heal so the healer and the error message can
never disagree about what an interrupted download looks like.
"""
low = str(text).lower()
for phrase in _INCOMPLETE_CACHE_PHRASES:
if isinstance(phrase, tuple):
if all(part in low for part in phrase):
return True
elif phrase in low:
return True
return False
#: The weight file is PRESENT but its bytes are not a valid tensor file.
#:
#: The sibling of ``_INCOMPLETE_CACHE_PHRASES`` above, and the half that had no
#: handling at all (#1406). An interrupted download that stops mid-file, an
#: antivirus that truncates a shard, or a proxy that saved an HTML error page
#: under the shard's name all leave a file transformers is happy to open — so
#: the "does not appear to have a file named …" check passes — and safetensors
#: then fails parsing its 8-byte header-length prefix. The user got a raw 500
#: ("Error while deserializing header: header too large") on every generation,
#: from voice design and gallery previews alike, with nothing actionable in it
#: and no repair attempted, because the recovery ladder is reached only through
#: the *missing*-weights signature.
#:
#: Matched on wording rather than exception type on purpose: safetensors raises
#: ``SafetensorError`` from a Rust extension, torch raises ``UnpicklingError``
#: or a bare ``RuntimeError`` for the same condition in a ``.bin``, and none of
#: them are ``OSError`` — the type is the least stable thing about this class.
_CORRUPT_MODEL_FILE_PHRASES = (
# safetensors (Rust): header length prefix is larger than the file, or the
# declared metadata runs past the end of the buffer.
"error while deserializing header",
"headertoolarge",
"metadataincompletebuffer",
"invalidheaderdeserialization",
"deserializing header",
# torch.load on a truncated / non-pickle .bin shard. "invalid load key" is
# unambiguous — only the pickle reader says it. "unexpected end of file" is
# not: zipfile, tarfile, gzip and several parsers share the wording, and any
# of them can surface inside a model-load chain, where a false positive
# would force a multi-GB re-download of an undamaged cache (CodeRabbit). It
# therefore needs a weight-file co-marker, like the entry below.
"invalid load key",
("unexpected end of file", "safetensors"),
("unexpected end of file", "pytorch_model"),
("unexpected end of file", "checkpoint"),
("failed to load", "checkpoint", "corrupt"),
# transformers wraps a JSONDecodeError from a truncated/HTML config file
# with this stable, path-bearing message (#1437).
("config file", "not a valid json file"),
)
def is_corrupt_model_file_message(text: str) -> bool:
"""True when a downloaded model weight or config file cannot be parsed.
Distinct from :func:`is_incomplete_cache_message`, which means the file is
absent. Here it exists and its bytes are wrong a different repair (force
a re-download; a resume would trust the bad blob) and a different thing to
tell the user. Shared by :func:`classify` and model_manager's self-heal so
the healer and the message can never disagree.
"""
low = str(text).lower()
for phrase in _CORRUPT_MODEL_FILE_PHRASES:
if isinstance(phrase, tuple):
if all(part in low for part in phrase):
return True
elif phrase in low:
return True
return False
def is_corrupt_weights_message(text: str) -> bool:
"""Backward-compatible name for :func:`is_corrupt_model_file_message`."""
return is_corrupt_model_file_message(text)
def is_os_write_refusal(reason: Optional[str]) -> bool:
"""True when *reason* looks like the OS refusing a file operation (a full
or removed drive, a read-only folder, an antivirus/cloud-sync lock) rather
@@ -504,6 +863,33 @@ def describe_path_target(path: str) -> str:
return "; ".join(facts)
#: Exception types whose ``str()`` is a bare VALUE rather than a sentence, so
#: showing it alone tells the user nothing about what went wrong.
#: ``str(KeyError("mgw39lx3"))`` is ``"'mgw39lx3'"`` — the repr of the key.
_VALUE_ONLY_STR_EXCEPTIONS = (KeyError,)
def describe_exception(exc: BaseException) -> str:
"""``str(exc)`` in a form a human can act on.
#1252/#1253: a dub ingest failed with the toast ``ingest: 'mgw39lx3'`` and
nothing else the entire user-facing reason was the repr of a dict key,
because ``str(KeyError)`` does not mention that a lookup failed, or that it
was an exception at all. The reporter's ``'mgw39lx3'`` was their own job id
reflected back at them with no context.
Naming the class is the floor, not the goal: a failure that reaches here at
all is one nobody wrote a message for. It keeps the report diagnosable
instead of cryptic while the specific path gets its own handling.
"""
text = str(exc).strip()
if not text:
return type(exc).__name__
if isinstance(exc, _VALUE_ONLY_STR_EXCEPTIONS):
return f"{type(exc).__name__}: {text}"
return text
def build_failure(
exc_or_msg: Any,
*,
@@ -517,11 +903,18 @@ def build_failure(
"""
if isinstance(exc_or_msg, BaseException):
error_class = type(exc_or_msg).__name__
raw = str(exc_or_msg).strip() or error_class
raw = describe_exception(exc_or_msg)
else:
error_class = "Error"
raw = str(exc_or_msg).strip() or "Unknown failure"
# Normalise the captured text before anything reads it — both the
# user-facing reason and classify() below, which would otherwise be
# matching against a build configuration string (#1309) or against
# terminal colour codes (#1344).
# ANSI first: the banner matcher anchors on "ffmpeg version " at the start
# of a line, which a leading colour code would push out of reach.
raw = strip_ffmpeg_banner(strip_ansi(raw))
reason = sanitize(raw) or error_class
docs_topic = classify(raw)
# HF_MIRROR_UNREACHABLE's hint is dynamic (it names the configured mirror)
+24
View File
@@ -0,0 +1,24 @@
"""Reliable file deletion for user-visible destructive operations."""
from __future__ import annotations
import os
class FileCleanupError(OSError):
"""A requested file exists but could not be removed."""
def unlink_if_present(path: str | os.PathLike[str]) -> bool:
"""Delete *path*, returning whether it existed.
Missing files make delete operations idempotent. Other failures must reach
the caller so it cannot discard the only record from which cleanup can be
retried.
"""
try:
os.unlink(path)
except FileNotFoundError:
return False
except OSError as exc:
raise FileCleanupError("file cleanup failed") from exc
return True
+5 -5
View File
@@ -4,7 +4,7 @@ Issue #54 — When a user downloads the .dmg/.zip outside the Mac App Store,
macOS Gatekeeper attaches an `com.apple.quarantine` extended attribute to the
.app bundle. On first launch, that attribute propagates to every binary inside
the bundle, and Gatekeeper refuses to exec any of them. The user sees
"OmniVoice Studio can't be opened" or the app crashes silently both bad UX.
"VoiceStudio can't be opened" or the app crashes silently both bad UX.
The fix is documented in Phase 1 Wave 2's `docs/install/macos-gatekeeper.md`
(shipped by Plan 01-02 Wave 2 scope) and reachable via the React
@@ -14,7 +14,7 @@ quarantined.
This module is **detection only**. It never runs `xattr -cr` to clear the
quarantine the app cannot fix its own quarantine state (the very process
calling `xattr -cr` would itself be quarantined and refused exec). Surface
the workaround to the user; they run `xattr -cr /Applications/OmniVoice\\ Studio.app`
the workaround to the user; they run `xattr -cr /Applications/VoiceStudio\\ Studio.app`
from Terminal once, and the next launch succeeds.
Plan: 01-03-PLAN.md (Phase 1 Wave 3)
@@ -38,11 +38,11 @@ ERROR_CLASS = "GATEKEEPER_QUARANTINE"
def _resolve_app_bundle_path() -> Optional[str]:
"""Walk up from ``sys.executable`` until we find a ``.app`` directory.
When OmniVoice is launched from an installed .app bundle, Python runs from
`OmniVoice Studio.app/Contents/Resources/.venv/bin/python` (or similar),
When VoiceStudio is launched from an installed .app bundle, Python runs from
`VoiceStudio.app/Contents/Resources/.venv/bin/python` (or similar),
so ``sys.executable`` is several levels below the bundle root.
Returns the .app path (e.g. ``/Applications/OmniVoice Studio.app``) or
Returns the .app path (e.g. ``/Applications/VoiceStudio.app``) or
``None`` for dev runs where we are not inside a bundle.
"""
if sys.platform != "darwin":
+90
View File
@@ -0,0 +1,90 @@
"""RFC 6266 ``Content-Disposition`` construction.
#1262: exporting a voice profile whose name isn't spelled in Latin letters
returned a 500:
'latin-1' codec can't encode characters in position 22-25:
ordinal not in range(256)
``attachment; filename="`` is exactly 22 characters, so positions 22-25 were
the first four characters of the user's own profile name. HTTP header values
are latin-1 by definition, and every download endpoint built the header by
f-string interpolation, so any name outside latin-1 Chinese, Japanese,
Korean, Greek, Cyrillic, Hebrew, emoji crashed the request.
The sanitisers in front of those f-strings did not catch it because they all
filtered with ``str.isalnum()``, which is **True for every alphabetic script**,
not just ASCII. ``"我的声音".isalnum()`` is ``True``. They were removing
punctuation and passing the exact characters that break the header.
`content_disposition` is the one construction site: an ASCII-safe
``filename=`` that any client can read, plus the RFC 5987 ``filename*=`` that
gives modern browsers the user's real name back, correctly encoded.
"""
from __future__ import annotations
import re
import unicodedata
from urllib.parse import quote
__all__ = ["ascii_filename", "content_disposition"]
#: Characters Windows forbids in a filename, plus the quoting/injection risks
#: (`"` and `\` end the quoted-string; CR/LF would split the header).
_UNSAFE = re.compile(r'[\\/:*?"<>|\r\n\t]')
def _fold(text: str) -> str:
"""One filename part, reduced to safe ASCII."""
folded = unicodedata.normalize("NFKD", text)
# Drop the combining marks NFKD split off, keeping the base letters.
folded = "".join(c for c in folded if not unicodedata.combining(c))
folded = folded.encode("ascii", "ignore").decode("ascii")
return _UNSAFE.sub("_", folded).strip()
def ascii_filename(filename: str, fallback: str = "download") -> str:
"""A latin-1-safe rendering of *filename* for the legacy ``filename=``.
Accented Latin is folded to its base letters (``Sébastien``
``Sebastien``) rather than deleted, since that stays readable. Scripts with
no ASCII form (CJK, Cyrillic, Hebrew, emoji) have no meaningful fold, so
they drop out and *fallback* carries the name the ``filename*`` parameter
is what actually preserves those, and every browser released this decade
prefers it.
"""
raw = filename or ""
# Split the extension off FIRST: folding runs per-part so a name that is
# entirely non-ASCII loses its stem without also losing ".ovsvoice", which
# is what tells the OS (and the user) what the file actually is.
stem, dot, suffix = raw.rpartition(".")
if not dot:
stem, suffix = raw, ""
stem, suffix = _fold(stem), _fold(suffix)
if not stem.strip("_ ."):
stem = fallback
return f"{stem}.{suffix}" if suffix else stem
def content_disposition(
filename: str,
*,
disposition: str = "attachment",
fallback: str = "download",
) -> str:
"""A ``Content-Disposition`` value that is safe for ANY filename (#1262).
Emits both forms per RFC 6266 §4.3: ``filename=`` for the lowest common
denominator and ``filename*=UTF-8''`` for the real name. Clients that
understand the extended form ignore the plain one, so the user gets
``我的声音.ovsvoice`` while nothing anywhere has to encode it as latin-1.
"""
# The fallback is a caller-supplied string that lands in the header
# verbatim whenever the real name folds away entirely, so it gets the same
# treatment as the name itself — otherwise a non-ASCII or quote/CRLF
# fallback walks straight past every guard here (#1262 review).
safe_fallback = _fold(fallback) or "download"
safe = ascii_filename(filename, fallback=safe_fallback)
encoded = quote(_UNSAFE.sub("_", filename or safe_fallback), safe="")
return f"{disposition}; filename=\"{safe}\"; filename*=UTF-8''{encoded}"
+2 -2
View File
@@ -7,7 +7,7 @@ Owned by Plan 01-02 (checker B-6 resolution). Read by:
Resolution order (highest lowest):
1. `frontend/src-tauri/tauri.conf.json` `plugins.updater.endpoints[0]`
this points at the desktop app fork (e.g. github.com/debpalash/
OmniVoice-Studio), which is where docs deeplinks should resolve.
VoiceStudio), which is where docs deeplinks should resolve.
2. `pyproject.toml [project.urls].Repository` fallback to the upstream
model repo URL when the Tauri config is unreadable.
@@ -94,7 +94,7 @@ def _resolve() -> str:
return (
_from_tauri()
or _from_pyproject()
or "https://github.com/debpalash/OmniVoice-Studio"
or "https://github.com/debpalash/VoiceStudio"
)
+48
View File
@@ -0,0 +1,48 @@
"""Safe rendering for untrusted values at the logging seam."""
from __future__ import annotations
import unicodedata
DEFAULT_LOG_VALUE_LIMIT = 512
def log_safe(value: object, *, limit: int = DEFAULT_LOG_VALUE_LIMIT) -> str:
"""Return one bounded log-line value with controls rendered visibly.
User filenames, identifiers, subprocess diagnostics, and exception text
can contain CR/LF or terminal controls. Rendering instead of deleting them
preserves forensic meaning without allowing forged records or ANSI output.
"""
try:
raw = (
f"{type(value).__name__}: {value}"
if isinstance(value, BaseException)
else str(value)
)
except Exception: # pragma: no cover - pathological __str__, safe fallback
raw = f"<{type(value).__name__}>"
# Keep the canonical CR/LF transformation explicit: besides documenting
# the primary invariant, static analyzers recognize this as the sanitizer
# before the broader Unicode-control rendering below.
raw = raw.replace("\r", r"\r").replace("\n", r"\n")
limit = max(8, int(limit))
parts: list[str] = []
used = 0
truncated = False
for char in raw:
code = ord(char)
if char == "\t":
rendered = r"\t"
elif unicodedata.category(char).startswith("C") or char in {"\u2028", "\u2029"}:
rendered = f"\\x{code:02x}" if code <= 0xFF else f"\\u{code:04x}"
else:
rendered = char
if used + len(rendered) > limit - 1:
truncated = True
break
parts.append(rendered)
used += len(rendered)
if truncated:
parts.append("")
return "".join(parts)
+3 -3
View File
@@ -19,18 +19,18 @@ _DEMO_AUDIO = os.path.join(
)
DEMO_PROFILE_ID = "demo0001"
DEMO_PROFILE_NAME = "OmniVoice Demo Voice"
DEMO_PROFILE_NAME = "VoiceStudio Demo Voice"
# Must match the actual spoken content of backend/assets/samples/demo_voice.wav.
# Regenerated by scripts/build_demos.sh — update both files in lockstep.
DEMO_REF_TEXT = (
"Hi, I'm the OmniVoice demo voice. Everything you hear me say from now on "
"Hi, I'm the VoiceStudio demo voice. Everything you hear me say from now on "
"was synthesized on your own machine. No cloud, no account, just you and "
"the model."
)
_DEMO_DESCRIPTION = (
"A neutral reference voice bundled with OmniVoice. Clone it to hear how "
"A neutral reference voice bundled with VoiceStudio. Clone it to hear how "
"the engine sounds on your machine, then replace it with your own "
"recording when you're ready."
)
+95
View File
@@ -0,0 +1,95 @@
"""Consume one-shot host paths authorized by the native Tauri process.
The web API never accepts a filesystem destination or executable path. Tauri
validates the user's native IPC request, writes a private capability file, and
only the unguessable capability token crosses loopback HTTP.
"""
from __future__ import annotations
import json
import os
import re
import secrets
import stat
from core.config import DATA_DIR
_TOKEN_RE = re.compile(r"[0-9a-f]{64}\Z")
_KINDS = {
"models_dir",
"ffmpeg",
"ffprobe",
"dub_export",
"soni_input",
"soni_output_dir",
}
_AUTH_DIR = os.path.join(DATA_DIR, ".path-authorizations")
class PathAuthorizationError(ValueError):
pass
def consume(token: str, expected_kind: str) -> str:
"""Consume and return a single Tauri-authorized path.
Capability files are one-shot and opened without following symlinks. Tauri
writes them into the app's private data directory; source/Docker callers
cannot mint a valid token through HTTP.
"""
if expected_kind not in _KINDS or not _TOKEN_RE.fullmatch(token or ""):
raise PathAuthorizationError("Invalid or expired desktop authorization")
root = _AUTH_DIR
candidate = None
try:
for entry in os.scandir(root):
if not _TOKEN_RE.fullmatch(entry.name.removesuffix(".json")):
continue
if not entry.is_file(follow_symlinks=False):
continue
try:
with open(entry.path, "r", encoding="utf-8") as handle:
probe = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError):
continue # Ignore corrupt/stale capabilities; they authorize nothing.
if isinstance(probe, dict) and secrets.compare_digest(
str(probe.get("token", "")), token
):
candidate = entry.path
break
if candidate is None:
raise OSError("capability not found")
claimed = os.path.join(root, f".consuming-{os.getpid()}-{secrets.token_hex(16)}")
os.replace(candidate, claimed)
except OSError as exc:
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
flags = os.O_RDONLY
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
fd = os.open(claimed, flags)
except OSError as exc:
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size > 16_384:
raise PathAuthorizationError("Invalid desktop authorization")
with os.fdopen(fd, "r", encoding="utf-8") as handle:
fd = -1
payload = json.load(handle)
except (OSError, UnicodeError, json.JSONDecodeError, TypeError) as exc:
raise PathAuthorizationError("Invalid desktop authorization") from exc
finally:
if fd >= 0:
os.close(fd)
try:
os.unlink(claimed)
except OSError:
pass # Best-effort cleanup; the random claimed name cannot be reused.
if not isinstance(payload, dict):
raise PathAuthorizationError("Invalid desktop authorization")
if not secrets.compare_digest(str(payload.get("token", "")), token):
raise PathAuthorizationError("Invalid desktop authorization")
if payload.get("kind") != expected_kind or not isinstance(payload.get("path"), str):
raise PathAuthorizationError("Desktop authorization does not match this setting")
return payload["path"]
+79
View File
@@ -0,0 +1,79 @@
"""Filesystem trust-boundary helpers.
Paths persisted in SQLite are still untrusted: older clients and imported job
records can contain absolute paths, traversal components, or symlink escapes.
Keep containment checks at the filesystem boundary instead of relying on the
route or database layer to have sanitised a value earlier.
"""
from __future__ import annotations
import ntpath
import os
from pathlib import Path
class UnsafePath(ValueError):
"""Raised when a path crosses its allowed filesystem boundary."""
def safe_filename(value: object) -> str:
"""Return a portable bare filename, rejecting traversal and drive paths."""
name = str(value or "")
if (
not name
or name in {".", ".."}
or "/" in name
or "\\" in name
or os.path.isabs(name)
or ntpath.isabs(name)
or ntpath.basename(name) != name
):
raise UnsafePath("expected a bare filename")
return name
def resolve_within(root: os.PathLike[str] | str, value: os.PathLike[str] | str) -> Path:
"""Resolve *value* beneath *root*, rejecting traversal and symlink escapes.
Absolute values are accepted only when they already resolve inside the
root. This preserves existing database rows, which historically stored a
mixture of relative filenames and absolute job-artifact paths.
"""
raw = os.fspath(value) if value is not None else ""
if not isinstance(raw, str) or not raw:
raise UnsafePath("path is empty")
# Treat both separator families as structural on every host. Otherwise a
# Windows traversal string is an innocent-looking filename when validated
# on Linux (and can become dangerous after persisted data is moved).
if os.sep != "\\" and ("\\" in raw or bool(ntpath.splitdrive(raw)[0])):
raise UnsafePath("path uses a foreign separator or drive")
root_path = Path(root).expanduser().resolve(strict=False)
root_text = str(root_path)
if os.path.isabs(raw):
prefix = root_text.rstrip(os.sep) + os.sep
if not os.path.normcase(raw).startswith(os.path.normcase(prefix)):
raise UnsafePath("path escapes its allowed root")
raw = raw[len(prefix):]
# Rebuild from individually sanitized basenames. Besides making the
# containment proof explicit to static analysis, this rejects empty,
# dot, parent, drive, and separator-bearing components before Path sees
# any persisted/request-derived string.
parts = raw.split(os.sep)
clean_parts: list[str] = []
for part in parts:
clean = os.path.basename(part)
if not clean or clean in {".", ".."} or clean != part:
raise UnsafePath("path contains an unsafe component")
clean_parts.append(clean)
candidate = root_path.joinpath(*clean_parts)
resolved = candidate.resolve(strict=False)
try:
if os.path.commonpath((str(root_path), str(resolved))) != str(root_path):
raise UnsafePath("path escapes its allowed root")
except ValueError as exc: # Windows paths on different drives
raise UnsafePath("path escapes its allowed root") from exc
if resolved == root_path:
raise UnsafePath("path must name an item below its allowed root")
return resolved
+2 -2
View File
@@ -6,7 +6,7 @@ that users can pick from a strip in the Voice Design tab.
Why taxonomy tokens and not prose
=================================
OmniVoice's ``model.generate(instruct=...)`` runs every instruct string
VoiceStudio's ``model.generate(instruct=...)`` runs every instruct string
through ``_resolve_instruct``, which splits on commas and validates each
item against a fixed vocabulary (e.g. ``male``, ``middle-aged``,
``moderate pitch``, ``british accent``). Anything outside that vocabulary
@@ -134,7 +134,7 @@ PERSONALITIES = [
"ChineseDialect": "Auto",
},
"script": (
"Thank you for calling OmniVoice support. I can see your account "
"Thank you for calling VoiceStudio support. I can see your account "
"here. Let's get this sorted out together."
),
"preview_url": "/demo_audio/voice_design/demo_voice_design_indian_support_agent.wav",
+113
View File
@@ -0,0 +1,113 @@
"""Data-independent error metadata safe for API and streaming responses."""
from __future__ import annotations
import logging
from typing import Any
_PROVIDER_DETAILS = {
"auth": "Authentication failed. Check the provider API key.",
"not_found": "Provider or model not found. Check the model and Base URL.",
"rate_limit": "The provider rate limit was reached. Try again later.",
"network": "The provider could not be reached. Check the connection and Base URL.",
"config": "Configure the provider before using it.",
"error": "The provider request failed. Try again.",
}
def provider_failure(kind: str) -> dict[str, str]:
"""Return a stable provider error class and remediation message."""
safe_kind = kind if kind in _PROVIDER_DETAILS else "error"
return {"kind": safe_kind, "detail": _PROVIDER_DETAILS[safe_kind]}
def stream_failure(code: str) -> dict[str, object]:
"""Return stable stream metadata selected only from an internal code."""
failures: dict[str, dict[str, object]] = {
"generation_busy": {
"code": "generation_busy",
"detail": "Generation capacity is busy. Try again shortly.",
"retryable": True,
},
"invalid_request": {
"code": "invalid_request",
"detail": "The generation request could not be processed.",
"retryable": False,
},
"generation_failed": {
"code": "generation_failed",
"detail": "Generation failed. Check the selected engine and try again.",
"retryable": True,
},
"transcription_failed": {
"code": "transcription_failed",
"detail": "Transcription failed. Check the selected ASR engine and try again.",
"retryable": True,
},
"transcription_memory": {
"code": "transcription_memory",
"detail": (
"Transcription ran out of GPU memory. Close other GPU apps or "
"Flush models, then try again; VoiceStudio will use CPU when "
"the remaining GPU memory is too low."
),
"retryable": True,
},
"transcription_timeout": {
"code": "transcription_timeout",
"detail": (
"Transcription timed out while the backend is running. Increase "
"OMNIVOICE_TRANSCRIBE_CHUNK_TIMEOUT_S or select the "
"faster-whisper-isolated engine, then try again."
),
"retryable": True,
},
}
return dict(failures.get(code, failures["generation_failed"]))
def public_failure(
logger: logging.Logger,
log_message: str,
error: BaseException | object,
*,
response: str,
traceback: bool = False,
) -> str:
"""Log fixed failure metadata and return a fixed public failure message.
``response`` must be authored by VoiceStudio, never derived from ``error``.
The helper intentionally does not attempt to redact exception text: a
deny-list cannot cover arbitrary secrets, paths, source lines or nested
tracebacks.
"""
del traceback
error_class = type(error).__name__ if isinstance(error, BaseException) else "Failure"
logger.error("%s (class=%s; details withheld)", log_message, error_class)
return response
def public_engine_health(ok: bool, diagnostic: Any) -> str:
"""Map an engine-owned health diagnostic to a stable response message."""
del diagnostic
return "Healthy" if ok else "Engine unavailable; check the backend log for details."
def public_exception_response(error: BaseException, *, fallback: str) -> dict[str, str]:
"""Return fixed remediation selected by a stable failure taxonomy.
Classification may inspect the private diagnostic locally, but response
values come exclusively from VoiceStudio-owned constants. No substring of
``error`` is copied into the payload.
"""
from core.failure import classify, public_hint_for_topic
try:
topic = classify(str(error))
hint = public_hint_for_topic(topic)
except Exception:
topic = ""
hint = ""
payload = {"detail": f"{fallback} {hint}".strip()}
if topic and hint:
payload.update({"docs_topic": topic, "hint": hint})
return payload
+9 -6
View File
@@ -6,7 +6,7 @@ surfaces as an honest "the backend crashed (exit code X)" notice. But that
forensics lives in the SHELL a ``bun run dev`` browser session, a Docker
deployment, or a LAN-share client has no shell, so when the backend process
dies there (OOM kill, shutdown race, native abort) the only user-visible
signal is "Can't reach the local OmniVoice backend" with ZERO diagnostics
signal is "Can't reach the local VoiceStudio backend" with ZERO diagnostics
exactly the shape of issue #1164.
This module is the backend-side equivalent, watcher-free by design (a dead
@@ -236,19 +236,22 @@ def touch_activity(kind: str, detail: str | None = None) -> None:
logger.debug("run-sentinel activity touch failed (non-fatal)", exc_info=True)
def clear_sentinel() -> None:
def clear_sentinel() -> bool:
"""Clean shutdown — remove the sentinel so the next startup knows this
run ended on purpose. Only removes a sentinel this run wrote."""
with _lock:
if not _state["owns"]:
return
return True
try:
os.remove(SENTINEL_PATH)
except FileNotFoundError:
pass
# Idempotent success: there is no stale marker to misread.
logger.debug("run sentinel already absent during clear")
except Exception:
logger.debug("run-sentinel clear failed (non-fatal)", exc_info=True)
logger.warning("run-sentinel clear failed; retaining ownership for retry")
return False
_state["owns"] = False
return True
# ── Unclean-shutdown detection + crash records ─────────────────────────────
@@ -336,7 +339,7 @@ def detect_unclean_shutdown(now: float | None = None) -> Optional[dict]:
if pid and pid != os.getpid() and _previous_run_alive(
pid, sentinel.get("started_at")
):
# Another OmniVoice backend is live against this DATA_DIR
# Another VoiceStudio backend is live against this DATA_DIR
# (second --reload worker, second container on a shared
# volume). Its sentinel is not evidence of anything — leave
# it alone and don't write ours over it.
+10 -10
View File
@@ -1,6 +1,6 @@
"""Privacy scrubber for diagnostic text that may leave the machine.
Everything OmniVoice renders into a bug report or diagnostic dump goes
Everything VoiceStudio renders into a bug report or diagnostic dump goes
through ``scrub_text()`` before it can reach a prefilled GitHub Issues URL
(the only outbound path see CLAUDE.md Capability 2). The scrubber is the
backend twin of ``frontend/src/utils/bugReport.js``'s ``scrubText`` and
@@ -91,9 +91,9 @@ def _env_secret_values() -> list[str]:
def scrub_text(text: str | None) -> str:
"""Return ``text`` with secrets and home paths redacted.
Never raises scrubbing failure must not block a bug report, and a
partially-scrubbed string is still better than an unscrubbed one, so
each pass is independent.
Never raises. If a redaction pass itself fails, return only the redaction
marker: diagnostic detail is less important than keeping credentials and
private paths out of a report.
"""
if not text:
return "" if text is None else str(text)
@@ -104,18 +104,18 @@ def scrub_text(text: str | None) -> str:
for val in _env_secret_values():
s = s.replace(val, REDACTED)
except Exception:
pass
return REDACTED
# 2. Credential-shaped substrings + URL query secrets.
for pat in _TOKEN_PATTERNS:
try:
s = pat.sub(REDACTED, s)
except Exception:
pass
return REDACTED
try:
s = _URL_SECRET_RE.sub(lambda m: m.group(1) + REDACTED, s)
except Exception:
pass
return REDACTED
# 3. This process's real home dir (covers symlinked/nonstandard homes
# the generic patterns miss), then the per-OS shapes. Boundary-aware so
@@ -126,12 +126,12 @@ def scrub_text(text: str | None) -> str:
if home and home not in ("/", "~"):
s = re.sub(re.escape(home) + r"(?=[/\\\s\"']|$)", "~", s)
except Exception:
pass
return REDACTED
for pat in _HOME_PATTERNS:
try:
s = pat.sub("~", s)
except Exception:
pass
return REDACTED
return s
@@ -153,5 +153,5 @@ def scrub_provider_error(detail: object, api_key: str | None = None) -> str:
if api_key and api_key != "local" and len(api_key) >= _MIN_SECRET_LEN:
s = s.replace(api_key, REDACTED)
except Exception:
pass
return REDACTED
return scrub_text(s)
+1 -1
View File
@@ -24,7 +24,7 @@ from pathlib import Path
# tests/test_app_version.py::test_all_version_files_in_lockstep and bumped by
# release.yml's version-bump job, so it stays equal to
# pyproject/tauri.conf/Cargo/package.json.
_FALLBACK_VERSION = "0.4.0"
_FALLBACK_VERSION = "0.4.2"
def _fallback_version() -> str:
+34 -1
View File
@@ -26,6 +26,21 @@ import traceback
MAX_FRAME_BYTES = 64 * 1024 * 1024
_model = None
# The parent's cuDNN 8 preload lives in the parent PROCESS, and this is a child
# with its own clean import path — so without this, an install whose side-loaded
# cuDNN 8 makes the in-process engine work still had the isolated engine die on
# every transcribe (#1371). Crash isolation turns that into a failed job rather
# than a dead backend, which is why it went unnoticed: it fails quietly forever.
# core.cudnn8 is stdlib-only, so importing it does not undo the cheap-startup
# rule the module docstring sets out (unlike the heavy `services` package).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
try:
from core.cudnn8 import preload as _preload_cudnn8
_preload_cudnn8()
except Exception: # noqa: BLE001 — best-effort; never block the ready handshake
pass
def _send(stream, obj):
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
@@ -114,7 +129,25 @@ def _transcribe(audio_path, word_timestamps):
def main() -> int:
stdin, stdout = sys.stdin.buffer, sys.stdout.buffer
stdin = sys.stdin.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428).
#
# This sidecar's protocol is length-prefixed binary on stdout, but it is
# not the only thing writing there: the libraries it loads print freely to
# fd 1 — wetextprocessing's FST logs, tqdm bars, native prints from torch
# and ONNX runtime. Those bytes interleave with frames, and the parent
# then reads four bytes of log text as a length prefix, which is how a
# generation dies with `OSError: frame too large: 1044258881` (that number
# is ASCII). Worse, it desyncs the stream, so every later request on the
# same sidecar reads stale bytes and no retry can recover.
#
# Duplicating fd 1 first keeps a clean channel only this module can write
# to; redirecting fd 1 to fd 2 sends the library noise to stderr, which
# the parent already drains into its own log (through the HF-token
# redactor). Nothing is lost and the frame stream cannot be corrupted.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
_send(stdout, {"op": "ready", "engine": "faster-whisper-isolated"})
while True:
try:
+18 -1
View File
@@ -78,7 +78,24 @@ def _silence_pcm_b64(sample_rate: int) -> tuple[str, int]:
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428).
#
# This sidecar's protocol is length-prefixed binary on stdout, but it is
# not the only thing writing there: the libraries it loads print freely to
# fd 1 — wetextprocessing's FST logs, tqdm bars, native prints from torch
# and ONNX runtime. Those bytes interleave with frames, and the parent
# then reads four bytes of log text as a length prefix, which is how a
# generation dies with `OSError: frame too large: 1044258881` (that number
# is ASCII). Worse, it desyncs the stream, so every later request on the
# same sidecar reads stale bytes and no retry can recover.
#
# Duplicating fd 1 first keeps a clean channel only this module can write
# to; redirecting fd 1 to fd 2 sends the library noise to stderr, which
# the parent already drains into its own log (through the HF-token
# redactor). Nothing is lost and the frame stream cannot be corrupted.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
test_mode = os.environ.get("OMNIVOICE_ECHO_TEST_MODE") == "1"
crash_after_one = os.environ.get("OMNIVOICE_ECHO_CRASH") == "1"
+155
View File
@@ -0,0 +1,155 @@
"""Bounded "can this interpreter import the engine?" probe (#1414).
Every subprocess engine resolves its sidecar interpreter the same way: try the
venv the user pointed at, then the one we bootstrapped, and confirm each
candidate by spawning it and importing the engine package. The confirmation
matters a candidate that exists but cannot import is worse than no candidate,
because the failure would otherwise surface 30 s later as an opaque sidecar
handshake timeout.
The bug this module exists to fix is what the probe did with *slowness*.
``import indextts.infer_v2`` (and every peer) pulls in torch and transformers:
seconds when the OS page cache is warm, but tens of seconds on a cold first
run, a spinning disk, a network share, or Windows with real-time AV scanning
every DLL it touches. The per-engine bounds were 10 s (IndexTTS) and 15 s (the
rest), and a bound that elapsed was treated as a **negative**: the candidate
was dropped exactly as if importing had raised. So on the machines where the
import is slowest, a perfectly good ``OMNIVOICE_INDEXTTS_DIR`` install was
silently discarded and the user got "IndexTTS-2 is not installed" or a 500 on
the first generation after launch reported with a precise root cause in
#1414. A negative result is also not memoised, so every retry paid the whole
probe again and failed the same way.
A timeout is not evidence of breakage. It is the absence of evidence, so this
returns three answers rather than two:
``"yes"``
The interpreter imported the engine. Use it.
``"no"``
It ran and failed a real, proven breakage (missing package, bad ABI, an
OS-level refusal to execute). Move on to the next candidate.
``"unproven"``
It did not finish in time. Nothing was learned. The caller keeps it as a
fallback and prefers any candidate that proves itself, but uses it rather
than declaring the engine missing: a genuinely broken venv then fails at
the sidecar handshake with a real error, which is a far better outcome
than a confident lie about the user's install.
The bound is also generous now and tunable, because the only thing it still
buys is how long we are willing to wait for a *negative*.
"""
from __future__ import annotations
import math
import os
import subprocess
from pathlib import Path
from typing import Literal
#: "yes" = imported, "no" = proven broken, "unproven" = did not finish in time.
ProbeResult = Literal["yes", "no", "unproven"]
#: Seconds to wait for the probe. Sized for a cold torch import on a slow
#: disk rather than for a warm one — overshooting only delays the fall-through
#: to the next candidate, while undershooting discards a working install.
DEFAULT_PROBE_TIMEOUT_S = 60.0
#: Applies to every engine.
_GLOBAL_ENV = "OMNIVOICE_ENGINE_IMPORT_PROBE_TIMEOUT_S"
def probe_timeout_s(engine: str) -> float:
"""Resolve the probe bound: per-engine env → global env → default.
``engine`` is the short slug used in the env var, e.g. ``"indextts"``
``OMNIVOICE_INDEXTTS_IMPORT_PROBE_TIMEOUT_S``. A malformed, non-positive
or non-finite value is ignored rather than honoured: disabling the bound
would let one wedged candidate hang engine resolution forever, and
``inf`` in particular parses cleanly but makes ``subprocess.run`` raise
``OverflowError``, breaking this module's never-raises contract from the
one place a user could reach it (CodeRabbit).
"""
for name in (
f"OMNIVOICE_{engine.upper()}_IMPORT_PROBE_TIMEOUT_S",
_GLOBAL_ENV,
):
raw = (os.environ.get(name) or "").strip()
if not raw:
continue
try:
value = float(raw)
except ValueError:
continue
if value > 0 and math.isfinite(value):
return value
return DEFAULT_PROBE_TIMEOUT_S
def log_safe(path: "Path | str") -> str:
"""A candidate interpreter path, safe to log.
Every one of these paths runs through the user's home directory, so it
carries their account name and these lines land in backend.log, which
goes into diagnostic bundles and prefilled bug reports (CWE-532;
CodeRabbit). ``core.failure.sanitize`` is the same redaction every other
surfaced string in the app gets.
Falls back to the basename rather than the full path if sanitizing is
unavailable: a probe must not fail because logging could not redact.
"""
try:
from core.failure import sanitize
return sanitize(str(path))
except Exception: # noqa: BLE001 — never fail a probe over a log line
return os.path.basename(str(path)) or "<path>"
def venv_can_import(
python_path: "Path | str",
import_stmt: str,
*,
engine: str,
logger,
) -> ProbeResult:
"""Spawn ``python_path`` and run ``import_stmt``; see module docstring.
Never raises: engine resolution must not die because a candidate
interpreter misbehaved.
"""
timeout = probe_timeout_s(engine)
try:
proc = subprocess.run(
[str(python_path), "-c", import_stmt],
capture_output=True,
timeout=timeout,
)
except subprocess.TimeoutExpired:
# Deliberately NOT "no" — see the module docstring. Warning, not debug:
# this is the branch that used to silently discard a working install,
# and a user hitting it needs the env var named where they will find it.
logger.warning(
"%s import probe for %s did not finish within %.0fs — keeping it "
"as a fallback rather than treating slow as broken (#1414). Raise "
"OMNIVOICE_%s_IMPORT_PROBE_TIMEOUT_S if this host is simply slow.",
engine, log_safe(python_path), timeout, engine.upper(),
)
return "unproven"
except OSError as exc:
# The OS refused to execute it at all: not a slow import, a bad path
# or a binary this machine cannot run. Proven negative.
logger.debug(
"%s import probe could not run %s: %s",
engine, log_safe(python_path), exc,
)
return "no"
if proc.returncode != 0:
logger.debug(
"%s import probe non-zero for %s: %s",
engine,
log_safe(python_path),
proc.stderr.decode("utf-8", errors="replace")[:200],
)
return "no"
return "yes"
+2 -2
View File
@@ -79,13 +79,13 @@ class Confucius4Backend(SubprocessBackend):
return False, (
"Confucius4-TTS venv not found. Set OMNIVOICE_CONFUCIUS4_TTS_DIR "
"to your Confucius4-TTS clone (the directory containing "
"requirements.txt) and restart OmniVoice. CUDA GPU recommended "
"requirements.txt) and restart VoiceStudio. CUDA GPU recommended "
"(CPU works but is slow). See docs/engines/confucius4-tts.md."
)
if not CONFUCIUS4_SIDECAR_SCRIPT.exists():
return False, (
"Confucius4-TTS sidecar script missing at "
f"{CONFUCIUS4_SIDECAR_SCRIPT} — reinstall OmniVoice."
f"{CONFUCIUS4_SIDECAR_SCRIPT} — reinstall VoiceStudio."
)
return True, "ok"
+53 -29
View File
@@ -30,6 +30,8 @@ import sys
from pathlib import Path
from typing import Optional
from engines._venv_probe import ProbeResult, log_safe, venv_can_import
logger = logging.getLogger("omnivoice.confucius4.bootstrap")
#: Absolute path to the sidecar entrypoint.
@@ -56,7 +58,6 @@ _IMPORT_PROBE = "confuciustts"
_resolved_python: Optional[Path] = None
_IMPORT_PROBE_TIMEOUT_S = 15
_UV_VENV_TIMEOUT_S = 120
_UV_PIP_INSTALL_TIMEOUT_S = 1800
@@ -82,25 +83,52 @@ def resolve_confucius4_venv() -> Path:
clone_dir = os.environ.get(_CLONE_DIR_ENV)
# A candidate whose probe ran out of time (#1414): preferred over
# bootstrapping or declaring the engine missing, but only after every
# candidate has had its chance to prove itself outright.
unproven: Optional[Path] = None
if clone_dir:
cand = _venv_python_path(Path(clone_dir) / ".venv")
if cand.is_file() and _venv_can_import(cand):
logger.info("Confucius4 venv resolved from %s: %s", _CLONE_DIR_ENV, cand)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import(cand)
if verdict == "yes":
logger.info("Confucius4 venv resolved from %s: %s", _CLONE_DIR_ENV, cand)
_resolved_python = cand
return cand
if verdict == "unproven":
unproven = cand
cand = _venv_python_path(_ENGINES_VENV_DIR)
if cand.is_file() and _venv_can_import(cand):
logger.info("Confucius4 venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import(cand)
if verdict == "yes":
logger.info("Confucius4 venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if verdict == "unproven" and unproven is None:
unproven = cand
if unproven is not None:
# Nothing proved itself, but something plausible is installed. Use
# it: a venv that really is broken fails the sidecar handshake with
# a real error, which beats reinstalling over the top of a working
# install or telling the user their engine isn't there.
logger.warning(
"Confucius4 venv %s could not be verified in time; using it "
"anyway rather than treating a slow import as a missing "
"install (#1414).",
log_safe(unproven),
)
_resolved_python = unproven
return unproven
if not clone_dir:
raise RuntimeError(
"Confucius4-TTS is not installed. Set the "
f"{_CLONE_DIR_ENV} environment variable to your Confucius4-TTS clone "
"(the directory that contains requirements.txt), then restart "
"OmniVoice. See docs/engines/confucius4-tts.md."
"VoiceStudio. See docs/engines/confucius4-tts.md."
)
cand = _bootstrap_engines_venv(Path(clone_dir))
@@ -132,23 +160,16 @@ def _import_probe_code() -> str:
return f"import {_IMPORT_PROBE}"
def _venv_can_import(python_path: Path) -> bool:
"""Spawn the candidate python and verify ``import confuciustts`` works."""
try:
proc = subprocess.run(
[str(python_path), "-c", _import_probe_code()],
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("Confucius4 import probe failed for %s: %s", python_path, exc)
return False
if proc.returncode != 0:
logger.debug(
"Confucius4 import probe non-zero for %s: %s",
python_path, proc.stderr.decode("utf-8", errors="replace")[:200],
)
return False
return True
def _venv_can_import(python_path: Path) -> ProbeResult:
"""Spawn the candidate python and verify the engine imports.
Tri-state "yes" / "no" / "unproven". See ``engines._venv_probe``:
a probe that runs out of time proves nothing, and treating that as
"no" is what discarded working user installs (#1414).
"""
return venv_can_import(
python_path, _import_probe_code(), engine="confucius4", logger=logger,
)
def _locate_uv() -> Optional[str]:
@@ -165,7 +186,7 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
raise RuntimeError(
"uv is required to bootstrap the Confucius4-TTS venv but was not "
"found on PATH (and OMNIVOICE_BUNDLED_UV was not set). Install uv "
"from https://docs.astral.sh/uv/ and re-launch OmniVoice."
"from https://docs.astral.sh/uv/ and re-launch VoiceStudio."
)
logger.info(
@@ -211,7 +232,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
"See docs/engines/confucius4-tts.md."
) from exc
if not _venv_can_import(python_path):
# Only a *proven* failure is fatal: a bootstrap that installed correctly
# and is merely slow to import must not be thrown away after spending
# minutes on the install (#1414).
if _venv_can_import(python_path) == "no":
raise RuntimeError(
f"Confucius4 bootstrap completed but `import {_IMPORT_PROBE}` still "
f"fails from {python_path}. Verify {clone_dir} is a valid clone. "
+24 -3
View File
@@ -128,8 +128,12 @@ def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
import numpy as np
arr = audio.detach().to("cpu").float().numpy() if hasattr(audio, "detach") else np.asarray(audio)
arr = np.asarray(arr, dtype=np.float32).squeeze()
if arr.ndim > 1:
arr = arr.mean(axis=0)
while arr.ndim > 1:
# Downmix along whichever axis is the channel axis. Hardcoded to axis 0
# this averaged across TIME for a channels-last (N, 2) array -- every
# output sample became the mean of two neighbouring samples, which is
# not a downmix but a destroyed waveform. (#1328)
arr = arr.mean(axis=int(np.argmin(arr.shape)))
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
@@ -172,7 +176,24 @@ def _handle_synthesize(msg: dict, stdout) -> None:
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428).
#
# This sidecar's protocol is length-prefixed binary on stdout, but it is
# not the only thing writing there: the libraries it loads print freely to
# fd 1 — wetextprocessing's FST logs, tqdm bars, native prints from torch
# and ONNX runtime. Those bytes interleave with frames, and the parent
# then reads four bytes of log text as a length prefix, which is how a
# generation dies with `OSError: frame too large: 1044258881` (that number
# is ASCII). Worse, it desyncs the stream, so every later request on the
# same sidecar reads stale bytes and no retry can recover.
#
# Duplicating fd 1 first keeps a clean channel only this module can write
# to; redirecting fd 1 to fd 2 sends the library noise to stderr, which
# the parent already drains into its own log (through the HF-token
# redactor). Nothing is lost and the frame stream cannot be corrupted.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
_send(stdout, {
"op": "ready",
+3 -3
View File
@@ -84,7 +84,7 @@ class DotsTTSBackend(SubprocessBackend):
if sys.platform == "win32":
return False, (
"dots.tts is not supported on Windows — upstream targets "
"Linux and macOS only. Run OmniVoice under WSL2, or use a "
"Linux and macOS only. Run VoiceStudio under WSL2, or use a "
"Linux/macOS host. See docs/engines/dots-tts.md."
)
@@ -101,13 +101,13 @@ class DotsTTSBackend(SubprocessBackend):
return False, (
"dots.tts venv not found. Set OMNIVOICE_DOTS_TTS_DIR to your "
"dots.tts clone (the directory containing pyproject.toml) and "
"restart OmniVoice. CUDA or CPU only (no MPS). See "
"restart VoiceStudio. CUDA or CPU only (no MPS). See "
"docs/engines/dots-tts.md for the full install walk-through."
)
if not DOTS_TTS_SIDECAR_SCRIPT.exists():
return False, (
"dots.tts sidecar script missing at "
f"{DOTS_TTS_SIDECAR_SCRIPT} — reinstall OmniVoice."
f"{DOTS_TTS_SIDECAR_SCRIPT} — reinstall VoiceStudio."
)
return True, "ok (CUDA when present, else CPU)"
+55 -34
View File
@@ -30,6 +30,8 @@ import sys
from pathlib import Path
from typing import Optional
from engines._venv_probe import ProbeResult, log_safe, venv_can_import
logger = logging.getLogger("omnivoice.dots_tts.bootstrap")
#: Absolute path to the sidecar entrypoint.
@@ -54,7 +56,6 @@ _CLONE_DIR_ENV: str = "OMNIVOICE_DOTS_TTS_DIR"
#: Per-process resolution cache. Cleared by :func:`invalidate` for tests.
_resolved_python: Optional[Path] = None
_IMPORT_PROBE_TIMEOUT_S = 15
_UV_VENV_TIMEOUT_S = 120
_UV_PIP_INSTALL_TIMEOUT_S = 1800
@@ -87,22 +88,49 @@ def resolve_dots_tts_venv() -> Path:
clone_dir = os.environ.get(_CLONE_DIR_ENV)
# A candidate whose probe ran out of time (#1414): preferred over
# bootstrapping or declaring the engine missing, but only after every
# candidate has had its chance to prove itself outright.
unproven: Optional[Path] = None
# Probe 1 — user's clone-level venv.
if clone_dir:
cand = _venv_python_path(Path(clone_dir) / ".venv")
if cand.is_file() and _venv_can_import_dots(cand):
logger.info(
"dots.tts venv resolved from %s: %s", _CLONE_DIR_ENV, cand,
)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import_dots(cand)
if verdict == "yes":
logger.info(
"dots.tts venv resolved from %s: %s", _CLONE_DIR_ENV, cand,
)
_resolved_python = cand
return cand
if verdict == "unproven":
unproven = cand
# Probe 2 — this package's own venv.
cand = _venv_python_path(_ENGINES_VENV_DIR)
if cand.is_file() and _venv_can_import_dots(cand):
logger.info("dots.tts venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import_dots(cand)
if verdict == "yes":
logger.info("dots.tts venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if verdict == "unproven" and unproven is None:
unproven = cand
if unproven is not None:
# Nothing proved itself, but something plausible is installed. Use
# it: a venv that really is broken fails the sidecar handshake with
# a real error, which beats reinstalling over the top of a working
# install or telling the user their engine isn't there.
logger.warning(
"dots.tts venv %s could not be verified in time; using it "
"anyway rather than treating a slow import as a missing "
"install (#1414).",
log_safe(unproven),
)
_resolved_python = unproven
return unproven
# Probe 3 — bootstrap.
if not clone_dir:
@@ -110,7 +138,7 @@ def resolve_dots_tts_venv() -> Path:
"dots.tts is not installed. Set the "
f"{_CLONE_DIR_ENV} environment variable to your dots.tts clone "
"(the directory that contains pyproject.toml and constraints/), "
"then restart OmniVoice. See docs/engines/dots-tts.md for the "
"then restart VoiceStudio. See docs/engines/dots-tts.md for the "
"full install walk-through."
)
@@ -137,26 +165,16 @@ def _probe_paths() -> list[Path]:
return out
def _venv_can_import_dots(python_path: Path) -> bool:
"""Spawn the candidate python and verify ``import dots_tts.runtime`` works.
Bounded by ``_IMPORT_PROBE_TIMEOUT_S``. False on any failure."""
try:
proc = subprocess.run(
[str(python_path), "-c", "import dots_tts.runtime"],
capture_output=True,
timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("dots.tts import probe failed for %s: %s", python_path, exc)
return False
if proc.returncode != 0:
logger.debug(
"dots.tts import probe non-zero for %s: %s",
python_path,
proc.stderr.decode("utf-8", errors="replace")[:200],
)
return False
return True
def _venv_can_import_dots(python_path: Path) -> ProbeResult:
"""Spawn the candidate python and verify the engine imports.
Tri-state "yes" / "no" / "unproven". See ``engines._venv_probe``:
a probe that runs out of time proves nothing, and treating that as
"no" is what discarded working user installs (#1414).
"""
return venv_can_import(
python_path, "import dots_tts.runtime", engine="dots_tts", logger=logger,
)
def _locate_uv() -> Optional[str]:
@@ -174,7 +192,7 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
raise RuntimeError(
"uv is required to bootstrap the dots.tts venv but was not found "
"on PATH (and OMNIVOICE_BUNDLED_UV was not set). Install uv from "
"https://docs.astral.sh/uv/ and re-launch OmniVoice, or set "
"https://docs.astral.sh/uv/ and re-launch VoiceStudio, or set "
"OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary."
)
@@ -219,7 +237,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
"See docs/engines/dots-tts.md."
) from exc
if not _venv_can_import_dots(python_path):
# Only a *proven* failure is fatal: a bootstrap that installed correctly
# and is merely slow to import must not be thrown away after spending
# minutes on the install (#1414).
if _venv_can_import_dots(python_path) == "no":
raise RuntimeError(
"dots.tts bootstrap completed but `import dots_tts.runtime` still "
f"fails from {python_path}. Verify that {clone_dir} is a valid "
+24 -3
View File
@@ -137,8 +137,12 @@ def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
arr = audio.detach().to("cpu").float().numpy()
arr = np.asarray(arr, dtype=np.float32).squeeze()
if arr.ndim > 1:
arr = arr.mean(axis=0) # defensive downmix to mono
while arr.ndim > 1:
# Downmix along whichever axis is the channel axis. Hardcoded to axis 0
# this averaged across TIME for a channels-last (N, 2) array -- every
# output sample became the mean of two neighbouring samples, which is
# not a downmix but a destroyed waveform. (#1328)
arr = arr.mean(axis=int(np.argmin(arr.shape)))
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
@@ -205,7 +209,24 @@ def _handle_synthesize(msg: dict, stdout) -> None:
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428).
#
# This sidecar's protocol is length-prefixed binary on stdout, but it is
# not the only thing writing there: the libraries it loads print freely to
# fd 1 — wetextprocessing's FST logs, tqdm bars, native prints from torch
# and ONNX runtime. Those bytes interleave with frames, and the parent
# then reads four bytes of log text as a length prefix, which is how a
# generation dies with `OSError: frame too large: 1044258881` (that number
# is ASCII). Worse, it desyncs the stream, so every later request on the
# same sidecar reads stale bytes and no retry can recover.
#
# Duplicating fd 1 first keeps a clean channel only this module can write
# to; redirecting fd 1 to fd 2 sends the library noise to stderr, which
# the parent already drains into its own log (through the HF-token
# redactor). Nothing is lost and the frame stream cannot be corrupted.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
# Ready handshake fires BEFORE any heavy import.
_send(stdout, {
+81 -16
View File
@@ -1,7 +1,7 @@
"""IndexTTS-2 sidecar package (Phase 2 Plan 02-03).
"""IndexTTS 2.5/2 sidecar package (Phase 2 Plan 02-03).
IndexTTS-2 runs in its own subprocess + dedicated venv with
``transformers<5``, isolated from the OmniVoice parent process which
``transformers<5``, isolated from the VoiceStudio parent process which
pins ``transformers>=5.3``. Closes issue #42 — the canonical
``OffloadedCache`` ImportError driven by the transformers v4 v5
incompatibility by making the two libraries live in separate OS
@@ -28,6 +28,8 @@ packages. The parent only ever spawns it as a subprocess.
from __future__ import annotations
import logging
import os
import re
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
@@ -38,11 +40,56 @@ if TYPE_CHECKING:
logger = logging.getLogger("omnivoice.indextts")
_LANGUAGE_ALIASES = {
"zh": "zh",
"chinese": "zh",
"mandarin": "zh",
"en": "en",
"english": "en",
"ja": "ja",
"jp": "ja",
"japanese": "ja",
"es": "es",
"spanish": "es",
"español": "es",
"ar": "ar",
"arabic": "ar",
}
def _normalize_indextts25_language(value, text: str) -> str:
"""Map VoiceStudio locale labels to IndexTTS 2.5's required token."""
raw = str(value or "").strip().lower().replace("_", "-")
base = raw.split("-", 1)[0]
resolved = _LANGUAGE_ALIASES.get(raw) or _LANGUAGE_ALIASES.get(base)
if resolved:
return resolved
# Auto/empty requests still need an explicit 2.5 language. Script
# detection is deterministic and local; ambiguous Latin text defaults EN.
if re.search(r"[\u0600-\u06ff\u0750-\u077f]", text):
return "ar"
if re.search(r"[\u3040-\u30ff]", text):
return "ja"
if re.search(r"[\u3400-\u9fff]", text):
return "zh"
return "en"
def _duration_factor(text: str, language: str, duration: float) -> float:
"""Map VoiceStudio's absolute duration to IndexTTS 2.5's relative scale."""
from services.speech_rate import expected_duration
natural_s = expected_duration(text, language)
if natural_s <= 0:
return 1.0
return max(0.5, min(float(duration) / natural_s, 2.0))
class IndexTTS2Backend(SubprocessBackend):
"""IndexTTS2 (Bilibili) — runs in its own subprocess + dedicated venv.
"""IndexTTS 2.5 (Bilibili) — isolated subprocess with IndexTTS-2 fallback.
Plan 02-03 migrated IndexTTS off the in-process import path because
IndexTTS pins ``transformers<5`` while OmniVoice pins
IndexTTS pins ``transformers<5`` while VoiceStudio pins
``transformers>=5.3``. The two cannot share a Python interpreter
without one of them blowing up at import time (issue #42 — the
canonical ``OffloadedCache`` ImportError). Running IndexTTS in a
@@ -61,22 +108,22 @@ class IndexTTS2Backend(SubprocessBackend):
Installation (transparent to existing v0.2.7 users ENGINE-07)::
git clone https://github.com/index-tts/index-tts.git
git clone --branch indextts-2.5 https://github.com/index-tts/index-tts.git
cd index-tts && uv pip install -e . # NOT uv sync --all-extras
hf download IndexTeam/IndexTTS-2 --local-dir=checkpoints
hf download IndexTeam/IndexTTS-2.5 --local-dir=checkpoints
Set ``OMNIVOICE_INDEXTTS_DIR`` to the repo root. OmniVoice will
Set ``OMNIVOICE_INDEXTTS_DIR`` to the repo root. VoiceStudio will
create ``backend/engines/indextts/.venv`` lazily on first launch if
no venv exists yet the user's existing
``${OMNIVOICE_INDEXTTS_DIR}/.venv`` is preferred if present, so no
re-install is needed.
License: Custom (Bilibili) free for research/non-commercial.
Commercial use requires contacting indexspeech@bilibili.com.
License: bilibili Model Use License. A separate license is required
above the upstream 100M-MAU or RMB 1B annual-revenue thresholds.
"""
id = "indextts2"
display_name = "IndexTTS2 (emotion control, duration control, zero-shot)"
display_name = "IndexTTS 2.5 (multilingual emotion-controlled cloning)"
supports_voice_design = False # requires ref audio for timbre
supports_emotion = True # graded emo_vector / emo_text / emo_alpha (#1208)
_DEFAULT_SAMPLE_RATE = 24000
@@ -100,15 +147,15 @@ class IndexTTS2Backend(SubprocessBackend):
)
if not is_indextts_installed():
return False, (
"IndexTTS-2 venv not found. Set OMNIVOICE_INDEXTTS_DIR to "
"IndexTTS 2.5 venv not found. Set OMNIVOICE_INDEXTTS_DIR to "
"your IndexTTS clone (the directory containing checkpoints/) "
"and restart OmniVoice. See docs/engines/indextts.md for the "
"and restart VoiceStudio. See docs/engines/indextts.md for the "
"full install walk-through."
)
if not INDEXTTS_SIDECAR_SCRIPT.exists():
return False, (
"IndexTTS sidecar script missing at "
f"{INDEXTTS_SIDECAR_SCRIPT} — reinstall OmniVoice."
f"{INDEXTTS_SIDECAR_SCRIPT} — reinstall VoiceStudio."
)
return True, "ok"
@@ -131,8 +178,13 @@ class IndexTTS2Backend(SubprocessBackend):
@property
def supported_languages(self) -> list[str]:
# Primarily Chinese + English with multilingual prompt handling.
return ["zh", "en"]
configured = os.environ.get("OMNIVOICE_INDEXTTS_DIR")
if configured and not os.path.isfile(
os.path.join(configured, "indextts", "infer_v2_5.py")
):
# Preserve truthful metadata for user-managed IndexTTS-2 checkouts.
return ["zh", "en"]
return ["zh", "en", "ja", "es", "ar"]
# ── parent-side emotion / duration arbitration ─────────────────────
#
@@ -171,7 +223,8 @@ class IndexTTS2Backend(SubprocessBackend):
if description and not emo_text and not emo_vector and not emo_audio:
emo_text = description
forwarded: dict = {"ref_audio": ref_audio}
language = _normalize_indextts25_language(kw.get("language"), text)
forwarded: dict = {"ref_audio": ref_audio, "lang": language}
# Duration control — codec frame rate ≈ 21 Hz.
duration = kw.get("duration")
@@ -179,6 +232,18 @@ class IndexTTS2Backend(SubprocessBackend):
target_tokens = int(float(duration) * 21)
if target_tokens > 0:
forwarded["target_tokens"] = target_tokens
duration_factor = kw.get("duration_factor")
if duration_factor is not None:
forwarded["duration_factor"] = max(0.5, min(float(duration_factor), 2.0))
elif duration is not None:
# IndexTTS 2.5 replaced absolute semantic-token control with a
# relative duration factor. Use the same language-aware natural
# reading estimate as the dubbing fit planner so the public
# ``duration`` control remains effective on 2.5; the sidecar
# drops this factor when it detects a legacy IndexTTS-2 checkout.
forwarded["duration_factor"] = _duration_factor(
text, language, float(duration)
)
if (
emo_vector
+67 -46
View File
@@ -1,4 +1,4 @@
"""IndexTTS-2 venv probe + lazy bootstrap (Phase 2 Plan 02-03).
"""IndexTTS 2.5/2 venv probe + lazy bootstrap (Phase 2 Plan 02-03).
The parent process needs to know *which Python interpreter* to spawn the
IndexTTS sidecar under. This module owns that resolution. The probe runs
@@ -13,9 +13,9 @@ Probe order (Open Question #1 resolution from 02-RESEARCH.md):
Windows). Highest priority power users who already cloned
IndexTTS and ran ``uv pip install -e .`` get zero migration cost.
2. ``backend/engines/indextts/.venv/`` this package's own venv,
created by step 3 if needed. Survives across OmniVoice upgrades;
created by step 3 if needed. Survives across VoiceStudio upgrades;
the IndexTTS clone is referenced via ``uv pip install -e`` so
weights and code live in the user's clone, not under OmniVoice.
weights and code live in the user's clone, not under VoiceStudio.
3. Bootstrap: run ``uv venv`` then ``uv pip install -e
${OMNIVOICE_INDEXTTS_DIR}`` to populate step-2's venv. Requires
OMNIVOICE_INDEXTTS_DIR to be set (otherwise we don't know where
@@ -45,6 +45,8 @@ import subprocess
from pathlib import Path
from typing import Optional
from engines._venv_probe import ProbeResult, log_safe, venv_can_import
logger = logging.getLogger("omnivoice.indextts.bootstrap")
# Absolute path to the sidecar entrypoint. ``IndexTTS2Backend.sidecar_script``
@@ -68,10 +70,10 @@ def _uv_env() -> "dict[str, str] | None":
# Per-process resolution cache. Cleared by :func:`invalidate` for tests.
_resolved_python: Optional[Path] = None
# Timeouts. ``_venv_can_import_indextts`` is a bounded probe so we never
# hang waiting on a broken venv; the bootstrap install can take minutes
# on a cold cache (indextts pulls torch, transformers<5, etc.).
_IMPORT_PROBE_TIMEOUT_S = 10
# Timeouts. The import probe's bound lives in ``engines._venv_probe`` (shared
# with every other subprocess engine, and tunable per host); the bootstrap
# install can take minutes on a cold cache (indextts pulls torch,
# transformers<5, etc.).
_UV_VENV_TIMEOUT_S = 120
_UV_PIP_INSTALL_TIMEOUT_S = 900
@@ -113,31 +115,57 @@ def resolve_indextts_venv() -> Path:
if _resolved_python is not None:
return _resolved_python
# A candidate whose probe ran out of time (#1414). Preferred over
# bootstrapping or declaring the engine missing, but only once every
# candidate has had its chance to prove itself outright.
unproven: Optional[Path] = None
# Probe 1 — user's clone-level venv (highest priority for back-compat).
omv_dir = os.environ.get("OMNIVOICE_INDEXTTS_DIR")
if omv_dir:
cand = _venv_python_path(Path(omv_dir) / ".venv")
if cand.is_file() and _venv_can_import_indextts(cand):
logger.info(
"IndexTTS venv resolved from OMNIVOICE_INDEXTTS_DIR: %s", cand,
)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import_indextts(cand)
if verdict == "yes":
logger.info(
"IndexTTS venv resolved from OMNIVOICE_INDEXTTS_DIR: %s", cand,
)
_resolved_python = cand
return cand
if verdict == "unproven":
unproven = cand
# Probe 2 — this package's own venv.
cand = _venv_python_path(_ENGINES_VENV_DIR)
if cand.is_file() and _venv_can_import_indextts(cand):
logger.info("IndexTTS venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import_indextts(cand)
if verdict == "yes":
logger.info("IndexTTS venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if verdict == "unproven" and unproven is None:
unproven = cand
if unproven is not None:
# Nothing proved itself, but something plausible is installed. Use it:
# a venv that really is broken fails the sidecar handshake with a real
# error, which beats reinstalling over the top of a working install or
# telling the user their engine isn't there.
logger.warning(
"IndexTTS venv %s could not be verified in time; using it anyway "
"rather than treating a slow import as a missing install (#1414).",
log_safe(unproven),
)
_resolved_python = unproven
return unproven
# Probe 3 — bootstrap.
if not omv_dir:
raise RuntimeError(
"IndexTTS-2 is not installed. Set the OMNIVOICE_INDEXTTS_DIR "
"IndexTTS 2.5 is not installed. Set the OMNIVOICE_INDEXTTS_DIR "
"environment variable to your IndexTTS clone (the directory "
"that contains checkpoints/ and pyproject.toml), then restart "
"OmniVoice. See docs/engines/indextts.md for the full install "
"VoiceStudio. See docs/engines/indextts.md for the full install "
"walk-through."
)
@@ -171,30 +199,20 @@ def _probe_paths() -> list[Path]:
return out
def _venv_can_import_indextts(python_path: Path) -> bool:
"""Spawn the candidate python and verify ``import indextts.infer_v2`` works.
def _venv_can_import_indextts(python_path: Path) -> ProbeResult:
"""Verify IndexTTS 2.5, retaining user-managed IndexTTS-2 compatibility.
Bounded by ``_IMPORT_PROBE_TIMEOUT_S`` so a wedged venv never hangs
the parent. Returns False on any failure (non-zero exit, timeout,
OSError).
Tri-state "yes" / "no" / "unproven". See ``engines._venv_probe``: a
probe that runs out of time proves nothing, and treating that as "no"
is what discarded working OMNIVOICE_INDEXTTS_DIR installs (#1414).
"""
try:
proc = subprocess.run(
[str(python_path), "-c", "import indextts.infer_v2"],
capture_output=True,
timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("indextts import probe failed for %s: %s", python_path, exc)
return False
if proc.returncode != 0:
logger.debug(
"indextts import probe non-zero for %s: %s",
python_path,
proc.stderr.decode("utf-8", errors="replace")[:200],
)
return False
return True
probe = (
"try:\n import indextts.infer_v2_5\n"
"except ModuleNotFoundError as exc:\n"
" if exc.name != 'indextts.infer_v2_5': raise\n"
" import indextts.infer_v2\n"
)
return venv_can_import(python_path, probe, engine="indextts", logger=logger)
def _locate_uv() -> Optional[str]:
@@ -220,10 +238,10 @@ def _bootstrap_engines_venv(indextts_clone: Path) -> Path:
uv = _locate_uv()
if not uv:
raise RuntimeError(
"uv is required to bootstrap the IndexTTS-2 venv but was not "
"uv is required to bootstrap the IndexTTS 2.5 venv but was not "
"found on PATH (and the bundled uv path was not set via the "
"OMNIVOICE_BUNDLED_UV env var). Install uv from "
"https://docs.astral.sh/uv/ and re-launch OmniVoice, or set "
"https://docs.astral.sh/uv/ and re-launch VoiceStudio, or set "
"OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary."
)
@@ -266,10 +284,13 @@ def _bootstrap_engines_venv(indextts_clone: Path) -> Path:
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
if not _venv_can_import_indextts(python_path):
# Only a *proven* failure is fatal here: a bootstrap that installed
# correctly and is merely slow to import must not be thrown away after
# spending minutes on the install (#1414).
if _venv_can_import_indextts(python_path) == "no":
raise RuntimeError(
"IndexTTS bootstrap completed but `import indextts.infer_v2` "
f"still fails from {python_path}. Verify that "
"IndexTTS bootstrap completed but neither the 2.5 nor 2 inference "
f"module imports from {python_path}. Verify that "
f"{indextts_clone} is a valid IndexTTS clone (contains "
"pyproject.toml with the indextts package). See "
"docs/engines/indextts.md."
+96 -24
View File
@@ -1,8 +1,8 @@
"""IndexTTS-2 sidecar entry point (Phase 2 Plan 02-03).
"""IndexTTS 2.5/2 sidecar entry point (Phase 2 Plan 02-03).
Runs inside ``engines/indextts/.venv`` (or the user's existing
``${OMNIVOICE_INDEXTTS_DIR}/.venv``) with ``transformers<5``, isolated
from the OmniVoice parent process which pins ``transformers>=5.3``.
from the VoiceStudio parent process which pins ``transformers>=5.3``.
Closes issue #42 — the canonical ``OffloadedCache`` ImportError that
results from running both libraries inside one Python interpreter.
@@ -50,7 +50,7 @@ Op flow expected by the parent:
Restrictions:
* NO imports from ``backend.services``, ``backend.engines`` (other
than this package), or any OmniVoice parent code. The sidecar runs
than this package), or any VoiceStudio parent code. The sidecar runs
under a venv where those modules may not resolve.
* NO logging of ``os.environ`` contents or env-var values. Defense in
depth against accidental token-bytes-on-stderr (T-02-08); the
@@ -109,6 +109,8 @@ EMOTION_KWARGS_ALLOWLIST = frozenset({
"use_emo_text", # bool — set by parent when emo_text supplied
"use_random", # bool
"target_tokens", # int — duration control
"duration_factor", # float — IndexTTS 2.5 duration scaling
"lang", # IndexTTS 2.5 language token
})
@@ -144,6 +146,40 @@ def _recv(stream):
# Module-level singleton — populated on the first synthesize op and reused
# for every subsequent request in this sidecar's lifetime.
_model = None
_model_version = None
def _torch_bf16_supported() -> bool:
"""Return whether this sidecar can safely enable IndexTTS 2.5 BF16."""
try:
import torch
supported = getattr(torch.cuda, "is_bf16_supported", None)
return bool(torch.cuda.is_available() and supported and supported())
except Exception:
return False
def _model_init_kwargs(
repo_dir: str, *, version: str, reduced_precision: bool,
) -> dict:
"""Build version-specific constructor arguments for IndexTTS 2.5 or 2."""
model_dir = os.path.join(repo_dir, "checkpoints")
cfg_name = "config_v2_5.yaml" if version == "2.5" else "config.yaml"
kwargs = {
"cfg_path": os.path.join(model_dir, cfg_name),
"model_dir": model_dir,
"use_cuda_kernel": False,
"use_deepspeed": False,
}
if version == "2.5":
kwargs.update(
use_bf16=reduced_precision and _torch_bf16_supported(),
use_qwen_emo=True,
)
else:
kwargs["use_fp16"] = reduced_precision
return kwargs
def _load_model(stdout) -> object:
@@ -155,29 +191,32 @@ def _load_model(stdout) -> object:
in-flight synthesize op and continues the dispatch loop (the next
request retries the load).
"""
global _model
global _model, _model_version
if _model is not None:
return _model
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0})
# Imported lazily so a missing dep doesn't block the ready handshake.
from indextts.infer_v2 import IndexTTS2 # type: ignore[import-not-found]
# A user-managed IndexTTS-2 checkout remains supported; app-managed
# installs use the reviewed 2.5 branch and take this first path.
try:
from indextts.infer_v2_5 import IndexTTS2 # type: ignore[import-not-found]
_model_version = "2.5"
except ModuleNotFoundError as exc:
if exc.name != "indextts.infer_v2_5":
raise
from indextts.infer_v2 import IndexTTS2 # type: ignore[import-not-found,no-redef]
_model_version = "2"
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50})
repo_dir = os.environ.get("OMNIVOICE_INDEXTTS_DIR", ".")
cfg_path = os.path.join(repo_dir, "checkpoints", "config.yaml")
model_dir = os.path.join(repo_dir, "checkpoints")
use_fp16 = os.environ.get("OMNIVOICE_INDEXTTS_FP16", "1") == "1"
_model = IndexTTS2(
cfg_path=cfg_path,
model_dir=model_dir,
use_fp16=use_fp16,
use_cuda_kernel=False,
use_deepspeed=False,
reduced_precision = os.environ.get("OMNIVOICE_INDEXTTS_FP16", "1") == "1"
model_kw = _model_init_kwargs(
repo_dir, version=_model_version, reduced_precision=reduced_precision,
)
_model = IndexTTS2(**model_kw)
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100})
return _model
@@ -229,14 +268,7 @@ def _handle_synthesize(msg: dict, stdout) -> None:
# Build infer_kwargs by filtering through the allowlist. The parent
# has already done any vector-vs-audio-vs-text emotion priority
# arbitration; we just forward whichever keys it sent.
infer_kw: dict = {
"spk_audio_prompt": ref_audio,
"text": text,
"verbose": False,
}
for k, v in msg.items():
if k in EMOTION_KWARGS_ALLOWLIST and v is not None:
infer_kw[k] = v
infer_kw = _build_infer_kwargs(msg, ref_audio, is_v25=_model_version == "2.5")
# IndexTTS2.infer() writes to a file; we route through tempfile so
# cleanup is automatic on success and on exit.
@@ -263,12 +295,52 @@ def _handle_synthesize(msg: dict, stdout) -> None:
})
def _build_infer_kwargs(msg: dict, ref_audio: str, *, is_v25: bool) -> dict:
"""Translate the stable VoiceStudio wire payload to either upstream API."""
infer_kw: dict = {
"spk_audio_prompt": ref_audio,
"text": msg.get("text"),
"verbose": False,
}
for k, v in msg.items():
if k in EMOTION_KWARGS_ALLOWLIST and v is not None:
infer_kw[k] = v
if is_v25:
# 2.5 requires language and replaced exact AR target_tokens with an
# S2M duration factor. Dubbing's fit stage remains the exact timeline
# authority, so an obsolete target_tokens kwarg must not leak into the
# upstream transformers generate call.
infer_kw.pop("target_tokens", None)
infer_kw["lang"] = str(infer_kw.get("lang") or "en").lower()
else:
infer_kw.pop("lang", None)
infer_kw.pop("duration_factor", None)
return infer_kw
# ── main loop ─────────────────────────────────────────────────────────────
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428).
#
# This sidecar's protocol is length-prefixed binary on stdout, but it is
# not the only thing writing there: the libraries it loads print freely to
# fd 1 — wetextprocessing's FST logs, tqdm bars, native prints from torch
# and ONNX runtime. Those bytes interleave with frames, and the parent
# then reads four bytes of log text as a length prefix, which is how a
# generation dies with `OSError: frame too large: 1044258881` (that number
# is ASCII). Worse, it desyncs the stream, so every later request on the
# same sidecar reads stale bytes and no retry can recover.
#
# Duplicating fd 1 first keeps a clean channel only this module can write
# to; redirecting fd 1 to fd 2 sends the library noise to stderr, which
# the parent already drains into its own log (through the HF-token
# redactor). Nothing is lost and the frame stream cannot be corrupted.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
# The ready handshake fires BEFORE any heavy import. SubprocessBackend's
# SPAWN_READY_TIMEOUT_S is 30 s; we comfortably make that even on a
+2 -2
View File
@@ -111,13 +111,13 @@ class MossTTSV15Backend(SubprocessBackend):
return False, (
"MOSS-TTS-v1.5 venv not found. Set OMNIVOICE_MOSS_TTS_V15_DIR "
"to your MOSS-TTS clone (the directory containing pyproject.toml) "
"and restart OmniVoice. CUDA or CPU only (no MPS). See "
"and restart VoiceStudio. CUDA or CPU only (no MPS). See "
"docs/engines/moss-tts-v15.md for the full install walk-through."
)
if not MOSS_TTS_V15_SIDECAR_SCRIPT.exists():
return False, (
"MOSS-TTS-v1.5 sidecar script missing at "
f"{MOSS_TTS_V15_SIDECAR_SCRIPT} — reinstall OmniVoice."
f"{MOSS_TTS_V15_SIDECAR_SCRIPT} — reinstall VoiceStudio."
)
return True, "ok (CUDA when present, else CPU)"
+53 -39
View File
@@ -38,6 +38,8 @@ import sys
from pathlib import Path
from typing import Optional
from engines._venv_probe import ProbeResult, log_safe, venv_can_import
logger = logging.getLogger("omnivoice.moss_tts_v15.bootstrap")
#: Absolute path to the sidecar entrypoint. ``MossTTSV15Backend.sidecar_script``
@@ -67,7 +69,6 @@ _resolved_python: Optional[Path] = None
# Timeouts — bounded so a wedged venv never hangs the parent. The bootstrap
# install can take many minutes on a cold cache (MOSS pulls a CUDA torch
# build + transformers + an audio codec stack).
_IMPORT_PROBE_TIMEOUT_S = 15
_UV_VENV_TIMEOUT_S = 120
_UV_PIP_INSTALL_TIMEOUT_S = 1800
@@ -109,22 +110,49 @@ def resolve_moss_tts_v15_venv() -> Path:
clone_dir = os.environ.get(_CLONE_DIR_ENV)
# A candidate whose probe ran out of time (#1414): preferred over
# bootstrapping or declaring the engine missing, but only after every
# candidate has had its chance to prove itself outright.
unproven: Optional[Path] = None
# Probe 1 — user's clone-level venv (highest priority for back-compat).
if clone_dir:
cand = _venv_python_path(Path(clone_dir) / ".venv")
if cand.is_file() and _venv_can_import_moss(cand):
logger.info(
"MOSS-TTS-v1.5 venv resolved from %s: %s", _CLONE_DIR_ENV, cand,
)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import_moss(cand)
if verdict == "yes":
logger.info(
"MOSS-TTS-v1.5 venv resolved from %s: %s", _CLONE_DIR_ENV, cand,
)
_resolved_python = cand
return cand
if verdict == "unproven":
unproven = cand
# Probe 2 — this package's own venv.
cand = _venv_python_path(_ENGINES_VENV_DIR)
if cand.is_file() and _venv_can_import_moss(cand):
logger.info("MOSS-TTS-v1.5 venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if cand.is_file():
verdict = _venv_can_import_moss(cand)
if verdict == "yes":
logger.info("MOSS-TTS-v1.5 venv resolved from engines path: %s", cand)
_resolved_python = cand
return cand
if verdict == "unproven" and unproven is None:
unproven = cand
if unproven is not None:
# Nothing proved itself, but something plausible is installed. Use
# it: a venv that really is broken fails the sidecar handshake with
# a real error, which beats reinstalling over the top of a working
# install or telling the user their engine isn't there.
logger.warning(
"MOSS-TTS-v1.5 venv %s could not be verified in time; using it "
"anyway rather than treating a slow import as a missing "
"install (#1414).",
log_safe(unproven),
)
_resolved_python = unproven
return unproven
# Probe 3 — bootstrap.
if not clone_dir:
@@ -132,7 +160,7 @@ def resolve_moss_tts_v15_venv() -> Path:
"MOSS-TTS-v1.5 is not installed. Set the "
f"{_CLONE_DIR_ENV} environment variable to your MOSS-TTS clone "
"(the directory that contains pyproject.toml), then restart "
"OmniVoice. See docs/engines/moss-tts-v15.md for the full "
"VoiceStudio. See docs/engines/moss-tts-v15.md for the full "
"install walk-through."
)
@@ -165,33 +193,16 @@ def _probe_paths() -> list[Path]:
return out
def _venv_can_import_moss(python_path: Path) -> bool:
"""Spawn the candidate python and verify the MOSS stack imports.
def _venv_can_import_moss(python_path: Path) -> ProbeResult:
"""Spawn the candidate python and verify the engine imports.
MOSS-TTS-v1.5 loads via ``transformers`` + ``trust_remote_code`` (no
fixed top-level package to import), so the readiness signal is that the
venv has a working ``transformers`` + ``torch`` which only the
``[torch-runtime]`` install provides. Bounded by
``_IMPORT_PROBE_TIMEOUT_S`` so a wedged venv never hangs the parent.
Returns False on any failure (non-zero exit, timeout, OSError).
Tri-state "yes" / "no" / "unproven". See ``engines._venv_probe``:
a probe that runs out of time proves nothing, and treating that as
"no" is what discarded working user installs (#1414).
"""
try:
proc = subprocess.run(
[str(python_path), "-c", "import transformers, torch"],
capture_output=True,
timeout=_IMPORT_PROBE_TIMEOUT_S,
)
except (subprocess.TimeoutExpired, OSError) as exc:
logger.debug("moss-tts-v15 import probe failed for %s: %s", python_path, exc)
return False
if proc.returncode != 0:
logger.debug(
"moss-tts-v15 import probe non-zero for %s: %s",
python_path,
proc.stderr.decode("utf-8", errors="replace")[:200],
)
return False
return True
return venv_can_import(
python_path, "import transformers, torch", engine="moss_tts_v15", logger=logger,
)
def _locate_uv() -> Optional[str]:
@@ -220,7 +231,7 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
raise RuntimeError(
"uv is required to bootstrap the MOSS-TTS-v1.5 venv but was not "
"found on PATH (and OMNIVOICE_BUNDLED_UV was not set). Install uv "
"from https://docs.astral.sh/uv/ and re-launch OmniVoice, or set "
"from https://docs.astral.sh/uv/ and re-launch VoiceStudio, or set "
"OMNIVOICE_BUNDLED_UV to the absolute path of a uv binary."
)
@@ -265,7 +276,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
) from exc
if not _venv_can_import_moss(python_path):
# Only a *proven* failure is fatal: a bootstrap that installed correctly
# and is merely slow to import must not be thrown away after spending
# minutes on the install (#1414).
if _venv_can_import_moss(python_path) == "no":
raise RuntimeError(
"MOSS-TTS-v1.5 bootstrap completed but the transformers/torch "
f"import still fails from {python_path}. Verify that {clone_dir} "
+53 -6
View File
@@ -36,6 +36,7 @@ from __future__ import annotations
import base64
import json
import os
import re
import struct
import sys
import traceback
@@ -50,8 +51,30 @@ MAX_FRAME_BYTES = 64 * 1024 * 1024
#: the loaded model at synthesize time; this is the handshake default).
MOSS_SAMPLE_RATE = 24000
#: HF repo id for the weights, overridable for air-gapped / mirror installs.
#: Reviewed HF repo and immutable revision for the default remote-code model.
_DEFAULT_REPO = "OpenMOSS-Team/MOSS-TTS-v1.5"
_DEFAULT_REVISION = "cdd3b911b1585e3f2dbc7775ef10f9926f58850a"
_SHA = re.compile(r"[0-9a-f]{40}\Z")
def _model_source() -> tuple[str, str]:
"""Return a pinned model source; custom remote code requires two opt-ins."""
repo = os.environ.get("OMNIVOICE_MOSS_TTS_V15_MODEL", _DEFAULT_REPO)
if repo == _DEFAULT_REPO:
return repo, _DEFAULT_REVISION
unsafe = os.environ.get("OMNIVOICE_MOSS_TTS_V15_TRUST_REMOTE_CODE", "").lower()
revision = os.environ.get("OMNIVOICE_MOSS_TTS_V15_REVISION", "")
if unsafe not in {"1", "true", "yes", "on"}:
raise RuntimeError(
"A custom MOSS model contains executable remote code. Set "
"OMNIVOICE_MOSS_TTS_V15_TRUST_REMOTE_CODE=1 only after auditing it."
)
if not _SHA.fullmatch(revision):
raise RuntimeError(
"A custom MOSS model requires OMNIVOICE_MOSS_TTS_V15_REVISION="
"<40-character commit SHA>; branches and tags are mutable."
)
return repo, revision
#: ISO-639-1 → MOSS language name. MOSS's ``build_user_message`` takes a
#: language *name* ("French"), not a code. Unknown codes are omitted so the
@@ -130,14 +153,16 @@ def _load_model(stdout):
import torch
from transformers import AutoModel, AutoProcessor
repo = os.environ.get("OMNIVOICE_MOSS_TTS_V15_MODEL", _DEFAULT_REPO)
repo, revision = _model_source()
device = "cuda" if torch.cuda.is_available() else "cpu"
dtype = torch.bfloat16 if device == "cuda" else torch.float32
# "sdpa" works on CUDA + CPU and needs no extra dep. flash_attention_2
# (Ampere+ CUDA, optional flash-attn) is opt-in via env.
attn = os.environ.get("OMNIVOICE_MOSS_TTS_V15_ATTN", "sdpa")
processor = AutoProcessor.from_pretrained(repo, trust_remote_code=True)
processor = AutoProcessor.from_pretrained(
repo, revision=revision, trust_remote_code=True,
)
# The audio tokenizer is a separate sub-module that must be moved to the
# device independently (easy to miss — see upstream README).
processor.audio_tokenizer = processor.audio_tokenizer.to(device)
@@ -146,6 +171,7 @@ def _load_model(stdout):
model = AutoModel.from_pretrained(
repo,
revision=revision,
trust_remote_code=True,
attn_implementation=attn,
torch_dtype=dtype,
@@ -169,8 +195,12 @@ def _tensor_to_pcm_b64(audio, sample_rate: int) -> tuple[str, int, int]:
arr = audio.detach().to("cpu").float().numpy()
arr = np.asarray(arr, dtype=np.float32).squeeze()
if arr.ndim > 1:
arr = arr.mean(axis=0) # defensive downmix to mono
while arr.ndim > 1:
# Downmix along whichever axis is the channel axis. Hardcoded to axis 0
# this averaged across TIME for a channels-last (N, 2) array -- every
# output sample became the mean of two neighbouring samples, which is
# not a downmix but a destroyed waveform. (#1328)
arr = arr.mean(axis=int(np.argmin(arr.shape)))
arr = np.clip(arr, -1.0, 1.0)
pcm = (arr * 32767.0).astype(np.int16).tobytes()
return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0])
@@ -249,7 +279,24 @@ def _handle_synthesize(msg: dict, stdout) -> None:
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
# Frames go down a PRIVATE fd, and fd 1 is pointed at stderr (#1428).
#
# This sidecar's protocol is length-prefixed binary on stdout, but it is
# not the only thing writing there: the libraries it loads print freely to
# fd 1 — wetextprocessing's FST logs, tqdm bars, native prints from torch
# and ONNX runtime. Those bytes interleave with frames, and the parent
# then reads four bytes of log text as a length prefix, which is how a
# generation dies with `OSError: frame too large: 1044258881` (that number
# is ASCII). Worse, it desyncs the stream, so every later request on the
# same sidecar reads stale bytes and no retry can recover.
#
# Duplicating fd 1 first keeps a clean channel only this module can write
# to; redirecting fd 1 to fd 2 sends the library noise to stderr, which
# the parent already drains into its own log (through the HF-token
# redactor). Nothing is lost and the frame stream cannot be corrupted.
_frame_fd = os.dup(1)
os.dup2(2, 1)
stdout = os.fdopen(_frame_fd, "wb")
# Ready handshake fires BEFORE any heavy import — nothing above this line
# touches transformers/torch, so we make the 30 s spawn window even cold.
+4 -4
View File
@@ -12,8 +12,8 @@ isolated from the parent's Python process.
- **Runtime** (`omnivoice.cpp`, the C++ inference binary in `bin/`): MIT.
- **Audio codec** (Higgs Audio v2, embedded in the tokenizer GGUF): Apache-2.0.
The license chain is identical to the engine OmniVoice Studio already
ships in v0.2.7 (`OmniVoiceBackend`); this engine is the same model
The license chain is identical to the engine VoiceStudio already
ships in v0.2.7 (`VoiceStudioBackend`); this engine is the same model
under a different runtime.
## Sources
@@ -104,7 +104,7 @@ as the `.app` quarantine (issue #54).
The workaround is identical:
```
xattr -cr "/Applications/OmniVoice Studio.app"
xattr -cr "/Applications/VoiceStudio.app"
```
This clears the quarantine xattr recursively — including on the bundled
@@ -116,7 +116,7 @@ silently hanging on a Gatekeeper-killed spawn.
If the macOS Apple Silicon Metal build fails to materialize in Wave 1
(no published `buildmetal.sh` in `omnivoice.cpp` per Pitfall 1), the
GGUF engine is unavailable on Apple Silicon and the existing in-process
`OmniVoiceBackend` remains the cloning default on that platform — no
`VoiceStudioBackend` remains the cloning default on that platform — no
hard block, no error toast on launch.
## Smoke test
+53 -7
View File
@@ -51,6 +51,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
import os
import platform
import re
@@ -119,6 +120,48 @@ def _binary_path(slug: Optional[str] = None) -> Path:
return _REPO_ROOT / "bin" / name
def _generate_timeout_s() -> float:
"""Hard per-spawn timeout for the C++ binary, read from the env at call time.
``OMNIVOICE_GGUF_GENERATE_TIMEOUT_S`` overrides; the default sits ABOVE
the GPU-pool generate budget (``OMNIVOICE_GENERATE_TIMEOUT_S``, 300s
floor) on purpose the pool guard is the real, well-diagnosed deadline,
and this one only reaps a truly wedged C++ process. The old hardcoded
120s killed legitimate CPU-only generates mid-synthesis (#1348).
"""
raw = os.environ.get("OMNIVOICE_GGUF_GENERATE_TIMEOUT_S")
if raw:
try:
val = float(raw)
except ValueError:
val = None
# inf would disarm the wedge guard entirely; nan poisons max().
if val is not None and math.isfinite(val):
return max(1.0, val)
logger.warning(
"Ignoring invalid OMNIVOICE_GGUF_GENERATE_TIMEOUT_S=%r", raw
)
return 600.0
def _spawn_env() -> dict[str, str]:
"""Environment for spawning the binary, with ``bin/`` on the loader path.
A source-built binary can be dynamically linked against the ``libggml*``
shared libraries the build script now drops next to it; without the
loader path the spawn dies with exit 127 ``libggml.so.0: cannot open
shared object file`` (#1348). Windows resolves DLLs from the exe's own
directory already; Linux and macOS need it stated. Both variables are
set unconditionally each OS ignores the other's.
"""
env = os.environ.copy()
bin_dir = str(_binary_path().parent)
for var in ("LD_LIBRARY_PATH", "DYLD_FALLBACK_LIBRARY_PATH"):
prev = env.get(var)
env[var] = bin_dir if not prev else bin_dir + os.pathsep + prev
return env
def _binary_repair_hint() -> str:
"""One actionable sentence for a broken/placeholder GGUF binary (#1172).
@@ -129,7 +172,7 @@ def _binary_repair_hint() -> str:
return (
f"the bundled GGUF runtime is not usable on this machine — build it "
f"with `scripts/build-omnivoice-tts.sh --platform {_platform_slug()}`, "
f"reinstall OmniVoice Studio, or switch to the default in-process "
f"reinstall VoiceStudio, or switch to the default in-process "
f"OmniVoice engine (Settings → Engines)"
)
@@ -314,8 +357,6 @@ def _make_backend_class():
# 24 kHz mono Higgs Audio v2 — same as the in-process OmniVoice.
_SAMPLE_RATE = 24_000
# Default per-call timeout matches the SubprocessBackend contract.
_GENERATE_TIMEOUT_S = 120.0
# Quick probe at startup to confirm the binary spawns at all.
_PROBE_TIMEOUT_S = 5.0
@@ -364,7 +405,7 @@ def _make_backend_class():
f"zero-byte placeholders until a real binary is "
f"built — run `scripts/build-omnivoice-tts.sh "
f"--platform {_platform_slug()}`, reinstall "
f"OmniVoice Studio, or use the default in-process "
f"VoiceStudio, or use the default in-process "
f"OmniVoice engine (Settings → Engines)."
)
# Manifest-based SHA-256 verification (T-04-01).
@@ -384,7 +425,7 @@ def _make_backend_class():
return False, (
f"GGUF binary {bin_path.name} is quarantined by "
f"macOS Gatekeeper. Run:\n\n"
f" xattr -cr '/Applications/OmniVoice Studio.app'\n\n"
f" xattr -cr '/Applications/VoiceStudio.app'\n\n"
f"This clears the quarantine on the .app and its "
f"bundled binaries. See docs/install/macos.md."
)
@@ -518,6 +559,7 @@ def _make_backend_class():
capture_output=True,
timeout=timeout,
check=False,
env=_spawn_env(),
)
except FileNotFoundError:
raise
@@ -718,19 +760,23 @@ def _make_backend_class():
validate_executable,
)
validate_executable(Path(argv[0]), hint=_binary_repair_hint())
timeout_s = _generate_timeout_s()
try:
proc = subprocess.run(
argv,
input=stdin_text,
text=True,
capture_output=True,
timeout=self._GENERATE_TIMEOUT_S,
timeout=timeout_s,
check=False,
env=_spawn_env(),
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"GGUF subprocess timed out after "
f"{self._GENERATE_TIMEOUT_S:.0f}s (T-04-06)"
f"{timeout_s:.0f}s (T-04-06; raise "
f"OMNIVOICE_GGUF_GENERATE_TIMEOUT_S if this host is "
f"genuinely that slow)"
) from exc
except OSError as exc:
raise InvalidBinaryError(
@@ -0,0 +1,105 @@
"""omnivoice-subprocess: the resident OmniVoice TTS engine in a crash-isolated
sidecar process (#730/#1190).
The default ``omnivoice`` engine runs in-process on the GPU ``ThreadPoolExecutor``.
When a generate or load there exceeds its execution budget the pool is "reset"
but the abandoned worker *thread* cannot be killed (Python cannot interrupt a
native torch/MPS call), so it holds the MPS device until it finishes on its
own, and every later synth contends with the zombie and hangs.
This engine runs the SAME OmniVoice model in a child process via
:class:`SubprocessBackend`. A child process CAN be hard-killed: on a recv
timeout the parent's watchdog calls ``proc.kill()``, reclaiming the child's
VRAM/device, and the next request transparently respawns a fresh sidecar. That
is the one thing the in-process engine structurally cannot do.
OPT-IN (Settings -> Engines, or ``OMNIVOICE_TTS_BACKEND=omnivoice-subprocess``);
the in-process ``omnivoice`` stays the default so existing users see no change.
Tradeoff vs the in-process engine: identical model and quality, a little extra
per-call overhead (one stdio round-trip), and it does not carry the native
advanced-parameter surface (``t_shift`` / ``layer_penalty_factor`` /
``position_temperature`` / ``class_temperature``) or parent-side seed
determinism, because the generic ``backend.generate`` path does not forward
those. Acceptable for unattended / reaction-triggered use where reliability
matters more than those controls.
Unlike IndexTTS / dots.tts / Supertonic-3, this sidecar runs under the PARENT
interpreter (``venv_python() -> sys.executable``): the goal here is crash
isolation, not dependency isolation, and the OmniVoice engine uses the host's
own pins.
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend
if TYPE_CHECKING:
import torch # noqa: F401
logger = logging.getLogger("omnivoice.omnivoice_subprocess")
class OmniVoiceSubprocessBackend(SubprocessBackend):
"""The resident OmniVoice model in a killable sidecar process."""
id = "omnivoice-subprocess"
display_name = "OmniVoice (subprocess-isolated, killable on timeout)"
_DEFAULT_SAMPLE_RATE = 24000
gpu_compat = ("cuda", "mps", "cpu")
# Match OmniVoiceBackend: the measured floor below which a render that
# should take seconds runs for minutes (the #1226/#1222 4 GB reports).
min_vram_gb = 6.0
@classmethod
def is_available(cls) -> tuple[bool, str]:
# Same probe as OmniVoiceBackend: the package must be importable. The
# interpreter is the parent's own (sys.executable), so there is no
# separate venv to validate.
try:
import omnivoice.models.omnivoice # noqa: F401
except Exception as e:
return False, f"omnivoice package missing: {e}"
return True, "ready"
@classmethod
def venv_python(cls) -> Path:
# Same interpreter as the parent: this engine isolates for crash
# recovery, not dependency pins, so it needs no dedicated venv.
return Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
return Path(__file__).resolve().parent / "main.py"
@property
def recv_timeout_s(self) -> float:
"""Override the base 60s recv timeout.
Aligns the kill deadline with the generate budget: a long-but-valid
OmniVoice synth (which can take tens of seconds) is not falsely killed,
while a genuinely wedged one is hard-killed and its VRAM reclaimed at
the deadline. That reclaim is the concrete behavior the in-process
engine lacks (it abandons but never frees the device).
"""
try:
return max(30.0, float(os.environ.get("OMNIVOICE_SIDECAR_RECV_TIMEOUT_S", "300")))
except (ValueError, TypeError):
return 300.0
@property
def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE
@property
def supported_languages(self) -> list[str]:
# OmniVoice advertises 600+ zero-shot; "multi" is the honest tag.
return ["multi"]
__all__ = ["OmniVoiceSubprocessBackend"]

Some files were not shown because too many files have changed in this diff Show More