Compare commits

..
Author SHA1 Message Date
Palash Debnath 215c4fed26 test: reproduce resource churn without per-user hook override 2026-09-07 16:44:20 +05:30
Palash Debnath 92e6a80472 test: run generated asset hook inside MSI fixture 2026-09-07 16:43:48 +05:30
Palash Debnath 990705fe0e fix: preserve frontend resources during per-user MSI build 2026-09-07 16:41:41 +05:30
Palash Debnath 4dd8a42d3a test: exercise MSI build hooks with changing resource filenames 2026-09-07 16:39:33 +05:30
Palash Debnath 33ce88e465 Merge pull request #1874 from debpalash/codex/pr-queue-integration
chore(integration): land reviewed app, lifecycle and installer fixes
2026-09-07 15:51:52 +05:30
Palash Debnath 10a0fc27f0 Merge commit '63ade08b' into codex/pr-queue-integration 2026-09-07 15:30:30 +05:30
Palash Debnath 63ade08b6f fix(i18n): reuse translated token cleanup error 2026-09-07 15:30:09 +05:30
Palash Debnath fc7021e2b4 Merge commit '1038a487' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 15:26:02 +05:30
Palash Debnath 1038a487a8 fix(auth): gate onboarding replacement on known token state 2026-09-07 15:25:01 +05:30
Palash Debnath 19b9b4a136 Merge commit 'd9bf2516' into codex/pr-queue-integration 2026-09-07 15:09:06 +05:30
Palash Debnath d9bf251665 test(auth): exercise token paths on native backend hosts 2026-09-07 15:08:24 +05:30
Palash Debnath 5bc9f7856a Merge commit '8428517d' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 15:04:50 +05:30
Palash Debnath 8428517d95 fix(auth): preserve local Hugging Face token paths and cleanup 2026-09-07 15:03:12 +05:30
Palash Debnath 6caabf3b0b Merge commit '6b802bea' into codex/pr-queue-integration
# Conflicts:
#	tests/test_locale_parity.py
2026-09-07 14:29:30 +05:30
Palash Debnath 6b802beaf9 fix: complete compact engine family locale labels 2026-09-07 14:28:45 +05:30
Palash Debnath 2ed38c476e Merge commit '99f93164' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
#	tests/test_locale_parity.py
2026-09-07 14:16:01 +05:30
Palash Debnath 078bc06428 Merge commit 'af245a45' into codex/pr-queue-integration 2026-09-07 14:15:11 +05:30
Palash Debnath 99f93164be fix: translate Hugging Face token source labels 2026-09-07 14:14:29 +05:30
Palash Debnath af245a45ee fix: complete workspace playback and engine translations 2026-09-07 14:14:28 +05:30
Palash Debnath dbbad3d58e Merge commit 'bcd2e531' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
#	docs/install/macos.md
2026-09-07 13:48:19 +05:30
Palash Debnath 8667b34ca5 Merge commit 'f18a7add' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
#	docs/install/macos.md
2026-09-07 13:47:50 +05:30
Palash Debnath bcd2e53175 fix(lifecycle): verify root exit after Darwin signal permission errors 2026-09-07 13:46:59 +05:30
Palash Debnath f18a7adddf fix(lifecycle): verify root exit after Darwin signal permission errors 2026-09-07 13:46:06 +05:30
Palash Debnath a968bc57b3 Merge commit 'f2cfdaa4' into codex/pr-queue-integration 2026-09-07 13:31:45 +05:30
Palash Debnath 7ac5e5ab3d Merge commit 'a768e84f' into codex/pr-queue-integration 2026-09-07 13:31:35 +05:30
Palash Debnath f2cfdaa430 test: assert first-sound Chinese taxonomy normalization 2026-09-07 13:31:12 +05:30
Palash Debnath a768e84ffe test: enforce device-neutral Confucius catalog label 2026-09-07 13:31:11 +05:30
Palash Debnath 4648c23256 Merge commit '24252712' into codex/pr-queue-integration 2026-09-07 13:19:19 +05:30
Palash Debnath 1e96748402 Merge commit '30307ae8' into codex/pr-queue-integration
# Conflicts:
#	.gitignore
2026-09-07 13:19:19 +05:30
Palash Debnath 169e36d5b6 Merge commit '0e7ee3d9' into codex/pr-queue-integration 2026-09-07 13:18:56 +05:30
Palash Debnath 135dd0a3a7 Merge commit '9dbf45ca' into codex/pr-queue-integration 2026-09-07 13:18:49 +05:30
Palash Debnath ef51c6b75d Merge commit 'e32bc942' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 13:18:49 +05:30
Palash Debnath 24252712d2 fix(moss): use declared device identifiers in install hint 2026-09-07 13:18:27 +05:30
Palash Debnath b49d2954d5 Merge commit '4008499e' into codex/pr-queue-integration 2026-09-07 13:18:20 +05:30
Palash Debnath bf8da941d1 Merge commit '3b64692e' into codex/pr-queue-integration 2026-09-07 13:18:11 +05:30
Palash Debnath ca3bf8367f Merge commit 'fc8db259' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 13:18:11 +05:30
Palash Debnath 0c255dfabd Merge commit '9afc9db2' into codex/pr-queue-integration 2026-09-07 13:17:45 +05:30
Palash Debnath 4008499ea5 fix(confucius): list supported device codes in the install hint 2026-09-07 13:17:37 +05:30
Palash Debnath e32bc94248 fix: preserve conversion state and finish workspace labels 2026-09-07 13:17:00 +05:30
Palash Debnath fc8db259a9 fix(bootstrap): prevent stale timeout after retry invalidation 2026-09-07 13:16:43 +05:30
Palash Debnath 0921292319 fix(confucius): keep catalogue hardware metadata accurate 2026-09-07 13:15:07 +05:30
Palash Debnath 9afc9db287 test(budget): resolve application modules at test runtime 2026-09-07 13:14:14 +05:30
Palash Debnath 3b64692eed fix(moss): align catalog install hint with accelerator routing 2026-09-07 13:13:49 +05:30
Palash Debnath 30307ae883 chore: ignore generated Windows MSI diagnostic artifacts 2026-09-07 13:13:36 +05:30
Palash Debnath 9dbf45ca2c test(onboarding): resolve the current runtime validator 2026-09-07 13:12:52 +05:30
Palash Debnath 0e7ee3d912 test(release): isolate cleanup module instances per test 2026-09-07 13:12:52 +05:30
Palash Debnath 140e9262b9 Merge commit '18a0c11c' into codex/pr-queue-integration 2026-09-07 12:47:40 +05:30
Palash Debnath 18a0c11c23 test(devices): clear mocked live probe cache 2026-09-07 12:44:32 +05:30
Palash Debnath a0b84a6b61 Merge commit '8c67b109' into codex/pr-queue-integration 2026-09-07 12:42:34 +05:30
Palash Debnath 8c67b10942 test(devices): patch the active capability module after reloads 2026-09-07 12:42:13 +05:30
Palash Debnath 760e9e5ac7 Merge commit '25498dd1' into codex/pr-queue-integration 2026-09-07 12:31:58 +05:30
Palash Debnath 25498dd17b docs(devices): explain optional DirectML fallback 2026-09-07 12:31:33 +05:30
Palash Debnath 16020c2178 Merge commit 'a41e66e0' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 12:28:46 +05:30
Palash Debnath e4471008b4 Merge commit 'df46b71b' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 12:28:27 +05:30
Palash Debnath 2823d6a8ea Merge commit '57150ede' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 12:28:06 +05:30
Palash Debnath 57150ede64 fix(moss): recover from accelerator probe failures 2026-09-07 12:26:04 +05:30
Palash Debnath df46b71be2 fix: tolerate Confucius and DOTS accelerator probe failures 2026-09-07 12:24:42 +05:30
Palash Debnath a41e66e0f0 docs: note capture widget hide permission repair 2026-09-07 12:23:48 +05:30
Palash Debnath 2b2bfe1a7f fix(capture): allow the widget window to hide after recording 2026-09-07 12:23:32 +05:30
Palash Debnath f728071819 Merge commit '59a487e7' into codex/pr-queue-integration 2026-09-07 12:13:44 +05:30
Palash Debnath 59a487e742 test: start upload watchdog at the blocked write 2026-09-07 12:13:29 +05:30
Palash Debnath ef9bda2e17 Merge commit 'e8a69508' into codex/pr-queue-integration 2026-09-07 12:09:10 +05:30
Palash Debnath e8a6950898 fix: allow exact nonsecret dubbing pane storage key 2026-09-07 12:08:47 +05:30
Palash Debnath 43d47f4d36 Merge commit '93bbfd64' into codex/pr-queue-integration 2026-09-07 12:04:25 +05:30
Palash Debnath 93bbfd64ea docs(device): explain optional accelerator probe fallbacks 2026-09-07 12:03:38 +05:30
Palash Debnath c973b1c983 Merge commit 'b4b10c14' into codex/pr-queue-integration 2026-09-07 12:03:21 +05:30
Palash Debnath c4530b4919 Merge commit '99218e2c' into codex/pr-queue-integration
# Conflicts:
#	frontend/src/components/Header.jsx
2026-09-07 12:03:21 +05:30
Palash Debnath b4b10c1431 fix(header): satisfy native Mac detection lint gate 2026-09-07 12:02:35 +05:30
Palash Debnath 99218e2c34 fix(header): satisfy native Mac detection lint gate 2026-09-07 12:02:15 +05:30
Palash Debnath b759ae7749 Merge commit 'ee3e1474' into codex/pr-queue-integration 2026-09-07 11:58:24 +05:30
Palash Debnath b2f5de328e Merge commit 'af7f1ceb' into codex/pr-queue-integration 2026-09-07 11:58:16 +05:30
Palash Debnath f2dd6c95db Merge branch 'codex/fix-per-user-msi' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:58:16 +05:30
Palash Debnath ee3e147401 test: prove upload revocation without scheduling threshold 2026-09-07 11:57:04 +05:30
Palash Debnath af7f1ceb3d test: construct heartbeat backend with lifecycle state 2026-09-07 11:56:16 +05:30
Palash Debnath 0cc22ad803 test: reject unresolved MSI authoring and invalid component identity 2026-09-07 11:56:13 +05:30
Palash Debnath 62b0e05c1d fix(windows): preserve registry separators during template expansion 2026-09-07 11:55:42 +05:30
Palash Debnath aed50121b4 docs: reference per-user MSI repair PR (#1873) 2026-09-07 11:54:25 +05:30
Palash Debnath 079be2fbe5 docs: describe automatic Windows MSI authoring validation 2026-09-07 11:52:41 +05:30
Palash Debnath f463276ca5 ci: validate both Windows MSI scopes with a tiny payload 2026-09-07 11:52:22 +05:30
Palash Debnath 5a1ada0757 fix(windows): give binary registry keypaths explicit stable GUIDs 2026-09-07 11:51:55 +05:30
Palash Debnath 6396cc2b3f style: format Windows installer authoring and regression tests 2026-09-07 11:49:37 +05:30
Palash Debnath 997ba9e673 fix(windows): author per-user MSI file keypaths and folder cleanup 2026-09-07 11:48:20 +05:30
Palash Debnath a438f5db0e Merge branch 'codex/fix-sidecar-timeout-reap' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:45:27 +05:30
Palash Debnath f72ab3a3b2 fix(sidecar): quarantine timeout owners until bounded cleanup succeeds 2026-09-07 11:41:18 +05:30
Palash Debnath 1d30a50c1c docs: reference sidecar recovery PR (#1872) 2026-09-07 11:30:21 +05:30
Palash Debnath c36df0ddf0 Merge branch 'codex/fix-sidecar-timeout-reap' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:29:26 +05:30
Palash Debnath 85cb7242ea fix(sidecars): finish timeout cleanup before allowing recovery 2026-09-07 11:28:13 +05:30
Palash Debnath c547da351c Merge branch 'codex/consolidate-1809' into codex/pr-queue-integration 2026-09-07 11:22:48 +05:30
Palash Debnath 602be02a17 Merge branch 'codex/review-1862' into codex/pr-queue-integration 2026-09-07 11:22:48 +05:30
Palash Debnath f395c90aa1 Merge branch 'codex/review-dialog-resolution' into codex/pr-queue-integration 2026-09-07 11:22:48 +05:30
Palash Debnath 01cb810c65 test(bootstrap): release launch gate before timeout assertion 2026-09-07 11:22:18 +05:30
Palash Debnath f4e5d7b9ab Merge branch 'codex/review-1806' into codex/pr-queue-integration 2026-09-07 11:22:11 +05:30
Palash Debnath caedcde0c6 Merge branch 'codex/review-logs-state' into codex/pr-queue-integration 2026-09-07 11:22:11 +05:30
Palash Debnath 2a36a24612 Merge branch 'codex/consolidate-1831' into codex/pr-queue-integration 2026-09-07 11:22:11 +05:30
Palash Debnath 610303ec11 Merge branch 'codex/consolidate-1830' into codex/pr-queue-integration 2026-09-07 11:22:11 +05:30
Palash Debnath 3a114d62dd test(header): isolate visual fixture system polling 2026-09-07 11:21:55 +05:30
Palash Debnath 65e6c275c6 fix(workers): retain conservative budgets for legacy missing workers 2026-09-07 11:21:39 +05:30
Palash Debnath 9a90fe41be test(vite): exercise conditional dialog alias configuration 2026-09-07 11:21:32 +05:30
Palash Debnath d55838542e fix(confucius): preserve legacy torch device selection 2026-09-07 11:21:13 +05:30
Palash Debnath 9c0a5469cc fix(moss): retain older manually provisioned torch environments 2026-09-07 11:21:12 +05:30
Palash Debnath c7a8d24af6 fix(logs): report failed refresh after clearing logs 2026-09-07 11:19:51 +05:30
Palash Debnath 7d963cf297 Merge branch 'fix/release-rerun-asset-collisions' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:19:07 +05:30
Palash Debnath b9cb6c204f Merge branch 'codex/review-1865' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
#	frontend/src/components/Header.jsx
2026-09-07 11:18:50 +05:30
Palash Debnath 6157c1717b Merge branch 'codex/review-1863' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:18:28 +05:30
Palash Debnath c3b54393c4 docs: record release retry recovery fix (#1871) 2026-09-07 11:17:30 +05:30
Palash Debnath f0bb3fb8b2 Merge branch 'codex/consolidate-1810' into codex/pr-queue-integration 2026-09-07 11:17:24 +05:30
Palash Debnath 157f2987dc fix(api): retain Node ESM compatibility for abortable delay imports 2026-09-07 11:17:24 +05:30
Palash Debnath 426bd1ecea fix(desktop): preserve macOS window behavior with native controls 2026-09-07 11:17:17 +05:30
Palash Debnath d34490fd15 docs: explain retrying partially published releases 2026-09-07 11:16:29 +05:30
Palash Debnath 9b9d4d6579 fix(header): reserve traffic-light space only in native Mac windows 2026-09-07 11:16:24 +05:30
Palash Debnath eee35ab771 Merge branch 'codex/consolidate-1831' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:16:00 +05:30
Palash Debnath 0095c4a089 Merge branch 'codex/consolidate-1830' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:15:51 +05:30
Palash Debnath ef30dbece7 fix(release): clear target asset collisions before retry uploads 2026-09-07 11:15:40 +05:30
Palash Debnath a9062c2fa1 Merge branch 'codex/review-logs-state' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
#	frontend/src/test/LogsFooterNotifications.test.jsx
2026-09-07 11:15:38 +05:30
Palash Debnath b7ebd6e54d Merge branch 'codex/review-hf-onboarding' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:15:16 +05:30
Palash Debnath cd58bbded0 fix(moss): align accelerator detection and routing status 2026-09-07 11:13:33 +05:30
Palash Debnath b9183bb2a6 fix(engines): align accelerator metadata and DOTS runtime precision 2026-09-07 11:12:38 +05:30
Palash Debnath e9495beb8b fix(auth): inspect token presence locally until explicit validation 2026-09-07 11:12:26 +05:30
Palash Debnath 409dd016ce fix(logs): require successful current snapshots before all-clear 2026-09-07 11:11:46 +05:30
Palash Debnath db10e4b70f Merge branch 'codex/review-1862' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
#	frontend/src/test/visual/specs.jsx
2026-09-07 11:10:42 +05:30
Palash Debnath 3613b3d39b Merge branch 'codex/review-1861' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 4277859a44 Merge branch 'codex/review-ui-1841' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath eefc45ec49 Merge branch 'codex/review-1821' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 2f5a52f9eb Merge branch 'codex/review-1819' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 729cad8912 Merge branch 'codex/review-1806' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 86f9eda65f Merge branch 'codex/consolidate-1810' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 32c96eeee4 Merge branch 'codex/consolidate-1809' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath f5d52e479e Merge branch 'codex/review-dialog-resolution' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath eafd94d0a6 Merge branch 'codex/review-oom-order' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 2b5e3cdbc6 Merge branch 'codex/consolidate-1799' into codex/pr-queue-integration
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:10:11 +05:30
Palash Debnath 2229a68cfc fix(gallery): calibrate preview guard against shipped speech fixtures 2026-09-07 11:07:16 +05:30
Palash Debnath 0581fb69fd Merge current main into PR #1819 2026-09-07 11:07:16 +05:30
Palash Debnath 85a368a08b test(header): verify reduced motion and responsive status visibility 2026-09-07 11:06:37 +05:30
Palash Debnath a8c57c816b test(onboarding): validate first-sound request against engine taxonomy 2026-09-07 11:05:30 +05:30
Palash Debnath 360d1d29c8 docs: record connection diagnostics fix under Unreleased 2026-09-07 11:04:51 +05:30
Palash Debnath 7366555c95 docs: record startup fix under Unreleased 2026-09-07 11:04:50 +05:30
Palash Debnath b8ff2721a5 fix(tts): normalize complete signed ranges without rewriting chains 2026-09-07 11:04:44 +05:30
Palash Debnath da26a240e2 Merge current main into PR #1821 2026-09-07 11:04:44 +05:30
Palash Debnath fb3d9b7139 fix(bootstrap): preserve retry cancellation across lifecycle acquisition 2026-09-07 11:03:42 +05:30
Palash Debnath 6c47ae7b21 fix(api): honor cancellation throughout transport diagnostic waits 2026-09-07 11:03:05 +05:30
Palash Debnath fda73384f0 fix(workers): retain granted deadlines across disconnects and restart 2026-09-07 11:02:45 +05:30
Palash Debnath 84add8b279 Merge current main into PR #1806 2026-09-07 11:02:27 +05:30
Palash Debnath 8b510d73db fix(ui): address workspace review findings and restore CI 2026-09-07 11:02:18 +05:30
Palash Debnath b1194c6223 test: cover nested and hoisted dialog dependency resolution 2026-09-07 11:02:06 +05:30
Palash Debnath 75f8924222 Merge remote-tracking branch 'origin/main' into codex/review-dialog-resolution
# Conflicts:
#	CHANGELOG.md
2026-09-07 11:00:31 +05:30
Palash Debnath 2a6d089f4a Merge remote-tracking branch 'origin/main' into codex/consolidate-1810 2026-09-07 11:00:17 +05:30
Palash Debnath 5d134f22ec test: enforce VRAM reclaim before clone prompt retry 2026-09-07 10:59:09 +05:30
Palash Debnath 08af0a971e Merge remote-tracking branch 'origin/main' into codex/review-oom-order 2026-09-07 10:58:32 +05:30
Palash Debnath ff0ce4d37d docs: keep pending transcription fix under Unreleased 2026-09-07 10:58:08 +05:30
Palash Debnath 1ab03067cf Merge remote-tracking branch 'origin/main' into codex/consolidate-1809 2026-09-07 10:58:01 +05:30
Palash Debnath 7a2f86066b fix(transcriptions): consolidate safe clipboard handling and regression tests 2026-09-07 10:57:08 +05:30
Palash Debnath d91beef0fd docs: credit Windows console help fix (#1815) 2026-09-07 10:56:19 +05:30
Palash Debnath 574b634688 Merge remote-tracking branch 'origin/main' into codex/review-cp1252 2026-09-07 10:55:45 +05:30
Palash Debnath 62ab62fc57 Merge remote-tracking branch 'origin/main' into codex/consolidate-1799 2026-09-07 10:55:05 +05:30
电车司机小李 ecbd152c43 fix(logs): avoid false all-clear state
Signed-off-by: 电车司机小李 <39351936+motodriver@users.noreply.github.com>
2026-09-07 11:51:02 +08:00
psiberfunkandClaude Sonnet 5 fec2e7b5f3 docs(changelog): credit the contributor for #1864
Greptile flagged the Unreleased entry as missing the contributor
credit the changelog convention requires for community PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 21:02:33 -04:00
psiberfunkandClaude Sonnet 5 04bc9cedac fix(header): hide the custom window controls on macOS
macOS draws its own native traffic-light cluster even with
decorations:false (tauri.conf.json's titleBarStyle:"Overlay" still
overlays it), but Header.jsx's showWindowControls only checked
whether the app was running under Tauri, not which OS — so the
custom Windows-style minimize/maximize/close row rendered on macOS
too, duplicating the native controls.

Gate it on platform using the same navigator.platform check already
used in HotkeyTab.jsx / SettingsSearch.jsx. Windows/Linux keep the
custom row since decorations:false gives them no chrome otherwise.

Fixes #1864.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 20:51:38 -04:00
psiberfunkandClaude Opus 5 e397e10d64 test(header): restore navigator.platform after mac-inset tests
CodeRabbit flagged that setPlatform() redefined navigator.platform as
an own property but nothing ever restored it, so after this file's
tests run the global stays pinned to whichever platform ran last
('Linux x86_64') — order-dependent and able to leak into any later
test in the same environment that reads navigator.platform. Capture
the original descriptor (undefined, since it's an inherited jsdom
getter) and restore it — or delete the own-property override — in
afterEach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:33:58 -04:00
psiberfunkandClaude Opus 5 8e1da3c22f fix(firstrun): send a voice-design-safe instruct instead of omitting it
Greptile flagged that omitting `instruct` from the first-sound request
fixes OmniVoice (whose `_resolve_instruct` rejected the old free-text
prose) but breaks a different engine: mlx-audio's Qwen3 VoiceDesign
backend requires a truthy `instruct` and raises ValueError without one
(`_is_voice_design()` in backend/services/tts_backend.py), so a user who
picked that engine during onboarding would still get silent first-sound
failure — same bug class, different engine.

Send 'middle-aged, low pitch' instead of omitting the field: it's the
exact taxonomy string the backend's own "Narrator" personality preset
uses (backend/core/personalities.py), so it's valid vocabulary for
OmniVoice's `_resolve_instruct` and a non-empty description for any
voice-design engine. Rewrote firstSoundInstruct.test.js, which
previously asserted instruct was absent entirely (passing for the wrong
reason); it now asserts a non-empty, taxonomy-only instruct is sent and
cross-checks its value against personalities.py's narrator preset so the
two can't silently drift apart.

Also credited the community contributor in CHANGELOG.md per the repo's
own convention (Greptile P2) and promoted the entry to Highlights,
matching every other credited entry in the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:32:25 -04:00
psiberfunkandClaude Opus 5 b92cc3bb79 fix(setup): keep the overwrite warning visible on narrow screens
CodeRabbit flagged that the hf_token_replace_warning text shared the
same `max-[560px]:hidden` class as the dismissable "add a token" pitch,
so a user replacing an already-active token on a narrow viewport (mobile
width, or a small first-run window) never saw the warning that doing so
clobbers the working token. Only the pitch should hide at that width —
the overwrite warning is safety copy and must always render. Added a
regression test asserting the warning's className never carries the
responsive-hide class.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:26:33 -04:00
psiberfunkandClaude Opus 5 d757f160fa fix(header): inset the breadcrumb clear of macOS traffic lights
tauri.conf.json sets decorations:false + titleBarStyle:"Overlay" on
every platform, so on macOS the native traffic-light cluster is drawn
on top of the web content instead of getting its own row; Windows and
Linux draw nothing there. The header's left block (status dot +
kicker) had no inset at all for that zone, so on macOS the traffic
lights sat on top of it.

Fix, macOS-only: detect macOS the same way HotkeyTab.jsx /
SettingsSearch.jsx already do (navigator.platform), and apply a new
.header-area__left--mac-inset class that completes header-area's own
16px left padding to the same flat 64px-from-window-edge total that
.header-area--tabs already reserves for the identical cluster. Windows
and Linux get no inset, so no space is wasted where nothing is
overlaid.

Fixes #1860.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:53:18 -04:00
psiberfunkandClaude Opus 5 a9ac8b1b46 fix(header): stop the status dot pulsing under Reduce Motion
Header.jsx's status dot animates via the hqPulse keyframes, applied as
a Tailwind arbitrary [animation:...] utility. None of index.css's
twelve @media (prefers-reduced-motion: reduce) blocks named hqPulse
(it isn't a stable CSS class, so those selector-based blocks can't
reach it), so the purely decorative pulse kept running with OS Reduce
Motion on.

Fix: append motion-reduce:[animation:none] to the dot's className -
the same mechanism LogsFooter.jsx already uses for its own
arbitrary-utility pulses (heart-glow, donate-pop-in).

Part B only, per the issue split - Part A, an in-app motion toggle, is
a product decision and stays open.

Fixes #1857 (Part B).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:49:32 -04:00
psiberfunkandClaude Opus 5 f88bfecabc fix(firstrun): stop sending free-text instruct prose on first sound
App.jsx's post-onboarding "first sound" request appended a hardcoded
narrator prose string as `instruct`. Every engine's instruct is a
controlled vocabulary (OmniVoice's `_resolve_instruct` rejects
anything outside a fixed token list), so this 400ed on every first
run — silently, since the surrounding catch is deliberately silent
by design (a first impression must never surface an error).

Omit `instruct` entirely instead of swapping in valid vocabulary:
it matches every other call site in the app (`if (instruct)
fd.append('instruct', ...)`), matches the seeded demo profile's
empty stored instruct, and every engine backend already treats a
missing/empty instruct as "no styling" rather than a required field
— so this can't regress no matter which TTS engine is active.

Fixes #1853.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:46:15 -04:00
psiberfunkandClaude Opus 5 753891adc9 fix(setup): stop pitching an HF token when one is already active
HfTokenCard.jsx unconditionally rendered the "add a free Hugging Face
token" pitch in first-run's Models & engines step, even when the
backend had already resolved and validated one (app/env/hf-cli). Since
Save persists via huggingface_hub.login(), which overwrites the
canonical $HF_HOME/token file outright, complying with the unnecessary
prompt could silently clobber an already-working token.

The card now checks GET /system/hf-token/state (the same resolver the
Settings -> API Keys panel already consumes) before rendering:
- an active, validated token shows the source + masked value instead
  of the pitch
- replacing it requires an explicit "Replace..." click plus an inline
  overwrite warning, rather than one blind paste-and-Save
- a still-loading check shows a neutral placeholder
- a failed check falls back to the pre-fix pitch rather than hiding
  the card

Fixes #1851.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:54:26 -04:00
Palash Debnath 4efde9ce4e feat(ui): polish dubbing workspace layout and controls 2026-09-06 20:26:43 +05:30
li-lizhe ae2fa83b02 fix: handle None from current_accelerator(); exclude MPS in dots_tts
current_accelerator() returns None on CPU-only builds (no accelerator
compiled in), so .type would crash. Use check_available=True and fall back
to 'cpu' when None. For dots_tts, also select fp32 on MPS since
DotsTtsRuntime is untested on MPS.

Addresses greptile P1 + coderabbit Functional Correctness review comments.
2026-09-06 21:06:33 +08:00
li-lizhe a671004cb7 fix: handle None from current_accelerator() on CPU-only builds
current_accelerator() returns None on CPU-only PyTorch builds (no
accelerator compiled in), so accel.type would crash. Use check_available=True
and fall back to 'cpu' when None.

Addresses greptile P1 + coderabbit Stability review comments.
2026-09-06 21:06:19 +08:00
li-lizhe ed6fca46a9 fix(tts-engines): select device via torch.accelerator in confucius4 and dots_tts
Both Confucius4 and DOTS-TTS engine sidecars hardcode device selection to
`torch.cuda.is_available()`, which returns False on Ascend NPU, Intel XPU,
and other non-CUDA accelerators — causing the models to silently run on CPU
(in fp32) instead of the available accelerator.

Replace with `torch.accelerator.current_accelerator().type`, the
device-agnostic API that auto-detects CUDA, NPU, XPU, MPS, and CPU. MPS is
excluded for Confucius4 (upstream untested on Apple Silicon). dtype stays
bf16 for any GPU-class accelerator and fp32 on CPU.

Also verified on Ascend 910B (torch 2.14, torch_npu, 4 NPU):
  before: cuda_available=False → device "cpu", precision "float32"
  after:  accelerator → confucius4 device="npu", dots_tts precision="bfloat16"
2026-09-06 09:24:39 +08:00
li-lizhe 65d37288ce fix(moss_tts_v15): select device via torch.accelerator instead of CUDA hardcode
MOSS-TTS-v1.5 engine hardcoded device selection to
`device = "cuda" if torch.cuda.is_available() else "cpu"`. On an Ascend
NPU (torch_npu) host, `torch.cuda.is_available()` is False, so the whole
model silently runs on CPU in fp32 — never using the accelerator — even
though torch.accelerator reports `npu` and bf16 is supported.

Replace with the device-agnostic `torch.accelerator.current_accelerator()`
so any backend (CUDA / NPU / XPU / MPS) is picked up automatically. MPS is
still excluded (MOSS's upstream trust_remote_code modelling code is untested
on Apple Silicon); dtype is bf16 for any GPU-class accelerator and fp32 on
CPU.

Verified on Ascend 910B (torch 2.14, torch_npu, 4 NPU):
  before: torch.cuda.is_available()==False -> device "cpu", dtype float32
  after:  accelerator -> device "npu", dtype bfloat16
2026-09-06 09:23:22 +08:00
Palash Debnath 293b0812f4 feat(ui): organize dubbing controls and export drawer 2026-09-05 23:17:50 +05:30
Palash Debnath b441a486cc feat(ui): refine voice controls and expandable navigation 2026-09-05 22:37:02 +05:30
Palash Debnath d4bf1fe9a9 feat(ui): polish voice interactions and fix notification badge 2026-09-05 20:43:42 +05:30
Palash Debnath 5574cbbc16 fix(ui): name OmniVoice correctly and cycle active engine labels 2026-09-05 20:06:24 +05:30
Palash Debnath 13eae6ff02 fix(ui): show selected engine on title bar 2026-09-05 19:44:55 +05:30
Palash Debnath fd5e78cdab refactor(ui): simplify engine quick access with family tabs 2026-09-05 19:38:19 +05:30
Palash Debnath 67c316e81b feat(studio): consolidate engine controls and pin mode actions 2026-09-05 18:52:43 +05:30
Palash Debnath 81fb585557 fix(studio): anchor engine menu to header trigger 2026-09-05 17:51:57 +05:30
Palash Debnath 3675d750d6 fix(studio): reuse rich language picker for cloning 2026-09-05 17:46:08 +05:30
Palash Debnath bbbf185cf3 fix(studio): keep sticky language picker inside viewport 2026-09-05 17:11:24 +05:30
Palash Debnath 0c4ed0546f fix(studio): constrain sticky controls on short screens 2026-09-05 16:38:43 +05:30
Palash Debnath 17df9210cd fix(studio): pin synthesis controls below scrolling form 2026-09-05 16:26:13 +05:30
Palash Debnath aedca15f7e docs: record voice workspace tabs 2026-09-05 15:23:59 +05:30
Palash Debnath 7a14c31a8e feat(studio): promote voice modes to workspace tabs 2026-09-05 15:22:44 +05:30
Palash Debnath 53ff367c1f fix(studio): simplify voice cloning setup (#1817)
* fix(studio): simplify voice cloning setup

* docs: link cloning redesign changelog

* fix(studio): keep clone recording controls available

* fix(studio): lock clone capture transitions
2026-09-05 14:12:43 +05:30
flutterkage2kandClaude Opus 5 ed746bab57 fix(tts): speak the tilde in digit ranges instead of mashing the numbers
"20~30초" is read aloud as a single number — OmniVoice says "이십삼" (23).
The separator never reaches the listener, so any written range is heard as
the wrong figure.

`normalize_text` only ran its number pass behind `_num2words_lang`, which
returns None for ko/ja/zh/th/vi (those scripts read digits natively and are
deliberately outside num2words). Nothing else looked at the range mark, so
the tilde went to the engine untouched and the two numbers ran together.

Rewrite `N~M` into the spoken form before the engine sees it, outside the
num2words gate so the CJK languages are covered too. Verified by rendering
each candidate and transcribing it back (ko, OmniVoice, cloned voice):

    "대략 20~30초짜리"      heard "23초"           WRONG
    "대략 20-30초짜리"      heard "23초"           WRONG (reproduces it)
    "대략 20에서 30초짜리"   heard "20에서 30초짜리"  correct
    "20〜30分ぐらい" → "20から30分" heard "20〜30分くらい"  correct

Deliberately narrow:

* Only the tilde family (U+007E, U+301C, U+FF5E). Japanese and Korean IMEs
  emit the latter two. An ASCII hyphen is left alone — between digits it
  also spells dates, phone numbers and product codes, where "to" is wrong
  (`tests` already pin "pages 3-5" as unchanged).
* Only languages with a verified spoken form (ko/ja/zh/en). Anything else
  keeps its tilde, matching how `_PERCENT_WORD` is scoped.
* Spacing belongs to the form, not the caller: a Korean postposition binds
  to its numeral ("20에서 30"), Japanese and Chinese set no spaces, English
  needs them on both sides.
* Neighbour guards block digits and ASCII letters but allow CJK, because
  CJK writes the unit hard against the digits ("20~30초"); a `\w` guard
  rejects exactly the cases the rule exists for.

`ko`/`ja`/`zh` join `_FULL_NAME_TO_CODE` so the new resolver can see them.
They stay out of `_NUM2WORDS_LANGS`, so this does not open a num2words path
for them — the same inert-entry pattern the file already documents for
"vietnamese".

`backend/services/text_normalization.py` joins the functional-CJK allowlist
in tests/test_no_hardcoded_cjk.py, under the text-processing group and by
the procedure that file documents: the range words are engine input, not
user-facing UI strings.

Tests: 7 new change-cases and 8 new leave-unchanged cases (hyphen, date,
phone number, product code, decimals, a non-numeric tilde, an unverified
language, and no language at all). All 7 change-cases fail against the
previous implementation.

Full suites before and after: the same 17 failures, none of them touched by
this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:06:15 +09:00
flutterkage2kandClaude Opus 5 847ca6d44c fix(desktop): resolve plugin-dialog wherever the package manager put it
`bun run desktop` dies before the window opens on a fresh clone:

    Error: ENOENT: no such file or directory, open
    '.../frontend/node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'

The alias hardcoded `frontend/node_modules/...`, but this is a bun
workspace: bun hoists the package to the workspace root and leaves
`frontend/node_modules` empty, so the path the alias names does not
exist. Vite's dep optimizer reads it directly and throws, taking
`beforeDevCommand` — and the whole desktop shell — down with it.

Probe both layouts and fall through to Vite's own resolution when
neither is present, so a missing package degrades to normal resolution
instead of crashing the dev server.

Verified on macOS 26.6 (Apple Silicon), bun 1.2.22, fresh clone: the
window now opens and the backend serves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:06:12 +09:00
flutterkage2kandClaude Opus 5 784494dc3c fix(gallery): measure spectral flatness per frame so real speech passes
Most gallery previews fail with "the voice engine returned no audible
audio for this archetype". The renders are fine — the guard is not.

Two problems, both in the degenerate-buzz check:

1. `_spectral_flatness` took ONE FFT of the whole clip. Spectral
   flatness is defined over short frames; a full-length transform gets
   finer frequency resolution the longer the clip is, so voiced
   harmonics carve deeper and deeper nulls and the geometric mean
   collapses. The number tracked clip length, not timbre.

2. `_DEGENERATE_FLATNESS = 0.015` was calibrated against
   `_speech_like()` in the unit test — a synthetic harmonics+noise
   stand-in that is far flatter than real speech. Real renders measure
   well below it, so the threshold sat inside the speech range.

Measured on this engine's own output (framed, per this patch):

    pure tone 80 Hz        2.6e-10    two-tone buzz    3.3e-09
    quietest real speech   2.0e-04    (VoxCPM2 ko)

Frame the measurement (1024/512, skipping inter-word frames at the
noise floor) and move the threshold to 1e-5 — ~3000x above the tonal
cases, ~20x below the quietest real render.

Before: 6 of 8 renders rejected; ml_japanese_explainer,
ml_japanese_companion and feat_23_the_explainer all 503 through
GET /archetypes/{id}/preview.
After: 0 false positives across 27 real clips (Japanese, Korean and
English archetypes, cloned voices, human reference recordings), and
those three previews return 200. Every accepted clip was confirmed as
real speech by transcribing it with the app's own ASR.

Not addressed: a render that collapses toward NOISE rather than a tone
still passes (one observed at flatness 0.073, ASR returns a
hallucination). The old threshold missed it too, so this is not a
regression — calibrating an upper bound needs more than one sample.

Tests: frame-based measurement must be clip-length invariant, and the
threshold must sit between the measured tonal ceiling and the measured
real-speech floor. Both fail against the previous implementation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:05:59 +09:00
dajiaohuang 6c42b50fe3 test(ci): document cp1252 help regression 2026-09-05 05:21:45 +08:00
dajiaohuang 2594abebf8 fix(ci): keep install-docs help cp1252-safe 2026-09-05 05:17:34 +08:00
Palash DebnathandClaude Opus 5 e4f00bc564 fix(desktop): let Retry preempt the readiness wait
Greptile's P1 on #1809, and it is right. `launch_backend_and_wait` holds
`BackendState::lifecycle` around the entire launch, including the readiness
wait — which this branch just made unbounded for as long as the backend
answers `/startup/progress`. Retry, Clean & Retry, reset and uninstall all
need that same lock, so on a slow start the user's own escape hatch would
block behind the wait instead of interrupting it: an app with no way out,
which is worse than the early kill the branch set out to remove.

Every flow that is about to take lifecycle ownership now bumps a generation
counter first, before reaching for the lock. The waiting loop snapshots that
counter once its caller holds ownership — so a bump that predates it is not
mistaken for a preemption — and stands down within one 500 ms poll when it
changes, releasing the lock for whoever asked.

That also settles what happens at the splash's six-minute stall budget: it
flips to failed and offers Retry and the logs, and Retry now actually works,
while its /health recovery poll still walks straight into the app if the slow
start finishes first. Either way the user gets out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 18:15:30 +05:30
Palash DebnathandClaude Opus 5 6a2ada2f11 fix(tts): don't answer a GPU OOM by repeating the same allocation
`_get_clone_prompt` catches everything and returns None so synthesis falls
back to `generate()`'s inline reference path. For a device OOM that is not a
fallback at all: the inline path runs the SAME encode on the SAME device —
producing identical output is the entire point of the precompute — so it is
guaranteed to hit the same wall moments later, on a GPU with even less
headroom than the first attempt found. Two reporters' backends died with a
Windows access violation (exit code -1073741819) seconds after this fallback
logged, mid-generation, on a card that had just refused an 86 MiB
allocation.

An OOM here is also the most recoverable kind. The allocator is typically
sitting on reserved-but-unallocated blocks — #1790's own log reports 90 MiB
reserved against that 86 MiB request — so drop them and try once more. If it
still will not fit, raise: the failure layer turns a device OOM into "close
other GPU-heavy apps or unload models, then retry", which is a far better
answer than walking into a native fault.

Every other failure still falls back silently, since for a non-memory fault
the inline path may genuinely succeed.

Fixes #1790.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 18:11:20 +05:30
Palash DebnathandClaude Opus 5 9ac1045917 test: reject a backslash in a tracked path too
CodeRabbit's catch on #1799: git stores paths with `/` separators, so a `\`
that survives into a path component is part of a NAME. It is legal to commit
one from Linux or macOS and impossible to check out on Windows, where git
refuses it under `core.protectNTFS` — the same checkout-time failure, before
any test runs, that the stray `:memory:.ses` caused.

The rule moves into a pure `windows_hostile_reason` so it can be exercised
directly: the repo cannot carry a fixture for each hostile shape without
becoming the very thing the test rejects. Both directions are pinned — every
shape Windows refuses, and ordinary paths that merely resemble one (a file
called `console.md`, `com10.py`, a component containing but not ending in a
dot).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 18:04:38 +05:30
Palash DebnathandClaude Opus 5 31d90def83 fix(errors): don't claim the backend crashed with no evidence that it did
Two Apple Silicon reporters were told "it most likely crashed or was killed
mid-request" while generating. Neither bug report carried a crash marker,
because none had been recorded — the app had no evidence for the one thing
it asserted, and the advice that follows that sentence is Retry and Clean &
Retry, which rebuilds the whole Python environment to fix a backend that had
not died.

Two causes, both fixed here.

The desktop shell learns the backend died from a ~2 s poll: it has to notice
the child exit before it can write the marker. `apiFetch` asked for that
marker exactly once, at the instant the transport gave up, so it raced the
poll and lost either way round — a backend that really died was reported
with the vague sentence instead of its exit code and crash notice, and one
that never died was reported as dead anyway. `streamDropError` already waits
that poll out (#1119); the request path never did. The loop is now a shared
`awaitBackendCrashMarker`, used by both, with a shorter budget here because
the transport cascade has already cost the user a few seconds.

And the copy itself overshot what it could know. By construction it is
reached only once a crash has been looked for and not found, so it no longer
names one: it says the backend stopped answering with no crash recorded, and
that a heavy job holding the engine is the likelier story — which on a
memory-pressured Mac mid-generation it is. Updated in all 21 locales, since
a translation still asserting a crash would be the same bug in another
language.

The #1337 test that required the crash wording is updated with it: #1337
established that the backend had answered seconds earlier, not what silenced
it, and requiring the stronger claim is what pinned this in place.

Fixes #1802.
Fixes #1805.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 18:03:23 +05:30
Palash DebnathandClaude Opus 5 d5c33eda27 fix(desktop): don't kill a backend that is still starting
The launcher waited a flat five minutes from spawn for the backend to
report ready, then killed it and tried again. On a host where the cold
start genuinely takes longer — the reporter's project lived on a mapped
network drive, and `import torch` off one is slow the first time, as is a
first CUDA load or a cold spinning disk — that deadline expired *while the
backend was still importing*. The respawn threw away the warm page cache
and raced the same clock, so the app could never start, and it blamed the
backend: "the backend never reported ready". Launching that same backend by
hand reached ready in well under a minute once the cache was warm.

A backend answering `/startup/progress` with `status: "starting"` is not
one we have to guess about: it bound its socket, it is serving HTTP, and it
is naming the step it is on. Killing it cannot make the retry faster, and
the launcher knows nothing the user doesn't. So keep waiting while it
answers, and keep narrating each step. The budget still governs silence —
nothing answering, or a self-reported `failed` — where a slow backend and a
wedged one really are indistinguishable and the existing stderr-tail
failure is the right answer.

The splash needed the same correction. Its stall watchdog keys on
`bootstrap_status`, which sits on `starting_backend` for the whole of a slow
start, so it would have called the launch stuck at six minutes anyway; the
proof of life arrives on the separate `bootstrap-log` stream. Output now
counts as activity, and a genuinely silent backend still trips the watchdog
so the info-less spinner of #879 stays fixed.

Fixes #1791.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 17:50:23 +05:30
Palash DebnathandClaude Opus 5 36a38bdbb9 fix(ci): don't ship a tracked path Windows cannot check out
A stray sqlite session artifact named `:memory:.ses` was committed by
accident on this branch. Git on Windows rejects a path containing `:` with
`error: invalid path` and exits 128 during **checkout** — so both Windows
jobs went red before a single build or test step ran, pointing at a file
nobody had edited, while Linux and macOS stayed green.

Drop the file, ignore the `*.ses` artifact class, and add a guard that scans
the index on every platform for paths Windows cannot represent: illegal
characters, components ending in a space or dot, and reserved DOS device
names. The failure now surfaces as a named test on every runner instead of
as a checkout crash on one leg of the matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 17:41:02 +05:30
VishvakR 02c3a651ac test(generate): drive the scheduler itself, and restore env before reloading
Third review round on the PR, both findings in the new test file.

CodeRabbit: the disconnect regression called deadlines.for_task directly, so it
would have passed even if Scheduler._budget_for stopped coercing a missing
worker to the CPU budget -- the very thing it exists to pin. It now builds a
real WorkerPool and Scheduler, assigns the task to the 4 GB worker, asserts the
bound budget is the CPU one, disconnects the worker and asserts the
recomputation is not shorter. Forcing under_provisioned=False in _budget_for
fails it with `assert 300 == 600`.

CodeRabbit: the two env-var tests deleted the variables and reloaded
model_manager inside a finally, which runs BEFORE pytest restores them -- so on
a machine that already exports either var, the module constants would describe
an environment pytest was about to put back, and every later test would read
the mismatch. Both use monkeypatch.context() now, so the environment is restored
before the reload.
2026-09-04 15:36:18 +05:30
VishvakR fa148ecb55 fix(generate): tighten the VRAM-floor tests and document budget precedence
Second review round on the PR.

CodeRabbit: the repo-wide dispatch assertion accepted any nested min_vram_gb
keyword, so a budget computed with 0 or another engine's floor would pass while
the guard used the right one. It now compares the two expressions.

CodeRabbit: the awaiting-side deadline test restated gpu_gateway's formula
instead of calling it, so it would not have noticed that function starting to
select a shorter ceiling. It calls _default_deadline now, on cuda and rocm.

CodeRabbit: the docs said an explicit OMNIVOICE_GENERATE_TIMEOUT_S is honoured
"everywhere" while also saying the CPU var governs under-provisioned cards --
the two cannot both be true. Verified against the code (both vars set, 4 GB
cuda, engine floor 6 GB -> 200s, the accelerated value) and documented as a
precedence table rather than prose. The accelerated var deliberately wins on an
under-provisioned host: that is what keeps "lower it to fail fast everywhere"
working. Pinned by a test so the table cannot drift from the behaviour.

CodeRabbit also flagged that Scheduler._budget_for recomputes with no worker
after a disconnect, dropping under_provisioned to False. That cannot shorten
anything: no worker means no execution_device, which _base_execution_seconds
already coerces to "cpu" -- the same budget the floor raises an
under-provisioned card to. Added a test pinning that rather than persisting a
dispatch-time budget on the attempt. The residual case it describes -- an
operator who raised the accelerated budget ABOVE the CPU one sees a shorter
recomputation once the worker is gone -- predates this change and applies to
every GPU worker, not just under-provisioned ones, so it belongs in its own fix.
2026-09-04 13:52:23 +05:30
VishvakR f172d0c3be fix(generate): apply the VRAM-floor budget to remote workers and /convert
Review findings on the PR, fixed here rather than left for a fourth report.

Greptile (P1): the control plane sets a remote attempt's deadline, so the same
inversion reached remote workers. Its suggested fix -- thread the engine floor
into generate_timeout_s() -- would read the wrong machine: that function probes
THIS host, so a Mac control plane dispatching to a 4 GB Windows worker learns
nothing (MPS is excluded by design), and a 4 GB box dispatching to a 24 GB
worker would wrongly get the longer budget. The worker already advertises both
figures it takes -- free_memory_bytes and min_memory_bytes, both set in
worker/capabilities.py -- so ConnectedWorker.under_provisioned() decides from
those, and deadlines.for_task() floors the execution budget at what the same job
would get on a CPU. The task-level ceiling in gpu_gateway._default_deadline is
computed before a worker is bound and already asks for the CPU budget, so it
still covers the raised lease; a test pins that.

CodeRabbit (major): /convert had the identical split -- min_vram_gb to the
guard so a timeout could name the card, and a budget computed without it.

CodeRabbit (minor): the docs promised the CPU-class floor for any GPU, while
the code scopes it to dedicated-VRAM families. Reworded to say CUDA/ROCm and to
say why MPS is excluded.

CodeRabbit (minor): the call-site assertion compared global occurrence counts,
so one dispatch could drop both arguments while another gained an extra and the
total still matched. It now walks the AST and checks each dispatch on its own,
and the pairing is additionally enforced repo-wide across backend/api/routers:
a dispatch that knows the engine's floor well enough to explain a timeout must
know it well enough to set the budget.

Three inline capability-selection loops in ConnectedWorker collapse into one
_capability_for(), so the new predicate cannot select a different capability
than execution_device() does.
2026-09-04 13:33:00 +05:30
VishvakR fcac8e1bae fix(generate): budget an under-provisioned GPU like the CPU it performs like
A GPU with less VRAM than the engine declares it needs pages to system RAM
over PCIe, so it renders slower than the same machine's CPU. The compute-time
budget picked its value from the device family alone, so that card was treated
as fast hardware and given 300s -- half the 600s a plain CPU host gets. It is
the slowest configuration the app supports and it had the shortest watchdog.

Everything else already acted on the verdict. resolve_routing() raises the
caveat, the synth preflight warns before the user waits, and _timeout_guidance()
names the card in the failure. Each TTS generate dispatch even hands the guard
the engine's floor on the line above the timeout that ignored it. #1226 and
#1222 were the same 4 GB cards on the same engine; both were closed by making
the app explain the timeout better, never by correcting the budget behind it.

generate_timeout_s() now floors an under-provisioned accelerator at the CPU
budget. The length scaling is unchanged, and an explicitly configured
OMNIVOICE_GENERATE_TIMEOUT_S is still honoured verbatim, so an operator who
lowered the watchdog to fail fast keeps that. The floor is a max(), never an
assignment, so a raised accelerated budget is never cut down. Engines that
declare no floor, a failed VRAM probe, and MPS (whose vram_gb is a unified-
memory heuristic, not a dedicated pool) are all untouched.

The three-clause "is this host under-provisioned" test was written out inline
in the caveat and in the timeout message, which is how the budget came to
disagree with the warning printed beside it; it is now one predicate,
under_provisioned_vram(), that all three read.

Reported on a GTX 1650 (4 GB) running the omnivoice engine, whose breadcrumbs
show the budget ending the job on the dot: 372s and 301s are exactly
300 + max(0, len - 1200) / 40 for the two takes.

Fixes #1804.
2026-09-04 13:02:14 +05:30
Palash DebnathandClaude Opus 5 4e6c36848c fix(transcriptions): render segments that have no timings
An OpenAI-compatible ASR answering in json/text format returns no
timestamps, and services/asr_backend.py records that honestly as
`end: None` rather than inventing a number. The segment list called
`.toFixed()` on it unconditionally, so the render threw and the whole
Transcriptions view went blank — a transcript that merely lacked timings
became one the user could not read at all.

Show whichever bound is known and nothing when neither is, so the text
stays readable either way. Non-finite values are treated as unknown too,
so a bad timing prints nothing rather than NaN.

Fixes #1798.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR6J9zKQop9TGGVUwjypnF
2026-09-04 03:26:51 +05:30
Palash Debnath f2302e8c95 fix(desktop): don't adopt a backend running stale code (#1796)
Exports failed with a 422 naming a field the current app never sends — twice, from different users. The cause was the attach handshake: if something already answers on the backend port and reports a matching version, the app adopts it and skips the source sync a normal launch performs. A version string holds steady for a whole release cycle, so a same-version process can still be running weeks-old code, and that code then serves a current UI.

The handshake now compares a fingerprint of the shipped Python sources, read from the same response as the version so a dropped probe can't masquerade as a missing field. A backend predating the mechanism is treated as stale; one that is current but started outside the app is still accepted. Refusals are logged with a greppable marker, since this class previously took two reports and a code audit to identify.

Fixes #1770. Closes the duplicate report tracked in #1792.
2026-09-04 03:22:41 +05:30
Palash Debnath 8b4e4ebf56 fix(windows): start the backend when the install path has non-English characters (#1795)
On Windows with a non-UTF-8 system code page, a venv path containing non-English characters (commonly a CJK username) killed the interpreter during startup, before any VoiceStudio code ran — Python reads .pth files in the active code page, and uv's editable install writes the project path there in UTF-8. The backend could never start, and the setup screen only said it had stalled.

A new or genuinely broken environment now builds at an ASCII-safe short path; an existing environment that starts cleanly is never relocated. When no safe path can be produced, the app says so before downloading rather than after. The failure message names the cause and a remedy that works for the install mode in use, since portable installs ignore the setting managed installs use.

Fixes #1783.
2026-09-04 03:21:42 +05:30
Palash Debnath 7f7a4c5f83 fix(settings): make the generation budget reachable and honest (#1797)
The compute-time error told users to raise a generation timeout that had no control anywhere in the app — the only knob was an environment variable, and on Windows the docs explicitly warn against the usual way of setting one. Both budgets are now editable in Settings under Performance & Device, persisted and applied on the next start.

Two defects found in review and fixed here rather than shipped: an explicit universal budget silently overrode a separately saved CPU budget, so the CPU row would have looked like it worked and done nothing; and a value already set in the environment shadowed the saved preference while the panel still reported success. A shadowed row now says so instead. Long-input warnings also fire on Apple Silicon, which gets the accelerated budget and was the device in one of the duplicate reports.

Fixes #1787. Closes the reports tracked in #1774 and #1778.
2026-09-04 01:55:06 +05:30
Palash Debnath e5916acc01 feat(design): simplify the Voice Design panel (#1793)
The panel printed every chosen value three times and held twelve control rows open before anyone touched it. Details now collapse to a single recipe line that expands on request, gender/age/pitch/style become selects, and English accent and Chinese dialect merge into one grouped field so the combination the engine rejects cannot be selected at all. Starting points show five with an overflow, the bottom-bar slider is labelled Steps, and the panel ends where its content ends.

Also fixes two races found in review: a manual pick or a cleared description now beats an in-flight describe response, and arrow-key chip navigation moves focus without resetting the design.
2026-09-04 00:20:36 +05:30
Palash Debnath 06e69d6d6b fix(desktop): resolve the capability-store dir from the running backend (#1789)
Exports and every other native-picker action 403'd with "Invalid or expired desktop authorization" whenever Tauri and the backend resolved different data directories — a dev backend spawned without the OMNIVOICE_* env, a custom data folder, or portable mode. Tauri now takes the directory the running backend advertises, so the two processes cannot disagree, falling back to its own resolution when the backend is unreachable. One resolver covers all six capability kinds. Also decodes JSON escapes when reading that field, which Windows paths depend on, requires an absolute path so a relative data dir cannot recreate the same split, and keeps the 403 and its log line free of filesystem paths. Fixes #1781.
2026-09-03 22:25:08 +05:30
Palash Debnath e4ce065864 fix(design): enforce dialect/accent exclusivity in the voice-design picker (#1788)
Voice Design rendered EnglishAccent and ChineseDialect as two unlinked controls, so a user could select both and only learn they conflict from a 400 after a round trip. A shared exclusive-groups map now mirrors the engine's rule across every path that builds or restores instruct state: the live picker, free-text entry, saved-profile and imported-session restore, plus a message-matching backstop for a conflict arriving by any other route. Picking one clears the other with a visible reason instead of a silent reset. Fixes #1771.
2026-09-03 21:26:07 +05:30
Jaesik Lee f95e73b710 fix(i18n): ja "Cleaning…" is denoising, not housekeeping (#1775)
The Japanese clone.cleaning status read 掃除中 (tidying up a room) instead of ノイズ除去中, which is what the step actually does: denoising the reference audio. Thanks @j30231!
2026-09-03 20:39:42 +05:30
Jaesik Lee b20cd6cb95 fix(i18n): overhaul the ko locale (#1776)
Corrects 231 machine-translation defects in the Korean locale and translates all 493 previously missing keys, dropping ko to zero in the missing-key baseline. Includes terminology consistency (Cinematic, export, UI scale) and fixes copy that named the wrong control. Maintainer commits merged current main and folded in the five live-preview keys added by #1769. Thanks @j30231!
2026-09-03 20:19:42 +05:30
Matt Van Horn 1515b46adb feat(batch): watch-folder auto-ingest (#1768)
Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with the last Add-to-queue settings, with pause/stop controls and copy-in-progress protection. Files stream to the loopback backend as bytes; paths never leave the app. Also gives the batch queue a reachable UI entry point and streams multipart uploads to disk. Maintainer fix: the watched directory handle is opened with full share mode on Windows so users can rename or delete the folder while it is watched, matching macOS/Linux behaviour, with a cross-platform regression test. Thanks @mvanhorn!
2026-09-03 18:47:32 +05:30
Matt Van Horn 999345de41 feat(dub): realtime dub preview (#1769)
Opt-in live preview for dub segments: edits debounce into a streamed /ws/tts synthesis played through the chunk player, with cancellation preserved through buffered playback. Maintainer fixes: /ws/tts added to the backend ticket allowlist (feature was dead off-loopback), handshake failures surface a toast, loopback-only plaintext refusal reverted to keep the documented remote-GPU setup working, PCM16 decode hardened. Thanks @mvanhorn!
2026-09-03 18:27:07 +05:30
Palash Debnath ac287c612f docs(readme): enrich with audio samples, hardware guide, and doc links (#1785)
* docs: polish README hero hierarchy

* docs: enrich README with audio samples, hardware guide, and doc links

* docs: address review findings on Docker port binding, MCP transport, and privacy

* docs: address CodeRabbit review feedback on cURL format and Colab links

* docs: align Docker quick-start with stable tag and named volume mount
2026-09-03 18:00:14 +05:30
Palash Debnath d80286562e docs: polish README hero hierarchy (#1780) 2026-09-03 14:28:42 +05:30
9d30774ee2 feat(audiobook): synced lyrics playback (#1766)
* feat(audiobook): synced-lyrics player

Replace the bare <audio controls> in the audiobook result with a player
that renders the chapter text and highlights the word under
audio.currentTime, karaoke-style. Timing reuses what the render stream
already emits — per-chapter duration_s on the chapter SSE events — with
words even-split inside each chapter (the karaoke burn-in's old-job
fallback, ported from services/karaoke_ass.py); after a reload the whole
book even-splits over the file's own duration. No ASR pass, no new
backend surface, nothing leaves the machine. Download keeps going
through the Tauri-safe downloadMedia util.

New pure helper utils/audiobookLyrics.js mirrors the longform parser's
chapter drop rules so cue indices line up with the stream's chapter
list, and degrades to the proportional split on any drift (script
edited after the render, stopped mid-book). Words are buttons —
click-to-seek, keyboard reachable — restyled as prose in index.css.
audiobook.lyrics translated in all 21 locales.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* docs(changelog): synced-lyrics audiobook player entry (#1766)

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* style(audiobook): apply current formatter

* fix(audiobook): preserve synced render cues

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Palash Debnath <4178343+debpalash@users.noreply.github.com>
2026-09-02 16:32:23 +05:30
65236774aa feat(dub): project-level drag casting board (#1767)
* feat(dub): project-level drag casting board

Adds an expandable Casting Board to the dub editor's CAST strip: speaker
rows (with the auto-clone chip when the extractor found a usable passage)
and draggable voice chips — Default, clone profiles, design presets.
Dropping a chip on a speaker writes the exact fields the CAST <select>
always has (profile_id + merge_parts/merge_parts_original attribution),
via a shared assignSpeakerProfile helper both views now call, so the
dropdowns stay in sync and job persistence is unchanged. Keyboard path:
focus a speaker row, pick from a listbox (arrows/Enter/Escape).

The pre-existing CAST dropdown strip moves verbatim into the new
CastingBoard.jsx (DubLeftColumn shrinks below 800 lines; the new file
holds the 300-line soft cap). Styles extend the .dub-cast-* cluster in
index.css. Six new i18n keys translated in all 21 locales.

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* docs: changelog + roadmap entries for the casting board (#1767)

Co-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>

* fix(casting): validate and preserve speaker assignments

* fix(casting): recover cleared merged assignments

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Palash Debnath <4178343+debpalash@users.noreply.github.com>
2026-09-02 15:37:57 +05:30
Palash Debnath 0ed7d2ec22 chore(release): prepare v0.5.2 (#1761)
Synchronize VoiceStudio release metadata, lockfiles, installers, container references, documentation, and the dated v0.5.2 changelog after all planned fixes landed.
2026-09-02 10:26:14 +05:30
Matt Van Horn a95041f1e6 feat(studio): speech-to-speech voice changer (#1765)
Add a bounded, local-first speech-to-speech Convert workflow with shared ASR/TTS admission, duration matching, stale-request cancellation, profile conditioning, watermarking, persistence, localization, and regression coverage.\n\nCo-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
2026-09-02 09:45:40 +05:30
Matt Van Horn 4053397921 feat(dub): karaoke word-highlight caption burn-in (#1764)
Adds opt-in word-timed ASS karaoke captions while preserving the existing line-caption default.\n\nCo-authored-by: Matt Van Horn <mvanhorn@users.noreply.github.com>
2026-09-02 08:32:43 +05:30
Palash Debnath e2446c3e61 fix(release): keep Preview ahead of Stable (#1763)
Closes #1762.
2026-09-02 07:49:22 +05:30
Palash Debnath ccd984f324 Merge pull request #1760 from agudmund/feat/mcp-output-mode-files
feat(mcp): output mode + base-path file lane so agents never carry audio in context
2026-09-02 06:27:46 +05:30
Palash Debnath 89f0a25082 fix(mcp): bound encoded audio before decode 2026-09-02 06:13:11 +05:30
Palash Debnath b351dc5b1a Merge remote-tracking branch 'origin/main' into review/pr-1760
# Conflicts:
#	CHANGELOG.md
2026-09-02 06:07:57 +05:30
Palash Debnath 026410fbc6 test(worker): make loop responsiveness checks deterministic (#1759)
Reviewed by CodeRabbit and Greptile. Required Tests (backend + frontend) gate passed on the current, mergeable head.
2026-09-02 05:43:08 +05:30
Palash Debnath b6a1ee50ff Merge remote-tracking branch 'origin/main' into fix/deterministic-inbound-loop-tests
# Conflicts:
#	tests/test_worker_inbound_transport.py
2026-09-02 05:29:25 +05:30
Ævar GuðmundssonandClaude Fable 5.1 3c3c37615c feat(mcp): output mode + base-path file lane so agents never carry audio in context
An LLM agent pays for every byte it receives, and generate_speech returned
each WAV as base64 inline - a short clip already brushed per-result limits.
This adds two knobs in the OMNIVOICE_* family, the pattern the ElevenLabs
MCP settled on (OUTPUT_MODE + a BASE_PATH security boundary):

- OMNIVOICE_MCP_OUTPUT_MODE = resources (default, the original contract) |
  files | both. In files mode generate_speech returns audio_url (the render
  the backend already keeps, served at /audio/<id>.wav) and, when a base
  path is set, output_path - the WAV written into that directory.
- OMNIVOICE_MCP_BASE_PATH: the one directory agents may read from and
  receive files in. transcribe(audio_path=) and clone_voice(ref_audio_path=)
  read only inside it (relative paths resolve against it, absolute ones must
  lie within it, symlinks resolved before the check); with no base path,
  path arguments are refused with a reason.
- OMNIVOICE_MCP_TIMEOUT_S (default 120): the tools' backend timeout, since a
  CPU host serializes generations and an agent queued behind another render
  outlasted the fixed budget with an empty-message ToolError.

Also: transcribe and clone_voice share one input helper (data-URI tolerance
now covers transcribe too), the upload filename carries the sniffed
extension, and the reply is built with json.dumps instead of hand-rolled
JSON. Tests cover the mode parsing, the boundary (escape and missing-base
refusals), both input lanes, all four reply shapes, and the timeout knob.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 23:46:44 +00:00
Palash Debnath c44f2fc042 test: make key revocation ordering deterministic (#1758)
Replaces a scheduler-sensitive 200 ms assertion with a deterministic ordering check against the blocked-write release barrier. Repairs the red post-merge main run from #1751.
2026-09-02 05:09:21 +05:30
Palash Debnath 62604ec9bb test(worker): make loop responsiveness checks deterministic 2026-09-02 04:56:29 +05:30
Palash Debnath 08569397d3 fix(asr): secure configured endpoints and refresh guidance (#1751)
Refreshes README and linked docs with accurate installation, platform, privacy, API, and model-license guidance; documents local gigastt; and pins OpenAI-compatible ASR traffic to the configured secure origin. Closes #1736.
2026-09-02 04:41:36 +05:30
Palash Debnath c9a468e500 Merge pull request #1756 from debpalash/fix/windows-direct-job-owner-1734
fix(windows): remove the sidecar supervisor hop
2026-09-02 04:07:36 +05:30
Palash Debnath aec4694628 test(windows): cover delayed Job exit 2026-09-02 03:52:17 +05:30
Palash Debnath 5df1dc973c Merge remote-tracking branch 'origin/main' into fix/windows-direct-job-owner-1734
# Conflicts:
#	CHANGELOG.md
2026-09-02 03:48:02 +05:30
Palash Debnath 8cc978588d Merge pull request #1754 from debpalash/fix/backend-startup-budget-1749
fix(frontend): align backend startup stall budget
2026-09-02 03:28:13 +05:30
Palash Debnath 6f80110a42 fix(windows): await timed-out Job teardown 2026-09-02 03:15:16 +05:30
Palash Debnath c5ac548100 Merge remote-tracking branch 'origin/main' into fix/windows-direct-job-owner-1734
# Conflicts:
#	CHANGELOG.md
2026-09-02 03:14:09 +05:30
Palash Debnath 26d7a333e8 Merge remote-tracking branch 'origin/main' into fix/backend-startup-budget-1749
# Conflicts:
#	CHANGELOG.md
2026-09-02 03:12:28 +05:30
Palash Debnath 75eb7c6099 test: make repository ID bound deterministic (#1757)
Replaces a runner-speed-dependent security assertion with a deterministic proof that oversized repository IDs are rejected before library validation. Repairs the red post-merge main run from #1755.
2026-09-02 02:51:20 +05:30
Palash Debnath 1d06c6f079 fix: accept ASR-detected dub source codes (#1755)
Accepts every Whisper language code persisted by ASR, including three-letter Cantonese yue, so subsequent dubbing uploads no longer fail validation. Closes #1737.
2026-09-02 02:26:56 +05:30
Palash Debnath 267ded0e79 fix(windows): simplify nested job cleanup 2026-09-02 01:44:52 +05:30
Palash Debnath 549a56fc2d fix: remove Windows sidecar supervisor hop 2026-09-02 01:38:05 +05:30
Palash Debnath 6e47b15e82 test: pin backend stall timeout boundary 2026-09-02 01:29:22 +05:30
Palash Debnath 5e08dde5e0 fix: align backend startup stall budget 2026-09-02 01:24:27 +05:30
342 changed files with 26698 additions and 4014 deletions
+31 -1
View File
@@ -11,6 +11,11 @@ on:
push:
branches: [main]
workflow_dispatch:
inputs:
windows_wix_diagnostic:
description: Run only the tiny nonpublishing Windows MSI authoring diagnostic
type: boolean
default: false
permissions:
contents: read
@@ -21,6 +26,7 @@ env:
jobs:
test:
if: ${{ !inputs.windows_wix_diagnostic }}
name: Tests (backend + frontend)
runs-on: ubuntu-22.04
env:
@@ -189,6 +195,7 @@ jobs:
# and `cargo test --lib` runs the shell's unit tests natively on each OS.
# Full bundling stays in release.yml on tag push.
tauri-cross-platform:
if: ${{ !inputs.windows_wix_diagnostic }}
name: Tauri shell check (${{ matrix.label }})
needs: test
strategy:
@@ -289,6 +296,7 @@ jobs:
# job above misses. Narrow scope (tests/smoke/ only) — full pytest stays
# on Linux until Phase 1's INST-01 lands setuptools for WhisperX.
smoke-matrix:
if: ${{ !inputs.windows_wix_diagnostic }}
name: Smoke (${{ matrix.label }})
needs: test
strategy:
@@ -428,8 +436,9 @@ jobs:
PY
- name: Run smoke tests
# Exercise credential paths on native Windows as well as POSIX hosts.
if: matrix.backend_supported
run: uv run --no-sync pytest tests/smoke/ -q --tb=short
run: uv run --no-sync pytest tests/smoke/ tests/test_hf_token_cache_paths.py -q --tb=short
env:
HF_HUB_OFFLINE: "1" # same no-silent-downloads guard as the main pytest job
HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache
@@ -442,3 +451,24 @@ jobs:
env:
HF_HUB_OFFLINE: "1"
HF_HUB_CACHE: ${{ runner.temp }}/worker-artifact-empty-hf-cache
windows-wix-diagnostic:
name: Windows MSI authoring (no publishing)
needs: test
if: ${{ !cancelled() && (inputs.windows_wix_diagnostic || needs.test.result == 'success') }}
runs-on: windows-2022
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Bundle canonical system and per-user templates with a tiny payload
shell: pwsh
run: ./scripts/diagnose-windows-wix.ps1
- name: Preserve verbose linker output and rendered authoring
if: always()
uses: actions/upload-artifact@v4
with:
name: windows-wix-diagnostic
path: wix-diagnostic-artifacts/
if-no-files-found: warn
retention-days: 3
+39 -24
View File
@@ -148,15 +148,20 @@ jobs:
preview-gate:
name: Preview gate
runs-on: ubuntu-22.04
permissions:
contents: read
outputs:
is_preview: ${{ steps.decide.outputs.is_preview }}
proceed: ${{ steps.decide.outputs.proceed }}
stable_tag: ${{ steps.decide.outputs.stable_tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 50
- id: decide
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
event="${{ github.event_name }}"
@@ -171,6 +176,13 @@ jobs:
exit 1
fi
echo "is_preview=true" >> "$GITHUB_OUTPUT"
# Resolve once before the matrix starts so every platform stamps
# against the same immutable Stable-channel snapshot.
STABLE_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)
[[ "$STABLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "::error::latest stable release has an invalid tag"; exit 1;
}
echo "stable_tag=$STABLE_TAG" >> "$GITHUB_OUTPUT"
else
echo "is_preview=false" >> "$GITHUB_OUTPUT"
fi
@@ -497,35 +509,22 @@ jobs:
echo "APPLE_TEAM_ID=$TID"
} >> "$GITHUB_ENV"
# Stamp each preview build with a unique, monotonically increasing semver
# PRERELEASE so the updater actually offers it (a rolling preview that
# always reported the static 0.3.0 never looked "newer", so no update was
# ever delivered). Ephemeral, CI-only — never committed. Tauri reads the
# bundle + updater version from tauri.conf.json, so rewriting it here
# stamps the artifacts + latest.json. Under the versioning hard rule
# (owner-set 2026-06-11) main is always last-release + 1, so BASE-N is a
# prerelease of the NEXT version and semver-sorts ABOVE the last stable
# (0.3.6-N > 0.3.5) — preview users naturally upgrade past stable, and
# the Windows MSI ProductVersion (which strips the prerelease → 0.3.6)
# is also correctly above the last stable.
# Stamp each preview with a numeric prerelease that is strictly above the
# latest stable release. Main may intentionally retain the released
# version while AUTO_VERSION_BUMP is disabled; in that case the helper
# advances the preview base by one patch so stable users can still opt in
# and receive it. The edit is ephemeral and never committed.
- name: Stamp preview version
if: needs.preview-gate.outputs.is_preview == 'true'
shell: bash
env:
STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }}
run: |
set -euo pipefail
# package.json is the single source of truth; tauri.conf.json reads its
# version from it ("version": "../package.json"), so stamping
# package.json restamps the whole bundle.
CONF=frontend/package.json
BASE=$(jq -r .version "$CONF")
# MSI/WiX requires the semver pre-release identifier to be numeric-only
# (and <= 65535). "preview.N" hard-fails the Windows bundler, so the
# preview stamp is BASE-N — still sorts below the stable BASE for the
# updater, still unique per run.
PREVIEW_VERSION="${BASE}-${{ github.run_number }}"
tmp=$(mktemp)
jq --arg v "$PREVIEW_VERSION" '.version = $v' "$CONF" > "$tmp"
mv "$tmp" "$CONF"
PREVIEW_VERSION=$(python scripts/stamp-preview-version.py \
--package-json frontend/package.json \
--stable-tag "$STABLE_TAG" \
--run-number "${{ github.run_number }}")
echo "Stamped preview version: $PREVIEW_VERSION"
# The rolling `preview` release is REUSED every night, and macOS updater
@@ -605,6 +604,21 @@ jobs:
fi
done < /tmp/stale.txt
# A retried job reuses its version and can collide with installers it
# uploaded before a later step failed. Keep other versions/arches intact;
# macOS versionless updater archives are scoped by release tag and arch.
- name: Clear this target's installer assets on retry
if: github.run_attempt > 1
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }}
RELEASE_TARGET: ${{ matrix.rust_target }}
run: |
VERSION=$(python -c 'import json; print(json.load(open("frontend/package.json"))["version"])')
python scripts/clear-release-rerun-assets.py \
--tag "$RELEASE_TAG" --version "$VERSION" --target "$RELEASE_TARGET"
- name: Build + release (Tauri)
uses: tauri-apps/tauri-action@v0
env:
@@ -660,6 +674,7 @@ jobs:
set -euo pipefail
python ../scripts/render-per-user-wix.py \
--source src-tauri/wix/main.wxs \
--system-wxs src-tauri/target/${{ matrix.rust_target }}/release/wix/x64/main.wxs \
--output src-tauri/target/wix-per-user/main.wxs
bunx tauri build --target ${{ matrix.rust_target }} --bundles msi \
--config src-tauri/tauri.per-user.conf.json
+10
View File
@@ -170,3 +170,13 @@ bin/omnivoice-tts-linux-aarch64
# committed (they ship with the app); the per-language source WAVs are just the
# inputs scripts/render_dub_demo_audio.py hands to scripts/build_dub_demo.sh.
backend/assets/samples/demo/dubbing/*.src.wav
# Stray sqlite session artifacts (`<db-path>.ses`). An in-memory DB yields the
# literal name `:memory:.ses`, and a path containing `:` cannot be checked out
# on Windows at all — committing one fails every Windows CI job at the git
# checkout step, before a single test runs. Guarded by
# tests/test_no_windows_hostile_paths.py.
*.ses
# Generated Windows MSI diagnostic logs and installer payloads
/wix-diagnostic-artifacts/
+2
View File
@@ -25,6 +25,8 @@ regexes = [
'''^hf_QWERTYUIOPasdfghjklZXCVBNM0123456789xyzAB$''',
# NLLB generation length argument, not the value of a credential.
'''^max_length=400$''',
# Dubbing pane split-position localStorage key, not a credential.
'''^omnivoice\.dubSplit\.v1$''',
# cryptography's Ed25519 private-key type name, not key material.
'''^Ed25519PrivateKey$''',
]
+116 -3
View File
@@ -10,24 +10,137 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available.
- The desktop app builds and opens from a fresh clone again (#1818) — thanks @flutterkage2k!
- GPUs with less VRAM than the engine needs no longer get half the compute-time budget a CPU gets (#1806) — thanks @VishvakR!
- Gallery voice previews play again — the quality guard was rejecting good renders as silent (#1819) — thanks @flutterkage2k!
- Tilde-separated number ranges are spoken clearly without running their endpoints together (#1821) — thanks @flutterkage2k!
- Voice modes use themed tabs, with Synthesize and Convert pinned below their scrolling forms (#1823)
- Fix current-user Windows installer validation and nested resource cleanup (#1873)
- Keep generated frontend assets available while building the current-user Windows installer (#1873)
- Voice cloning now starts with a clear upload-or-record choice, reveals recording and reference details only when needed, and keeps sampling controls under Production Overrides (#1817)
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
### Changed
- Casting uses responsive SVG voice cards and searchable speaker menus that stay above surrounding panels (#1823)
- Dubbing aligns output settings, brings review status forward, and simplifies transcript and glossary editing; Launchpad files and voices reflow into responsive grids (#1823)
- Transcript segments use three readable rows for text, timing/status and voice controls, with heights that adapt to wrapping (#1823)
- Dragging the waveform pans horizontally while a click still seeks, keeping the timed transcript aligned (#1823)
- Bulk segment editing uses searchable voice and language menus, readable language names and a responsive selection toolbar (#1823)
- Dubbing overlays playback controls on video, combines waveform and transcript in a compact timeline, and removes header/action background fills (#1823)
- Dubbing uses compact casting, translation and output controls with responsive rows to leave more room for editing (#1823)
- Export uses grouped format settings, themed track menus and switches, with a pinned filename summary and download action (#1823)
- Dubbing output settings use icon-labelled switches, themed track and speaker menus, and clearer timing/transcript controls (#1823)
- Casting voice menus use searchable themed options with SVG preset icons instead of native dropdowns (#1823)
- Dubbing groups casting and translation controls with readable labels, SVG icons, searchable menus, and compact timeline spacing (#1823)
- Production Overrides use readable icon-labelled controls and accessible Denoise/Postprocess switches (#1823)
- Expanded navigation uses a theme-accent tint with subtle static wave gradients (#1823)
- Convert groups source audio, target voice, and timing options into clearer controls; design choices include theme-matched SVG icons (#1823)
- The expandable sidebar reveals workspace labels with restrained active states; language menus adapt to multiple columns on wider screens (#1823)
- Voice design and recording use themed, keyboard-accessible selectors with clearer spacing and labels (#1823)
- Voice tabs and upload/record controls have subtle SVG motion; Text adds clipboard paste and the upload area fills available height (#1823)
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
### Added
### Docs
### Fixed
- Install documentation help now prints correctly on Windows consoles using legacy encodings (#1815) — thanks @dajiaohuang!
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
- `bun run desktop` now opens on a fresh clone: the Vite alias for `@tauri-apps/plugin-dialog` no longer assumes a nested `frontend/node_modules`, which bun's workspace hoisting leaves empty (#1818) — thanks @flutterkage2k!
- Slow backend startups remain running with progress updates, and Retry interrupts startup without stale timeout failures (#1809)
- Backend connection errors report crashes only when recorded evidence exists, and diagnostic waits honor cancellation (#1810)
- A CUDA or ROCm GPU with less VRAM than the engine needs now gets the CPU compute-time budget instead of the shorter accelerated one, since it pages to system RAM and renders slower than the CPU would — applied to local generation, voice conversion, and remote worker deadlines alike (#1806) — thanks @VishvakR!
- Gallery previews no longer fail with "the voice engine returned no audible audio" on perfectly good renders: the degenerate-buzz guard measured spectral flatness over the whole clip (so the value tracked clip length) against a threshold calibrated on a synthetic signal, and rejected real speech in every language tested (#1819) — thanks @flutterkage2k!
- Speak tilde separators in integer, signed, and decimal ranges in English, Korean, Japanese, and Chinese (#1821) — thanks @flutterkage2k!
- Keep recording and conversion work safe while switching methods, synchronize dubbing language controls, and localize timeline controls and timing warnings (#1841)
- Dubbing playback starts before waveform decoding, automatic cast names are readable, and transcript timestamps have more room (#1823)
- The title-bar engine button stays compact and stable while cycling labels, with engine names aligned right (#1823)
- Long dubbing segment errors wrap in a bounded scrollable notice instead of widening the editor (#1823)
- Voice dropdowns match their field width, use theme accents, and show recent voices only once (#1823)
- Language menus no longer show a pale frame around their search header (#1823)
- The notification count stays inside the title bar instead of clipping above the bell (#1823)
- The workspace engine menu opens beside its button instead of at the opposite edge of the page (#1823)
- Cloning reuses the dubbing language picker with flags, search, and single selection, opening above the pinned synthesis controls (#1823)
- The first-run welcome line uses an instruction accepted by OmniVoice and VoiceDesign engines (#1861) — thanks @psiberfunk!
- The header status dot now honors OS Reduce Motion instead of pulsing regardless (#1862) — thanks @psiberfunk!
- Onboarding reads Hugging Face tokens locally, preserves Windows CLI logins, and requires successful discovery before replacing saved credentials (#1852) — thanks @psiberfunk!
- The logs panel no longer reports “All clear” before log retrieval succeeds or while logs contain warnings or errors (#1870) — thanks @motodriver!
- MOSS accelerator routing and status match runtime selection, with CPU fallback when device probing fails (#1830) — thanks @li-lizhe!
- Confucius accelerator routing tolerates failed device probes, and dots.tts keeps safe default precision on non-CUDA hosts (#1831) — thanks @li-lizhe!
- On macOS, the header status dot and kicker no longer render underneath the overlaid traffic lights (#1863) — thanks @psiberfunk!
- The capture widget can hide after recording and recover from being left visible while idle (#1865) — thanks @psiberfunk!
- macOS retains the shared desktop window sizing, resize limits, and file-drop behavior when native chrome is applied (#1865) — thanks @psiberfunk!
- On macOS, the header no longer shows Windows-style minimize/maximize/close buttons alongside the native traffic lights (#1865) — thanks @psiberfunk!
- Release retries replace their own partially uploaded installers without colliding with existing assets (#1871)
- Timed-out voice engines finish process cleanup before retrying, and old timeout callbacks cannot kill replacement engines (#1872)
- Fast macOS process exits no longer turn a completed shutdown into a permission error (#1809)
## [0.5.2] — 2026-09-02
**Highlights**
- Show estimated and measured model, dependency, cache, and temporary disk costs in the engine catalogue (#1718)
- Preview builds now stay newer than Stable even when automatic post-release version bumps are disabled (#1762)
- CosyVoice setup guidance now separates downloaded model files from the runtime that makes the engine available (#1761)
- MCP tools can now keep audio out of agent context by returning files and accepting base-path-confined file inputs (#1760) — thanks @agudmund!
- Hear a dub line as you type it — an opt-in live preview streams TTS for the edited segment (#1769) — thanks @mvanhorn!
- Studio gains a Convert method: re-say any clip in one of your saved voices, speech to speech, fully local (#1765) — thanks @mvanhorn!
- Hardsub video export gains an opt-in karaoke word-highlight caption style (#1764) — thanks @mvanhorn!
- The batch queue can now watch a folder: new videos dropped into it are dubbed automatically (#1768) — thanks @mvanhorn!
- The audiobook player now shows the chapter text and highlights the word being narrated (#1766) — thanks @mvanhorn!
- The dub editor gains a casting board: drag voice chips onto speakers, dropdowns stay in sync (#1767) — thanks @mvanhorn!
### Changed
- Voice Design simplified: the 12-row fine-grained block collapses to one summary line with a five-field editor, English accent and Chinese dialect merge into a single field, and the starting-point chips now show 5 with an overflow toggle (#1793)
### Added
- The audiobook result is now a synced-lyrics player: chapter text follows playback with the current word highlighted and click-to-seek, timed from the render's own chapter durations with a karaoke-style even split — no ASR pass, fully local (#1766) — thanks @mvanhorn!
- The dub CAST strip expands into a project-level casting board: drag voice chips (clone profiles, design presets, Default) onto speaker rows — or pick from a keyboard listbox — writing the same per-speaker cast fields as the existing dropdowns (#1767) — thanks @mvanhorn!
- Studio's new Convert method turns a dropped or recorded clip into an existing voice profile's voice, with optional source-duration matching (#1765) — thanks @mvanhorn!
- Opt-in watch folder on the batch queue: pick a directory once and new videos are auto-enqueued with your last Add-to-queue settings, with pause/stop controls and copy-in-progress protection — files upload as bytes, paths never leave the app (#1768) — thanks @mvanhorn!
- Hardsub export can now burn karaoke word-highlight captions: an opt-in Line | Karaoke control renders a word-timed ASS sweep from timings persisted at transcription, with an even-split fallback for older jobs and translated tracks, plus a `GET /dub/ass/{job_id}` sidecar (#1764) — thanks @mvanhorn!
- Windows releases now include an independently updatable per-user MSI that installs and uninstalls without elevation (#1713)
- Dub segments can now stream live TTS while you edit a translated line — opt-in toggle, existing `/ws/tts` socket, shared generation admission, exports still render at full quality (#1769) — thanks @mvanhorn!
- Engine status and diagnostic bundles now record loaded execution provider, device, precision, fallback stage, accelerator identity, runtime versions, and parent-process memory visibility (#1717)
### Docs
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631).
- Local gigastt is now documented as a supported OpenAI-compatible ASR endpoint, with loopback privacy distinguished from remote servers (#1736) — thanks @ekhodzitsky!
- The CosyVoice guide now states that packaged builds have no one-click runtime installer and records the exact readiness checks exposed by [Discussion 1631](https://github.com/debpalash/VoiceStudio/discussions/1631) (#1761)
- A production private-API guide now covers pinned containers, root credentials, network isolation, streaming proxies, health checks, upgrades, and benchmark evidence (#1720)
- RX 6700 XT/gfx1031 over WSL2 ROCDXG is now explicitly unverified until a published end-to-end GPU workload proves the mapped path (#1716)
### Fixed
- The generation compute-time budget is now a Settings control (Performance & Device) instead of an env-var-only setting the timeout error recommended with no UI path — the error copy points there too, and long CPU/MPS renders get an upfront heads-up before they start (#1787)
- Windows: the backend can now start when the install path contains non-English characters (e.g. a CJK username) on a non-UTF-8 system code page — a new or broken Python environment now builds at an ASCII-safe path automatically (a healthy existing one is never relocated), and a specific error message names the cause and a working fix if the interpreter still crashes in `site` (#1783)
- Exports and other native-picker actions no longer 403 with "Invalid or expired desktop authorization" when the desktop app and backend resolve different data directories, e.g. dev mode or a custom data folder (#1781)
- Voice Design no longer lets you pick a Chinese dialect and an English accent together — the picker keeps them mutually exclusive instead of round-tripping a 400 (#1771)
- The desktop app no longer attaches to an already-running backend on version string alone: it now verifies the backend's actual code fingerprint too, so an orphaned or manually started backend reporting the current version but running older code (e.g. a stale `destination_path` export 422) gets replaced instead of adopted (#1770)
- Korean locale overhauled: 231 mistranslations corrected and all 493 missing keys translated (#1776) — thanks @j30231!
- Japanese "Cleaning…" clone status now reads as denoising instead of housekeeping (#1775) — thanks @j30231!
- The batch dubbing queue now has a UI entry point — a quiet link on the Dub landing (it was previously unreachable: the app switched on a mode nothing ever set) (#1768) — thanks @mvanhorn!
- OpenAI-compatible ASR now requires HTTPS outside loopback and refuses redirects so audio stays on the configured origin (#1736)
- Windows isolated engines now retain direct Job ownership without an extra Python supervisor process that can deadlock the child loader (#1734)
- The setup splash now waits through the backend's full startup budget instead of reporting slow Windows CUDA initialization as stuck after two minutes (#1749)
- Dubbing jobs can now reuse every source-language code produced by automatic ASR detection without a 400 error on the next upload (#1737)
- Incomplete Sherpa-ONNX model snapshots now self-repair before recognizer startup instead of failing on a missing ONNX file (#1733)
- OmniVoice subprocess startup now allows slow packaged Windows Python runtimes to signal readiness before termination (#1711)
+10 -4
View File
@@ -10,10 +10,10 @@ Copyright 2024-present Palash Debnath and VoiceStudio contributors.
VoiceStudio is **free and open-source software, licensed under the GNU
Affero General Public License, Version 3 (AGPL-3.0)**. You are free to use,
copy, modify, and redistribute it — and that **includes commercial and internal
business use**: run the app, use its outputs commercially, sell the audio you
produce with it, provide professional/client services with it, and deploy it
within your organization.
copy, modify, and redistribute it. That **includes commercial and internal
business use** of the application itself. Model weights, tokenizers, and other
third-party assets retain their own terms; this application license does not
grant or summarize rights under those separate terms.
Because this is the **Affero** GPL, one additional obligation applies: if you
modify VoiceStudio and make that modified version available to others over
@@ -41,6 +41,12 @@ is **separately licensed under Apache License 2.0** by its upstream authors and
is not relicensed here. Apache License 2.0 is compatible with, and may be
combined under, the GNU AGPL-3.0. See `pyproject.toml`.
Downloaded model weights are not relicensed by VoiceStudio. The default
`k2-fsa/OmniVoice` model card identifies its code as Apache-2.0 and pretrained
weights as CC-BY-NC. Its `audio_tokenizer/LICENSE` contains separate Boson
Higgs Audio 2 and Meta Llama community terms. A commercial license for
VoiceStudio-owned code does not replace any of those terms.
Third-party dependencies retain their own licenses. See `Cargo.lock`,
`bun.lock`, and `uv.lock` for the resolved set.
+166 -68
View File
@@ -1,26 +1,30 @@
<div align="center">
<a href="https://trendshift.io/repositories/28176?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/28176" alt="debpalash%2FVoiceStudio | Trendshift" width="250" height="55" /></a>
<img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" />
<p><img src="docs/logo.png" alt="VoiceStudio logo" width="120" height="120" /></p>
<h1>VoiceStudio</h1>
<p>
<a href="https://trendshift.io/repositories/28176?utm_source=repository-badge&amp;utm_medium=badge&amp;utm_campaign=badge-repository-28176" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/28176" alt="VoiceStudio ranking on Trendshift" width="220" height="48" /></a>
</p>
<p><sub>Previously OmniVoice-Studio</sub></p>
<h3>Local voice cloning, dubbing, dictation, and long-form audio.</h3>
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, and Linux</p>
<p><strong>Local-first.</strong> No account, API key, subscription, or usage meter for the core workflow.</p>
<h3>Clone voices, dub video, dictate, and produce long-form audio on your own hardware.</h3>
<p>16 TTS engines · 11 ASR engines · 646-language catalogue · macOS, Windows, Linux, and Docker</p>
<p>No account, API key, subscription, or usage meter for the local workflow.</p>
<p>
<a href="#install">Install</a> ·
<a href="#features">Features</a> ·
<a href="#comparison">Compare</a> ·
<a href="#requirements">Requirements</a> ·
<a href="#hardware-recommendations">Hardware</a> ·
<a href="#engines">Engines</a> ·
<a href="#architecture">Architecture</a> ·
<a href="#api">API</a> ·
<a href="#documentation">Docs</a> ·
<a href="#faq">FAQ</a> ·
<a href="README_CN.md"><strong>简体中文</strong></a>
</p>
<p>
<a href="https://github.com/debpalash/VoiceStudio/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/debpalash/VoiceStudio/ci.yml?branch=main&style=flat-square&label=CI" alt="CI status" /></a>
<a href="https://github.com/debpalash/VoiceStudio/stargazers"><img src="https://img.shields.io/github/stars/debpalash/VoiceStudio?style=flat-square&color=f59e0b" alt="GitHub stars" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases"><img src="https://img.shields.io/github/downloads/debpalash/VoiceStudio/total?style=flat-square&color=8b5cf6&label=downloads" alt="Total downloads" /></a>
<a href="https://github.com/debpalash/VoiceStudio/releases/latest"><img src="https://img.shields.io/github/v/release/debpalash/VoiceStudio?style=flat-square&color=10b981" alt="Latest release" /></a>
@@ -38,7 +42,7 @@
</div>
> [!WARNING]
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work or `main` for current fixes. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
> **Active beta.** Use the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) for stable work. `main` contains the newest fixes and may change between releases. Report problems through [GitHub Issues](https://github.com/debpalash/VoiceStudio/issues).
## At a glance
@@ -51,33 +55,63 @@
| **Compute** | CUDA · Apple Silicon MPS/MLX · ROCm on Linux · CPU · optional remote workers |
| **Interfaces** | Desktop app · local REST/SSE/WebSocket API · OpenAI-compatible audio API · MCP Server |
| **Storage** | Voices, projects, settings, and outputs stay on the machine by default |
| **License** | AGPL-3.0; optional engines keep their own model licenses |
| **License** | AGPL-3.0 application; downloaded models keep their upstream terms |
The Voice workspace starts with three tabs: **From audio** for cloning, **By design** for creating a voice, and **Convert** for speech-to-speech conversion. Each tab displays its own workflow, with Synthesize Audio or Convert pinned below the scrolling form. The top-bar **Engines** panel combines engine selection, loaded models, and unload/flush controls; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>E</kbd> opens it. The searchable language picker shares Dubbings flags and language list layout, selects one output language, and retains Auto and the full cloning catalogue. Language options flow into multiple columns when space allows. Expand **Workspaces** in the sidebar to reveal navigation labels; Escape collapses it.
Dubbing places playback controls over the video with background blur and combines the waveform and timed transcript in one compact editing surface. Drag the zoomed waveform left or right to pan; click to seek. Translation language and ISO-code controls stay synchronized; Auto clears any previous language code and dialect. Transcript items group editable text, timing and status, and voice controls into three readable rows that wrap with the panel width. Output Options stays compact with the active settings shown in its summary; expand it to change output, timing, or voice matching. Transcript, glossary, and paste controls share a toolbar above the segment editor. Project details, workflow steps, and Generate/Verify/Export actions use an unfilled header.
Output settings use aligned rows; review status appears before the collapsible transcript and glossary. Glossary terms have labelled entry fields and an explicit edit action. Launchpad arranges recent files and saved voices side by side when space allows, with responsive card grids and visible Open actions.
The casting board shows icon-based voice cards and searchable selectors for each speaker. Drag a card onto a speaker or choose a voice from that speakers menu.
<a id="install"></a>
## Install
Download a package from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest), then follow the platform guide.
| Platform | Package | Guide |
|---|---|---|
| macOS 13.3+ | DMG, Apple Silicon | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | MSI, x64 | [Install on Windows](docs/install/windows.md) |
| macOS 13.3+ | Apple Silicon DMG | [Install on macOS](docs/install/macos.md) |
| Windows 10/11 | x64 MSI; choose the current-user build when listed to install without admin access | [Install on Windows](docs/install/windows.md#install-pre-built-msi) |
| Linux | AppImage, x86_64 with glibc 2.39+ | [Install on Linux](docs/install/linux.md) |
| Docker | CUDA, ROCm, or CPU; worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
| Docker | CUDA, ROCm, CPU, and worker-only GPU profiles | [Run with Docker](docs/install/docker.md) |
Download packages from the [latest release](https://github.com/debpalash/VoiceStudio/releases/latest). First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
First launch creates a managed Python environment and downloads the default model. Later launches reuse both.
> [!NOTE]
> On macOS, first launch needs a one-time right-click **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
> On macOS, first launch needs a one-time right-click, then **Open** approval. Intel Macs cannot run the local Python backend; use a [remote backend](docs/install/macos.md) instead.
### Quick Docker run
```bash
docker run -d -p 127.0.0.1:3900:3900 -v omnivoice-data:/app/omnivoice_data --name voicestudio palashdeb/omnivoice-studio:stable
```
### First voice
1. Launch VoiceStudio and open **Voice Cloning**.
2. Add a clean voice sample. Three seconds works; 515 seconds usually gives a better prompt.
2. Add a clean voice sample. Three seconds works; 5 to 15 seconds usually gives a better prompt.
3. Enter text, choose a language, then select **Generate**.
> [!TIP]
> **Try without installing:** Run VoiceStudio in the cloud via the [Google Colab notebook](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb). Explore audio quality comparisons in [benchmarks](docs/benchmarks.md) and prompt design tips in [expressive speech](docs/expressive-speech.md).
### Audio samples
Listen to sample outputs produced locally with VoiceStudio:
| Workflow | Prompt / Reference Audio | Generated Audio |
|---|---|---|
| **Voice Cloning** | [demo_voice.wav](backend/assets/samples/demo_voice.wav) | [demo_clone_output.wav](backend/assets/samples/demo_clone_output.wav) |
| **Voice Design** (US News Anchor) | *"Clear, authoritative American broadcast tone"* | [demo_voice_design_us_news_anchor.wav](backend/assets/samples/voice_design/demo_voice_design_us_news_anchor.wav) |
| **Voice Design** (UK Audiobook) | *"Warm, expressive British storytelling voice"* | [demo_voice_design_audiobook_uk_narrator.wav](backend/assets/samples/voice_design/demo_voice_design_audiobook_uk_narrator.wav) |
| **Video Dubbing** (Multilingual) | [source.src.wav](backend/assets/samples/demo/dubbing/source.src.wav) | [Spanish](backend/assets/samples/demo/dubbing/dubbed_es.src.wav) · [French](backend/assets/samples/demo/dubbing/dubbed_fr.src.wav) · [Japanese](backend/assets/samples/demo/dubbing/dubbed_ja.src.wav) · [Chinese](backend/assets/samples/demo/dubbing/dubbed_zh.src.wav) |
### Run from source
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup), then:
Install the [development prerequisites](.github/CONTRIBUTING.md#development-setup) (Node 20+/Bun and Python 3.11+), then:
```bash
git clone https://github.com/debpalash/VoiceStudio.git
@@ -86,7 +120,7 @@ bun install
bun run desktop
```
Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md) for services, tests, and platform packages.
The desktop launcher configures Python dependencies on first run via `uv` automatically. Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md) for services, tests, and platform packages.
### If setup fails
@@ -101,22 +135,22 @@ Use `bun run dev` for the browser UI. See [Contributing](.github/CONTRIBUTING.md
| Area | Included |
|---|---|
| **Voice Cloning** | Zero-shot synthesis from a short reference clip |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video |
| **Voice Cloning** | Zero-shot synthesis from a short reference clip ([guide](docs/engines/README.md)) |
| **Voice Design** | Create a voice from age, accent, pitch, style, and delivery instructions ([expressive speech](docs/expressive-speech.md)) |
| **Video Dubbing** | Transcribe, translate, preserve speakers, synthesize, and export video; compact translation settings include track selection, and completed dubs flag timing issues for review ([export guide](docs/dubbing/export.md)) |
| **Stories and audiobooks** | Multi-voice scripts · EPUB/PDF import · chapter rendering · `.m4b` export |
| **[Dictation Widget](docs/features/dictation.md)** | System-wide shortcut, live transcription, optional local-LLM cleanup |
| **Vocal Isolation** | Demucs speech/background separation |
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment |
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress |
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models |
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress |
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks |
| **Speaker Diarization** | Pyannote and WhisperX speaker assignment ([guide](docs/features/diarization.md)) |
| **Batch Queue** | Queue large sets of audio and video jobs with per-job progress, or watch a local folder for new videos |
| **Model Catalogue** | Install, remove, select, and route TTS, ASR, and LLM models ([catalogue](docs/engines/README.md)) |
| **Remote Model Downloads** | Install models on enrolled remote workers with live progress ([guide](docs/downloading-models.md)) |
| **GPU Auto-Detect** | CUDA, MPS, ROCm, and CPU routing with per-engine checks ([performance](docs/performance.md)) |
| **AI Watermark** | AudioSeal embedding and detection |
| **MCP Server** | Synthesis and transcription tools for MCP clients |
| **Diagnostics** | Self-checks, error journal, logs, and scrubbed support bundles |
| **MCP Server** | Synthesis and transcription tools for MCP clients ([guide](docs/mcp.md)) |
| **Diagnostics** | Self-checks, error journal, logs, and scrubbed support bundles ([troubleshooting](docs/install/troubleshooting.md)) |
| **Local-first** | Core creation stays local; network-backed features are explicit opt-ins |
| **Extensible** | Registry-based TTS, ASR, and plugin interfaces |
| **Extensible** | Registry-based TTS, ASR, and plugin interfaces ([acceptance](docs/engine-acceptance.md)) |
<table>
<tr>
@@ -159,10 +193,20 @@ Requirements vary by engine. These values cover the default local workflow.
| **Disk** | 10 GB free | 20 GB+ SSD |
| **GPU** | Optional; CPU mode is supported | NVIDIA CUDA or Apple Silicon |
| **VRAM** | 4 GB when using a GPU | 8 GB+; large optional engines need more |
| **Python from source** | 3.11+ | 3.113.12 |
| **Python from source** | 3.11+ | 3.11 or 3.12 |
ROCm is Linux-only and opt-in. Windows AMD/Ryzen AI uses CPU. Systems with limited VRAM offload work to CPU when required. See [performance](docs/performance.md), [benchmarks](docs/benchmarks.md), and [engine disk usage](docs/engines/disk-usage.md).
<a id="hardware-recommendations"></a>
### Recommended stack by hardware
| Hardware | Recommended TTS | Recommended ASR | Why |
|---|---|---|---|
| **Apple Silicon (M1M4)** | [MLX-Audio](docs/engines/mlx-audio.md) · [OmniVoice](docs/engines/omnivoice.md) (MPS) | [MLX Whisper](docs/engines/mlx-whisper.md) · [Parakeet MLX](docs/engines/parakeet-mlx.md) | Native unified memory, lowest latency on macOS |
| **NVIDIA GPU (8 GB+ VRAM)** | [OmniVoice](docs/engines/omnivoice.md) · [CosyVoice 3](docs/engines/cosyvoice.md) | [WhisperX](docs/engines/whisperx.md) | High-fidelity zero-shot cloning, word timestamps, diarization |
| **Low VRAM / CPU-only** | [PocketTTS](docs/engines/pockettts.md) · [Sherpa-ONNX](docs/engines/sherpa-onnx.md) · [KittenTTS](docs/engines/kittentts.md) | [Moonshine](docs/engines/moonshine.md) · [Faster-Whisper](docs/engines/faster-whisper.md) (`int8`) | Low memory footprint, optimized CPU inference |
<a id="engines"></a>
## Engines
@@ -175,22 +219,22 @@ Engine support is capability-specific. Check cloning, language, platform, memory
| Engine | Languages | Clone | Instruct | Linux | macOS ARM | Windows | License |
|---|:---:|:---:|:---:|:---:|:---:|:---:|---|
| **VoiceStudio** (default, powered by k2-fsa/OmniVoice) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **CosyVoice 3** | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **GPT-SoVITS** | 5 | Yes | | CUDA/CPU | | CUDA/CPU | MIT |
| **VoxCPM2** | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
| **MOSS-TTS-Nano** | 20 | Yes | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **KittenTTS** | English | | | CPU | CPU | CPU | MIT |
| **MLX-Audio** | Model-dependent | Varies | Varies | | MLX | | Varies |
| **Sherpa-ONNX** | 20+ | | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **IndexTTS 2.5** ⚡ | ZH · EN · JA · ES · AR | Yes | | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
| **OmniVoice GGUF** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **OmniVoice (subprocess)** ⚡ | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0](LICENSE-NOTICE.md) model |
| **PocketTTS** ⚡ | EN · FR · DE · PT · IT · ES | Yes | | CPU | CPU | CPU | CC-BY-4.0, gated² |
| **Supertonic 3** ⚡ | 31 | | | CPU | CPU | CPU | OpenRAIL-M |
| **MOSS-TTS-v1.5** ⚡ | 31 | Yes | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| **dots.tts** ⚡ | 24 | Yes | | CUDA/CPU | CPU | | Apache-2.0 |
| **Confucius4-TTS** ⚡ | 14 | Yes | | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| [**VoiceStudio** (default, powered by k2-fsa/OmniVoice)](docs/engines/omnivoice.md) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license |
| [**CosyVoice 3**](docs/engines/cosyvoice.md) | 9 + 18 dialects | Yes | Yes | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| [**GPT-SoVITS**](docs/engines/gpt-sovits.md) | 5 | Yes | No | CUDA/CPU | No | CUDA/CPU | MIT |
| [**VoxCPM2**](docs/engines/voxcpm2.md) | 30 | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | Apache-2.0 |
| [**MOSS-TTS-Nano**](docs/engines/moss-tts-nano.md) | 20 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| [**KittenTTS**](docs/engines/kittentts.md) | English | No | No | CPU | CPU | CPU | MIT |
| [**MLX-Audio**](docs/engines/mlx-audio.md) | Model-dependent | Varies | Varies | No | MLX | No | Varies |
| [**Sherpa-ONNX**](docs/engines/sherpa-onnx.md) | 20+ | No | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| [**IndexTTS 2.5** ⚡](docs/engines/indextts.md) | ZH · EN · JA · ES · AR | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Bilibili model license¹ |
| [**OmniVoice GGUF** ⚡](docs/engines/omnivoice-gguf.md) | 600+ | Yes | Yes | CUDA/CPU | MPS/CPU | CUDA/CPU | [AGPL-3.0](LICENSE) app · [review the derivative model terms](https://huggingface.co/Serveurperso/OmniVoice-GGUF#license |
| [**OmniVoice (subprocess)** ⚡](docs/engines/omnivoice-subprocess.md) | 600+ | Yes | Yes | CUDA/CPU | MPS | CUDA/CPU | [AGPL-3.0](LICENSE) app · [Apache-2.0 code, CC-BY-NC weights](https://huggingface.co/k2-fsa/OmniVoice#license |
| [**PocketTTS** ⚡](docs/engines/pockettts.md) | EN · FR · DE · PT · IT · ES | Yes | No | CPU | CPU | CPU | CC-BY-4.0, gated² |
| [**Supertonic 3** ⚡](docs/engines/supertonic3.md) | 31 | No | No | CPU | CPU | CPU | OpenRAIL-M |
| [**MOSS-TTS-v1.5** ⚡](docs/engines/moss-tts-v15.md) | 31 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
| [**dots.tts** ⚡](docs/engines/dots-tts.md) | 24 | Yes | No | CUDA/CPU | CPU | No | Apache-2.0 |
| [**Confucius4-TTS** ⚡](docs/engines/confucius4-tts.md) | 14 | Yes | No | CUDA/CPU | CPU | CUDA/CPU | Apache-2.0 |
⚡ Installed or registered on demand.
@@ -198,6 +242,8 @@ Engine support is capability-specific. Check cloning, language, platform, memory
² PocketTTS shows its gated-access and CC-BY-4.0 terms before first use.
³ The OmniVoice snapshot also includes an audio tokenizer under separate [Boson Higgs Audio 2 and Meta Llama community terms](https://huggingface.co/k2-fsa/OmniVoice/blob/main/audio_tokenizer/LICENSE). VoiceStudio's application license does not replace model or tokenizer terms.
Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voice batch jobs. VoiceStudio rejects those jobs instead of silently changing engines. Heavy engines have separate memory and platform limits; check their engine guide first.
<a id="asr-engines"></a>
@@ -206,17 +252,17 @@ Clone-less engines cannot preserve a reference speaker in dubbing or pinned-voic
| Engine | ID | Languages | Best fit |
|---|---|:---:|---|
| **WhisperX** (default) | `whisperx` | ~100 | Dubbing, subtitles, word-level timing |
| **Faster-Whisper** | `faster-whisper` | ~100 | General cross-platform transcription |
| **Faster-Whisper (isolated)** | `faster-whisper-isolated` | ~100 | Crash-isolated batch transcription |
| **MLX Whisper** | `mlx-whisper` | ~100 | Apple Silicon |
| **PyTorch Whisper** | `pytorch-whisper` | ~100 | CUDA, MPS, and CPU fallback |
| **Parakeet TDT** | `nemo-parakeet` | English + 25 EU | Fast CPU/CUDA transcription |
| **Parakeet TDT v3 (MLX)** | `parakeet-mlx` | 25 EU | Apple Silicon dictation and word timestamps |
| **Moonshine** | `moonshine` | English | Low-power, low-latency ONNX |
| **FunASR** | `funasr` | 50+ | VAD and inline diarization |
| **sherpa-onnx** (live dictation) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
| **OpenAI-compatible** ⚠️ remote | `openai-compat-asr` | Server-dependent | Qwen3-ASR or another compatible endpoint; audio leaves the machine |
| [**WhisperX** (default)](docs/engines/whisperx.md) | `whisperx` | ~100 | Dubbing, subtitles, word-level timing |
| [**Faster-Whisper**](docs/engines/faster-whisper.md) | `faster-whisper` | ~100 | General cross-platform transcription |
| [**Faster-Whisper (isolated)**](docs/engines/faster-whisper-isolated.md) | `faster-whisper-isolated` | ~100 | Crash-isolated batch transcription |
| [**MLX Whisper**](docs/engines/mlx-whisper.md) | `mlx-whisper` | ~100 | Apple Silicon |
| [**PyTorch Whisper**](docs/engines/pytorch-whisper.md) | `pytorch-whisper` | ~100 | CUDA, MPS, and CPU fallback |
| [**Parakeet TDT**](docs/engines/nemo-parakeet.md) | `nemo-parakeet` | English + 25 EU | Fast CPU/CUDA transcription |
| [**Parakeet TDT v3 (MLX)**](docs/engines/parakeet-mlx.md) | `parakeet-mlx` | 25 EU | Apple Silicon dictation and word timestamps |
| [**Moonshine**](docs/engines/moonshine.md) | `moonshine` | English | Low-power, low-latency ONNX |
| [**FunASR**](docs/engines/funasr.md) | `funasr` | 50+ | VAD and inline diarization |
| [**sherpa-onnx** (live dictation)](docs/engines/sherpa-onnx-asr.md) | `sherpa-onnx-asr` | Model-dependent | Streaming CPU dictation |
| [**OpenAI-compatible** ⚠️ configured server](docs/engines/openai-compatible-asr.md) | `openai-compat-asr` | Server-dependent | Local gigastt/Qwen3-ASR or a remote endpoint; audio goes only to that server |
WhisperX and Faster-Whisper retry with `int8` when efficient `float16` is unavailable. Pin `ASR_COMPUTE_TYPE=int8` or `float32` only if automatic selection still fails.
@@ -251,8 +297,8 @@ FastAPI backend
- The desktop talks to a loopback-only backend on `localhost:3900`.
- Loopback API calls need no server key. Remote access requires a share PIN or API key.
- Remote workers and OpenAI-compatible ASR are opt-in. The UI identifies when audio leaves the machine.
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata—not text, audio, file names, or projects.
- Remote workers and OpenAI-compatible ASR are opt-in. Loopback ASR may use HTTP and keeps audio on the machine; non-loopback endpoints require HTTPS, and redirects are not followed.
- Analytics is off until consent. If enabled, it sends allowlisted, content-free usage metadata. It never sends text, audio, file names, or projects.
<a id="api"></a>
@@ -287,12 +333,19 @@ with client.audio.speech.with_streaming_response.create(
response.stream_to_file("speech.wav")
```
The bundled Rust control sidecar also lets Herdr, coding agents, VS Code,
desktop apps, and TUIs trigger the existing system-wide dictation flow or reuse
its safe native insertion. See the [speech platform guide](docs/speech-platform.md).
The full API reference is in **Settings → OpenAPI Reference**. For LAN,
Tailscale, or proxy access, read [API authentication](docs/api-auth.md) before
exposing the backend.
```bash
# Quick test via cURL
curl http://localhost:3900/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "tts-1", "input": "Made on my own hardware.", "voice": "default", "response_format": "wav"}' \
--output speech.wav
```
The bundled Rust control sidecar lets Herdr, coding agents, VS Code, desktop apps,
and TUIs trigger the system-wide dictation flow or reuse its native text
insertion. See the [speech platform guide](docs/speech-platform.md). The full API
reference is in **Settings → OpenAPI Reference**. For LAN, Tailscale, or proxy
access, read [API authentication](docs/api-auth.md) before exposing the backend.
### Agent skills
@@ -305,6 +358,36 @@ npx skills add debpalash/VoiceStudio
- `omnivoice`: synthesize speech and transcribe audio through local VoiceStudio.
- `oss-maintainer`: the repository's open-source maintenance workflow.
### Model Context Protocol (MCP)
VoiceStudio mounts an MCP server at `http://localhost:3900/mcp` for Claude Desktop, Cursor, and AI agents:
```json
{
"mcpServers": {
"voicestudio": {
"url": "http://localhost:3900/mcp"
}
}
}
```
For clients requiring stdio transport, use the bundled local shim (`docs/mcp.json`):
```json
{
"mcpServers": {
"voicestudio": {
"command": "python",
"args": ["-m", "backend.mcp_shim"],
"cwd": "/path/to/VoiceStudio"
}
}
}
```
See the [MCP guide](docs/mcp.md) for tools (`generate_speech`, `clone_voice`, `transcribe`), file streaming modes, and client bindings.
### Google Colab
[![Open in Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
@@ -326,6 +409,8 @@ The [notebook](notebooks/OmniVoice_Studio_Colab.ipynb) runs the app and web UI o
| Track changes | [Changelog](CHANGELOG.md) · [roadmap](docs/ROADMAP.md) · [latest release](https://github.com/debpalash/VoiceStudio/releases/latest) |
| Remove everything | [Uninstall guide](docs/install/uninstall.md) |
<a id="faq"></a>
## FAQ
<details>
@@ -337,19 +422,19 @@ Apple Silicon is supported with MPS and MLX options. Intel Macs cannot run the l
<details>
<summary><strong>How much VRAM do I need?</strong></summary>
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 1216 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
A GPU is optional. Use 4 GB VRAM as the minimum for accelerated work and 8 GB+ for the default multi-stage workflow. Large optional engines can require 12 to 16 GB or more. Check the [benchmarks](docs/benchmarks.md) and engine guide.
</details>
<details>
<summary><strong>Why does a longer reference clip not always improve the clone?</strong></summary>
Cloning is zero-shot: the clip is a prompt, not training data. Use 515 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
Cloning is zero-shot: the clip is a prompt, not training data. Use 5 to 15 seconds of one speaker, close to the microphone, without music, noise, or reverb. Match the tone and pace you want in the output. For training, see [data preparation](docs/data_preparation.md) and [training](docs/training.md).
</details>
<details>
<summary><strong>Can I use generated audio commercially?</strong></summary>
Yes under VoiceStudio's AGPL-3.0 terms. Optional engines and model weights may use different licenses; review the selected engine's license before commercial use.
VoiceStudio's application license does not restrict generated audio, but it does not grant rights under a model's separate terms. The default OmniVoice repository labels its pretrained weights CC-BY-NC and includes a tokenizer under separate community terms. Review the selected model terms before commercial use.
</details>
<details>
@@ -371,17 +456,30 @@ Use `scripts/uninstall.sh` on macOS/Linux or `scripts\uninstall.ps1` on Windows.
- [Good first issues](https://github.com/debpalash/VoiceStudio/labels/good%20first%20issue) for a scoped starting point.
- [Contributing guide](.github/CONTRIBUTING.md) for setup, tests, and pull requests.
<p align="center">
<a href="https://star-history.com/#debpalash/VoiceStudio&Date">
<img src="https://api.star-history.com/svg?repos=debpalash/VoiceStudio&type=Date" alt="Star History Chart" width="100%" />
</a>
</p>
## Support development
VoiceStudio is free and has no paid tier. Donations fund development and infrastructure.
[Ko-fi](https://ko-fi.com/debpalash) · [PayPal](https://paypal.me/palashCoder) · [Sponsorship details](SPONSORS.md)
## Responsible use and safety
VoiceStudio enables zero-shot voice cloning and speech generation on personal hardware. Please use it responsibly:
- **Consent:** Only clone or synthesize voices with explicit permission from the speaker.
- **Audio provenance:** VoiceStudio integrates [AudioSeal](https://github.com/facebookresearch/audioseal) imperceptible watermarking by default to detect and identify synthetic speech without altering sound quality.
- **Local privacy:** For the default local workflow, audio recordings, transcripts, voices, and projects remain strictly on your local disk; data leaves your device only when you explicitly configure remote workers or external ASR endpoints.
## License
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, use it internally, and sell generated audio. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license is available for proprietary embedding; contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
VoiceStudio is licensed under [AGPL-3.0](LICENSE). You may run it, modify it, and use it internally. The application license itself does not restrict selling generated audio, but downloaded model and tokenizer terms may. If you modify VoiceStudio and provide that modified version as a network service, AGPL requires you to offer the corresponding source under the same license. A commercial license for VoiceStudio-owned code is available for proprietary embedding; it does not relicense third-party models. Contact **VoiceStudio@palash.dev**. See [LICENSE-NOTICE.md](LICENSE-NOTICE.md) for the plain-language scope.
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` model remains Apache-2.0 upstream.
Optional engines and downloaded models retain their own licenses. The bundled `omnivoice/` Python code is Apache-2.0 upstream; the default downloaded weights and audio tokenizer use separate terms.
## Acknowledgments
+67 -3
View File
@@ -20,6 +20,7 @@
</p>
<p>
<a href="https://github.com/debpalash/VoiceStudio/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/debpalash/VoiceStudio/ci.yml?branch=main&style=flat-square&label=CI" alt="CI 状态" /></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>
@@ -65,11 +66,27 @@
- 🐧 **Linux** — [docs/install/linux.md](docs/install/linux.md)
- 🐳 **Docker** — [docs/install/docker.md](docs/install/docker.md) · [Docker Hub: `palashdeb/omnivoice-studio`](https://hub.docker.com/r/palashdeb/omnivoice-studio)
```bash
# Docker 快速运行 (CPU / 本地环回模式)
docker run -d -p 127.0.0.1:3900:3900 -v omnivoice-data:/app/omnivoice_data --name voicestudio palashdeb/omnivoice-studio:stable
```
**三步克隆出你的第一个声音:**
1. **安装并启动。** 首次启动会自动搭建 Python 运行环境并下载模型权重——启动画面会逐步显示进度(仅首次,需要几分钟;之后即开即用)。
2. 从启动台打开**语音克隆**,拖入任意声音的 **3 秒音频**
3. **输入一句话,点击生成。** 音频完全属于你——在你的设备上生成保存,支持 646 种语言。
3. **输入一句话,点击生成。** 音频在你的设备上生成保存,支持 646 种语言(商业使用前请审阅所选模型与分词器的许可条款)
### 🎧 音频示例
在线试听 VoiceStudio 本地生成的实际音频样例:
| 工作流 | 提示词 / 参考音频 | 生成音频 |
|---|---|---|
| **声音克隆** | [demo_voice.wav](backend/assets/samples/demo_voice.wav) | [demo_clone_output.wav](backend/assets/samples/demo_clone_output.wav) |
| **声音设计** (美语新闻主播) | *"清晰、权威的美国广播级音色"* | [demo_voice_design_us_news_anchor.wav](backend/assets/samples/voice_design/demo_voice_design_us_news_anchor.wav) |
| **声音设计** (英式有声书) | *"温暖生动的英式故事讲述音色"* | [demo_voice_design_audiobook_uk_narrator.wav](backend/assets/samples/voice_design/demo_voice_design_audiobook_uk_narrator.wav) |
| **视频配音** (多语种) | [source.src.wav](backend/assets/samples/demo/dubbing/source.src.wav) | [西班牙语](backend/assets/samples/demo/dubbing/dubbed_es.src.wav) · [法语](backend/assets/samples/demo/dubbing/dubbed_fr.src.wav) · [日语](backend/assets/samples/demo/dubbing/dubbed_ja.src.wav) · [中文](backend/assets/samples/demo/dubbing/dubbed_zh.src.wav) |
觉得慢?[docs/performance.md](docs/performance.md) 讲清了生成时间到底花在哪里、有哪些调优开关,以及“它变慢了”的三个经典原因。各引擎/设备的实测数据见 [docs/benchmarks.md](docs/benchmarks.md)。
@@ -217,6 +234,16 @@ Hugging Face Token 的配置见
> [!IMPORTANT]
> **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="hardware-recommendations"></a>
### 💡 按硬件推荐引擎配置
| 硬件配置 | 推荐 TTS 引擎 | 推荐 ASR 语音识别 | 优势 |
|---|---|---|---|
| **Apple Silicon (M1M4)** | [MLX-Audio](docs/engines/mlx-audio.md) · [OmniVoice](docs/engines/omnivoice.md) (MPS) | [MLX Whisper](docs/engines/mlx-whisper.md) · [Parakeet MLX](docs/engines/parakeet-mlx.md) | 原生统一内存,macOS 上延迟最低、性能最强 |
| **NVIDIA 显卡 (8 GB+ 显存)** | [OmniVoice](docs/engines/omnivoice.md) · [CosyVoice 3](docs/engines/cosyvoice.md) | [WhisperX](docs/engines/whisperx.md) | 极致零样本克隆品质、字级时间戳对齐与说话人分离 |
| **低显存 / 仅 CPU 设备** | [PocketTTS](docs/engines/pockettts.md) · [Sherpa-ONNX](docs/engines/sherpa-onnx.md) · [KittenTTS](docs/engines/kittentts.md) | [Moonshine](docs/engines/moonshine.md) · [Faster-Whisper](docs/engines/faster-whisper.md) (`int8`) | 超低内存占用,针对 CPU 指令集深度优化 |
<a id="tts-engines"></a>
### 🗣️ TTS 引擎
@@ -338,9 +365,9 @@ print(result.text)
### 📓 在 Google Colab 上运行
[![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)
[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/debpalash/VoiceStudio/blob/main/notebooks/OmniVoice_Studio_Colab.ipynb)
没有本地 GPU?官方笔记本([notebooks/VoiceStudio_Studio_Colab.ipynb](notebooks/VoiceStudio_Studio_Colab.ipynb))可在免费的 Colab T4 上启动完整应用(包含 Web 界面):在笔记本内直接构建前端,用 uv 安装后端(复用 Colab 预装的 CUDA PyTorch),并通过 Colab 内置端口代理打开界面。无需第三方隧道,也无需任何 API 密钥。随后还有一套覆盖全部主要功能的 API 导览,全部可在笔记本内直接播放:多语言 TTS、声音克隆与声音设计、已保存的声音档案、语音转写、AI 水印检测、OpenAI 兼容 API、多角色故事、带章节的 m4b 有声书,以及一个附带人声分离音轨的迷你视频配音。
没有本地 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 有声书,以及一个附带人声分离音轨的迷你视频配音。
### 🤝 智能体技能(Agent Skills
@@ -352,6 +379,36 @@ npx skills add debpalash/omnivoice-studio
内含两个 [skills](https://skills.sh)**`omnivoice`**——让任何智能体通过你的本地安装进行语音合成与转录(包括你克隆的声音),免费且离线;以及 **`oss-maintainer`**——本项目所遵循的维护者方法论,适合任何用智能体运营自己开源项目的人。
### 🔌 模型上下文协议(MCP 服务器)
VoiceStudio 在 `http://localhost:3900/mcp` 挂载了 MCP 服务,可供 Claude Desktop、Cursor 与自主智能体调用:
```json
{
"mcpServers": {
"voicestudio": {
"url": "http://localhost:3900/mcp"
}
}
}
```
对于需要 stdio 管道传输的客户端,请使用内置的本地桥接脚本(`docs/mcp.json`):
```json
{
"mcpServers": {
"voicestudio": {
"command": "python",
"args": ["-m", "backend.mcp_shim"],
"cwd": "/path/to/VoiceStudio"
}
}
}
```
支持 `generate_speech``clone_voice``transcribe` 等工具与流式文件输出模式,详见 [docs/mcp.md](docs/mcp.md)。
---
## 🗺️ 路线图
@@ -542,6 +599,13 @@ VoiceStudio **免费**且采用 **AGPL-3.0** 许可——没有付费版,没
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>
## 🛡️ 负责任使用与安全
VoiceStudio 在个人硬件上提供零样本语音克隆与语音创作能力。我们提倡负责任的技术使用:
- **明确授权:** 严禁在未经说话人本人知情并明确授权的情况下克隆其声音。
- **AI 溯源:** VoiceStudio 默认集成 [AudioSeal](https://github.com/facebookresearch/audioseal) 不可见神经音频水印,在完全不影响听感音质的前提下精准标记合成语音。
- **本地隐私:** 默认本地工作流下,所有音频、声音档案、项目与转录文本始终保存在你的本地设备上;仅当你主动配置远程工作节点或第三方 ASR 端点时,相应数据才会传输到对应服务。
---
<a id="license"></a>
+37 -14
View File
@@ -57,11 +57,12 @@ _PREVIEW_SEED = 42
# 32 reliably converges to speech across the gallery's instruct/script space
# at a one-time (cached) render cost.
_PREVIEW_NUM_STEP = 32
# Spectral-flatness floor below which a render is a degenerate tonal artifact
# rather than speech. Real, mastered speech sits ~0.040.07; a tonal buzz
# collapses to <0.005. 0.015 separates the two with wide margin and sits well
# below even breathy/whisper voices (which are broadband → high flatness).
_DEGENERATE_FLATNESS = 0.015
# Reject near-pure tonal artifacts using mean framed spectral flatness.
# Calibrated against the tracked speech demos exercised by
# test_archetype_preview_quality.py: the quietest (Mandarin dubbing, 44.1 kHz)
# measures ~7.7e-6, while the worst tested tonal buzz measures ~3.3e-9.
# 1e-7 leaves >10x margin on both sides without rejecting low-flatness speech.
_DEGENERATE_FLATNESS = 1e-7
def _preview_key(a: dict) -> str:
@@ -248,24 +249,46 @@ def _is_blank_audio(audio_tensor) -> bool:
return False
_FLATNESS_FRAME = 1024
_FLATNESS_HOP = 512
#: Frames quieter than this fraction of the loudest frame's energy are the gaps
#: between words, not speech; their spectrum is the noise floor and averaging it
#: in drags the measurement toward the value of whatever silence sounds like.
_FLATNESS_FRAME_FLOOR = 1e-4
def _spectral_flatness(audio_tensor) -> Optional[float]:
"""Geometric-mean / arithmetic-mean of the power spectrum.
"""Mean per-frame geometric-mean / arithmetic-mean of the power spectrum.
~1.0 for broadband noise, 0 for a pure tone. The degenerate diffusion
renders this guards against are near-pure tonal buzzes (flatness <0.005),
distinct from both silence (caught by ``_is_blank_audio``) and real speech
(~0.04+). Returns ``None`` if it can't be computed so callers don't act on
a bad measurement.
renders this guards against are near-pure tonal buzzes, distinct from both
silence (caught by ``_is_blank_audio``) and real speech. Returns ``None``
if it can't be computed so callers don't act on a bad measurement.
Measured over short frames and averaged the standard definition. A single
FFT of the whole clip (what this used to do) is not the same quantity: its
frequency resolution grows with clip length, so speech harmonics carve
ever-deeper nulls into the spectrum and the geometric mean collapses. That
made the result depend on how long the clip was rather than on what it
sounded like, and put real speech below the rejection threshold.
"""
try:
import torch
t = audio_tensor if isinstance(audio_tensor, torch.Tensor) else torch.as_tensor(audio_tensor)
t = t.detach().to("cpu", dtype=torch.float32).flatten()
if t.numel() < 1024 or not torch.isfinite(t).all():
t = t.detach().to("cpu", dtype=torch.float32)
if t.ndim > 1:
t = t.mean(dim=0)
t = t.flatten()
if t.numel() < _FLATNESS_FRAME or not torch.isfinite(t).all():
return None
spec = torch.fft.rfft(t * torch.hann_window(t.numel())).abs().pow(2) + 1e-12
return float(torch.exp(torch.mean(torch.log(spec))) / torch.mean(spec))
frames = t.unfold(0, _FLATNESS_FRAME, _FLATNESS_HOP)
spec = torch.fft.rfft(frames * torch.hann_window(_FLATNESS_FRAME)).abs().pow(2) + 1e-12
energy = spec.sum(dim=1)
spec = spec[energy > energy.max() * _FLATNESS_FRAME_FLOOR]
if spec.shape[0] == 0:
return None
return float((torch.exp(spec.log().mean(dim=1)) / spec.mean(dim=1)).mean())
except Exception: # never let the checker itself block a render
return None
+19 -3
View File
@@ -111,6 +111,24 @@ BATCH_WIDTH_ENV = "OMNIVOICE_DUB_BATCH_WIDTH"
#: that costs more than the saving.
_MAX_BATCH_WIDTH = 16
# Bound each allocation while persisting multipart uploads. Video inputs can
# be many gigabytes; `await UploadFile.read()` with no size used to mirror the
# entire file in process memory before writing it back out.
_UPLOAD_CHUNK_BYTES = 1024 * 1024
async def _save_upload(upload: UploadFile, destination: str) -> None:
try:
with open(destination, "wb") as output:
while chunk := await upload.read(_UPLOAD_CHUNK_BYTES):
output.write(chunk)
except BaseException:
try:
unlink_if_present(destination)
except FileCleanupError:
logger.warning("Could not remove incomplete batch upload", exc_info=True)
raise
def _native_batch_width(backend) -> int:
"""How many segments to render in one native batch on THIS host.
@@ -690,9 +708,7 @@ async def enqueue_batch_job(
ext = os.path.splitext(video.filename or "video.mp4")[1] or ".mp4"
video_path = os.path.join(batch_dir, f"{job_id}{ext}")
with open(video_path, "wb") as f:
content = await video.read()
f.write(content)
await _save_upload(video, video_path)
job = {
"id": job_id,
+4 -9
View File
@@ -558,11 +558,6 @@ def _detected_source_lang(value: str | None) -> str:
return short if short in _DUB_SOURCE_LANG_CODES else "en"
def _resolved_source_lang(override: str | None, detected: str | None) -> str:
"""Prefer an explicit source while preserving a valid ASR language code."""
return override or _detected_source_lang(detected)
@router.post("/dub/upload")
async def dub_upload(
video: UploadFile = File(...),
@@ -1828,8 +1823,8 @@ async def dub_transcribe_stream(
except Exception as e:
logger.warning("speaker_clone extraction skipped: %s", e)
job["source_lang"] = _resolved_source_lang(
job.get("source_lang_override"), detected_lang
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
@@ -2027,8 +2022,8 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
job["source_lang"] = _resolved_source_lang(
job.get("source_lang_override"), detected_lang
job["source_lang"] = job.get("source_lang_override") or _detected_source_lang(
detected_lang
)
scene_cuts = job.get("scene_cuts") or []
+97 -3
View File
@@ -23,6 +23,7 @@ from services.ffmpeg_utils import (
find_ffmpeg,
run_ffmpeg,
)
from services.karaoke_ass import build_ass, scale_words
from services.video_retime import (
DRIFT_TOLERANCE_S,
RetimeError,
@@ -403,6 +404,27 @@ def _write_burn_srt(job: dict, exports_dir: str, stamp: str, dual: bool,
return sub_path
def _write_burn_ass(job: dict, exports_dir: str, stamp: str,
fitted_segments: "list[dict] | None" = None,
lang: "str | None" = None) -> str | None:
"""Karaoke variant of ``_write_burn_srt``: word-timed ASS via ``build_ass``.
Same text/timing resolution (``_segments_for_lang`` + fitted-cue overlay,
which also scales per-word times onto the fitted timeline); the basename
is plain ASCII under exports_dir so it is ffmpeg-filter-safe. Returns
None if there are no segments to render.
"""
segments = _segments_for_lang(job, lang)
if not segments:
return None
if fitted_segments:
segments = _apply_fitted_times(segments, fitted_segments)
sub_path = os.path.join(exports_dir, f"burn_subs_{stamp}.ass")
with open(sub_path, "w", encoding="utf-8") as f:
f.write(build_ass(segments))
return sub_path
def _ffmpeg_filter_escape(path: str) -> str:
"""Escape a path for use inside an ffmpeg filter value (subtitles=...).
@@ -515,6 +537,20 @@ def _apply_fitted_times(segments: list[dict], fitted: list[dict]) -> list[dict]:
patched = dict(seg)
patched["start"] = float(cue["start"])
patched["end"] = float(cue["end"])
# Karaoke burn-in: persisted word times live on the original timeline;
# scale them linearly onto the fitted cue span so the highlight sweep
# follows the retimed audio. Degenerate spans drop the words — export
# then falls back to an even split over the fitted span. Inert for
# SRT/VTT, which never read ``words``.
if isinstance(seg.get("words"), list) and seg.get("words"):
scaled = scale_words(
seg["words"], seg.get("start", 0.0), seg.get("end", 0.0),
patched["start"], patched["end"],
)
if scaled is not None:
patched["words"] = scaled
else:
patched.pop("words", None)
out.append(patched)
return out
@@ -577,6 +613,7 @@ async def dub_download(
save_authorization: str = Header("", alias="X-VoiceStudio-Path-Authorization"),
burn_subs: bool = Query(False, description="Burn subtitles into the video stream (forces re-encode). Uses dual-subtitle layout when dual=1."),
dual: bool = Query(False, description="When burn_subs=1, render translated on top of italicised original."),
karaoke: bool = Query(False, description="When burn_subs=1, burn a word-timed karaoke highlight (ASS) instead of line subtitles. Ignored when dual=1 (dual karaoke is unsupported — the line burn renders instead)."),
out_format: str = Query("m4a", description="Audio-only jobs (#119): output container — wav, m4a, mp3, or flac. Ignored for video jobs."),
):
# Strict allowlist on the path param BEFORE it reaches any filesystem
@@ -729,7 +766,18 @@ async def dub_download(
fitted_segments = _fitted_segments_for(job, default_track) if default_track and default_track != "original" else None
# Burn the DEFAULT track's text (P1.2) — it's the audio the viewer hears.
_burn_lang = default_track if default_track and default_track != "original" else None
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang) if burn_subs else None
# Karaoke (word-highlight) burn writes an ASS instead of the line SRT.
# Dual layout keeps the line burn — dual karaoke is out of scope, matching
# the disabled control in the Export drawer. The default (karaoke off)
# takes exactly the legacy SRT path.
sub_path = None
sub_is_ass = False
if burn_subs:
if karaoke and not dual:
sub_path = _write_burn_ass(job, exports_dir, stamp, fitted_segments=fitted_segments, lang=_burn_lang)
sub_is_ass = sub_path is not None
if sub_path is None:
sub_path = _write_burn_srt(job, exports_dir, stamp, dual, fitted_segments=fitted_segments, lang=_burn_lang)
# ── Smart Fit video retime (two-tier) ─────────────────────────────────
# Tier 1 (≤48 chunks): single filter_complex graph inlined into the mux
@@ -829,14 +877,16 @@ async def dub_download(
esc = _ffmpeg_filter_escape(sub_path)
# Burn AFTER any retime so cues (already on the fitted timeline for
# Smart Fit) land on the retimed video. Without retime this reduces
# to the legacy `[0:v]subtitles=…[vsub]` graph.
# to the legacy `[0:v]subtitles=…[vsub]` graph. Karaoke burns the
# word-timed ASS through the ass filter at the same graph position.
if video_map.startswith("["):
sub_src = video_map
elif retimed_idx is not None:
sub_src = f"[{retimed_idx}:v]"
else:
sub_src = "[0:v]"
filter_parts.append(f"{sub_src}subtitles='{esc}'[vsub]")
_sub_filter = "ass" if sub_is_ass else "subtitles"
filter_parts.append(f"{sub_src}{_sub_filter}='{esc}'[vsub]")
video_map = "[vsub]"
if stretch_entry:
orig_dur = float(stretch_entry.get("orig_duration") or job.get("duration") or 0.0)
@@ -1744,6 +1794,50 @@ async def dub_export_vtt(
)
@router.get("/dub/ass/{job_id}")
@router.get("/dub/ass/{job_id}/{filename}")
async def dub_export_ass(
job_id: str,
lang: str = Query(None, description="Track language code. Same text/timing resolution as /dub/srt, rendered as a karaoke (word-highlight) ASS sidecar."),
):
"""Karaoke ASS sidecar — the same script the karaoke burn-in renders.
Raw text body like /dub/srt and /dub/vtt (the Tauri side writes the file
itself; no ?save_path= variant see the comment above /dub/srt).
"""
_job_dir_or_400(job_id)
lang = _safe_lang_or_400(lang)
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = _segments_for_lang(job, lang)
if not segments:
raise HTTPException(status_code=400, detail="No transcript segments available")
# Same strategy-aware cue timing as /dub/srt. The fitted overlay also
# scales word times; the stretch_video cue path has no per-word record,
# so words are dropped and build_ass even-splits over the new spans.
fitted = _fitted_segments_for(job, lang)
if fitted:
segments = _apply_fitted_times(segments, fitted)
else:
cues = _fitted_cue_times(job, lang)
if cues:
segments = [
{**{k: v for k, v in seg.items() if k != "words"}, "start": s, "end": e}
for seg, (s, e) in zip(segments, cues)
]
base_name = os.path.splitext(job.get('filename', 'video'))[0]
dl_name = f"subtitles_{base_name}_karaoke.ass"
return Response(
content=build_ass(segments),
media_type="text/plain",
headers={"Content-Disposition": content_disposition(dl_name)},
)
@router.get("/dub/export-segments/{job_id}")
async def dub_export_segments_zip(job_id: str, lang: str = Query(None)):
import zipfile
+124 -71
View File
@@ -125,6 +125,96 @@ def _profile_instruct(row):
return heal_design_instruct(row["instruct"], vd)
def _resolve_profile_conditioning(row, *, ref_text=None, instruct=None,
seed=None, language=None):
"""Resolve a ``voice_profiles`` row into generation conditioning.
Extracted verbatim from /generate's inline profile-resolution block so
other synthesis routes (POST /convert) share the exact same semantics
lock wins, ``kind`` is authoritative (0005), legacy pre-0004 rows fall
back to the is_locked/instruct inference, and #533's language fill.
Request-supplied values (``ref_text``/``instruct``/``seed``/``language``)
always win over the stored row; only gaps are filled. Returns a dict with
``ref_audio_path`` / ``ref_text`` / ``instruct`` / ``seed`` / ``language``
/ ``kind`` plus ``persist_ref_text`` True when the caller should cache
an auto-transcribed reference transcript back onto the row (#1032).
"""
out = {
"ref_audio_path": None, "ref_text": ref_text, "instruct": instruct,
"seed": seed, "language": language, "kind": None,
"persist_ref_text": False,
}
# `kind` is authoritative (0005): 'design' profiles condition on their
# deterministic rendered sample + instruct; 'clone' on the user's
# reference. Lock always wins (it pins a specific take). Rows from
# pre-0004 DBs mid-upgrade may lack the column → fall back to the legacy
# is_locked/instruct inference.
try:
profile_kind = row["kind"] or "clone"
except (KeyError, IndexError):
profile_kind = "design" if (
row["instruct"] and not row["is_locked"] and not row["ref_audio_path"]
) else "clone"
out["kind"] = profile_kind
if row["is_locked"] and row["locked_audio_path"]:
out["ref_audio_path"] = os.path.join(VOICES_DIR, row["locked_audio_path"])
if not out["ref_text"]:
out["ref_text"] = row["ref_text"]
if not out["instruct"]:
out["instruct"] = _profile_instruct(row)
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
elif profile_kind == "design":
# Rendered sample (if present) carries the voice identity; instruct
# alone is the fallback for legacy archetype rows.
out["ref_audio_path"] = (
os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
)
if out["ref_audio_path"] and not out["ref_text"] and row["ref_text"]:
out["ref_text"] = row["ref_text"]
if not out["instruct"]:
out["instruct"] = _profile_instruct(row)
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
elif row["instruct"] and not row["is_locked"] and not row["ref_audio_path"]:
# Legacy design-shaped row (pre-0004 archetype materialization failure
# path): instruct-only conditioning.
if not out["instruct"]:
out["instruct"] = _profile_instruct(row)
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
else:
out["ref_audio_path"] = (
os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
)
if not out["ref_text"] and row["ref_text"]:
out["ref_text"] = row["ref_text"]
elif out["ref_audio_path"] and not out["ref_text"]:
# Empty stored transcript → the caller's auto-transcribe will run;
# cache its result onto the profile so it runs ONCE, not on every
# generate (#1032 perf regression).
out["persist_ref_text"] = True
if not out["instruct"] and row["instruct"]:
out["instruct"] = row["instruct"]
if out["seed"] is None and row["seed"] is not None:
out["seed"] = row["seed"]
if out["language"] == "Auto":
out["language"] = None
# #533: a profile's stored language must drive generation when the request
# didn't pin one. An EXPLICIT non-Auto request language still wins; we
# only fill the gap. `row` is a sqlite3.Row, so guard the column lookup
# for pre-language DBs mid-upgrade.
if out["language"] is None:
try:
prof_lang = row["language"]
except (KeyError, IndexError):
prof_lang = None
if prof_lang and prof_lang != "Auto":
out["language"] = prof_lang
return out
def _note_generate_progress() -> None:
"""Tell the pool guard this render just finished a unit of work (#1391).
@@ -714,16 +804,23 @@ def _oom_friendly_reraise(e):
) from e
def _generate_timeout_s(text: str, *, execution_device=None) -> float:
def _generate_timeout_s(text: str, *, execution_device=None, min_vram_gb=0.0) -> float:
"""Wall-clock budget for one generate, scaled to the request.
Thin alias for the canonical helper, which moved to
``services.model_manager.generate_timeout_s`` (#1190) so /v1/audio/speech,
batch, dub and archetype previews share it instead of each re-deriving (or,
as they did, silently keeping the flat 300s).
``min_vram_gb`` is the engine's declared VRAM floor. A GPU below it pages to
system RAM and renders slower than this machine's CPU, so it must not be
budgeted as fast hardware (#1804) — the same figure the dispatch already
hands the guard so a timeout message can name the card (#1226/#1222).
"""
from services.model_manager import generate_timeout_s
return generate_timeout_s(text, execution_device=execution_device)
return generate_timeout_s(
text, execution_device=execution_device, min_vram_gb=min_vram_gb,
)
def _run_inference(
@@ -1461,70 +1558,21 @@ async def generate_speech(
row = conn.execute("SELECT * FROM voice_profiles WHERE id=?", (profile_id,)).fetchone()
if row:
resolved_profile_id = profile_id
# `kind` is authoritative (0005): 'design' profiles condition on
# their deterministic rendered sample + instruct; 'clone' on the
# user's reference. Lock always wins (it pins a specific take).
# Rows from pre-0004 DBs mid-upgrade may lack the column → fall
# back to the legacy is_locked/instruct inference.
try:
profile_kind = row["kind"] or "clone"
except (KeyError, IndexError):
profile_kind = "design" if (row["instruct"] and not row["is_locked"] and not row["ref_audio_path"]) else "clone"
history_mode = profile_kind
if row["is_locked"] and row["locked_audio_path"]:
ref_audio_path = os.path.join(VOICES_DIR, row["locked_audio_path"])
if not ref_text:
ref_text = row["ref_text"]
if not instruct:
instruct = _profile_instruct(row)
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
elif profile_kind == "design":
# Rendered sample (if present) carries the voice identity;
# instruct alone is the fallback for legacy archetype rows.
ref_audio_path = os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
if ref_audio_path and not ref_text and row["ref_text"]:
ref_text = row["ref_text"]
if not instruct:
instruct = _profile_instruct(row)
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
elif row["instruct"] and not row["is_locked"] and not row["ref_audio_path"]:
# Legacy design-shaped row (pre-0004 archetype materialization
# failure path): instruct-only conditioning.
if not instruct:
instruct = _profile_instruct(row)
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
else:
ref_audio_path = os.path.join(VOICES_DIR, row["ref_audio_path"]) if row["ref_audio_path"] else None
if not ref_text and row["ref_text"]:
ref_text = row["ref_text"]
elif ref_audio_path and not ref_text:
# Empty stored transcript → the auto-transcribe below will
# run; cache its result onto the profile so it runs ONCE,
# not on every generate (#1032 perf regression).
persist_ref_text_profile_id = profile_id
if not instruct and row["instruct"]:
instruct = row["instruct"]
if used_seed is None and row["seed"] is not None:
used_seed = row["seed"]
if language == "Auto":
language = None
# #533: a profile's stored language must drive generation when the
# request didn't pin one. Without this the German (etc.) archetype
# generates with language=None and the model drifts to English —
# even though the archetype PREVIEW renders correctly (archetypes.py
# passes the language). An EXPLICIT non-Auto request language still
# wins; we only fill the gap. `row` is a sqlite3.Row, so guard the
# column lookup for pre-language DBs mid-upgrade.
if language is None:
try:
prof_lang = row["language"]
except (KeyError, IndexError):
prof_lang = None
if prof_lang and prof_lang != "Auto":
language = prof_lang
# Shared with POST /convert — see _resolve_profile_conditioning
# for the resolution rules (kind-authoritative, lock wins, #533
# language fill, #1032 transcript-cache signal).
_cond = _resolve_profile_conditioning(
row, ref_text=ref_text, instruct=instruct, seed=used_seed,
language=language,
)
history_mode = _cond["kind"]
ref_audio_path = _cond["ref_audio_path"]
ref_text = _cond["ref_text"]
instruct = _cond["instruct"]
used_seed = _cond["seed"]
language = _cond["language"]
if _cond["persist_ref_text"]:
persist_ref_text_profile_id = profile_id
elif ref_audio is not None:
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as f:
@@ -1677,7 +1725,8 @@ async def generate_speech(
local=gpu_gateway.LocalCall(
_remote_only_local_call(_target_label),
what="TTS generate",
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
min_vram_gb=_engine_min_vram_gb,
),
remote=_remote_call,
@@ -1971,7 +2020,8 @@ async def generate_speech(
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
on_abandon=release,
)
)
@@ -1991,7 +2041,8 @@ async def generate_speech(
),
what="TTS generate",
min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
on_abandon=release,
)
)
@@ -2031,7 +2082,8 @@ async def generate_speech(
# Budget scaled to THIS chunk (#1190) — the flat
# 300s here is what made long streamed renders fail
# even after the v0.3.22 scaled budget shipped.
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"]),
timeout=_generate_timeout_s(chunk_text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
on_abandon=release,
)
)
@@ -2191,7 +2243,8 @@ async def generate_speech(
_REMOTE_OP,
local=gpu_gateway.LocalCall(
_local_render, what="TTS generate",
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"]),
timeout=_generate_timeout_s(text, execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb),
min_vram_gb=_engine_min_vram_gb,
on_abandon=release,
),
+10 -11
View File
@@ -35,11 +35,11 @@ class _HFTokenBody(BaseModel):
token: str = Field(..., min_length=1, description="HuggingFace access token")
def _state_response() -> dict:
def _state_response(*, validate: bool = False) -> dict:
"""Return the same shape the React panel renders. Never includes raw token."""
from services import token_resolver
s = token_resolver.state()
s = token_resolver.state(validate=validate)
return {
"active": s["active"],
"sources": [asdict(row) for row in s["sources"]],
@@ -65,8 +65,7 @@ def save_hf_token(body: _HFTokenBody):
@router.delete("/hf-token")
def clear_hf_token(also_clear_hf_cli: bool = Query(False)):
"""Clear the App-source token. Optionally also call huggingface_hub.logout
to clear the canonical HF file. Returns the updated cascade state."""
"""Clear the App token and optionally recognized local Hub token files."""
from services import token_resolver
try:
token_resolver.clear_app_token(also_clear_hf_cli=also_clear_hf_cli)
@@ -82,13 +81,12 @@ def get_hf_token_state(fresh: bool = Query(False)):
``fresh=1`` drops the resolver's whoami validation cache first so the
response re-runs whoami for every source this is what the panel's
"Test now" button sends. Plain GETs (panel mounts) keep the 300s cache
so repeat Settings visits don't hammer the HF API.
"Test now" button sends. Plain GETs only inspect local token presence.
"""
from services import token_resolver
if fresh:
token_resolver.invalidate_cache()
return _state_response()
return _state_response(validate=fresh)
# ── Performance settings (INST-12) ────────────────────────────────────────
@@ -150,7 +148,7 @@ def _compute_device_state() -> dict:
caps = device_caps.detect_host_caps()
env_pin = (os.environ.get("OMNIVOICE_DEVICE") or "").strip().lower()
auto_family = next(
(f for f in ("cuda", "rocm", "xpu", "mps") if f in caps.available_families),
(f for f in device_caps.ACCELERATOR_PRIORITY if f in caps.available_families),
"cpu",
)
value = device_caps.requested_device_override()
@@ -1035,9 +1033,10 @@ def set_asr_openai_compat(body: _ASROpenAICompatBody):
from services import asr_backend, settings_store
if body.base_url is not None:
url = body.base_url.strip().rstrip("/")
if url and not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Base URL must start with http(s)://")
try:
url = asr_backend.normalize_openai_compat_asr_base_url(body.base_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
settings_store.set_text(asr_backend._ASR_OPENAI_COMPAT_BASE_URL_KEY, url)
if body.model is not None:
settings_store.set_text(
+68 -9
View File
@@ -203,8 +203,22 @@ def system_info():
"""
try:
_ffmpeg = find_ffmpeg()
from services import model_manager as _mm
from core import prefs as _prefs_mod
return {
"app_version": APP_VERSION,
"generate_timeout_s": _mm.GPU_JOB_TIMEOUT_S,
"cpu_generate_timeout_s": _mm.CPU_JOB_TIMEOUT_S,
# #1787 review fix: a saved prefs.json value for either key can be
# silently shadowed by an external env var (os.environ.setdefault
# in core.prefs.restore_env is a no-op when one is already
# present) — the Settings panel must say so rather than promise a
# restart will apply a value that never will.
"generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
"OMNIVOICE_GENERATE_TIMEOUT_S"),
"cpu_generate_timeout_shadowed": _prefs_mod.is_env_shadowed(
"OMNIVOICE_CPU_GENERATE_TIMEOUT_S"),
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": CRASH_LOG_PATH,
@@ -240,6 +254,11 @@ def system_info():
logger.exception("system_info failed — returning safe defaults")
return {
"app_version": APP_VERSION,
"generate_timeout_s": 300.0,
"cpu_generate_timeout_s": 600.0,
"generate_timeout_shadowed": False,
"cpu_generate_timeout_shadowed": False,
"code_fingerprint": os.environ.get("OMNIVOICE_BUILD_FINGERPRINT", ""),
"data_dir": DATA_DIR,
"outputs_dir": OUTPUTS_DIR,
"crash_log_path": str(CRASH_LOG_PATH),
@@ -848,6 +867,14 @@ PERSISTENT_KEYS = {
# the Rust sidecar reads OMNIVOICE_PORT at startup and the backend derives
# the LAN-share/UI ports from the others.
"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT",
# Per-job compute-time budgets (#1787). Both are captured at import time
# by services/model_manager.py (GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S), so
# a value saved here takes effect on the NEXT backend restart — same
# contract as OMNIVOICE_PORT above. Restored into os.environ during the
# "env_prefs" startup step (main.py), which runs before model_manager is
# first imported ("ml_imports"), so the restored value is what the module
# captures. The Settings UI must say so (RestartBadge).
"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S",
}
# Sidecar-engine install dirs (OMNIVOICE_INDEXTTS_DIR, …). The one-click
@@ -865,6 +892,16 @@ except Exception: # pragma: no cover — defensive: env panel > installer wirin
# being set so a bad value never reaches uvicorn / the share listener.
_PORT_KEYS = {"OMNIVOICE_PORT", "OMNIVOICE_SHARE_PORT", "OMNIVOICE_UI_PORT"}
# Keys whose value is a wall-clock compute-time budget in seconds (#1787).
# Validated the same way as _PORT_KEYS: reject anything that isn't a
# positive number before it reaches services/model_manager.py. Upper bound is
# generous — long enough that a legitimate multi-hour, audiobook-length CPU
# render is never blocked — but still bounded, so a fat-fingered extra digit
# (300 -> 3000000) can't turn a wedged job into one that silently occupies a
# worker for days before the guard ever fires.
_TIMEOUT_KEYS = {"OMNIVOICE_GENERATE_TIMEOUT_S", "OMNIVOICE_CPU_GENERATE_TIMEOUT_S"}
_MAX_GENERATE_TIMEOUT_S = 21600.0 # 6 hours
@router.post("/system/set-env")
async def set_env_var(body: dict):
@@ -873,7 +910,7 @@ async def set_env_var(body: dict):
Persistent keys (proxy, FFMPEG_PATH, translation provider keys, ) are
saved to ``prefs.json`` so they survive backend restarts (restored at
startup in ``main.py``). HF_TOKEN is persisted via
``huggingface_hub.login()`` (and cleared via ``logout()``). Other keys
``huggingface_hub.login()`` (and cleared with the shared token-file helper). Other keys
are set on ``os.environ`` for the running process.
The loopback-origin gate that previously lived inline here is now applied
@@ -908,6 +945,22 @@ async def set_env_var(body: dict):
status_code=400,
detail=f"Invalid port for {key}: must be between 1024 and 65535.",
)
if key in _TIMEOUT_KEYS:
try:
timeout_n = float(value)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid timeout for {key}: '{value}' is not a number.",
)
if not (0 < timeout_n <= _MAX_GENERATE_TIMEOUT_S):
raise HTTPException(
status_code=400,
detail=(
f"Invalid timeout for {key}: must be greater than 0 "
f"and at most {_MAX_GENERATE_TIMEOUT_S:.0f} seconds."
),
)
os.environ[key] = value
logger.info("Environment variable set (length=%d)", len(value))
@@ -932,14 +985,14 @@ async def set_env_var(body: dict):
# Mirror the persistence on clear — wipe the saved token file too.
if key == "HF_TOKEN":
try:
from huggingface_hub import logout as _hf_logout
_hf_logout()
logger.info("HF token cleared from $HF_HOME/token via logout()")
except Exception as e:
logger.warning("Could not clear HF token file: %s", e)
from services.token_resolver import clear_hf_cli_tokens
clear_hf_cli_tokens()
logger.info("Local Hugging Face token files cleared")
except Exception:
raise HTTPException(status_code=500, detail="Could not clear local Hugging Face token files") from None
# HF_TOKEN persistence is handled above via huggingface_hub.login()/
# logout() — it never touches prefs.json. Everything else in
# clear_hf_cli_tokens() — it never touches prefs.json. Everything else in
# PERSISTENT_KEYS (proxy, FFMPEG_PATH, translation provider keys, …) is
# saved to prefs.json so it survives backend restarts (restored at
# startup in main.py). Non-persistent keys stay process-local.
@@ -950,7 +1003,13 @@ async def set_env_var(body: dict):
else:
prefs_delete(prefs_key)
return {"key": key, "set": bool(value)}
# #1787 review fix: tell the caller up front when the value just saved is
# being shadowed by an external env var — set at THIS process's startup,
# before our own prefs restore ran, so it predicts the next restart too.
# A response that just said {"set": True} let the Settings panel promise
# a restart would apply a value that never will.
from core.prefs import is_env_shadowed
return {"key": key, "set": bool(value), "shadowed": is_env_shadowed(key)}
@router.post("/clean-audio")
@@ -1041,7 +1100,7 @@ def asr_backends():
def hf_token_state():
"""Return the 3-source HF token cascade state for the Settings UI
(Wave 2 React panel consumes this). Never returns the raw token
only a masked preview, whoami username, and per-source validity.
only a masked preview and local presence; no outbound validation.
"""
from dataclasses import asdict
from services import token_resolver
+380
View File
@@ -0,0 +1,380 @@
"""Speech-to-speech voice changer — Studio's Convert method (POST /convert).
The user drops (or records) a source clip, picks an existing voice profile,
and gets the same words back in that profile's voice: the active ASR backend
transcribes the clip (no word timestamps the text is all we need), the
active TTS engine re-synthesizes it conditioned on the profile's reference
audio, and by default the take is pitch-preservingly time-stretched
(ffmpeg atempo, clamped to one well-behaved 0.52.0 stage) so it lands near
the source clip's duration.
Deliberately reuses the /generate choke points instead of re-deriving them:
* profile row conditioning via ``generation._resolve_profile_conditioning``
(lock wins, ``kind`` authoritative, #533 language fill),
* engine resolution via ``services.tts_backend.resolve_generation_backend``
(never a silent OmniVoice fallback; ``require_cloning=True`` refuses
clone-less engines with the actionable switch-engine message),
* synthesis via ``generation._run_backend_inference`` on the guarded GPU
pool (#730 bound + reset; busy/timeout → retryable 503),
* provenance + persistence via ``services.watermark.mark_synthetic_async``
and ``generation._finalize_generation`` (watermark WAV in OUTPUTS_DIR
history row retention prune), marked AFTER the stretch so the take users
keep carries exactly one whole-take mark.
Local-first: no network calls; ASR-model-less installs get the same typed
409 download CTA as /transcribe; a backend mid-shutdown surfaces the global
503 ``[shutting_down]`` (ModelLoadInterruptedByShutdown main.py handler).
Reachability matches /generate: loopback bind by default, with the shared
network-share PIN / API-key middleware gating any non-loopback exposure.
"""
from __future__ import annotations
import asyncio
import functools
import logging
import os
import tempfile
import time
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
router = APIRouter()
logger = logging.getLogger("omnivoice.convert")
#: ffmpeg's atempo filter is well-behaved in [0.5, 2.0] per stage. Convert
#: clamps to ONE stage by design: needing more than 2× either way means the
#: synthesized speech differs so much from the source that "matching" it
#: would produce chipmunk/slow-motion artifacts worse than the mismatch.
ATEMPO_MIN = 0.5
ATEMPO_MAX = 2.0
#: Within this relative tolerance the durations already match — stretching
#: would resample the whole take for an inaudible gain.
_MATCH_TOLERANCE = 0.02
#: Convert clips are short conversational inputs, not long-form media. Stream
#: them to disk in bounded chunks so a network-share client cannot make the
#: backend materialize an arbitrarily large multipart upload in memory.
_MAX_SOURCE_AUDIO_BYTES = 64 * 1024 * 1024
_UPLOAD_CHUNK_BYTES = 1024 * 1024
async def _copy_source_upload(audio: UploadFile, destination) -> int:
"""Stream ``audio`` into ``destination`` with the Convert upload cap."""
total = 0
while True:
chunk = await audio.read(_UPLOAD_CHUNK_BYTES)
if not chunk:
return total
total += len(chunk)
if total > _MAX_SOURCE_AUDIO_BYTES:
raise HTTPException(
status_code=413,
detail="Source audio is too large (maximum 64 MB).",
)
destination.write(chunk)
def _clamped_tempo_ratio(tts_duration_s: float, source_duration_s: float) -> "float | None":
"""The atempo ratio that fits the take into the source duration, or None.
ratio > 1 speeds the take up (it came out longer than the source),
ratio < 1 slows it down. Clamped to a single atempo stage's [0.5, 2.0];
None when either duration is unusable or they already match.
"""
if not source_duration_s or source_duration_s <= 0:
return None
if not tts_duration_s or tts_duration_s <= 0:
return None
ratio = tts_duration_s / source_duration_s
if abs(ratio - 1.0) <= _MATCH_TOLERANCE:
return None
return min(ATEMPO_MAX, max(ATEMPO_MIN, ratio))
async def _match_source_duration(audio_tensor, sample_rate: int, source_duration_s: float):
"""Best-effort pitch-preserving stretch of the take toward the source
clip's duration. Returns the input unchanged when no stretch is needed
or ffmpeg fails a duration mismatch is better than a failed convert."""
n_samples = int(audio_tensor.shape[-1])
ratio = _clamped_tempo_ratio(n_samples / sample_rate, source_duration_s)
if ratio is None:
return audio_tensor
target_samples = max(1, int(round(n_samples / ratio)))
from services.ffmpeg_utils import _pitch_preserving_stretch
try:
return await _pitch_preserving_stretch(audio_tensor, target_samples, sample_rate)
except Exception as e: # noqa: BLE001 — stretch is opt-in polish, never fatal
logger.warning("duration match skipped — atempo stretch failed: %s", e)
return audio_tensor
async def _transcribe_source(tmp_path: str, *, source_lease=None) -> dict:
"""Active-ASR transcription of the uploaded clip (no word timestamps).
Mirrors POST /transcribe: typed 409 + download CTA before any backend
is constructed (never a silent multi-GB auto-download), the guarded GPU
pool dispatch (#730), 504 on timeout, and the same 409 when the loader
degrades onto an engine with no weights on disk (#1185).
"""
from services.asr_backend import (
ASRModelMissingError,
ASRTimeoutError,
asr_model_missing_detail,
asr_model_missing_error,
run_transcribe_guarded,
)
missing = await asyncio.to_thread(asr_model_missing_error, purpose="transcribe")
if missing is not None:
raise HTTPException(
status_code=409,
detail={**missing, "message": asr_model_missing_detail(missing)},
)
def _run():
# `load_*`, not `get_*`: the loader runs ensure_loaded() and degrades
# past an engine whose deep import chain is broken (#1185).
from services.asr_backend import load_active_asr_backend
backend = load_active_asr_backend()
return backend.transcribe(tmp_path, word_timestamps=False)
from services.model_manager import _gpu_pool
release = source_lease.acquire() if source_lease is not None else None
abandoned = False
try:
return await run_transcribe_guarded(
_gpu_pool,
_run,
what="Voice convert",
on_abandon=release,
)
except asyncio.CancelledError:
# The guard now owns the lease token until the native worker drains.
abandoned = True
raise
except ASRTimeoutError as e:
abandoned = True
logger.warning("Convert transcription timed out: %s", e)
raise HTTPException(status_code=504, detail=str(e))
except ASRModelMissingError as e:
raise HTTPException(
status_code=409,
detail={**e.payload, "message": asr_model_missing_detail(e.payload)},
)
finally:
if release is not None and not abandoned:
release()
@router.post("/convert")
async def convert_speech(
audio: UploadFile = File(...),
profile_id: str = Form(...),
match_duration: bool = Form(True),
):
"""Convert a spoken clip into an existing voice profile's voice.
Multipart form: ``audio`` (the source clip), ``profile_id`` (an existing
voice profile), optional ``match_duration`` (default on atempo the take
toward the source clip's length, clamped to 0.52.0×).
Returns JSON ``{audio_url, text, duration_s, id}`` the take is saved to
OUTPUTS_DIR and served from the ``/audio`` mount like every other take.
"""
from core.db import db_conn
from api.routers.generation import _resolve_profile_conditioning, _TempReferenceLease
# ── Profile first: strict 404, unlike /generate's silent skip — Convert
# has no meaning without a target voice.
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (profile_id,)
).fetchone()
if not row:
raise HTTPException(
status_code=404,
detail="That voice profile doesn't exist. It may have been deleted from another tab.",
)
cond = _resolve_profile_conditioning(row)
# ── Save the upload before loading an engine. Every ASR backend (and
# ffprobe) needs a file path; the bounded streaming copy rejects oversized
# network-share requests without materializing them in process memory or
# starting heavyweight model work.
ext = os.path.splitext(audio.filename or "audio.wav")[1] or ".wav"
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
source_lease = None
try:
try:
await _copy_source_upload(audio, tmp)
finally:
tmp.close()
source_lease = _TempReferenceLease(tmp.name)
# ── Engine gate before ASR/TTS work: the shared resolver refuses a
# clone-less engine with the actionable switch-engine message (→ 400),
# and a backend mid-shutdown raises ModelLoadInterruptedByShutdown out
# of the model load → the global 503 [shutting_down] handler.
from services.tts_backend import resolve_generation_backend
try:
backend = await resolve_generation_backend(
require_cloning=True, cloning_purpose="voice conversion",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
result = await _transcribe_source(tmp.name, source_lease=source_lease)
segments = result.get("segments", [])
text = result.get("text", "")
if not text and segments:
text = " ".join(s.get("text", "") for s in segments).strip()
# Same final-text hygiene as /transcribe: strip Whisper hallucination
# loops, then deterministic polish (leading capital + terminal
# punctuation) so the TTS input reads as typed text.
from services.refinement import collapse_repetitive_artifacts
from services.text_polish import polish_text
text = polish_text(collapse_repetitive_artifacts(text))
if not text or not text.strip():
raise HTTPException(
status_code=422,
detail=(
"No speech was recognized in the source clip, so there is "
"nothing to convert. Record or drop a clip with clear, "
"audible speech and try again."
),
)
# #308/#1032 parity with /generate: a clone profile saved without a
# transcript conditions better when its reference clip is transcribed,
# and that transcript is cached onto the row so it happens ONCE, not
# per convert. Best-effort exactly like /generate — a timeout/failure
# degrades to ref_text=None and the engine's own fallback. The ASR
# model is already warm here (the source transcribe above just used it).
if cond["ref_audio_path"] and not cond["ref_text"]:
from api.routers.generation import (
_generate_timeout_s,
_persist_profile_ref_text,
)
from services.asr_backend import transcribe_reference
from services.model_manager import run_on_gpu_pool_guarded
try:
cond["ref_text"] = await run_on_gpu_pool_guarded(
functools.partial(transcribe_reference, cond["ref_audio_path"]),
what="Reference transcribe",
timeout=_generate_timeout_s(""),
)
except TimeoutError as e:
logger.warning(
"reference transcribe hung (%s); using engine ASR fallback", e,
)
cond["ref_text"] = None
if cond["ref_text"] and cond["persist_ref_text"]:
_persist_profile_ref_text(profile_id, cond["ref_text"])
# Source duration for the optional match: the container's own length
# (ffprobe), falling back to the last ASR segment end. Best-effort —
# None just skips the stretch.
source_duration_s = None
if match_duration:
from services.ffmpeg_utils import probe_duration
source_duration_s = await probe_duration(
tmp.name, allowed_root=os.path.dirname(tmp.name),
)
if not source_duration_s and segments:
source_duration_s = max((s.get("end", 0) or 0) for s in segments) or None
# ── Same text choke point as /generate: engine-agnostic normalization
# (numbers→words, junk strip) on the fully resolved language.
from services.text_normalization import normalize_for_tts
language = cond["language"]
text = normalize_for_tts(text, language)
used_seed = cond["seed"]
if used_seed is None:
import random
used_seed = random.randint(0, 2**31 - 1)
from api.routers.generation import (
_finalize_generation,
_generate_timeout_s,
_run_backend_inference,
)
from services.model_manager import (
GpuJobTimeoutError,
GpuPoolBusyError,
run_on_gpu_pool_guarded,
)
start_time = time.time()
_render = functools.partial(
_run_backend_inference,
backend, text, language, cond["ref_audio_path"], cond["ref_text"],
cond["instruct"],
None, # duration — the model picks; match_duration owns pacing
16, 2.0, # num_step / guidance_scale (the /generate defaults)
1.0, # speed
True, True, # denoise / postprocess_output
used_seed,
)
try:
audio_tensor = await run_on_gpu_pool_guarded(
_render,
what="Voice convert",
timeout=_generate_timeout_s(
text, min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
),
min_vram_gb=getattr(type(backend), "min_vram_gb", 0.0),
)
except GpuPoolBusyError as e:
raise HTTPException(
status_code=503, detail=str(e),
headers={"Retry-After": str(e.retry_after),
"X-OmniVoice-Retryable": "true"},
) from e
except GpuJobTimeoutError as e:
raise HTTPException(
status_code=503, detail=str(e),
headers={"Retry-After": "30", "X-OmniVoice-Retryable": "true"},
) from e
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
sample_rate = backend.sample_rate
if match_duration and source_duration_s:
audio_tensor = await _match_source_duration(
audio_tensor, sample_rate, source_duration_s,
)
# Provenance mark AFTER the stretch (one whole-take mark on the audio
# the user actually keeps), then the shared finalize tail — WAV in
# OUTPUTS_DIR, self-healing history row, retention prune, event emit.
from services.watermark import mark_synthetic_async
audio_tensor = await mark_synthetic_async(
audio_tensor, sample_rate, context="convert.finalize",
)
_, meta = await _finalize_generation(
audio_tensor, sample_rate, text=text, history_mode="convert",
ref_audio_path=cond["ref_audio_path"], language=language,
instruct=cond["instruct"], resolved_profile_id=profile_id,
used_seed=used_seed, start_time=start_time,
already_marked=True,
)
return {
"id": meta["id"],
"audio_url": f"/audio/{meta['filename']}",
"text": text,
"duration_s": meta["duration"],
"gen_time_s": meta["gen_time"],
}
finally:
if source_lease is not None:
source_lease.finish_request()
else:
try:
os.unlink(tmp.name)
except OSError:
pass
+15
View File
@@ -26,6 +26,21 @@ class SystemInfoResponse(BaseModel):
model_config = ConfigDict(extra="allow")
app_version: str = ""
# Effective compute-time budgets (seconds) for one synthesis job — the
# values services/model_manager.py's GPU_JOB_TIMEOUT_S / CPU_JOB_TIMEOUT_S
# captured at backend import time (#1787). A value just saved via
# /system/set-env is NOT reflected here until the next restart.
generate_timeout_s: float = 300.0
cpu_generate_timeout_s: float = 600.0
# True when an external env var (shell, `.env`, Docker, …) is currently
# shadowing a prefs.json save for this key — see core.prefs.is_env_shadowed.
generate_timeout_shadowed: bool = False
cpu_generate_timeout_shadowed: bool = False
# #1770: the desktop attach handshake's code fingerprint — whatever
# Tauri set OMNIVOICE_BUILD_FINGERPRINT to when it spawned this process,
# echoed back verbatim. Blank when unset (dev mode, a manually started
# backend). See frontend/src-tauri/src/backend.rs::code_fingerprint_is_current.
code_fingerprint: str = ""
data_dir: str
outputs_dir: str
crash_log_path: str
+20
View File
@@ -15,6 +15,18 @@ def get_app_data_dir():
return os.path.expanduser("~/.omnivoice")
def _configured_hf_token_path():
"""Match Hub's token location without importing or refreshing credentials."""
default_cache = os.path.join(os.path.expanduser("~"), ".cache")
hf_home = os.environ.get("HF_HOME", os.path.join(os.environ.get("XDG_CACHE_HOME", default_cache), "huggingface"))
return os.path.expandvars(os.path.expanduser(os.environ.get("HF_TOKEN_PATH", os.path.join(hf_home, "token"))))
# Snapshot recognized locations before automatic model-cache redirection.
# Explicit cache/token overrides restrict clearing to their selected location.
HF_CLI_TOKEN_PATHS = (_configured_hf_token_path(),)
def _ensure_short_hf_cache_on_windows():
"""Redirect HuggingFace cache to a short path on Windows.
@@ -38,6 +50,14 @@ def _ensure_short_hf_cache_on_windows():
return
short_cache = os.path.join(local_app, "OmniVoice", "hf_cache")
os.makedirs(short_cache, exist_ok=True)
if "HF_TOKEN_PATH" not in os.environ:
global HF_CLI_TOKEN_PATHS
canonical = HF_CLI_TOKEN_PATHS[0]
legacy = os.path.join(short_cache, "token")
HF_CLI_TOKEN_PATHS = tuple(dict.fromkeys((canonical, legacy)))
# Keep existing app-written logins usable without copying credentials.
selected = canonical if os.path.exists(canonical) or not os.path.exists(legacy) else legacy
os.environ.setdefault("HF_TOKEN_PATH", selected)
os.environ["HF_HOME"] = short_cache
os.environ["HF_HUB_CACHE"] = short_cache
+141 -34
View File
@@ -3,13 +3,14 @@
The desktop owns the backend with an OS process group/Job. Engine and
installer operations also need an independently terminable subtree: killing
only their direct child on a timeout leaves uv/git/model workers holding pipes
and mutating files. A small direct-child supervisor bridges both lifetimes.
and mutating files.
On POSIX the supervisor is the unreaped leader of a nested process group. A
control-pipe EOF (including kernel EOF when the backend dies) kills that group;
the parent also drains the group before reaping its stable leader. On Windows
the supervisor assigns the operation, while suspended, to a nested
kill-on-close Job. The outer desktop Job still contains both levels.
On POSIX a small supervisor is the unreaped leader of a nested process group.
A control-pipe EOF (including kernel EOF when the backend dies) kills that
group; the parent also drains the group before reaping its stable leader. On
Windows the backend retains a nested kill-on-close Job directly and assigns
the suspended operation before resuming it. The outer desktop Job remains the
terminal fallback.
Standalone/server launches use the same nested owner, preserving their
independently terminable subtree without relying on ``taskkill`` or discovery.
@@ -263,42 +264,148 @@ class OwnedPopen:
pass
def spawn_owned(argv: list[str], **kwargs: Any) -> "subprocess.Popen | OwnedPopen":
class WindowsJobPopen:
"""Popen-compatible handle whose child tree lives in a retained Job.
Windows Job handles already provide the stable ownership that POSIX needs
a supervisor process group for. Keeping the handle in the backend means an
abrupt backend exit closes it in the kernel and kills the whole operation
tree, without inserting a second Python process in the sidecar loader path
(#1734).
"""
def __init__(self, proc: subprocess.Popen, job: Any, kernel32: Any) -> None:
self._proc = proc
self._job = job
self._kernel32 = kernel32
self._lock = threading.RLock()
self.stdin = proc.stdin
self.stdout = proc.stdout
self.stderr = proc.stderr
@property
def pid(self) -> int:
return self._proc.pid
@property
def args(self) -> Any:
return self._proc.args
@property
def returncode(self) -> Optional[int]:
return self._proc.returncode
def _close_job(self, *, terminate: bool) -> None:
job, self._job = self._job, None
if job is None:
return
try:
if terminate:
self._kernel32.TerminateJobObject(job, 1)
finally:
self._kernel32.CloseHandle(job)
def poll(self) -> Optional[int]:
with self._lock:
rc = self._proc.poll()
if rc is None:
return None
# A successful direct child may leave helpers behind. Match the
# supervisor contract by draining the retained Job before return.
self._close_job(terminate=True)
return rc
def wait(self, timeout: Optional[float] = None) -> int:
try:
rc = self._proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
raise
with self._lock:
self._close_job(terminate=True)
return rc
def terminate(self) -> None:
with self._lock:
self._close_job(terminate=True)
def kill(self) -> None:
self.terminate()
def __getattr__(self, name: str) -> Any:
return getattr(self._proc, name)
def __del__(self) -> None:
try:
self._close_job(terminate=True)
except Exception:
pass # interpreter shutdown; closing the OS handle is best-effort
def _spawn_windows_owned(argv: list[str], kwargs: dict[str, Any]) -> WindowsJobPopen:
"""Start *argv* suspended, assign its tree to a Job, then resume it."""
import ctypes
job, kernel32, wintypes = _windows_job()
child: Optional[subprocess.Popen] = None
popen_kwargs = dict(kwargs)
supplied_env = popen_kwargs.get("env")
operation_env = dict(os.environ if supplied_env is None else supplied_env)
operation_env.pop(_DRAIN_FD_ENV, None)
operation_env.pop(_DESKTOP_MARKER, None)
popen_kwargs["env"] = operation_env
supplied_flags = int(popen_kwargs.pop("creationflags", 0))
popen_kwargs["creationflags"] = supplied_flags | 0x08000000 | 0x00000004
try:
child = subprocess.Popen(argv, **popen_kwargs)
assign = kernel32.AssignProcessToJobObject
assign.argtypes = (wintypes.HANDLE, wintypes.HANDLE)
assign.restype = wintypes.BOOL
if not assign(job, wintypes.HANDLE(child._handle)):
raise OSError(ctypes.get_last_error(), "AssignProcessToJobObject")
_resume_windows_process(kernel32, wintypes, child.pid)
return WindowsJobPopen(child, job, kernel32)
except BaseException:
kernel32.TerminateJobObject(job, 1)
if child is not None:
try:
child.kill()
except OSError:
pass # the suspended child may already have exited
try:
child.wait(timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass # Job termination remains the authoritative cleanup
kernel32.CloseHandle(job)
raise
def spawn_owned(
argv: list[str], **kwargs: Any
) -> "subprocess.Popen | OwnedPopen | WindowsJobPopen":
"""Spawn an operation with a stable, independently terminable owner."""
drain_fd = backend_drain_fd(required=True) if os.name == "posix" else None
if os.name == "nt":
return _spawn_windows_owned(argv, kwargs)
drain_fd = backend_drain_fd(required=True)
control_read, control_write = os.pipe()
result_read, result_write = os.pipe()
control_token = control_read
result_token = result_write
if os.name == "nt":
import msvcrt
control_token = msvcrt.get_osfhandle(control_read)
result_token = msvcrt.get_osfhandle(result_write)
wrapper_argv = _supervisor_argv(
control_token,
result_token,
control_read,
result_write,
argv,
)
wrapper_kwargs = dict(kwargs)
if os.name == "posix":
wrapper_kwargs["start_new_session"] = True
pass_fds = [control_read, result_write]
if drain_fd is not None:
pass_fds.append(drain_fd)
if wrapper_kwargs.get("env") is not None:
wrapper_env = dict(wrapper_kwargs["env"])
wrapper_env[_DESKTOP_MARKER] = "1"
wrapper_env[_DRAIN_FD_ENV] = str(drain_fd)
wrapper_kwargs["env"] = wrapper_env
wrapper_kwargs["pass_fds"] = tuple(pass_fds)
else:
# Python's Windows fd inheritance requires inheritable CRT handles.
# All unrelated descriptors are non-inheritable by default (PEP 446).
os.set_handle_inheritable(control_token, True)
os.set_handle_inheritable(result_token, True)
wrapper_kwargs["close_fds"] = False
wrapper_kwargs["start_new_session"] = True
pass_fds = [control_read, result_write]
if drain_fd is not None:
pass_fds.append(drain_fd)
if wrapper_kwargs.get("env") is not None:
wrapper_env = dict(wrapper_kwargs["env"])
wrapper_env[_DESKTOP_MARKER] = "1"
wrapper_env[_DRAIN_FD_ENV] = str(drain_fd)
wrapper_kwargs["env"] = wrapper_env
wrapper_kwargs["pass_fds"] = tuple(pass_fds)
try:
proc = subprocess.Popen(wrapper_argv, **wrapper_kwargs)
except BaseException:
+2 -1
View File
@@ -274,7 +274,8 @@ _BASE_SCHEMA = """
started_at REAL,
finished_at REAL,
lease_expires_at REAL,
grace_expires_at REAL
grace_expires_at REAL,
deadlines_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_remote_attempts_task ON remote_task_attempts(task_id);
CREATE INDEX IF NOT EXISTS idx_remote_attempts_worker ON remote_task_attempts(worker_id, state);
+25 -4
View File
@@ -35,7 +35,8 @@ import sys
from dataclasses import dataclass
from typing import Literal
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "cpu"]
DeviceFamily = Literal["cuda", "rocm", "mps", "xpu", "npu", "cpu"]
ACCELERATOR_PRIORITY = ("cuda", "rocm", "xpu", "npu", "mps")
# Stable substring stamped onto notes that represent a real kernel-launch risk
# (arch/driver mismatch) — as opposed to advisory notes (multi-GPU, VRAM query
@@ -533,9 +534,13 @@ def _probe() -> HostCaps:
# is the whole truth in that case (CodeRabbit, #1425).
notes.extend(why_no_gpu(torch))
# ── Intel XPU via IPEX ───────────────────────────────────────────────
# Older builds register XPU through IPEX; modern torch exposes it directly.
try:
import intel_extension_for_pytorch # noqa: F401
except Exception:
# Optional IPEX may be absent or incompatible; still probe native torch XPU.
pass
try:
if hasattr(torch, "xpu") and torch.xpu.is_available():
detected.append("xpu")
if not device_name:
@@ -546,7 +551,23 @@ def _probe() -> HostCaps:
pass
notes.append("XPU VRAM not queried (unreliable across IPEX versions)")
except Exception:
# IPEX absent or XPU probe failed — no XPU on this host.
# XPU probe failed — no usable XPU on this host.
pass
# Vendor extensions may register an NPU with torch. Probe only an already
# registered backend; never install or import an optional vendor package.
try:
if hasattr(torch, "npu") and torch.npu.is_available():
detected.append("npu")
if not device_name:
try:
device_name = torch.npu.get_device_name(0)
except Exception:
# An unavailable display name does not invalidate a usable NPU.
pass
notes.append("NPU VRAM not queried")
except Exception:
# Missing or broken vendor backends mean no usable NPU; continue probing.
pass
# ── Apple Silicon MPS ────────────────────────────────────────────────
@@ -579,7 +600,7 @@ def _probe() -> HostCaps:
# Preferred family by priority; cpu when nothing accelerated was detected.
family: DeviceFamily = "cpu"
for pref in ("cuda", "rocm", "xpu", "mps"):
for pref in ACCELERATOR_PRIORITY:
if pref in detected:
family = pref # type: ignore[assignment]
break
+43 -16
View File
@@ -7,6 +7,7 @@ only the unguessable capability token crosses loopback HTTP.
from __future__ import annotations
import json
import logging
import os
import re
import secrets
@@ -14,6 +15,8 @@ import stat
from core.config import DATA_DIR
logger = logging.getLogger("omnivoice.path_authorization")
_TOKEN_RE = re.compile(r"[0-9a-f]{64}\Z")
_KINDS = {
"models_dir",
@@ -40,25 +43,49 @@ def consume(token: str, expected_kind: str) -> str:
if expected_kind not in _KINDS or not _TOKEN_RE.fullmatch(token or ""):
raise PathAuthorizationError("Invalid or expired desktop authorization")
root = _AUTH_DIR
# Distinguish "the store exists but this token isn't in it" (expired /
# already consumed / never issued — normal, no server-side signal) from
# "the store doesn't exist at all" (the desktop app and this backend are
# very likely pointed at different data directories, e.g. a dev backend
# started without OMNIVOICE_DATA_DIR, or a stale custom data folder — see
# #1781). The client-facing message is byte-identical either way (never
# leak local filesystem paths, or even which case occurred, over HTTP —
# CWE-200); the mismatch case additionally gets a server log line so it's
# diagnosable instead of a silent 403. That log line is deliberately
# path-free too (CWE-532: per-user filesystem paths, e.g. a home
# directory username, are sensitive and don't belong in application
# logs) — it names the failure mode, not the directory.
try:
entries = os.scandir(root)
except FileNotFoundError as exc:
logger.warning(
"path authorization store does not exist; the desktop app and "
"this backend likely resolved different data directories "
"(see #1781)"
)
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
except OSError as exc:
raise PathAuthorizationError("Invalid or expired desktop authorization") from exc
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
with entries:
for entry in entries:
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")
raise PathAuthorizationError("Invalid or expired desktop authorization")
claimed = os.path.join(root, f".consuming-{os.getpid()}-{secrets.token_hex(16)}")
os.replace(candidate, claimed)
except OSError as exc:
+50
View File
@@ -91,3 +91,53 @@ def resolve(key: str, *, env: Optional[str] = None, default: Any = None) -> Any:
if v:
return v
return get(key, default)
# ── external-override detection (#1787 review fix) ──────────────────────────
# restore_env() below uses os.environ.setdefault(), so a value already present
# in the process's environment (shell profile, `.env`, Docker `-e`, systemd
# unit, …) silently wins over anything saved in prefs.json — the setdefault
# call is a no-op. That is the right behavior (env stays authoritative,
# matching resolve()'s contract above), but a Settings control that persists a
# value to prefs.json must not tell the user it "took effect after restart"
# when an external source will keep shadowing it on every future restart too.
#
# _EXTERNALLY_PROVIDED records, once per process start, every bare key that
# was ALREADY present in os.environ the moment restore_env() ran — i.e.
# before our own setdefault() calls could have put it there, and before any
# value our Settings UI ever wrote (Settings only ever writes prefs.json plus
# the CURRENT process's os.environ; it never touches a shell profile or `.env`
# file). Snapshotting unconditionally — not only for keys prefs.json already
# has an entry for — means is_env_shadowed() also answers correctly for a key
# a user is about to save for the FIRST time. Membership is stable for the
# life of the process (nothing removes an inherited env var), and since a
# plain restart re-inherits the same shell / container environment, it is
# also a reliable predictor for the NEXT start: if the external source is
# still exporting the key, the next restart will be shadowed again the same
# way.
_EXTERNALLY_PROVIDED: frozenset[str] = frozenset()
def restore_env(data: dict) -> None:
"""Restore ``env.*`` prefs into ``os.environ`` (startup only).
Called once from main.py's ``env_prefs`` step, before any user code reads
``os.environ``. Snapshots which keys were already externally provided
see :func:`is_env_shadowed` then applies every saved ``env.*`` pref via
``setdefault`` (never overriding an explicitly-set env var).
"""
global _EXTERNALLY_PROVIDED
_EXTERNALLY_PROVIDED = frozenset(os.environ.keys())
for k, v in data.items():
if not k.startswith("env.") or not v:
continue
os.environ.setdefault(k[len("env."):], str(v))
def is_env_shadowed(key: str) -> bool:
"""Whether *key* was already present in the environment from a source
other than our own prefs restore, as of the last time :func:`restore_env`
ran. If prefs.json holds (or will hold) a saved value for *key*, that
value is being silently ignored and will be again on the next restart
unless the external source is removed."""
return key in _EXTERNALLY_PROVIDED
+2 -2
View File
@@ -32,8 +32,8 @@ def stream_failure(code: str) -> dict[str, object]:
"code": "generation_timeout",
"detail": (
"Generation exceeded the compute-time limit. The backend is "
"still running; try a shorter passage or raise the generation "
"timeout."
"still running; try a shorter passage, or raise the "
"compute-time budget in Settings → Performance & Device."
),
"retryable": True,
},
+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.5.1"
_FALLBACK_VERSION = "0.5.2"
def _fallback_version() -> str:
+4 -4
View File
@@ -56,15 +56,15 @@ class Confucius4Backend(SubprocessBackend):
id = "confucius4-tts"
display_name = (
"Confucius4-TTS (LLM, 14 langs, cross-lingual zero-shot clone, CUDA/CPU, Apache-2.0)"
"Confucius4-TTS (LLM, 14 langs, cross-lingual zero-shot clone, Apache-2.0)"
)
supports_voice_design = False # timbre comes from a reference clip
# Upstream vocoder rate (config target_sample_rate) — confirmed 22 050 Hz by
# a live run (2026-07-02); still re-read from the sidecar's ready/audio frames.
_DEFAULT_SAMPLE_RATE = 22050
# CUDA fast path + CPU fallback, both exercised (CPU end-to-end validated).
# No MPS claim — upstream has no Metal path.
gpu_compat = ("cuda", "cpu")
# Match device propagation into upstream .to(device). XPU/NPU routing is
# contract-tested, not a claim of physical-hardware synthesis validation.
gpu_compat = ("cuda", "rocm", "xpu", "npu", "cpu")
@classmethod
def is_available(cls) -> tuple[bool, str]:
+13 -2
View File
@@ -104,7 +104,7 @@ def _ensure_clone_on_sys_path() -> None:
def _load_model(stdout):
"""Cold-construct the Confucius4 model (CUDA, else CPU — both validated)."""
"""Cold-construct using an available torch accelerator, with CPU fallback."""
global _model
if _model is not None:
return _model
@@ -115,7 +115,18 @@ def _load_model(stdout):
import torch
from confuciustts.cli.inference import ConfuciusTTS # type: ignore[import-not-found]
device = "cuda" if torch.cuda.is_available() else "cpu"
try:
# Existing manually provisioned venvs may predate torch.accelerator.
current_accelerator = getattr(getattr(torch, "accelerator", None), "current_accelerator", None)
if current_accelerator is None:
device = torch.device("cuda") if torch.cuda.is_available() else None
else:
device = current_accelerator(check_available=True)
device = device.type if device is not None else "cpu" # 'cuda', 'npu', 'mps', 'xpu', 'cpu'
except Exception:
device = "cpu" # Broken accelerator drivers must not block CPU loading.
if device == "mps":
device = "cpu" # MPS was slower than CPU in the existing validation run
_send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50})
_model = ConfuciusTTS(config_path=_config_path(), device=device)
+6 -1
View File
@@ -115,7 +115,12 @@ def _load_runtime(stdout):
from dots_tts.runtime import DotsTtsRuntime # type: ignore[import-not-found]
repo = os.environ.get("OMNIVOICE_DOTS_TTS_MODEL", _DEFAULT_REPO)
default_precision = "bfloat16" if torch.cuda.is_available() else "float32"
# Match DotsTtsRuntime's own CUDA/CPU selection. Its _check_torch_env
# rejects half precision without CUDA, even when an XPU/NPU is available.
try:
default_precision = "bfloat16" if torch.cuda.is_available() else "float32"
except Exception:
default_precision = "float32" # Probe failure must not force half precision.
precision = os.environ.get("OMNIVOICE_DOTS_TTS_PRECISION", default_precision)
optimize = os.environ.get("OMNIVOICE_DOTS_TTS_OPTIMIZE", "0") == "1"
+11 -15
View File
@@ -29,15 +29,11 @@ Do NOT import ``main.py`` from the parent process — it runs under a
different venv (``transformers==5.0.0``) and importing it in-process would
re-introduce the exact conflict this isolation exists to avoid.
Hardware honesty (cross-platform rule): MOSS-TTS-v1.5's upstream documents
only CUDA and CPU. There is **no documented or tested MPS path** the
custom ``trust_remote_code`` modelling code and the separate audio
tokenizer are unverified on Apple Silicon. We therefore advertise
``gpu_compat = ("cuda", "cpu")`` and the sidecar selects ``cuda`` when
present else ``cpu`` it never silently routes to MPS where it might
crash. On Apple Silicon the engine honestly resolves to CPU (slow but
correct), and the engine is opt-in regardless, so it never becomes a
broken default on any platform.
Hardware routing follows the sidecar's runtime-available PyTorch accelerator:
CUDA/ROCm, XPU, or a registered NPU. MPS remains excluded; CPU is the fallback.
XPU/NPU routing is covered with mocked device contracts, not physical-hardware
synthesis certification; users need a compatible torch/vendor runtime in the
isolated engine venv.
"""
from __future__ import annotations
@@ -85,13 +81,13 @@ class MossTTSV15Backend(SubprocessBackend):
id = "moss-tts-v15"
display_name = (
"MOSS-TTS-v1.5 (8B, 31 langs, zero-shot clone, CUDA/CPU, Apache-2.0)"
"MOSS-TTS-v1.5 (8B, 31 langs, zero-shot clone, Apache-2.0)"
)
supports_voice_design = False # requires ref audio for timbre cloning
_DEFAULT_SAMPLE_RATE = 24000
# Honest hardware surface: upstream documents CUDA + CPU only. MPS is
# undocumented / untested, so we do NOT claim it (cross-platform rule).
gpu_compat = ("cuda", "cpu")
# Accelerator routing requires its matching runtime in the isolated venv.
# MPS remains untested and is deliberately excluded.
gpu_compat = ("cuda", "rocm", "xpu", "npu", "cpu")
# ── availability ───────────────────────────────────────────────────────
@@ -111,7 +107,7 @@ 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 VoiceStudio. CUDA or CPU only (no MPS). See "
"and restart VoiceStudio. Install the matching PyTorch runtime. See "
"docs/engines/moss-tts-v15.md for the full install walk-through."
)
if not MOSS_TTS_V15_SIDECAR_SCRIPT.exists():
@@ -119,7 +115,7 @@ class MossTTSV15Backend(SubprocessBackend):
"MOSS-TTS-v1.5 sidecar script missing at "
f"{MOSS_TTS_V15_SIDECAR_SCRIPT} — reinstall VoiceStudio."
)
return True, "ok (CUDA when present, else CPU)"
return True, "ok (runtime-available accelerator or CPU; no MPS)"
@classmethod
def venv_python(cls):
+22 -7
View File
@@ -138,11 +138,12 @@ _state = None
def _load_model(stdout):
"""Cold-construct the MOSS-TTS-v1.5 processor + model.
Device selection is CUDA-or-CPU only MOSS's upstream documents no MPS
path and the custom ``trust_remote_code`` modelling code is untested on
Apple Silicon, so we never route to MPS where it might crash. dtype is
bf16 on CUDA, fp32 on CPU (bf16 CPU ops are spotty). Emits progress
frames so the parent can surface the multi-GB cold-load latency.
Device selection uses the torch.accelerator API to support any backend
(CUDA, NPU, XPU, etc.) automatically. MPS is excluded MOSS's upstream
``trust_remote_code`` modelling code is untested on Apple Silicon. dtype is
bf16 on GPU-class accelerators, fp32 on CPU (bf16 CPU ops are spotty).
Emits progress frames so the parent can surface the multi-GB cold-load
latency.
"""
global _state
if _state is not None:
@@ -154,8 +155,22 @@ def _load_model(stdout):
from transformers import AutoModel, AutoProcessor
repo, revision = _model_source()
device = "cuda" if torch.cuda.is_available() else "cpu"
dtype = torch.bfloat16 if device == "cuda" else torch.float32
# current_accelerator() returns None on CPU-only builds (no accelerator
# compiled in) or when no accelerator is available; fall back to "cpu".
# Existing manually provisioned venvs may predate torch.accelerator.
current_accelerator = getattr(getattr(torch, "accelerator", None), "current_accelerator", None)
try:
if current_accelerator is None:
accel = torch.device("cuda") if torch.cuda.is_available() else None
else:
accel = current_accelerator(check_available=True)
except Exception:
# Optional drivers can fail during probing; CPU loading remains usable.
accel = None
device = accel.type if accel is not None else "cpu" # 'cuda', 'npu', 'mps', 'xpu', 'cpu'
if device == "mps":
device = "cpu" # MOSS is untested on MPS; fall back to CPU for safety
dtype = torch.bfloat16 if device != "cpu" 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")
+9 -7
View File
@@ -585,13 +585,14 @@ def _phase_a_build_inner() -> None:
pass # never block startup on the migration; it retries next launch
# Restore persisted env vars from prefs.json (Settings UI writes them
# there so they survive backend restarts) — before any user code reads
# os.environ, and never overriding an explicitly-set env var.
# os.environ, and never overriding an explicitly-set env var. Also
# snapshots which keys an external source (shell, `.env`, Docker, …)
# already provided, so a Settings control can tell the user their saved
# value is being shadowed instead of silently promising it will apply
# (core.prefs.is_env_shadowed — #1787 review fix).
try:
from core.prefs import _load as _load_all_prefs
_prefs = _load_all_prefs()
for _k, _v in _prefs.items():
if _k.startswith("env.") and _v:
os.environ.setdefault(_k[len("env."):], str(_v))
from core.prefs import _load as _load_all_prefs, restore_env
restore_env(_load_all_prefs())
except Exception:
pass # prefs.json missing or broken — fine on first run
# yt-dlp user-update overlay: must run before anything imports yt_dlp so
@@ -685,11 +686,12 @@ def _phase_a_build_inner() -> None:
settings as settings_router, # Phase 1 AUTH-03: HF token save/clear/state
media_tools as media_tools_router, # Audio tools: ffmpeg/ffprobe/yt-dlp
auth as auth_router,
voice_convert, # Studio Convert: speech-to-speech via ASR → TTS
)
from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402
from api.routers import workers as workers_router # noqa: E402
_router_modules.extend([
system, profiles, exports, generation, dub_core, dub_generate,
system, profiles, exports, generation, voice_convert, dub_core, dub_generate,
dub_export, dub_translate, projects, glossary, engines, tools,
stories, setup, gallery, archetypes, describe_voice, community,
batch, watermark, events, capture, capture_ws, speech_platform, dictation,
+297 -37
View File
@@ -7,8 +7,8 @@ Run standalone:
Tools exposed:
generate_speech text WAV audio (voice clone or design)
clone_voice base64 reference audio new voice profile
transcribe base64 audio text
clone_voice reference audio (base64, or a file path) new voice profile
transcribe audio (base64, or a file path) text
list_voices enumerate saved voice profiles
list_languages available TTS languages
list_personalities voice personality presets
@@ -17,6 +17,18 @@ Tools exposed:
Resources exposed:
voice://{profile_id} voice profile metadata
history://recent last 20 generated audio items
Output mode (OMNIVOICE_MCP_OUTPUT_MODE):
resources generate_speech returns the WAV as base64 inline (the original
contract; default)
files it returns a URL to the render (and, with a base path, a WAV
written there); nothing large ever enters the agent's context
both both of the above
File inputs (OMNIVOICE_MCP_BASE_PATH):
One directory that agents may read audio from (transcribe / clone_voice
`*_path` arguments) and receive files in (files mode). It is the security
boundary: with no base path configured, path-shaped inputs are refused.
"""
from __future__ import annotations
@@ -25,6 +37,8 @@ import base64
import json
import logging
import os
import re
import stat
import sys
logger = logging.getLogger("omnivoice.mcp")
@@ -69,6 +83,244 @@ def _sniff_audio_ext(raw: bytes) -> str:
return ".wav"
# ── Output mode + the base path boundary ─────────────────────────────────
# An LLM agent that receives a WAV as base64 pays for every byte in context:
# a 1.4 s clip already brushes per-result token caps, and a paragraph of
# narration blows them outright. The ElevenLabs MCP settled this with an
# OUTPUT_MODE (files / resources / both) and a BASE_PATH that doubles as the
# security boundary for file-shaped inputs; the same two knobs here, named in
# the OMNIVOICE_* family the rest of the server reads.
_OUTPUT_MODES = ("resources", "files", "both")
_MAX_INPUT_BYTES = 200 * 1024 * 1024
_SAFE_AUDIO_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
def _output_mode() -> str:
"""How generate_speech hands audio back (OMNIVOICE_MCP_OUTPUT_MODE).
'resources' is the original base64-inline contract and stays the default
so existing integrations see no change; 'files' returns a URL to the
render (plus a WAV under the base path when one is configured); 'both'
returns everything. Anything unrecognized falls back to 'resources' with
a warning rather than failing the tool."""
mode = os.environ.get("OMNIVOICE_MCP_OUTPUT_MODE", "resources").strip().lower()
if mode not in _OUTPUT_MODES:
logger.warning(
"OMNIVOICE_MCP_OUTPUT_MODE=%r is not one of %s; using 'resources'",
mode, _OUTPUT_MODES,
)
return "resources"
return mode
def _base_path() -> "str | None":
"""The one directory agents may read audio from and receive files in
(OMNIVOICE_MCP_BASE_PATH), realpath'd; None when unset."""
raw = os.environ.get("OMNIVOICE_MCP_BASE_PATH", "").strip()
if not raw:
return None
return os.path.realpath(os.path.expanduser(raw))
def _resolve_under_base(path: str) -> str:
"""Absolute realpath of ``path`` when it lies inside the base path.
Relative paths resolve against the base; absolute paths must already be
inside it. Both sides are realpath'd, so a symlink pointing outward cannot
smuggle a read in. Raises ValueError with an agent-legible reason when no
base path is configured or the path escapes it."""
base = _base_path()
if base is None:
raise ValueError(
"OMNIVOICE_MCP_BASE_PATH is not set; file paths are refused until it "
"names a directory"
)
candidate = os.path.realpath(os.path.join(base, os.path.expanduser(path)))
if not _path_is_under_base(base, candidate):
raise ValueError(f"{path!r} resolves outside OMNIVOICE_MCP_BASE_PATH")
return candidate
def _opened_file_is_confined(fd: int, resolved: str, base: str) -> bool:
"""Verify that an opened descriptor still names a file under ``base``."""
proc_fd = f"/proc/self/fd/{fd}"
if os.path.exists(proc_fd):
return _path_is_under_base(base, os.path.realpath(proc_fd))
try:
current = os.path.realpath(resolved)
return _path_is_under_base(base, current) and os.path.samestat(
os.fstat(fd), os.stat(current, follow_symlinks=False)
)
except OSError:
return False
def _path_is_under_base(base: str, candidate: str) -> bool:
try:
common = os.path.commonpath([base, candidate])
except ValueError: # different drives on Windows
return False
return os.path.normcase(common) == os.path.normcase(base)
def _open_under_base(path: str, flags: int, *, mode: int = 0o600) -> tuple[int, str]:
"""Open ``path`` without following a component replaced after validation."""
base = _base_path()
if base is None:
raise ValueError(
"OMNIVOICE_MCP_BASE_PATH is not set; file paths are refused until it "
"names a directory"
)
resolved = _resolve_under_base(path)
relative = os.path.relpath(resolved, base)
parts = [part for part in relative.split(os.sep) if part not in ("", ".")]
if not parts or parts[0] == os.pardir:
raise ValueError(f"{path!r} resolves outside OMNIVOICE_MCP_BASE_PATH")
no_follow = getattr(os, "O_NOFOLLOW", 0)
close_on_exec = getattr(os, "O_CLOEXEC", 0)
binary = getattr(os, "O_BINARY", 0)
file_flags = flags | no_follow | close_on_exec | binary
supports_dir_fd = os.open in getattr(os, "supports_dir_fd", ())
directory_flag = getattr(os, "O_DIRECTORY", 0)
if supports_dir_fd and directory_flag:
directory_flags = os.O_RDONLY | directory_flag | no_follow | close_on_exec
directory_fd = os.open(base, directory_flags)
try:
for component in parts[:-1]:
next_fd = os.open(component, directory_flags, dir_fd=directory_fd)
os.close(directory_fd)
directory_fd = next_fd
fd = os.open(parts[-1], file_flags, mode, dir_fd=directory_fd)
finally:
os.close(directory_fd)
else:
fd = os.open(resolved, file_flags, mode)
if not _opened_file_is_confined(fd, resolved, base):
os.close(fd)
raise ValueError(f"{path!r} resolves outside OMNIVOICE_MCP_BASE_PATH")
return fd, resolved
def _read_input_audio(
audio_base64: "str | None",
audio_path: "str | None",
*,
label: str = "audio_base64",
too_big: str = "audio exceeds 200 MB limit",
) -> "tuple[bytes | None, str | None]":
"""Audio bytes from exactly one of the two input lanes, or (None, error).
The base64 lane keeps its data-URI tolerance and 200 MB cap; the path lane
is honored only inside the base path (the security boundary) and applies
the same cap to the file's size before reading it."""
if bool(audio_base64) == bool(audio_path):
return None, f"pass exactly one of {label} or the matching *_path argument"
if audio_path:
try:
fd, _resolved = _open_under_base(audio_path, os.O_RDONLY)
except ValueError as e:
return None, str(e)
except FileNotFoundError:
return None, f"no such file under OMNIVOICE_MCP_BASE_PATH: {audio_path!r}"
except OSError as e:
return None, f"could not safely read {audio_path!r}: {e}"
with os.fdopen(fd, "rb") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode):
return None, f"{audio_path!r} is not a regular file"
if info.st_size > _MAX_INPUT_BYTES:
return None, too_big
raw = handle.read(_MAX_INPUT_BYTES + 1)
if len(raw) > _MAX_INPUT_BYTES:
return None, too_big
if not raw:
return None, f"{label} is empty"
return raw, None
encoded = (
audio_base64.split(",", 1)[-1]
if audio_base64.startswith("data:")
else audio_base64
)
max_encoded_bytes = 4 * ((_MAX_INPUT_BYTES + 2) // 3)
if len(encoded) > max_encoded_bytes:
return None, too_big
raw = _decode_ref_audio(audio_base64)
if raw is None:
return None, f"{label} is not valid base64"
if not raw:
return None, f"{label} is empty"
if len(raw) > _MAX_INPUT_BYTES:
return None, too_big
return raw, None
def _write_output(audio_id: str, raw: bytes) -> str:
"""Land a render under the base path as ``<audio_id>.wav``; returns the path."""
if not _SAFE_AUDIO_ID.fullmatch(audio_id):
raise ValueError("backend returned an invalid X-Audio-Id header")
base = _base_path()
os.makedirs(base, exist_ok=True)
filename = f"{audio_id}.wav"
fd, path = _open_under_base(filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
with os.fdopen(fd, "wb") as handle:
handle.write(raw)
return path
def _post_timeout_s() -> float:
"""Seconds the tools wait on a backend POST (OMNIVOICE_MCP_TIMEOUT_S,
default 120). A CPU host renders a paragraph in minutes and serializes
generations, so an agent behind another render used to hit the fixed
budget with an empty-message timeout; the knob follows the backend's own
OMNIVOICE_GENERATE_TIMEOUT_S when a deployment raises that."""
raw = os.environ.get("OMNIVOICE_MCP_TIMEOUT_S", "").strip()
try:
value = float(raw) if raw else 120.0
except ValueError:
logger.warning("OMNIVOICE_MCP_TIMEOUT_S=%r is not a number; using 120", raw)
return 120.0
return value if value > 0 else 120.0
def _maybe_number(value):
"""A response-header number as a number, or the raw text (e.g. '?')."""
try:
return float(value)
except (TypeError, ValueError):
return value
def _speech_result(audio_id: str, gen_time, duration, raw: bytes, api_base: str) -> dict:
"""The generate_speech reply shaped by the output mode.
The backend already keeps every render on disk and serves it at
``/audio/<audio_id>.wav``, so files mode costs nothing but a URL - plus one
write when a base path invites the WAV into the agent's own directory."""
if not _SAFE_AUDIO_ID.fullmatch(audio_id):
raise ValueError("backend returned an invalid X-Audio-Id header")
mode = _output_mode()
out = {
"audio_id": audio_id,
"generation_time_s": gen_time,
"audio_duration_s": duration,
"format": "wav",
"output_mode": mode,
}
if mode in ("files", "both"):
out["audio_url"] = f"{api_base.rstrip('/')}/audio/{audio_id}.wav"
if _base_path() is not None:
out["output_path"] = _write_output(audio_id, raw)
else:
out["note"] = "set OMNIVOICE_MCP_BASE_PATH to also receive the WAV as a file"
if mode in ("resources", "both"):
out["wav_base64"] = base64.b64encode(raw).decode("ascii")
return out
# ── Lazy imports — keeps startup fast when not using MCP ────────────────
@@ -147,7 +399,7 @@ def create_mcp_server():
async def _api_post_form(path: str, data: dict, files: dict | None = None):
import httpx
async with httpx.AsyncClient(base_url=_api_base(), timeout=120) as c:
async with httpx.AsyncClient(base_url=_api_base(), timeout=_post_timeout_s()) as c:
r = await c.post(path, data=data, files=files or {})
r.raise_for_status()
return r
@@ -190,8 +442,12 @@ def create_mcp_server():
steps: Diffusion steps (8=fast/draft, 16=balanced, 32=quality).
Returns:
JSON with audio_id, generation_time, audio_duration, and
base64-encoded WAV data.
JSON with audio_id, generation_time_s, audio_duration_s and the
audio itself shaped by OMNIVOICE_MCP_OUTPUT_MODE: base64 WAV data
('resources', the default), a URL to the render plus a WAV under
OMNIVOICE_MCP_BASE_PATH when one is set ('files'), or all of the
above ('both'). Prefer 'files' for LLM agents: nothing large
enters the context.
"""
# Per-agent voice binding (Wave 2.2): explicit arg wins; otherwise
# resolve this client's bound profile, then the global default.
@@ -218,18 +474,10 @@ def create_mcp_server():
r = await _api_post_form("/generate", data=form)
audio_id = r.headers.get("X-Audio-Id", "unknown")
gen_time = r.headers.get("X-Gen-Time", "?")
duration = r.headers.get("X-Audio-Duration", "?")
gen_time = _maybe_number(r.headers.get("X-Gen-Time", "?"))
duration = _maybe_number(r.headers.get("X-Audio-Duration", "?"))
wav_b64 = base64.b64encode(r.content).decode("ascii")
return (
f'{{"audio_id":"{audio_id}",'
f'"generation_time_s":{gen_time},'
f'"audio_duration_s":{duration},'
f'"format":"wav",'
f'"wav_base64":"{wav_b64}"}}'
)
return json.dumps(_speech_result(audio_id, gen_time, duration, r.content, _api_base()))
@mcp.tool()
async def list_voices() -> str:
@@ -266,30 +514,39 @@ def create_mcp_server():
)
@mcp.tool()
async def transcribe(audio_base64: str, language: str | None = None) -> str:
async def transcribe(
audio_base64: str | None = None,
audio_path: str | None = None,
language: str | None = None,
) -> str:
"""Transcribe spoken audio to text.
Pass exactly one of audio_base64 or audio_path.
Args:
audio_base64: Base64-encoded audio bytes (wav/mp3/webm/m4a).
audio_path: Path to an audio file under OMNIVOICE_MCP_BASE_PATH
(relative to it, or absolute inside it). The base path is the
security boundary: with none configured, paths are refused.
Prefer this lane for LLM agents - the audio never enters the
agent's context.
language: Optional language hint; omit for auto-detect.
Returns:
JSON with the recognized text, language, and duration.
"""
try:
raw = base64.b64decode(audio_base64, validate=True)
except Exception:
return '{"error":"audio_base64 is not valid base64"}'
# 200 MB cap — same spirit as voicebox's transcribe gate. Keeps a
# buggy/hostile agent from posting an unbounded blob.
if len(raw) > 200 * 1024 * 1024:
return '{"error":"audio exceeds 200 MB limit"}'
# 200 MB cap on both lanes — same spirit as voicebox's transcribe
# gate. Keeps a buggy/hostile agent from posting an unbounded blob.
raw, err = _read_input_audio(audio_base64, audio_path)
if err:
return json.dumps({"error": err})
data = {}
if language:
data["language"] = language
r = await _api_post_form(
"/transcribe", data=data,
files={"audio": ("audio.wav", raw, "application/octet-stream")},
files={"audio": (f"audio{_sniff_audio_ext(raw)}", raw,
"application/octet-stream")},
)
return str(r.json())
@@ -319,15 +576,17 @@ def create_mcp_server():
@mcp.tool()
async def clone_voice(
name: str,
ref_audio_base64: str,
ref_audio_base64: str | None = None,
ref_text: str = "",
instruct: str = "",
language: str = "Auto",
ref_audio_path: str | None = None,
) -> str:
"""Clone a new voice profile from a reference audio sample.
The new voice is immediately available for use with generate_speech
(pass the returned profile_id as the profile_id argument).
(pass the returned profile_id as the profile_id argument). Pass
exactly one of ref_audio_base64 or ref_audio_path.
Args:
name: A human-friendly name for the cloned voice.
@@ -338,19 +597,20 @@ def create_mcp_server():
quality for some engines).
instruct: Optional style instruction (e.g. 'whisper', 'excited').
language: Language of the reference audio (ISO code or 'Auto').
ref_audio_path: Path to the reference audio under
OMNIVOICE_MCP_BASE_PATH (relative to it, or absolute inside
it); refused when no base path is configured. Prefer this
lane for LLM agents - the clip never enters the context.
Returns:
JSON with the new profile's id, name, and kind.
"""
# Reject oversized inputs before decoding (base64 is always larger
# than raw, so this is a safe lower bound on the decoded size).
if len(ref_audio_base64) > 200 * 1024 * 1024:
return '{"error":"reference audio exceeds 200 MB limit"}'
raw = _decode_ref_audio(ref_audio_base64)
if raw is None:
return '{"error":"ref_audio_base64 is not valid base64"}'
if not raw:
return '{"error":"ref_audio_base64 is empty"}'
raw, err = _read_input_audio(
ref_audio_base64, ref_audio_path,
label="ref_audio_base64", too_big="reference audio exceeds 200 MB limit",
)
if err:
return json.dumps({"error": err})
import httpx
try:
r = await _api_post_form(
@@ -0,0 +1,18 @@
"""Retain the dispatch-time deadline policy across worker/control-plane loss."""
from alembic import op
import sqlalchemy as sa
revision = "0011_remote_attempt_deadlines"
down_revision = "0010_remote_worker_schema"
branch_labels = None
depends_on = None
def upgrade() -> None:
columns = op.get_bind().execute(sa.text("PRAGMA table_info(remote_task_attempts)"))
if not any(row[1] == "deadlines_json" for row in columns):
op.add_column("remote_task_attempts", sa.Column("deadlines_json", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("remote_task_attempts", "deadlines_json")
+4 -1
View File
@@ -33,8 +33,11 @@ WS_TICKET_PREFIX = "ovs_ws_ticket_"
_TOKEN_BYTES = 32
_ENCODED_TOKEN_LENGTH = 43
_TOKEN_BODY_RE = re.compile(rf"^[A-Za-z0-9_-]{{{_ENCODED_TOKEN_LENGTH}}}$")
# Every ticketed WebSocket route. The first-party mirror is ``ALLOWED_WS_PATHS``
# in frontend/src/api/authSession.ts — a route missing here mints a 422 and the
# UI consumer fails silently (#1769 added /ws/tts for the live dub preview).
_ALLOWED_WS_PATHS = frozenset(
{"/ws/events", "/ws/transcribe", "/v1/audio/transcriptions/stream"}
{"/ws/events", "/ws/transcribe", "/ws/tts", "/v1/audio/transcriptions/stream"}
)
_ADMIN_CAPABILITIES = frozenset({"consume", "admin"})
_KEY_GENERATION_INFO = b"omnivoice-admin-key-generation-v1"
+118 -13
View File
@@ -24,6 +24,7 @@ faster-whisper because it's available on every platform we ship to).
from __future__ import annotations
import asyncio
import ipaddress
import logging
import os
import re
@@ -31,6 +32,7 @@ import contextlib
import threading
import time
import weakref
from urllib.parse import urlsplit
from utils.containment import contain_system_exit
from abc import ABC, abstractmethod
@@ -147,25 +149,78 @@ def _isolated_engine_hint(streak: int) -> str:
async def run_transcribe_guarded(executor, fn, *, what: str = "ASR",
timeout: float = ASR_TRANSCRIBE_TIMEOUT_S,
timeout_env: str = "OMNIVOICE_ASR_TRANSCRIBE_TIMEOUT_S",
reset_on_timeout: bool = False):
reset_on_timeout: bool = False,
on_abandon=None):
"""Run a blocking transcribe ``fn`` in ``executor`` with a hard wall-clock
bound. On timeout, raise :class:`ASRTimeoutError` with guidance instead of
letting the request hang forever.
``run_in_executor`` cannot cancel the underlying thread, so a timed-out
A future cannot cancel the underlying thread, so a timed-out
in-process CTranslate2/whisperx call still owns its model and device. The
default deliberately leaves that worker accounted for: swapping in a fresh
pool and immediately retrying the same backend overlaps two native calls,
which produced the Windows access violation in #1669. A caller backed by a
genuinely killable process may opt into ``reset_on_timeout``.
``on_abandon`` is called once after a timed-out or cancelled worker can no
longer access its inputs. Queued work cancelled before it starts calls it
immediately; running work calls it from the worker finalizer. Normal
completion leaves cleanup with the caller.
"""
loop = asyncio.get_running_loop()
# Same SystemExit containment as the TTS pool (#1133 class): an ASR
# dependency written as a CLI must not be able to shut the backend down.
fut = loop.run_in_executor(executor, contain_system_exit(fn, what))
inner = contain_system_exit(fn, what)
abandon_lock = threading.Lock()
abandon_state = {
"requested": False,
"finished": False,
"callback_called": False,
}
def _fire_abandon_callback() -> None:
if on_abandon is None:
return
with abandon_lock:
if abandon_state["callback_called"]:
return
abandon_state["callback_called"] = True
try:
on_abandon()
except Exception: # noqa: BLE001 — cleanup cannot hide the ASR result
logger.exception("%s abandon cleanup failed", what)
def _job():
try:
return inner()
finally:
with abandon_lock:
abandon_state["finished"] = True
abandoned = abandon_state["requested"]
if abandoned:
_fire_abandon_callback()
concurrent_fut = executor.submit(_job)
fut = asyncio.wrap_future(concurrent_fut, loop=loop)
def _abandon() -> None:
cancelled_before_start = concurrent_fut.cancel()
with abandon_lock:
abandon_state["requested"] = True
finished = abandon_state["finished"]
fut.cancel()
if cancelled_before_start or finished:
_fire_abandon_callback()
try:
result = await asyncio.wait_for(fut, timeout=timeout)
# Shield the wrapper so timeout does not discard our ability to tell a
# queued cancellation from a native thread that is still running.
result = await asyncio.wait_for(asyncio.shield(fut), timeout=timeout)
except asyncio.CancelledError:
_abandon()
raise
except asyncio.TimeoutError:
_abandon()
if reset_on_timeout:
reset_pool_after_wedge(executor, what=what)
streak = _note_transcribe_timeout()
@@ -2000,6 +2055,42 @@ _ASR_OPENAI_COMPAT_MODEL_KEY = "asr.openai_compat.model"
_ASR_OPENAI_COMPAT_SECRET_NAME = "asr_openai_compat_key"
def normalize_openai_compat_asr_base_url(value: str) -> str:
"""Normalize a safe ASR endpoint, allowing plain HTTP only on loopback."""
base = (value or "").strip().rstrip("/")
if not base:
return ""
try:
parsed = urlsplit(base)
_ = parsed.port
except (TypeError, ValueError) as exc:
raise ValueError("Invalid OpenAI-compatible ASR base URL") from exc
scheme = parsed.scheme.lower()
if (
scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
"OpenAI-compatible ASR base URL must be a credential-free HTTP(S) URL"
)
host = parsed.hostname.lower()
loopback = host == "localhost"
if not loopback:
try:
address = ipaddress.ip_address(host)
address = getattr(address, "ipv4_mapped", None) or address
loopback = address.is_loopback
except ValueError:
loopback = False
if scheme == "http" and not loopback:
raise ValueError("Non-loopback OpenAI-compatible ASR endpoints require HTTPS")
return base
def resolve_openai_compat_asr_base_url() -> str:
from services import settings_store
return (
@@ -2063,7 +2154,7 @@ def probe_openai_compat_server(
maps to a translated message:
not_configured no base URL anywhere
invalid_url base URL without an http(s):// scheme
invalid_url malformed URL or non-loopback HTTP endpoint
ok 2xx ``model_found`` says whether the configured
model appears in the server's list (None = unknown)
ok_no_models 404/405/501 reachable, but no /models endpoint
@@ -2078,7 +2169,7 @@ def probe_openai_compat_server(
from core.scrub import scrub_text
base = (base_url if base_url is not None else resolve_openai_compat_asr_base_url()).strip().rstrip("/")
configured_base = base_url if base_url is not None else resolve_openai_compat_asr_base_url()
mdl = (model if model is not None else resolve_openai_compat_asr_model()).strip()
if api_key is None:
key = resolve_openai_compat_asr_api_key()
@@ -2094,9 +2185,11 @@ def probe_openai_compat_server(
"model_found": None,
"detail": None,
}
if not base:
if not configured_base.strip():
return out
if not base.startswith(("http://", "https://")):
try:
base = normalize_openai_compat_asr_base_url(configured_base)
except ValueError:
out["status"] = "invalid_url"
return out
@@ -2107,7 +2200,7 @@ def probe_openai_compat_server(
try:
with httpx.Client(
timeout=httpx.Timeout(timeout_s, connect=min(5.0, timeout_s)),
follow_redirects=True,
follow_redirects=False,
) as client:
resp = client.get(f"{base}/models", headers=headers)
except httpx.TimeoutException as exc:
@@ -2170,13 +2263,20 @@ class OpenAICompatASRBackend(ASRBackend):
gpu_compat = ("cpu",) # network client only — no local compute
def __init__(self):
self._base_url = resolve_openai_compat_asr_base_url()
self._base_url = normalize_openai_compat_asr_base_url(
resolve_openai_compat_asr_base_url()
)
self._model = resolve_openai_compat_asr_model()
@classmethod
def is_available(cls) -> tuple[bool, str]:
if not resolve_openai_compat_asr_base_url():
base_url = resolve_openai_compat_asr_base_url()
if not base_url:
return False, "Configure a server endpoint in Model Catalogue → Engines"
try:
normalize_openai_compat_asr_base_url(base_url)
except ValueError as exc:
return False, str(exc)
try:
import openai # noqa: F401
except ImportError:
@@ -2184,13 +2284,18 @@ class OpenAICompatASRBackend(ASRBackend):
return True, "ready"
def _client(self):
from openai import OpenAI
from openai import DefaultHttpxClient, OpenAI
api_key = resolve_openai_compat_asr_api_key() or "not-needed"
# max_retries=0: mirrors llm_skills.resolve_skill_client — a
# rate-limited/slow server retrying inside the SDK would blow past
# whatever bounded timeout the caller (dub transcribe, dictation)
# expects from a single call.
return OpenAI(base_url=self._base_url, api_key=api_key, max_retries=0)
return OpenAI(
base_url=self._base_url,
api_key=api_key,
max_retries=0,
http_client=DefaultHttpxClient(follow_redirects=False),
)
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
logger.info(
+26 -11
View File
@@ -31,6 +31,30 @@ class RoutingResult(TypedDict):
routing_reason: str | None # raw, pre-scrub
def under_provisioned_vram(caps: HostCaps, min_vram_gb: float = 0.0) -> bool:
"""Is this host's DEDICATED VRAM below the engine's declared floor?
The one definition of "under-provisioned", shared by everything that acts
on the verdict: the routing caveat below, the timeout guidance, and since
#1804 — the compute-time budget itself (``model_manager
.generate_timeout_s``). It was written out inline in each of them, which is
how the budget came to disagree with the warning printed next to it.
Dedicated-VRAM families ONLY. On MPS, ``HostCaps.vram_gb`` is a heuristic
(system RAM / 2, see device_caps) for a UNIFIED memory pool; comparing it
against a floor measured on discrete CUDA hardware would tell every 8 GB Mac
its 4 GB "VRAM" is too small for an engine that runs fine there. A VRAM
figure of 0 means the probe failed don't guess from it. A floor of 0 means
the engine declares none, and inventing one is worse than staying quiet.
"""
if not min_vram_gb or min_vram_gb <= 0:
return False
if getattr(caps, "family", None) not in ("cuda", "rocm"):
return False
vram_gb = float(getattr(caps, "vram_gb", 0.0) or 0.0)
return 0 < vram_gb < float(min_vram_gb)
def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
"""A caveat string for an otherwise-accelerated host, or None.
@@ -51,16 +75,7 @@ def _caveat(caps: HostCaps, min_vram_gb: float = 0.0) -> str | None:
for note in caps.notes:
if KERNEL_RISK_MARKER in note:
return f"{caps.family.upper()} selected, but: {note}"
# Dedicated-VRAM families ONLY. On MPS, HostCaps.vram_gb is a heuristic
# (system RAM / 2, see device_caps) for a UNIFIED memory pool — comparing
# it against a floor measured on discrete CUDA hardware would tell every
# 8 GB Mac its 4 GB "VRAM" is too small for an engine that runs fine there.
# Different memory model, different (unmeasured) floor; don't guess.
if (
caps.family in ("cuda", "rocm")
and min_vram_gb > 0
and 0 < caps.vram_gb < min_vram_gb
):
if under_provisioned_vram(caps, min_vram_gb):
device = caps.device_name or caps.family.upper()
return (
f"{device} has {caps.vram_gb:.1f} GB VRAM; this engine wants about "
@@ -208,5 +223,5 @@ def routing_fields(
__all__ = [
"RoutingStatus", "RoutingResult", "resolve_routing", "routing_fields",
"routing_notice", "header_safe_reason",
"routing_notice", "header_safe_reason", "under_provisioned_vram",
]
+220
View File
@@ -0,0 +1,220 @@
"""Karaoke (word-highlight) ASS builder for dub hardsub export.
Pure text-in/text-out: no ffmpeg, no models, no filesystem. ``build_ass``
turns subtitle cues into an ASS script whose lines carry ``\\k``/``\\kf``
karaoke tags, so ffmpeg's ``ass=`` filter burns a word-by-word highlight
sweep instead of the static line the SRT path renders.
Word timing sources, in order:
1. ``cue["words"]`` per-word ``{text, start, end}`` persisted at
transcribe time (services.segmentation). Used only when the words still
spell the cue's display text: after translation the persisted ASR words
are source-language tokens, so re-using their timing would burn the
wrong language. The display text is always authoritative.
2. Even split the cue text's whitespace tokens spread uniformly across
``[start, end]``. This is the compatibility path for jobs transcribed
before word persistence and for translated tracks.
Dual-layout karaoke is intentionally unsupported (out of scope): callers
must fall back to the line (SRT) burn when the dual layout is requested.
"""
from __future__ import annotations
import re
from typing import Optional, Sequence
_WS = re.compile(r"\s+")
#: Default ASS canvas. libass scales the script to the real video size, so
#: one reference resolution keeps font/margin proportions stable everywhere.
DEFAULT_PLAY_RES = (1920, 1080)
_HEADER_TEMPLATE = """[Script Info]
; Generated by VoiceStudio karaoke burn-in
ScriptType: v4.00+
PlayResX: {res_x}
PlayResY: {res_y}
WrapStyle: 0
ScaledBorderAndShadow: yes
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,64,&H0000E7FF,&H00FFFFFF,&H00101010,&H7F000000,0,0,0,0,100,100,0,0,1,3,1,2,96,96,48,1
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
def _norm(text: object) -> str:
return _WS.sub(" ", str(text or "").strip())
def _ass_time(seconds: float) -> str:
"""``H:MM:SS.CC`` (centiseconds) — the ASS event timestamp format."""
cs = max(0, int(round(float(seconds) * 100)))
h, rem = divmod(cs, 360000)
m, rem = divmod(rem, 6000)
s, c = divmod(rem, 100)
return f"{h}:{m:02d}:{s:02d}.{c:02d}"
def _ass_escape(text: str) -> str:
"""Escape a display token for an ASS Dialogue text field.
Braces would open an override block (user text like ``{\\b1}`` must render
literally, never execute); newlines become ASS hard line breaks.
"""
return (
str(text)
.replace("{", "\\{")
.replace("}", "\\}")
.replace("\r\n", "\\N")
.replace("\n", "\\N")
.replace("\r", "\\N")
)
def _cs(seconds: float) -> int:
"""Karaoke tag duration in centiseconds; ≥1 so a tag never renders as 0."""
return max(1, int(round(float(seconds) * 100)))
def even_split_words(text: str, start: float, end: float) -> list[dict]:
"""Uniformly distribute the cue text's whitespace tokens over [start, end].
The export fallback for jobs transcribed before per-word persistence and
for translated tracks (whose persisted words are source-language tokens).
"""
tokens = [tok for tok in _WS.split(str(text or "").strip()) if tok]
if not tokens:
return []
start = float(start)
dur = max(0.0, float(end) - start) / len(tokens)
return [
{"text": tok, "start": start + i * dur, "end": start + (i + 1) * dur}
for i, tok in enumerate(tokens)
]
def scale_words(
words: Sequence[dict],
orig_start: float,
orig_end: float,
new_start: float,
new_end: float,
) -> Optional[list[dict]]:
"""Map word times linearly from [orig_start, orig_end] → [new_start, new_end].
Used when Smart Fit moves a cue onto the fitted timeline: the persisted
word times live on the original timeline and must ride along. Returns
``None`` when either span is degenerate (caller should drop the words so
export falls back to an even split over the new span).
"""
orig_span = float(orig_end) - float(orig_start)
new_span = float(new_end) - float(new_start)
if orig_span <= 0 or new_span <= 0:
return None
ratio = new_span / orig_span
out: list[dict] = []
for w in words:
try:
ws = float(w["start"])
we = float(w["end"])
except (KeyError, TypeError, ValueError):
return None
out.append({
**w,
"start": round(float(new_start) + (ws - float(orig_start)) * ratio, 3),
"end": round(float(new_start) + (we - float(orig_start)) * ratio, 3),
})
return out
def _usable_words(cue: dict, text: str) -> Optional[list[tuple[str, float, float]]]:
"""Persisted words, iff well-formed AND they spell the cue's display text."""
words = cue.get("words")
if not isinstance(words, list) or not words:
return None
clean: list[tuple[str, float, float]] = []
for w in words:
if not isinstance(w, dict):
return None
wtext = _norm(w.get("text"))
try:
ws = float(w["start"])
we = float(w["end"])
except (KeyError, TypeError, ValueError):
return None
if wtext:
clean.append((wtext, ws, we))
if not clean:
return None
if _norm(" ".join(t for t, _, _ in clean)) != text:
return None
return clean
def _karaoke_text(cue: dict, text: str, start: float, end: float) -> str:
"""One Dialogue text field: ``{\\k…}`` lead-in + per-word ``{\\kf…}`` tags.
Each word's sweep runs until the next word starts (the classic karaoke
layout inter-word gaps finish the previous word's fill), and the last
word sweeps out to the cue end.
"""
words = _usable_words(cue, text) or [
(w["text"], w["start"], w["end"]) for w in even_split_words(text, start, end)
]
# Clamp into the cue span and enforce monotonic starts so malformed
# persisted data can only mistime the sweep, never corrupt the script.
clamped: list[tuple[str, float]] = []
prev = start
for wtext, ws, _ in words:
ws = min(max(ws, prev), end)
clamped.append((wtext, ws))
prev = ws
parts: list[str] = []
lead = clamped[0][1] - start
if lead > 0.005:
parts.append(f"{{\\k{_cs(lead)}}}")
for i, (wtext, ws) in enumerate(clamped):
nxt = clamped[i + 1][1] if i + 1 < len(clamped) else end
sep = " " if i + 1 < len(clamped) else ""
parts.append(f"{{\\kf{_cs(max(nxt, ws) - ws)}}}{_ass_escape(wtext)}{sep}")
return "".join(parts)
def build_ass(
cues: Sequence[dict],
*,
dual: bool = False,
play_res: tuple[int, int] = DEFAULT_PLAY_RES,
) -> str:
"""Build a karaoke ASS script from subtitle cues ({text, start, end, words?}).
One ``Default`` style; one Dialogue event per cue. ``dual`` exists for
signature parity with the line burn but dual-layout karaoke is out of
scope callers must keep the SRT line burn for dual, so requesting it
here is a contract violation, not a rendering mode.
"""
if dual:
raise ValueError(
"dual-layout karaoke is not supported; use the line (SRT) burn for dual subtitles"
)
res_x, res_y = play_res
lines = [_HEADER_TEMPLATE.format(res_x=int(res_x), res_y=int(res_y))]
for cue in cues or []:
text = _norm(cue.get("text"))
if not text:
continue
start = float(cue["start"])
end = float(cue["end"])
if end <= start:
end = start + 0.1
lines.append(
f"Dialogue: 0,{_ass_time(start)},{_ass_time(end)},Default,,0,0,0,,"
f"{_karaoke_text(cue, text, start, end)}"
)
return "\n".join(lines) + "\n"
+68 -20
View File
@@ -404,7 +404,23 @@ _CONFIGURED_GPU_JOB_TIMEOUT_S = GPU_JOB_TIMEOUT_S
# CPU synthesis is healthy but substantially slower than accelerated inference.
# Keep a separate, bounded floor so a short render on CPU is not abandoned at
# the GPU-oriented five-minute deadline (#1588).
#
# #1787 review fix: an explicit OMNIVOICE_CPU_GENERATE_TIMEOUT_S must ALWAYS
# govern CPU dispatches, even when OMNIVOICE_GENERATE_TIMEOUT_S is ALSO
# explicit. Before this flag existed, `universal_override` below treated any
# explicit GENERATE_TIMEOUT_S as authoritative for CPU too, so the Settings
# panel's "CPU budget" row could be saved and silently never apply whenever
# the "Accelerated" row was also set — the exact defect (a control that looks
# like it works and doesn't) issue #1787 exists to remove. Setting ONLY
# OMNIVOICE_GENERATE_TIMEOUT_S keeps its historical "universal" behavior
# unchanged (test_explicit_universal_generate_timeout_wins_on_cpu) — nobody
# who already relies on that single-var override loses it. The only case that
# changes is the previously-undocumented, previously-broken combination of
# setting BOTH: the more specific (CPU) value now wins for CPU jobs, matching
# what a user who filled in both Settings rows was told would happen.
_CPU_GENERATE_TIMEOUT_EXPLICIT = "OMNIVOICE_CPU_GENERATE_TIMEOUT_S" in os.environ
CPU_JOB_TIMEOUT_S = float(os.environ.get("OMNIVOICE_CPU_GENERATE_TIMEOUT_S", "600.0"))
_CONFIGURED_CPU_JOB_TIMEOUT_S = CPU_JOB_TIMEOUT_S
# Queue-wait budget — a SEPARATE, deliberately generous clock (#1190/#1202).
# The execution bound above must never be spent waiting in line: a job queued
@@ -509,6 +525,7 @@ class GpuPoolBusyError(TimeoutError):
def generate_timeout_s(
text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
min_vram_gb: float = 0.0,
) -> float:
"""THE wall-clock execution budget for one synthesis job, scaled to input.
@@ -520,15 +537,33 @@ def generate_timeout_s(
on long inputs. Lives here (not in a router) so every router shares it
without importing generation.py.
Policy: floor at the configured OMNIVOICE_GENERATE_TIMEOUT_S, plus 1s per
40 characters past a 1200-character free allowance generous enough for
Policy: floor at the configured OMNIVOICE_GENERATE_TIMEOUT_S (accelerated
hosts) or OMNIVOICE_CPU_GENERATE_TIMEOUT_S (CPU hosts the latter wins
for CPU whenever it is itself explicit, even if the former also is; see
the #1787 comment on the module-level constants), plus 1s per 40
characters past a 1200-character free allowance generous enough for
CPU-class hardware, still bounded (a wedged job is caught in minutes, not
hours).
#1804: "accelerated" is not one performance class. A card with less VRAM
than the engine declares it needs pages to system RAM over PCIe and renders
SLOWER than the same machine's CPU would — yet, judged by device family
alone, it was handed HALF the CPU budget. That inversion is what three 4 GB
reporters hit (#1226 GTX 1650 Ti, #1222 Quadro P2000, #1804 GTX 1650), all
on the engine that declares a 6 GB floor. Every layer already knew: routing
raises a caveat, the preflight toast warns, and the timeout message names
the card. Only the budget ignored it. So an under-provisioned accelerator
now floors at the CPU budget the class of hardware it actually performs
like. ``min_vram_gb`` is the engine's declared floor; callers that pass
``engine`` get it read off the engine automatically.
"""
base = GPU_JOB_TIMEOUT_S
try:
from core.device_caps import detect_host_caps
family = execution_device or detect_host_caps().family
caps = detect_host_caps()
family = execution_device or caps.family
if not min_vram_gb and engine is not None:
min_vram_gb = float(getattr(engine, "min_vram_gb", 0.0) or 0.0)
if execution_device is None and engine is not None:
from services.engine_routing import resolve_routing
compat = getattr(engine, "gpu_compat", None)
@@ -537,16 +572,27 @@ def generate_timeout_s(
if tuple(compat) == ("cpu",):
family = "cpu"
else:
family = resolve_routing(
compat, detect_host_caps(),
float(getattr(engine, "min_vram_gb", 0.0) or 0.0),
)["effective_device"]
family = resolve_routing(compat, caps, min_vram_gb)["effective_device"]
universal_override = (
_GENERATE_TIMEOUT_EXPLICIT
or GPU_JOB_TIMEOUT_S != _CONFIGURED_GPU_JOB_TIMEOUT_S
)
if family == "cpu" and not universal_override:
# An explicit (env-set, or runtime-changed the same way tests do)
# CPU budget is more specific than the universal override and always
# wins for CPU dispatches — see the #1787 comment above.
cpu_explicit = (
_CPU_GENERATE_TIMEOUT_EXPLICIT
or CPU_JOB_TIMEOUT_S != _CONFIGURED_CPU_JOB_TIMEOUT_S
)
if family == "cpu" and (cpu_explicit or not universal_override):
base = CPU_JOB_TIMEOUT_S
elif not universal_override and family in ("cuda", "rocm"):
from services.engine_routing import under_provisioned_vram
if under_provisioned_vram(caps, min_vram_gb):
# `max`, never a plain assignment: an operator who raised the
# accelerated budget above the CPU one must not have it cut.
base = max(base, CPU_JOB_TIMEOUT_S)
except Exception:
# Device probing is advisory here; the configured universal bound is
# still safe when a platform probe is unavailable during startup.
@@ -1050,6 +1096,7 @@ def _timeout_guidance(
"""
family = "cuda" # conservative default: GPU wording if the probe fails
device_name, vram_gb = "", 0.0
_caps = None # a failed probe stays None; under_provisioned_vram() reads it safely
try:
from core.device_caps import detect_host_caps
_caps = detect_host_caps()
@@ -1097,11 +1144,9 @@ def _timeout_guidance(
# a threshold applied without knowing whose job it is would confidently
# misdiagnose most of them. And on MPS `vram_gb` is a unified-memory
# heuristic (RAM/2), not a dedicated pool to compare against.
if (
min_vram_gb > 0
and family in ("cuda", "rocm")
and 0 < vram_gb < min_vram_gb
):
from services.engine_routing import under_provisioned_vram
if under_provisioned_vram(_caps, min_vram_gb):
return common + (
f"{device_name or 'this GPU'} has {vram_gb:.1f} GB of VRAM and "
f"this engine wants about {min_vram_gb:.0f} GB — generations here "
@@ -1480,14 +1525,17 @@ def get_best_device():
# ── DirectML — universal Windows GPU (probe reports this as "cpu") ─
# Reached only when no torch family was detected (family == "cpu"), which is
# exactly the DirectML case — the probe classifies DirectML hosts as cpu.
try:
import torch_directml
if torch_directml.device_count() > 0:
logger.info("Using DirectML device (GPU %d)", 0)
return str(torch_directml.device(0))
except ImportError:
pass
if family == "cpu":
try:
import torch_directml
if torch_directml.device_count() > 0:
logger.info("Using DirectML device (GPU %d)", 0)
return str(torch_directml.device(0))
except ImportError:
# DirectML is optional; an absent package leaves CPU available.
pass
# Other families need an explicitly compatible loader (e.g. NPU sidecars).
return "cpu"
_COMPILE_ERR_MODULE_PREFIXES = ("torch._dynamo", "torch._inductor", "torch.fx", "triton")
+44 -1
View File
@@ -100,10 +100,33 @@ class Segment:
}
def _serialize_words(words: Sequence[Word]) -> list[dict]:
"""Word objects → the ``{text, start, end}`` dicts persisted on segments.
Per-word timing is kept on each segment (``Segment.extra["words"]``, so
``to_dict`` carries it onto the job) to drive the karaoke hardsub export.
"""
return [
{"text": w.text, "start": round(w.start, 3), "end": round(w.end, 3)}
for w in words
]
def _merge_segment_extra(target: Segment, incoming: Segment, *, prepend: bool) -> None:
"""Preserve editor metadata when cleanup folds ``incoming`` into ``target``."""
# Word lists must CONCATENATE in text order (the setdefault below would
# otherwise adopt the incoming list wholesale when the target has none,
# then double it). Capture both sides before setdefault runs.
raw_target_words = target.extra.get("words")
raw_incoming_words = incoming.extra.get("words")
for key, value in incoming.extra.items():
target.extra.setdefault(key, value)
target_words = raw_target_words if isinstance(raw_target_words, list) else []
incoming_words = raw_incoming_words if isinstance(raw_incoming_words, list) else []
if target_words or incoming_words:
target.extra["words"] = (
incoming_words + target_words if prepend else target_words + incoming_words
)
def joined(left: object, right: object) -> str:
return _clean(f"{left or ''} {right or ''}")
@@ -233,7 +256,10 @@ def _build_segments_from_words(words: Sequence[Word]) -> List[Segment]:
if not text:
buf = []
return
segments.append(Segment(start=buf_start, end=buf[-1].end, text=text))
segments.append(Segment(
start=buf_start, end=buf[-1].end, text=text,
extra={"words": _serialize_words(buf)},
))
buf = []
if not force:
buf_start = 0.0
@@ -291,6 +317,7 @@ def _build_segments_from_words(words: Sequence[Word]) -> List[Segment]:
start=buf_start,
end=left_buf[-1].end,
text=_clean(" ".join(x.text for x in left_buf)),
extra={"words": _serialize_words(left_buf)},
))
buf = list(right_buf)
buf_start = right_buf[0].start
@@ -479,11 +506,23 @@ def _apply_scene_cuts(segments: List[Segment], scene_cuts: Iterable[float]) -> L
or (remaining.end - cut) < MIN_DUR
):
continue
# Segment text is the joined word texts, so a whitespace-boundary
# text split maps exactly onto a word-count split of the list.
words = remaining.extra.get("words")
left_extra: dict = {}
right_extra: dict = {}
if isinstance(words, list) and words:
n_left = len(left_text.split())
if n_left and len(words) > n_left:
left_extra = {"words": words[:n_left]}
right_extra = {"words": words[n_left:]}
out.append(Segment(
start=remaining.start, end=cut, text=left_text, speaker_id=remaining.speaker_id,
extra=left_extra,
))
remaining = Segment(
start=cut, end=remaining.end, text=right_text, speaker_id=remaining.speaker_id,
extra=right_extra,
)
out.append(remaining)
return out
@@ -715,6 +754,10 @@ def _resplit_core(
piece["text"] = text
piece["start"] = s0 if k == 0 else ws[0].start
piece["end"] = s1 if k == n_runs - 1 else ws[-1].end
# dict(seg) copied the WHOLE segment's word list into every piece;
# each piece keeps only its own run's words (karaoke burn-in).
if "words" in piece:
piece["words"] = _serialize_words(ws)
if label:
piece["speaker_id"] = label
if piece_no > 0:
+5 -4
View File
@@ -60,7 +60,7 @@ from pathlib import Path
from typing import Callable, Optional
from core.config import DATA_DIR
from core.contained_subprocess import OwnedPopen, spawn_owned
from core.contained_subprocess import OwnedPopen, WindowsJobPopen, spawn_owned
logger = logging.getLogger("omnivoice.sidecar_install")
@@ -1057,7 +1057,8 @@ def _run_logged(job: dict, argv: list[str], *, timeout: float,
would hang past the timeout waiting for pipe EOF.
"""
# ``spawn_owned`` creates the local timeout group/Job before the operation
# starts and links it to backend death through its control pipe.
# starts. POSIX links it to backend death through a control pipe; Windows
# retains a kill-on-close Job handle in this backend process.
popen_kwargs = _install_containment_kwargs()
try:
proc = spawn_owned(
@@ -1096,14 +1097,14 @@ def _run_logged(job: dict, argv: list[str], *, timeout: float,
def _kill_tree(proc: "subprocess.Popen") -> None:
"""Kill an operation through its stable nested group/Job owner."""
if isinstance(proc, OwnedPopen):
if isinstance(proc, (OwnedPopen, WindowsJobPopen)):
# The retained supervisor/process-group or nested Job is the stable
# per-operation owner. Do not fall back to a direct PID kill.
proc.kill()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
return
return
# A test double or a legacy caller without the nested owner can only be
# stopped through its stable direct-process handle.
+46 -15
View File
@@ -32,9 +32,9 @@ Threat-model summary (see Plan 02-01 frontmatter):
AUTH-05 installed (``HFTokenRedactor``) on the root logger.
T-02-04 compromised sidecar emitting unexpected ops: parent allowlist
``PARENT_INBOUND_OPS`` rejects everything else.
T-02-05 nested containment: a retained supervisor process group/Job owns
each engine operation and is linked to backend death by a control
pipe, while still permitting independent timeout teardown.
T-02-05 nested containment: a retained POSIX supervisor process group or
Windows Job owns each engine operation, while still permitting
independent timeout teardown and cleanup on backend death.
"""
from __future__ import annotations
@@ -385,6 +385,10 @@ class SubprocessBackend(TTSBackend):
def __init__(self) -> None:
self._proc: Optional[subprocess.Popen] = None
# A failed bounded reap must retain ownership and forbid reuse. This
# lock is separate from _lock: the receive owner joins its watchdog.
self._timeout_quarantine: list[subprocess.Popen] = []
self._timeout_quarantine_lock = threading.Lock()
# Single lock serialises spawn + every send/recv pair so two threads
# can't interleave half-frames on the same pipe.
self._lock = threading.Lock()
@@ -451,6 +455,10 @@ class SubprocessBackend(TTSBackend):
def _spawn(self) -> None:
"""Launch the sidecar if not already running. Blocks on the ready
handshake. Caller must hold self._lock."""
if not self._retry_timeout_cleanup():
raise RuntimeError(
f"{self.id} sidecar is still stopping after a timeout; retry once it exits"
)
if self._proc is not None and self._proc.poll() is None:
return # already up
@@ -542,6 +550,7 @@ class SubprocessBackend(TTSBackend):
"""Idempotent. Sends {op:shutdown}; falls back to terminate/kill."""
proc = self._proc
if proc is None:
self._retry_timeout_cleanup()
return
try:
try:
@@ -577,6 +586,7 @@ class SubprocessBackend(TTSBackend):
pass
finally:
self._proc = None
self._retry_timeout_cleanup()
def _force_kill(self) -> None:
"""Internal: kill a sidecar that never reached the ready state."""
@@ -777,38 +787,59 @@ class SubprocessBackend(TTSBackend):
return msg
def _recv_with_timeout(self, timeout_s: float) -> Optional[dict]:
"""Recv that aborts if the sidecar goes silent.
"""Read one frame, finishing timeout cleanup before the caller can retry.
Implemented by polling the proc for liveness with a deadline. We
don't block on a `select` of the pipe because Windows can't select
on subprocess pipes keeping the implementation cross-platform
means a simpler polling loop here.
A watchdog closes the pipe on timeout; Windows cannot select on pipes.
EOF alone does not prove the owned process/supervisor has exited.
"""
# On Unix we could use selectors; on Windows the pipe is not
# selectable. Use a watchdog thread that kills the sidecar on
# timeout — that triggers EOF on stdout, so _recv returns None
# and the caller raises.
watchdog = threading.Timer(timeout_s, self._timeout_kill)
proc = self._proc
watchdog = threading.Timer(timeout_s, self._timeout_kill, args=(proc,))
watchdog.daemon = True
watchdog.start()
try:
return self._recv()
finally:
watchdog.cancel()
# cancel() cannot stop an already-running callback. Finish its
# bounded reap before another receive or generation starts.
watchdog.join()
self._touch() # any reply (or attempt) counts as recent activity
def _timeout_kill(self) -> None:
proc = self._proc
def _timeout_kill(self, proc: Optional[subprocess.Popen]) -> None:
"""Kill only the child this receive captured, then reap its owner."""
if proc is None:
return
logger.error("[%s] sidecar exceeded recv timeout; killing", self.id)
try:
logger.error(
"[%s] sidecar exceeded recv timeout; killing",
self.id,
)
proc.kill()
except Exception:
# A raced exit can make kill fail, but its owner still needs reaping.
pass
try:
proc.wait(timeout=2)
except Exception:
# Do not discard a possibly live owner, or replace a newer _proc.
with self._timeout_quarantine_lock:
if not any(item is proc for item in self._timeout_quarantine):
self._timeout_quarantine.append(proc)
else:
with self._timeout_quarantine_lock:
self._timeout_quarantine = [
item for item in self._timeout_quarantine if item is not proc
]
def _retry_timeout_cleanup(self) -> bool:
"""Retry bounded cleanup, retaining every owner that could still be live."""
with self._timeout_quarantine_lock:
pending = tuple(self._timeout_quarantine)
for proc in pending:
self._timeout_kill(proc)
with self._timeout_quarantine_lock:
return not self._timeout_quarantine
# ── stderr drain ───────────────────────────────────────────────────────
+88
View File
@@ -112,6 +112,13 @@ _FULL_NAME_TO_CODE = {
"vietnamese": "vi",
"kazakh": "kz",
"standard arabic": "ar",
# Below: inert for num2words (absent from _NUM2WORDS_LANGS, which reads
# digits natively for these scripts), present so _plain_lang_code can
# resolve them for the digit-range rule.
"korean": "ko",
"japanese": "ja",
"chinese": "zh",
"mandarin chinese": "zh",
}
# ISO codes whose num2words locale name differs.
@@ -178,6 +185,82 @@ def _num2words_lang(language: Optional[str]) -> Optional[str]:
return None
def _plain_lang_code(language: Optional[str]) -> Optional[str]:
"""Resolve a request language to a bare ISO code, with no num2words gate.
:func:`_num2words_lang` answers "may I call num2words for this?" and so
returns ``None`` for ko/ja/zh/th/vi. Rules that are not num2words-backed
need the code itself, which is what this returns.
"""
if not language:
return None
s = str(language).strip().lower()
if not s or s == "auto":
return None
code = _FULL_NAME_TO_CODE.get(s)
if code:
return code
m = _ISO_CODE_RE.match(s)
if m:
return _ISO_ALIASES.get(m.group(1), m.group(1))
return None
# ── Digit ranges ─────────────────────────────────────────────────────────────
# "20~30" loses its separator at the engine and reads as ONE number: OmniVoice
# says "이십삼" (23) for "20~30초". Speak the separator instead. Verified by
# rendering each form and transcribing it back (ko, OmniVoice):
# "20~30초" → heard "23초" ✗
# "20에서 30초" → heard "20에서 30초" ✓
# Only the tilde family is rewritten — those are unambiguously range marks
# between digits. An ASCII hyphen is left alone on purpose: it also spells
# dates, phone numbers and product codes, where "to" would be wrong.
#: Spacing is part of the form, not decoration: a Korean postposition binds to
#: the numeral ("20에서 30"), Japanese and Chinese set no spaces at all, and
#: English needs them on both sides.
_RANGE_FORM = {
"ko": "{a}에서 {b}",
"ja": "{a}から{b}",
"zh": "{a}{b}",
"en": "{a} to {b}",
}
#: ASCII tilde, wave dash, fullwidth tilde — Japanese and Korean IMEs emit the
#: latter two, so all three have to match.
#:
#: Match complete signed/decimal endpoints; reject partial numbers and product
#: codes while allowing adjacent CJK units. Guard all tilde forms so malformed
#: chains cannot be partially rewritten, including when their separators have
#: whitespace around them.
_RANGE_MARKS = "~\u301c\uff5e"
_RANGE_ENDPOINT = r"[+-]?(?:\d{1,6}(?:\.\d{1,6})?|\.\d{1,6})"
_NUM_RANGE_RE = re.compile(
rf"(?<![\d.,A-Za-z+{_RANGE_MARKS}-])({_RANGE_ENDPOINT})"
rf"\s*[{_RANGE_MARKS}]\s*({_RANGE_ENDPOINT})"
rf"(?![\d.,A-Za-z+{_RANGE_MARKS}-])"
)
def _speak_number_ranges(text: str, lang: str) -> str:
"""Speak complete tilde ranges only for languages with a verified form."""
form = _RANGE_FORM.get(lang)
if not form:
return text
def replace(match: re.Match) -> str:
before, after = match.start() - 1, match.end()
while before >= 0 and text[before].isspace():
before -= 1
while after < len(text) and text[after].isspace():
after += 1
if ((before >= 0 and text[before] in _RANGE_MARKS)
or (after < len(text) and text[after] in _RANGE_MARKS)):
return match.group(0)
return form.format(a=match.group(1), b=match.group(2))
return _NUM_RANGE_RE.sub(replace, text)
# ── Universal safety filters (all languages) ─────────────────────────────────
# Zero-width & bidi controls, C0/C1 controls (except \t \n \r), BOM, U+FFFD.
@@ -487,6 +570,11 @@ def normalize_text(text: str, language: Optional[str] = None) -> str:
if not text:
return text or ""
out = _safety_filters(text)
# Runs outside the num2words gate below: ko/ja/zh keep their digits (that
# gate returns None for them) but still need the range mark spoken.
plain = _plain_lang_code(language)
if plain:
out = _outside_brackets(out, lambda t: _speak_number_ranges(t, plain))
lang = _num2words_lang(language)
if lang:
if lang in _ABBREV_COMPILED:
+41 -18
View File
@@ -4,7 +4,7 @@ Resolution priority (highest → lowest):
1. app `settings_store.get_hf_token()` (encrypted in SQLite)
2. env `HF_TOKEN` or the legacy `HUGGING_FACE_HUB_TOKEN` env var
3. hf-cli `huggingface_hub.get_token()` (canonical ~/.cache/huggingface/token)
3. hf-cli the selected local Hub token file (`HF_TOKEN_PATH`)
For each candidate, the resolver calls `huggingface_hub.whoami(token=...)`
to verify the token is live; any HTTP error (401, 403, network) skips to
@@ -20,6 +20,7 @@ from __future__ import annotations
import hashlib
import logging
import os
from pathlib import Path
import threading
import time
from dataclasses import dataclass
@@ -46,7 +47,7 @@ class SourceState:
set: bool
masked: Optional[str]
whoami_user: Optional[str]
whoami_ok: bool
whoami_ok: Optional[bool]
# ── module-level cache ────────────────────────────────────────────────────
@@ -79,19 +80,26 @@ def _read_app() -> Optional[str]:
return None
def _clean_token(value: Optional[str]) -> Optional[str]:
if not value:
return None
return value.replace("\r", "").replace("\n", "").strip() or None
def _read_env() -> Optional[str]:
# HF docs explicitly accept either name; user may have either exported.
val = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
return val or None
return _clean_token(val)
def _read_hf_cli() -> Optional[str]:
try:
import huggingface_hub
tok = huggingface_hub.get_token()
return tok or None
from huggingface_hub import constants
return _clean_token(Path(constants.HF_TOKEN_PATH).read_text(encoding="utf-8"))
except FileNotFoundError:
return None
except Exception:
logger.exception("huggingface_hub.get_token failed")
logger.warning("Could not read the local Hugging Face token file")
return None
@@ -184,17 +192,17 @@ def on_401(active_source: Source) -> Optional[ResolvedToken]:
return resolve(skip=frozenset({active_source}))
def state() -> dict:
def state(*, validate: bool = False) -> dict:
"""Return one SourceState per priority position so the Settings UI can
render the cascade table. Includes a masked token + whoami result;
never includes the raw token."""
never includes the raw token. Reads are local unless validation is explicitly requested."""
rows: list[SourceState] = []
active: Optional[Source] = None
for source in _PRIORITY:
token = _READERS[source]()
if token:
username = _validate(source, token)
ok = username is not None
username = _validate(source, token) if validate else None
ok = (username is not None) if validate else None
rows.append(SourceState(
source=source,
set=True,
@@ -249,15 +257,30 @@ def save_app_token(token: str) -> None:
invalidate_cache()
def clear_hf_cli_tokens() -> None:
"""Remove recognized Hub token files without refreshing or revoking tokens."""
from core.config import HF_CLI_TOKEN_PATHS
from huggingface_hub import constants
# Hub's active path remains authoritative if imported before app config.
paths = set(HF_CLI_TOKEN_PATHS) | {constants.HF_TOKEN_PATH}
failed = False
for token_path in paths:
path = Path(token_path)
for target in (path, path.parent / "stored_tokens"):
try:
target.unlink(missing_ok=True)
except OSError:
failed = True
invalidate_cache()
if failed:
raise OSError("Could not clear all local Hugging Face token files")
def clear_app_token(also_clear_hf_cli: bool = False) -> None:
"""Remove from the encrypted settings store; optionally also call
`huggingface_hub.logout()` to clear the canonical HF file."""
"""Clear the encrypted app token, optionally recognized local Hub files."""
from services import settings_store
settings_store.clear_hf_token()
if also_clear_hf_cli:
try:
import huggingface_hub
huggingface_hub.logout()
except Exception:
logger.exception("huggingface_hub.logout failed (non-fatal)")
clear_hf_cli_tokens()
invalidate_cache()
+46 -8
View File
@@ -566,7 +566,12 @@ def _get_clone_prompt(
):
"""Return a cached/precomputed ``VoiceClonePrompt`` for
(ref_audio, ref_text, preprocess_prompt), or ``None`` to fall back to the
inline ref path. Never raises.
inline ref path.
Raises only on a device OOM that survives a cache-drop retry (#1790): the
inline path is the same allocation on the same device, so falling back to
it after an OOM cannot succeed and has been observed taking the whole
process down instead. Every other failure still falls back silently.
``store=False`` still *reads* the cache (a hit is free) but never inserts:
it exists for single-use references a dub's per-segment ref clips are each
@@ -596,10 +601,43 @@ def _get_clone_prompt(
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
)
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
logger.warning(
"voice-clone prompt precompute failed; using inline ref: %s", e
)
return None
# #1790/#1777: a GPU OOM is the one failure this fallback cannot
# absorb. `generate()`'s inline ref path runs the SAME encode on the
# SAME device — the docstring above says so, because producing
# identical output is the point — so returning None after an OOM
# guarantees a second OOM moments later, on a device with even less
# headroom than the first attempt found. Both reporters' backends
# then died with a Windows access violation (exit code
# -1073741819) seconds after this exact log line, mid-generation on
# a GPU that had just refused an 86 MiB allocation.
#
# An OOM here is also the most recoverable kind: the allocator is
# typically holding reserved-but-unallocated blocks (#1790's own
# log reports 90 MiB reserved against an 86 MiB request). Drop them
# and try once more. If it still will not fit, raise — the failure
# layer turns a device OOM into the actionable GPU_OOM message
# ("close other GPU-heavy apps or unload models…"), which is a far
# better answer than walking into a native fault.
from core.failure import is_gpu_oom
if is_gpu_oom(e):
logger.warning(
"voice-clone prompt precompute hit a device OOM (%s) — "
"releasing allocator caches and retrying once", e,
)
try:
from services.model_manager import free_vram
free_vram()
except Exception: # noqa: BLE001 — reclaim is best-effort
logger.debug("VRAM reclaim before OOM retry failed", exc_info=True)
prompt = model.create_voice_clone_prompt(
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
)
else:
logger.warning(
"voice-clone prompt precompute failed; using inline ref: %s", e
)
return None
if store:
_prompt_disk_save(key, prompt)
if not store:
@@ -2298,9 +2336,9 @@ _INSTALL_HINTS: dict[str, str] = {
"omnivoice-gguf":"Bundled — runs the C++ omnivoice-tts binary in bin/. Quants download lazily from Serveurperso/OmniVoice-GGUF on first generate.",
"supertonic3": "uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)",
"pockettts": "uv sync --extra pockettts (Kyutai, CPU-only, ~100 MB model on first use; MIT code + CC-BY-4.0 weights; HF-gated, review terms and set HF_TOKEN)",
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/CPU, no MPS; Apache-2.0)",
"moss-tts-v15": "git clone OpenMOSS/MOSS-TTS + set OMNIVOICE_MOSS_TTS_V15_DIR (own venv, transformers==5.0; 8B, ~16 GB weights; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
"dots-tts": "git clone rednote-hilab/dots.tts + set OMNIVOICE_DOTS_TTS_DIR (own venv, transformers==4.57; 2B, ~9 GB weights; CUDA/CPU, Linux/macOS only — no Windows; Apache-2.0)",
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/CPU, no MPS; Apache-2.0)",
"confucius4-tts":"git clone netease-youdao/Confucius4-TTS + set OMNIVOICE_CONFUCIUS4_TTS_DIR (own Python 3.10 venv; 14-lang cross-lingual zero-shot clone; ~5 GB weights auto-download; CUDA/ROCm/XPU/NPU/CPU, no MPS; Apache-2.0)",
}
@@ -2371,7 +2409,7 @@ def list_backends() -> list[dict]:
"one_click_install": bool, # services.sidecar_install can provision it in-app
"last_error": Optional[str], # cached most-recent failure
"isolation_mode": "in-process" | "subprocess",
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, cpu}
"gpu_compat": list[str], # subset of {cuda, rocm, mps, xpu, npu, cpu}
"supports_cloning": Optional[bool], # True/False from the class attr; None when
# model-dependent (property, e.g. mlx-audio)
"effective_device": str, # device this engine uses on THIS host
@@ -7,13 +7,15 @@ old silence-only guard missed it (the buzz is loud, not silent) so the garbage
was cached and served.
These tests cover the fix *without the 5 GB model / a GPU*: they drive the pure
``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic signals,
and assert the render constants didn't regress. The real end-to-end render is
verified manually (spectral flatness back in the speech range + Whisper ASR).
``_spectral_flatness`` / ``_is_unusable_audio`` helpers with synthetic tones and
tracked speech demo renders, and assert the render constants did not regress.
"""
from __future__ import annotations
import math
from pathlib import Path
import soundfile as sf
import pytest
@@ -39,6 +41,13 @@ def _white_noise() -> "torch.Tensor":
return 0.5 * (torch.rand(N, generator=g) * 2 - 1)
def _two_tone_buzz() -> "torch.Tensor":
"""Two inharmonic partials — the other shape a collapsed render takes."""
t = torch.arange(N, dtype=torch.float32) / SR
s = torch.sin(2 * math.pi * 180.0 * t) + 0.6 * torch.sin(2 * math.pi * 361.0 * t)
return 0.8 * s / s.abs().max()
def _speech_like() -> "torch.Tensor":
"""Broadband + harmonic + amplitude-modulated — a coarse stand-in for voiced
speech: several harmonics (formant-ish), additive noise (consonants), and a
@@ -85,6 +94,71 @@ def test_speech_like_is_usable():
assert arch._is_unusable_audio(_speech_like()) is False
# ── Threshold stays between the two things it has to separate ───────────────
# Synthetic broadband speech has much higher flatness than real voiced audio.
# Measure both sides of the threshold against actual inputs, including the
# existing demo renders that the old thresholds rejected.
def test_tonal_ceiling_is_measured_not_assumed():
"""Derive the tonal side of the margin instead of trusting a literal.
A bare constant would keep passing if `_spectral_flatness` stopped scoring
tones near zero, so measure the degenerate signals here and require the
threshold to clear the worst of them tenfold.
"""
tones = [
arch._spectral_flatness(_pure_tone(80.0)),
arch._spectral_flatness(_pure_tone(220.0)),
arch._spectral_flatness(_two_tone_buzz()),
]
assert all(t is not None for t in tones)
assert max(tones) * 10 < arch._DEGENERATE_FLATNESS
_SAMPLES = Path(__file__).resolve().parents[1] / "assets" / "samples"
_SPEECH_FIXTURES = [
"demo_voice.wav",
"demo_clone_output.wav",
*[f"voice_design/demo_voice_design_{name}.wav" for name in (
"audiobook_uk_narrator", "aussie_podcaster", "bedtime_storyteller",
"gravelly_villain", "indian_support_agent", "mandarin_sichuan", "us_news_anchor",
)],
*[f"dictation/{name}.wav" for name in (
"en_conversational", "en_technical", "fr_reservation",
)],
*[f"demo/dubbing/{name}.src.wav" for name in (
"source", "dubbed_es", "dubbed_fr", "dubbed_ja", "dubbed_zh",
)],
]
@pytest.mark.parametrize("fixture", _SPEECH_FIXTURES)
def test_real_shipped_speech_clears_quality_floor(fixture):
# These are existing tracked demo renders, not synthesized stand-ins or
# asserted measurements. Loading PCM needs neither a model nor a network.
audio, _sample_rate = sf.read(_SAMPLES / fixture, dtype="float32", always_2d=True)
speech = torch.from_numpy(audio.T)
flatness = arch._spectral_flatness(speech)
assert flatness is not None
assert flatness > arch._DEGENERATE_FLATNESS * 10
assert arch._is_unusable_audio(speech) is False
def test_flatness_is_not_clip_length_dependent():
"""Repeating a signal must not change what it measures.
The whole-clip FFT this replaced failed exactly here: its frequency
resolution grew with duration, so the same audio measured 0.0229 at 3 s and
~0 at 12 s (100% drift). Framed, the drift is under 0.1%.
"""
short = _speech_like()
long = torch.cat([short] * 4)
a, b = arch._spectral_flatness(short), arch._spectral_flatness(long)
assert a is not None and b is not None
assert abs(a - b) / a < 0.02
# ── Constants didn't regress ────────────────────────────────────────────────
def test_preview_render_constants():
# 16 steps under-converged on the social script; the fix bumped it.
@@ -11,6 +11,7 @@ that the error message tells the user what to do.
import asyncio
import os
import sys
import threading
import time
import pytest
@@ -75,6 +76,62 @@ def test_fast_transcribe_passes_through():
pool.shutdown(wait=True)
def test_timeout_defers_abandon_cleanup_until_running_worker_finishes():
"""A timed-out native worker may still be reading request-owned inputs."""
pool = ThreadPoolExecutor(max_workers=1)
started = threading.Event()
finish = threading.Event()
cleaned = threading.Event()
def _slow():
started.set()
finish.wait(timeout=5)
assert not cleaned.is_set()
return "done"
async def _go():
with pytest.raises(ASRTimeoutError):
await run_transcribe_guarded(
pool,
_slow,
what="Convert",
timeout=0.05,
on_abandon=cleaned.set,
)
assert started.is_set()
assert not cleaned.is_set()
finish.set()
await asyncio.to_thread(cleaned.wait, 2)
assert cleaned.is_set()
try:
asyncio.run(_go())
finally:
finish.set()
pool.shutdown(wait=True)
def test_normal_completion_keeps_abandon_cleanup_with_caller():
pool = ThreadPoolExecutor(max_workers=1)
cleaned = threading.Event()
async def _go():
result = await run_transcribe_guarded(
pool,
lambda: "done",
what="Convert",
timeout=5,
on_abandon=cleaned.set,
)
assert result == "done"
assert not cleaned.is_set()
try:
asyncio.run(_go())
finally:
pool.shutdown(wait=True)
def test_timeout_error_is_a_timeouterror_subclass():
# Routers that catch broad TimeoutError (openai_compat) must also catch ours.
assert issubclass(ASRTimeoutError, TimeoutError)
+36
View File
@@ -112,6 +112,42 @@ class TestEnqueue:
job = client.get(f"/batch/jobs/{job_id}").json()
assert job["filename"] == "test.mp4"
@pytest.mark.asyncio
async def test_upload_is_persisted_in_bounded_chunks(self, batch, tmp_path):
class RecordingUpload:
def __init__(self):
self.read_sizes = []
self.remaining = b"video"
async def read(self, size):
self.read_sizes.append(size)
chunk, self.remaining = self.remaining[:size], self.remaining[size:]
return chunk
upload = RecordingUpload()
destination = tmp_path / "video.mp4"
await batch._save_upload(upload, str(destination))
assert destination.read_bytes() == b"video"
assert upload.read_sizes == [batch._UPLOAD_CHUNK_BYTES, batch._UPLOAD_CHUNK_BYTES]
@pytest.mark.asyncio
async def test_failed_upload_removes_partial_file(self, batch, tmp_path):
class FailingUpload:
calls = 0
async def read(self, _size):
self.calls += 1
if self.calls == 1:
return b"partial"
raise OSError("upload interrupted")
destination = tmp_path / "video.mp4"
with pytest.raises(OSError, match="upload interrupted"):
await batch._save_upload(FailingUpload(), str(destination))
assert not destination.exists()
class TestListJobs:
def test_empty(self, client):
@@ -257,3 +257,90 @@ def test_windows_assignment_failure_kills_suspended_unowned_child(monkeypatch):
names = [event[0] for event in events]
assert names.index("assign") < names.index("terminate") < names.index("kill")
assert names.index("kill") < names.index("wait") < names.index("write")
def test_windows_direct_job_owner_assigns_before_resume(monkeypatch):
"""Windows skips the extra Python wrapper but retains pre-start Job ownership."""
events = []
job = 99
kernel = type("Kernel", (), {})()
kernel.AssignProcessToJobObject = _Call(
lambda assigned_job, process: events.append(("assign", assigned_job, process)) or True
)
kernel.TerminateJobObject = _Call(
lambda assigned_job, code: events.append(("terminate", assigned_job, code)) or True
)
kernel.CloseHandle = _Call(
lambda handle: events.append(("close", getattr(handle, "value", handle))) or True
)
monkeypatch.setattr(owned, "_windows_job", lambda: (job, kernel, wintypes))
monkeypatch.setattr(
owned,
"_resume_windows_process",
lambda _kernel, _types, pid: events.append(("resume", pid)),
)
class Child:
_handle = 77
pid = 123
args = ["operation.exe"]
stdin = None
stdout = object()
stderr = object()
returncode = None
def poll(self):
return self.returncode
def wait(self, timeout=None):
events.append(("wait", timeout))
return self.returncode
def kill(self):
events.append(("kill",))
child = Child()
def fake_popen(argv, **kwargs):
events.append(("spawn", argv, kwargs))
return child
monkeypatch.setattr(owned.subprocess, "Popen", fake_popen)
proc = owned._spawn_windows_owned(
["operation.exe"],
{
"env": {
"KEEP": "yes",
"OMNIVOICE_DESKTOP_CONTAINED": "1",
"OMNIVOICE_DESKTOP_DRAIN_FD": "42",
},
"creationflags": 0x00000200,
},
)
names = [event[0] for event in events]
assert names[:3] == ["spawn", "assign", "resume"]
spawn_argv, spawn_kwargs = events[0][1:]
assert spawn_argv == ["operation.exe"]
assert spawn_kwargs["creationflags"] == 0x08000204
assert spawn_kwargs["env"] == {"KEEP": "yes"}
assert proc.stdout is child.stdout
child.returncode = 0
assert proc.poll() == 0
assert [event[0] for event in events][-2:] == ["terminate", "close"]
def test_spawn_owned_selects_direct_windows_job_path(monkeypatch):
sentinel = object()
calls = []
monkeypatch.setattr(owned.os, "name", "nt")
monkeypatch.setattr(
owned,
"_spawn_windows_owned",
lambda argv, kwargs: calls.append((argv, kwargs)) or sentinel,
)
assert owned.spawn_owned(["sidecar.exe"], text=True) is sentinel
assert calls == [(["sidecar.exe"], {"text": True})]
+160
View File
@@ -523,3 +523,163 @@ def test_generation_proxy_forwards_native_controls_and_seed():
"class_temperature": 0.8,
"seed": 321,
})]
def test_timeout_reaps_captured_process_before_recv_returns(monkeypatch):
import threading
class Process:
def __init__(self):
self.killed = threading.Event()
self.reaped = False
self.wait_entered = threading.Event()
self.release_wait = threading.Event()
def kill(self):
self.killed.set() # EOF may arrive before the process is reaped.
def wait(self, timeout):
assert timeout is not None
self.wait_entered.set()
assert self.release_wait.wait(2)
self.reaped = True
return -9
proc = Process()
backend = OmniVoiceSubprocessBackend()
backend._proc = proc
def recv():
assert proc.killed.wait(2)
return None
monkeypatch.setattr(backend, '_recv', recv)
returned = threading.Event()
results = []
def receive():
results.append(backend._recv_with_timeout(0.01))
returned.set()
reader = threading.Thread(target=receive)
reader.start()
try:
assert proc.wait_entered.wait(2)
assert not returned.wait(0.05), "EOF must not release the caller before process cleanup"
finally:
proc.release_wait.set()
reader.join(2)
backend._proc = None
assert not reader.is_alive()
assert returned.is_set()
assert results == [None]
assert proc.reaped
def test_timeout_never_kills_a_replacement_process(monkeypatch):
from unittest.mock import Mock
import services.subprocess_backend as module
class ManualTimer:
def __init__(self, _timeout, callback, args=()):
self.callback = lambda: callback(*args)
self.daemon = False
def start(self):
pass
def cancel(self):
pass
def join(self):
pass
timers = []
def timer(*args, **kwargs):
result = ManualTimer(*args, **kwargs)
timers.append(result)
return result
monkeypatch.setattr(module.threading, 'Timer', timer)
backend = OmniVoiceSubprocessBackend()
original, replacement = Mock(), Mock()
backend._proc = original
def recv():
backend._proc = replacement
timers[0].callback()
return None
monkeypatch.setattr(backend, '_recv', recv)
try:
backend._recv_with_timeout(1)
original.kill.assert_called_once()
replacement.kill.assert_not_called()
finally:
backend._proc = None
@pytest.mark.parametrize("failure", ["wait", "kill"])
def test_timeout_quarantine_blocks_reuse_and_retains_cleanup_handle(failure):
class StuckProcess:
stdin = None
def __init__(self):
self.exited = False
self.kill_calls = 0
def poll(self):
return 0 if self.exited else None
def kill(self):
self.kill_calls += 1
if failure == "kill" and not self.exited:
raise PermissionError("kill failed")
def terminate(self):
pass
def wait(self, timeout):
if not self.exited:
raise subprocess.TimeoutExpired("stuck-sidecar", timeout)
return 0
backend = OmniVoiceSubprocessBackend()
proc = StuckProcess()
backend._proc = proc
try:
backend._timeout_kill(proc)
with pytest.raises(RuntimeError, match="still stopping"):
backend._spawn()
backend.shutdown()
# Even after shutdown clears the current slot, ownership survives;
# retry must not silently start a second process next to this one.
before = proc.kill_calls
with pytest.raises(RuntimeError, match="still stopping"):
backend._spawn()
assert proc.kill_calls > before
finally:
proc.exited = True
backend.shutdown()
def test_timeout_quarantine_does_not_clear_or_kill_replacement():
from unittest.mock import Mock
backend = OmniVoiceSubprocessBackend()
original = Mock()
original.wait.side_effect = subprocess.TimeoutExpired("old-sidecar", 2)
replacement = Mock()
replacement.poll.return_value = None
backend._proc = replacement
try:
backend._timeout_kill(original)
with pytest.raises(RuntimeError, match="still stopping"):
backend._spawn()
assert backend._proc is replacement
replacement.kill.assert_not_called()
# Once the captured owner is reaped, reuse of the healthy replacement
# is allowed without starting or terminating another process.
original.wait.side_effect = None
original.wait.return_value = 0
backend._spawn()
assert backend._proc is replacement
replacement.kill.assert_not_called()
finally:
original.wait.side_effect = None
backend._proc = None
backend.shutdown()
+17 -2
View File
@@ -127,16 +127,29 @@ class Deadlines:
def _base_execution_seconds(
text: Optional[str], *, execution_device: Optional[str] = None
text: Optional[str], *, execution_device: Optional[str] = None,
under_provisioned: bool = False,
) -> float:
"""Delegate to model_manager's budget; fall back to its formula.
The lazy import keeps this module usable in a process that has no torch
the control plane schedules work it never executes.
``under_provisioned`` is the worker's own verdict that its card sits below
the engine's declared VRAM floor (``ConnectedWorker.under_provisioned``).
It floors the budget at what the same job would get on a CPU, because that
is what a card paging to system RAM performs like (#1804). Derived by asking
for the CPU budget rather than by probing VRAM here: this process is the
control plane, and its hardware is not the worker's.
"""
target_device = str(execution_device or "cpu").lower()
if target_device not in {"cpu", "cuda", "mps", "mlx", "directml", "rocm", "xpu"}:
target_device = "cpu"
if under_provisioned and target_device != "cpu":
return max(
_base_execution_seconds(text, execution_device=target_device),
_base_execution_seconds(text, execution_device="cpu"),
)
try:
from services import model_manager # noqa: PLC0415 — intentionally lazy
@@ -171,6 +184,7 @@ def for_task(
model_downloaded: bool = True,
input_seconds: float = 0.0,
execution_device: Optional[str] = None,
under_provisioned: bool = False,
) -> Deadlines:
"""Compute the deadlines for one attempt.
@@ -183,7 +197,8 @@ def for_task(
multiplier, grace = _PROFILE[op]
execution = _base_execution_seconds(
text, execution_device=execution_device
text, execution_device=execution_device,
under_provisioned=under_provisioned,
) * multiplier
# Media-length operations scale on duration, not characters.
if input_seconds > 0:
+4
View File
@@ -32,6 +32,7 @@ import uuid
from dataclasses import dataclass, field
from typing import Iterable, Optional
from worker.deadlines import Deadlines
from worker.clock import resolve
from worker.errors import ErrorClass, WorkerError
@@ -224,6 +225,9 @@ class Attempt:
stage: str = ""
error: Optional[WorkerError] = None
# Snapshot the lease policy granted at dispatch, including after restart.
deadlines: Optional[Deadlines] = None
def matches(self, *, session_epoch: Optional[int] = None) -> bool:
"""Fence check: reject messages from a superseded session."""
if session_epoch is None:
+50 -19
View File
@@ -93,12 +93,11 @@ class ConnectedWorker:
return "busy"
return "ready"
def supports(self, engine: str, model_id: str, operation: str) -> bool:
"""Can this worker run this work at all?
def _capability_for(self, engine: str, model_id: str, operation: str):
"""The advertised capability this task would actually be run by.
``supported`` alone is not enough an engine whose weights are not on
disk cannot start without a download, and one that is not installed
cannot start at all. Both are capability mismatches, not failures.
One selection rule, so the answers below cannot describe different
capabilities of the same worker.
"""
for cap in self.record.capabilities:
if cap.get("engine") != engine:
@@ -107,23 +106,55 @@ class ConnectedWorker:
continue
if operation and operation not in (cap.get("operations") or [operation]):
continue
return bool(cap.get("supported")) and bool(cap.get("installed", True))
return False
return cap
return None
def supports(self, engine: str, model_id: str, operation: str) -> bool:
"""Can this worker run this work at all?
``supported`` alone is not enough an engine whose weights are not on
disk cannot start without a download, and one that is not installed
cannot start at all. Both are capability mismatches, not failures.
"""
cap = self._capability_for(engine, model_id, operation)
if cap is None:
return False
return bool(cap.get("supported")) and bool(cap.get("installed", True))
def execution_device(self, engine: str, model_id: str, operation: str) -> str:
"""Device used by the exact capability selected for this task."""
for cap in self.record.capabilities:
if cap.get("engine") != engine:
continue
if model_id and cap.get("model_id") not in (model_id, "", None):
continue
if operation and operation not in (cap.get("operations") or [operation]):
continue
if cap.get("cpu_fallback"):
return "cpu"
backend = str(cap.get("backend") or "").lower()
return backend if backend in _KNOWN_EXECUTION_DEVICES else "cpu"
return "cpu"
cap = self._capability_for(engine, model_id, operation)
if cap is None:
return "cpu"
if cap.get("cpu_fallback"):
return "cpu"
backend = str(cap.get("backend") or "").lower()
return backend if backend in _KNOWN_EXECUTION_DEVICES else "cpu"
def under_provisioned(self, engine: str, model_id: str, operation: str) -> bool:
"""Is this worker's GPU below the engine's declared VRAM floor?
The remote half of #1804. A card under the floor pages to system RAM and
renders slower than a CPU, so it must not be given the shorter
accelerated deadline. Decided from the two figures the WORKER itself
advertises (``free_memory_bytes`` / ``min_memory_bytes``, both set in
``worker/capabilities.py``): the control plane's own VRAM says nothing
about the machine that will run the job, so
``engine_routing.under_provisioned_vram`` which probes THIS host
cannot answer for a remote worker.
Same rules as that predicate otherwise: dedicated-VRAM devices only
(unified memory is not a comparable pool), and a zero on either side
means "unknown", never "too small".
"""
cap = self._capability_for(engine, model_id, operation)
if cap is None or cap.get("cpu_fallback"):
return False
if str(cap.get("backend") or "").lower() not in ("cuda", "rocm"):
return False
floor = int(cap.get("min_memory_bytes") or 0)
have = int(cap.get("free_memory_bytes") or 0)
return floor > 0 and 0 < have < floor
def is_warm(self, engine: str, model_id: str) -> bool:
return self.capacity.is_resident(engine, model_id)
+17 -1
View File
@@ -652,7 +652,11 @@ class Scheduler:
execution_device=worker.execution_device(
task.engine, task.model_id, task.operation
),
under_provisioned=worker.under_provisioned(
task.engine, task.model_id, task.operation
),
)
attempt.deadlines = budget
attempt.renew_lease(budget.accept_seconds, now=now)
self._save(task, now=now)
self._emit("assigned", task)
@@ -1295,7 +1299,13 @@ class Scheduler:
def _budget_for(self, task: Task) -> deadline_policy.Deadlines:
attempt = task.active_attempt
if attempt is not None and attempt.deadlines is not None:
return attempt.deadlines
# A legacy attempt has no recorded device once its worker is absent.
# Cover both configured device classes instead of assuming the shorter
# CPU budget; for_task floors an under-provisioned GPU at max(CPU, GPU).
worker = self.pool.get(attempt.worker_id) if attempt else None
unknown_legacy_worker = attempt is not None and worker is None
return deadline_policy.for_task(
task.operation,
text=task.params.get("text"),
@@ -1303,7 +1313,13 @@ class Scheduler:
input_seconds=float(task.params.get("input_seconds") or 0.0),
execution_device=(
worker.execution_device(task.engine, task.model_id, task.operation)
if worker else None
if worker else ("cuda" if unknown_legacy_worker else None)
),
under_provisioned=unknown_legacy_worker or bool(
worker
and worker.under_provisioned(
task.engine, task.model_id, task.operation
)
),
)
+8 -3
View File
@@ -33,6 +33,7 @@ from core.db import db_conn
from core.path_security import UnsafePath, resolve_within, safe_filename
from worker.clock import resolve
from worker.errors import ErrorClass, WorkerError
from worker.deadlines import Deadlines
from worker.lifecycle import Attempt, AttemptState, PriorityClass, Task, TaskState
logger = logging.getLogger("omnivoice.worker")
@@ -67,6 +68,8 @@ def _row_to_attempt(row) -> Attempt:
state=AttemptState(row["state"]),
created_at=float(row["created_at"]),
)
if row["deadlines_json"]:
attempt.deadlines = Deadlines(**json.loads(row["deadlines_json"]))
attempt.accepted_at = row["accepted_at"]
attempt.started_at = row["started_at"]
attempt.finished_at = row["finished_at"]
@@ -635,12 +638,13 @@ def _upsert_attempts(conn, task: Task) -> None:
"INSERT INTO remote_task_attempts "
"(id, task_id, worker_id, session_epoch, attempt_number, state, progress, stage, "
" error_json, created_at, accepted_at, started_at, finished_at, lease_expires_at, "
" grace_expires_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
" grace_expires_at, deadlines_json) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET state=excluded.state, progress=excluded.progress, "
" stage=excluded.stage, error_json=excluded.error_json, accepted_at=excluded.accepted_at, "
" started_at=excluded.started_at, finished_at=excluded.finished_at, "
" lease_expires_at=excluded.lease_expires_at, grace_expires_at=excluded.grace_expires_at",
" lease_expires_at=excluded.lease_expires_at, grace_expires_at=excluded.grace_expires_at, "
" deadlines_json=excluded.deadlines_json",
(
attempt.attempt_id,
attempt.task_id,
@@ -657,6 +661,7 @@ def _upsert_attempts(conn, task: Task) -> None:
attempt.finished_at,
attempt.lease_expires_at,
attempt.grace_expires_at,
json.dumps(attempt.deadlines.to_dict()) if attempt.deadlines else None,
),
)
+146 -172
View File
@@ -6,16 +6,16 @@
"name": "omnivoice-studio-monorepo",
"devDependencies": {
"concurrently": "^9.2.4",
"playwright": "^1.62.1",
"playwright": "^1.63.0",
"taze": "^19.17.2",
"turbo": "^2.10.9",
"turbo": "^2.10.12",
"typescript": "^6.0.3",
"wait-on": "^9.1.0",
},
},
"frontend": {
"name": "omnivoice-studio",
"version": "0.5.1",
"version": "0.5.2",
"dependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/source-serif-4": "^5.3.0",
@@ -83,7 +83,7 @@
},
},
"packages": {
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g7nE4PFtngOZNZSy1lOPpkC+FAiHxqBJXqyRMEG7NUrEVZlz5goBdtHg1YgWRJIX776JTXAmbOI5JreAKVAsVA=="],
@@ -103,49 +103,49 @@
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
"@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g=="],
"@codemirror/commands": ["@codemirror/commands@6.10.4", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg=="],
"@codemirror/commands": ["@codemirror/commands@6.11.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA=="],
"@codemirror/lang-css": ["@codemirror/lang-css@6.3.1", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.0.2", "@lezer/css": "^1.1.7" } }, "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.11", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw=="],
"@codemirror/lang-html": ["@codemirror/lang-html@6.4.12", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", "@codemirror/lang-javascript": "^6.0.0", "@codemirror/language": "^6.4.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/css": "^1.1.0", "@lezer/html": "^1.3.12" } }, "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w=="],
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
@@ -159,19 +159,19 @@
"@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="],
"@codemirror/state": ["@codemirror/state@6.7.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-Zbl9NyscLMZkfXPQnNAIIAFftidrA1UbcJEIMp24C0Bukc2I5T8wJS0wsXYsnDOqCFJUeJ1BITGNs5CqPDSmSg=="],
"@codemirror/state": ["@codemirror/state@6.7.4", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA=="],
"@codemirror/view": ["@codemirror/view@6.43.4", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-YImu23iyKfncJzT7sRy+rEqEhSc8RhOHqDxwy4WzXRKJwYm6iwf/9OJk5ctCAdZ6yi2ZqaGEvmf55fSVqMDrgg=="],
"@codemirror/view": ["@codemirror/view@6.43.11", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="],
"@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="],
"@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="],
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.2", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg=="],
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="],
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.12", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw=="],
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
@@ -181,7 +181,7 @@
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
@@ -193,15 +193,15 @@
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="],
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
"@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.9", "", { "dependencies": { "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
@@ -221,7 +221,7 @@
"@hapi/pinpoint": ["@hapi/pinpoint@2.0.1", "", {}, "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q=="],
"@hapi/tlds": ["@hapi/tlds@1.1.6", "", {}, "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw=="],
"@hapi/tlds": ["@hapi/tlds@1.1.7", "", {}, "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA=="],
"@hapi/topo": ["@hapi/topo@6.0.2", "", { "dependencies": { "@hapi/hoek": "^11.0.2" } }, "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg=="],
@@ -231,17 +231,19 @@
"@henrygd/queue": ["@henrygd/queue@1.2.0", "", {}, "sha512-jW/BLSTpcvExDhqJGxtIPgGr2O0IFF8XUNDwEbfCfhrXT8a4xztQ9Lv6U/vbYzYC0xVWn+3zv6YnLUh3bEFUKA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@internationalized/date": ["@internationalized/date@3.12.2", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw=="],
"@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="],
"@internationalized/number": ["@internationalized/number@3.6.7", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg=="],
"@internationalized/number": ["@internationalized/number@3.6.8", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -249,13 +251,13 @@
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
"@lezer/css": ["@lezer/css@1.3.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q=="],
"@lezer/css": ["@lezer/css@1.3.6", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.0" } }, "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g=="],
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
@@ -271,9 +273,9 @@
"@lezer/yaml": ["@lezer/yaml@1.0.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.4.0" } }, "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.3", "", {}, "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA=="],
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.4", "", {}, "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -435,11 +437,11 @@
"@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="],
"@posthog/browser-common": ["@posthog/browser-common@0.5.0", "", { "dependencies": { "@posthog/core": "^1.47.0", "@posthog/types": "^1.402.2" } }, "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q=="],
"@posthog/browser-common": ["@posthog/browser-common@0.5.2", "", { "dependencies": { "@posthog/core": "^1.48.11", "@posthog/types": "^1.405.3" } }, "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw=="],
"@posthog/core": ["@posthog/core@1.48.6", "", { "dependencies": { "@posthog/types": "^1.405.0" } }, "sha512-lvSO1nrxxakrAfB51fetHC29gSqdDtT+AyRrGlnc5nDSWhiBGtyqKNjDqsbnlTQoj14bAaWXS7RtzuGoTo5IsQ=="],
"@posthog/core": ["@posthog/core@1.50.5", "", { "dependencies": { "@posthog/types": "^1.409.0" } }, "sha512-afEchuShDaVIoxAIj76kDZQ1DhfesDmgfVp+mtTzsA3wlc8DF5uoz8YjuTjnxOicWkpP5HCDqYidK/1kT125Cg=="],
"@posthog/types": ["@posthog/types@1.405.0", "", {}, "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw=="],
"@posthog/types": ["@posthog/types@1.409.0", "", {}, "sha512-239umoaZVb2GBaXeEyJpwFvjhrrChJH8NHCwiao23EBSu3NA6EN0MTMoaHSmMEf4yjiXEFFoYJA6FjJAXF5HGA=="],
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
@@ -519,35 +521,35 @@
"@replit/codemirror-css-color-picker": ["@replit/codemirror-css-color-picker@6.3.0", "", { "peerDependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-19biDANghUm7Fz7L1SNMIhK48tagaWuCOHj4oPPxc7hxPGkTVY2lU/jVZ8tsbTKQPVG7BO2CBDzs7CBwb20t4A=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.5", "", { "os": "android", "cpu": "arm" }, "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA=="],
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.7", "", { "os": "android", "cpu": "arm" }, "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.5", "", { "os": "android", "cpu": "arm64" }, "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.7", "", { "os": "android", "cpu": "arm64" }, "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.5", "", { "os": "linux", "cpu": "arm" }, "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.7", "", { "os": "linux", "cpu": "arm" }, "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.5", "", { "os": "none", "cpu": "arm64" }, "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.7", "", { "os": "none", "cpu": "arm64" }, "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
@@ -647,7 +649,7 @@
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.8", "", {}, "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.31", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-wZMEoSf852jQqaf3Ika1J7PiBae6341LNy/2CxmIyn0XKDQXMuK41wVX+xp6G0yx8jyR95Ef+Tdr13DK7mbJtQ=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.36", "", { "dependencies": { "@tanstack/virtual-core": "3.17.8" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-gKpExv4RbB9luVG+SucTXoqPZv/gzu/Yvz6BNO+8kpNxJ2x+I/ulryzl5W9BRciahZGp5Tls3Dp5XP1ztVGbMw=="],
"@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="],
@@ -691,17 +693,17 @@
"@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-v3R+1R/Ysozyo+p7Ri8MCIbndOvYt3DgPFrGLhrhQHfvyvbxyH3WyJj+A/2JTNmNleuAlh3JUyCV0iSVHIONTA=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.10.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-9nKgKoF6ZOUsM+or0OtNf+TTJSfGvDNP7ZFv/ZGWVwOSCkumyctQiTeHwB4UNljHTnC41AqylgbunLDHoccNrA=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R0a0CvGAeYYsBgPIgFNB3agGXh6qukjduNhFlwVVX1Ss2IdBJLXmgjytNGmo084bLKS0B6UdLRwKhXMHKKaObQ=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-H4Elb1jqTZVeIC9bbcNwjSzemZ6RegoTOVHeuV5Osirt2Z8UguTyisMEkvZjPVZgMeN9J4ERZBFad40tFnkb7w=="],
"@turbo/linux-64": ["@turbo/linux-64@2.10.11", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-dGlY2vg7jpsLjGS1bf9sD/cw1sGMAYbeHGQKGRFxd5Zaj+Ufbj8cNXl/vIit8ueCU97zhL/znn60ZV5iSfZAxw=="],
"@turbo/linux-64": ["@turbo/linux-64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "x64" }, "sha512-lr7KIotukvjZwEXiFSYAeOH3BWzjFVBbSzTbv0fuGFsNukYyH0+g1hB5ecqnJkgkYU+KHEMG1edOhnjiKON1wQ=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.11", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-eSP9+jjsSCBs2x0QpJZlp49dSD1EIMwXH7PsMIhdWk7w6IThhuKJ+jL95JQ2IW7tRjRmdh8gKcKQtrHLD5R5lA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.12", "", { "os": [ "linux", "android", ], "cpu": "arm64" }, "sha512-f0pZDTtvzB5SuNwuXBaKbZHUCMCukgc8nMlHEuvLmj91Fzec+MEbr3cAvGNor5htEDqZnO6Lxt9N/GPI/77oGA=="],
"@turbo/windows-64": ["@turbo/windows-64@2.10.11", "", { "os": "win32", "cpu": "x64" }, "sha512-4aD7edogJ8arK8DOyvjTI2KKwmchD5M/WM4BZgidrtFf7aSgn8Ce2rJnDwmriw980c2cKlHnhXtlGy38VtKB8g=="],
"@turbo/windows-64": ["@turbo/windows-64@2.10.12", "", { "os": "win32", "cpu": "x64" }, "sha512-SDOueJRjS/QcykWf2KCRtTLmIl5YMKsLbXkXQGhDwcTXvKXZiS5ih5lBl/gkwZIpYFjqA/rAlfMzlAFcVHNe0g=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-m8tJkIrTrbQ9O1uHxV0GUq623Zg1678xGna8eMsy4KbygUX/wVFgdZDYV/AxyoyaW/U7nyKoBvaDVwm22p9xqA=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
@@ -715,11 +717,11 @@
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@@ -727,7 +729,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="],
"@types/node": ["@types/node@24.13.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q=="],
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
@@ -739,9 +741,9 @@
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.2", "", {}, "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="],
"@unhead/vue": ["@unhead/vue@2.1.15", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.15" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-SSByXfEjhzPn8gXdEdgpYqpLMPSkLUH2HVE0GxZfOtNsJ0GgOHQs0g9T67ZZ1z0kTELLKdtOtYrzrbv9+ffF7g=="],
"@unhead/vue": ["@unhead/vue@2.1.17", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.17" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-pnC8x9HLV3qQXdvWfylUEU25uhfCAy3ly9nmpQz84j9py818DRfU8jOsQ5wjdWtxyU1vX/fW2udfm4jtxUK8Bg=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
@@ -761,23 +763,23 @@
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.42", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.42", "", { "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.42", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.42", "@vue/compiler-dom": "3.5.42", "@vue/compiler-ssr": "3.5.42", "@vue/shared": "3.5.42", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.42", "", { "dependencies": { "@vue/shared": "3.5.42" } }, "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.42", "", { "dependencies": { "@vue/reactivity": "3.5.42", "@vue/runtime-core": "3.5.42", "@vue/shared": "3.5.42", "csstype": "^3.2.3" } }, "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.42", "", { "dependencies": { "@vue/compiler-ssr": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/shared": "3.5.42" } }, "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ=="],
"@vue/shared": ["@vue/shared@3.5.41", "", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="],
"@vue/shared": ["@vue/shared@3.5.42", "", {}, "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g=="],
"@vueuse/core": ["@vueuse/core@13.9.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.9.0", "@vueuse/shared": "13.9.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA=="],
@@ -787,7 +789,7 @@
"@vueuse/shared": ["@vueuse/shared@13.9.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -795,7 +797,7 @@
"ai": ["ai@6.0.33", "", { "dependencies": { "@ai-sdk/gateway": "3.0.13", "@ai-sdk/provider": "3.0.2", "@ai-sdk/provider-utils": "4.0.5", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bVokbmy2E2QF6Efl+5hOJx5MRWoacZ/CZY/y1E+VcewknvGlgaiCzMu8Xgddz6ArFJjiMFNUPHKxAhIePE4rmg=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -809,19 +811,19 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.19.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="],
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.17", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="],
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
"browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="],
"cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="],
@@ -829,7 +831,7 @@
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -863,7 +865,7 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"country-flag-icons": ["country-flag-icons@1.6.20", "", {}, "sha512-py8JiEKzjhYw6HPJ0L7SxLgCYim36UPRTZX43/kqGueUCZLSvnrqAiwW8HtQibur7mdkFQUkjOgdK+o/9FBtaw=="],
@@ -913,7 +915,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.334", "", {}, "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog=="],
"electron-to-chromium": ["electron-to-chromium@1.5.422", "", {}, "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -925,9 +927,9 @@
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
@@ -955,9 +957,9 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
@@ -971,7 +973,7 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="],
"fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
@@ -979,7 +981,7 @@
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
"focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="],
@@ -989,7 +991,7 @@
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -1017,7 +1019,7 @@
"globals": ["globals@17.11.0", "", {}, "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw=="],
"goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="],
"goober": ["goober@2.1.19", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
@@ -1071,7 +1073,7 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"highlight.js": ["highlight.js@11.12.0", "", {}, "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg=="],
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
@@ -1089,7 +1091,7 @@
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"identifier-regex": ["identifier-regex@1.0.1", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-ZrYyM0sozNPZlvBvE7Oq9Bn44n0qKGrYu5sQ0JzMUnjIhpgWYE2JB6aBoFwEYdPjqj7jPyxXTMJiHDOxDfd8yw=="],
"identifier-regex": ["identifier-regex@1.1.0", "", { "dependencies": { "reserved-identifiers": "^1.0.0" } }, "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -1105,7 +1107,7 @@
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-identifier": ["is-identifier@1.0.1", "", { "dependencies": { "identifier-regex": "^1.0.0", "super-regex": "^1.0.0" } }, "sha512-HQ5v4rEJ7REUV54bCd2l5FaD299SGDEn2UPoVXaTHAyGviLq2menVUD2udi3trQ32uvB6LdAh/0ck2EuizrtpA=="],
"is-identifier": ["is-identifier@1.1.0", "", { "dependencies": { "identifier-regex": "^1.1.0", "super-regex": "^1.1.0" } }, "sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw=="],
"is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="],
@@ -1119,7 +1121,7 @@
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"joi": ["joi@18.2.5", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ=="],
"joi": ["joi@18.2.8", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.1.0" } }, "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ=="],
"js-base64": ["js-base64@3.9.3", "", {}, "sha512-uwYQp+VJ38FVvtim6qNbit6e9uT6dwWQ4Y1+H9TxhW5hcHjpHwoxlR0nMpqUmIFOmu4VqMxwdJA88gIVuZJQ/g=="],
@@ -1179,7 +1181,7 @@
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
"lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
"lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="],
@@ -1219,7 +1221,7 @@
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
"microdiff": ["microdiff@1.5.0", "", {}, "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q=="],
"microdiff": ["microdiff@1.6.0", "", {}, "sha512-w7JWt8Bno6I8h0rEqlxr4lNG4UbT1FVtWo42wWosIiaJ+rP3pXh7shCYDnyqBrYx36LJ1CZ64p8A62aVp0sJpQ=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
@@ -1285,7 +1287,7 @@
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1299,7 +1301,7 @@
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="],
"node-releases": ["node-releases@2.0.54", "", {}, "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
@@ -1343,27 +1345,27 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
"playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="],
"playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="],
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
"pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.8.0", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-rqGldoFOzjalWzlPtzAi/RcjAyep+Pjl4QUKJ6/oEZXzhVAX79GS/i8gjhQT715g75kTkyfHjL9eBPGZ9Co++Q=="],
"pnpm-workspace-yaml": ["pnpm-workspace-yaml@1.9.1", "", { "dependencies": { "yaml": "^2.9.0" } }, "sha512-Xj/T2X6DNAWbtk/9UQ0/TJRswltiHZevmcMjqoL7WrpJi67lu3qaslU4+I+zJ8wtwqmuvAE0KkB8rbv2ZdogBg=="],
"postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
"postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="],
"posthog-js": ["posthog-js@1.418.6", "", { "dependencies": { "@posthog/browser-common": "^0.5.0", "@posthog/core": "^1.48.6", "@posthog/types": "^1.405.0", "core-js": "^3.49.0", "dompurify": "^3.4.13", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0", "web-vitals-soft-navs": "npm:web-vitals@6.0.0" } }, "sha512-s1+5sPSOElZ8twPNRQytBhY3fOp+rOkf6tZzS5xSZlNZG7UE/jRWcI0zRqykhMaeJOVwSABMuaeaap7rmX82Pg=="],
"preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="],
"preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="],
"property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="],
@@ -1431,7 +1433,7 @@
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="],
"rolldown": ["rolldown@1.2.7", "", { "dependencies": { "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.7", "@rolldown/binding-android-arm64": "1.2.7", "@rolldown/binding-darwin-arm64": "1.2.7", "@rolldown/binding-darwin-x64": "1.2.7", "@rolldown/binding-freebsd-x64": "1.2.7", "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", "@rolldown/binding-linux-arm64-gnu": "1.2.7", "@rolldown/binding-linux-arm64-musl": "1.2.7", "@rolldown/binding-linux-ppc64-gnu": "1.2.7", "@rolldown/binding-linux-s390x-gnu": "1.2.7", "@rolldown/binding-linux-x64-gnu": "1.2.7", "@rolldown/binding-linux-x64-musl": "1.2.7", "@rolldown/binding-openharmony-arm64": "1.2.7", "@rolldown/binding-win32-arm64-msvc": "1.2.7", "@rolldown/binding-win32-x64-msvc": "1.2.7" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
@@ -1463,7 +1465,7 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"string-byte-length": ["string-byte-length@3.0.1", "", {}, "sha512-yJ8vP0HMwZ54CcA8S8mKoXbkezpZHANFtmafFo8lGxZThCQcAwRHjdFabuSLgOzxj9OFJcmssmiAvmcOK4O2Hw=="],
@@ -1507,19 +1509,19 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
"tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="],
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
"tldts-core": ["tldts-core@7.4.11", "", {}, "sha512-CW3WN2rIIE/Of21mulhgnGOwoDyEFNygyIBOONSdyAuSATgMMUCpLeUlB+E8sAwA5xRV9hYPl+kyZ9citHCaKg=="],
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
"tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="],
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
@@ -1533,29 +1535,29 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"turbo": ["turbo@2.10.11", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.11", "@turbo/darwin-arm64": "2.10.11", "@turbo/linux-64": "2.10.11", "@turbo/linux-arm64": "2.10.11", "@turbo/windows-64": "2.10.11", "@turbo/windows-arm64": "2.10.11" }, "bin": { "turbo": "bin/turbo" } }, "sha512-yQfwQVoRXwOuyX1LxiJFBFNg6VfuYh+/RyZLd82+isgyLkBXw3S5XRRzvcck1FAjSCG5sVyLd+O1eDMvYa3J7g=="],
"turbo": ["turbo@2.10.12", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.12", "@turbo/darwin-arm64": "2.10.12", "@turbo/linux-64": "2.10.12", "@turbo/linux-arm64": "2.10.12", "@turbo/windows-64": "2.10.12", "@turbo/windows-arm64": "2.10.12" }, "bin": { "turbo": "bin/turbo" } }, "sha512-AswgMPnpOoaVZHrrSBejETzEbuIA69OVGwfkHwfrY0A23VjWXBANzgq9+OymWOHAIArB7D1+1z498WY8fGg1Jw=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
"type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"unbash": ["unbash@4.0.10", "", {}, "sha512-b7zoBQvpWp0vuN5q2vK2RRBR2SvuruQAs50DApdDveBSn3eSYd84IaHodFqQIMlvY9K2VnyBUEXgwOBuGU9GBg=="],
"unbash": ["unbash@4.0.11", "", {}, "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ=="],
"unconfig": ["unconfig@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^1.0.0", "unconfig-core": "7.5.0" } }, "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA=="],
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
"undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unhead": ["unhead@2.1.15", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-MCt5T90mCWyr3Z6pUCdM9lVRXoMoVBlL7z7U4CYVIiaDiuzad/UCfLuMqz5MeNmpZUgoBCQnrucJimU7EZR+XA=="],
"unhead": ["unhead@2.1.17", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-HLMKXOszRhAPBrr6VlqCeVeJq2kbC4kXwzGLEZvvojPLWNYTJw22xG7Bfwhsvs31+IBet3Wl8ADg9dwYdyphfQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
@@ -1571,7 +1573,7 @@
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"update-browserslist-db": ["update-browserslist-db@1.3.2", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -1593,9 +1595,9 @@
"vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
"vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="],
"vue": ["vue@3.5.42", "", { "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", "@vue/runtime-dom": "3.5.42", "@vue/server-renderer": "3.5.42", "@vue/shared": "3.5.42" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA=="],
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.6", "", {}, "sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ=="],
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.11", "", {}, "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA=="],
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
@@ -1651,7 +1653,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
@@ -1663,16 +1665,16 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@floating-ui/core/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@floating-ui/core/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@floating-ui/dom/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"@playwright/test/playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="],
"@scalar/api-client/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/api-client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@scalar/api-reference/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/icons/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -1681,8 +1683,6 @@
"@scalar/types/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"@scalar/types/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@scalar/use-hooks/tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1699,61 +1699,45 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tanstack/vue-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
"@tauri-apps/plugin-process/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@tauri-apps/plugin-window-state/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@vue/compiler-core/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"es-set-tostringtag/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"get-intrinsic/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
"knip/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"lowlight/highlight.js": ["highlight.js@11.11.2", "", {}, "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ=="],
"make-asynchronous/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"playwright/playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
"radix-vue/@floating-ui/vue": ["@floating-ui/vue@1.1.11", "", { "dependencies": { "@floating-ui/dom": "^1.7.6", "@floating-ui/utils": "^0.2.11", "vue-demi": ">=0.13.0" } }, "sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw=="],
"radix-vue/@vueuse/core": ["@vueuse/core@10.11.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.20", "@vueuse/metadata": "10.11.1", "@vueuse/shared": "10.11.1", "vue-demi": ">=0.14.8" } }, "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww=="],
"radix-vue/@vueuse/shared": ["@vueuse/shared@10.11.1", "", { "dependencies": { "vue-demi": ">=0.14.8" } }, "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA=="],
"radix-vue/nanoid": ["nanoid@5.1.16", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"react-i18next/@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.148.0", "", {}, "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A=="],
"rolldown/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="],
"tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"@playwright/test/playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -1777,10 +1761,6 @@
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"@vue/compiler-core/@babel/parser/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"@vue/compiler-sfc/@babel/parser/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
"hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
@@ -1793,18 +1773,12 @@
"qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
"radix-vue/@floating-ui/vue/@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="],
"radix-vue/@vueuse/core/@types/web-bluetooth": ["@types/web-bluetooth@0.0.20", "", {}, "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="],
"radix-vue/@vueuse/core/@vueuse/metadata": ["@vueuse/metadata@10.11.1", "", {}, "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw=="],
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@vue/compiler-core/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@vue/compiler-sfc/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"qrcode/yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
+2 -2
View File
@@ -111,12 +111,12 @@ publishing the web UI — see the [Docker install guide](https://github.com/debp
|-----|--------------|
| `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
| `:stable` | Most recent versioned release (updated on every `v*` git tag) |
| `:0.5.1` | Exact release version |
| `:0.5.2` | Exact release version |
| `:0.5` | Latest patch within the `0.5` minor |
| `:main` | Alias of the same rolling `main` build as `:latest` |
| `:sha-xxxxxxx` | A specific commit (produced by manual workflow dispatch) |
| `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
| `:stable-rocm`, `:0.5.1-rocm`, `:0.5-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
| `:stable-rocm`, `:0.5.2-rocm`, `:0.5-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding tags above |
Preview builds always come from `main` and never version-sort below `:stable`,
so upgrades flow naturally. The same images and tags
+10 -1
View File
@@ -96,7 +96,7 @@ bug to fix immediately, not backlog.
| Channel | Source | Produced by | How to verify |
|---|---|---|---|
| GitHub Release: installers + signed `latest.json` (**Stable** updater channel) | the `vX.Y.Z` tag | `release.yml` on tag push | Release page has dmg (arm+intel), msi/exe, AppImage/deb, `latest.json`; body = the CHANGELOG section (not the auto-generated fallback), followed by per-platform checksums and a **Contributors** avatar strip (owner + every PR author for the tag — the `contributors-strip` job) |
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` stamps `X.Y.Z-N` and semver-sorts above stable |
| **Preview** updater channel (rolling `preview` prerelease) | **`main` only** | `release.yml` nightly cron / manual dispatch | preview `latest.json` uses main's version when it is ahead; otherwise it advances the stable patch, then appends `-N` so it semver-sorts above stable |
| GHCR CUDA image: `:X.Y.Z`, `:X.Y`, `:stable` | the tag | `docker.yml` on tag push | `docker manifest inspect ghcr.io/debpalash/omnivoice-studio:X.Y.Z` |
| GHCR ROCm image: `:X.Y.Z-rocm`, `:X.Y-rocm`, `:stable-rocm` | the tag | `docker.yml` on tag push | same, with `-rocm` suffix |
| Docker Hub mirror of **all** the above tags | the tag | `docker.yml` (gated on `DOCKERHUB_*` secrets) | tag list at hub.docker.com/r/palashdeb/omnivoice-studio/tags |
@@ -147,3 +147,12 @@ There's no "revert update" flow for clients — they'll only see a *newer* versi
3. Clients auto-update to the "new" v0.2.1 which is actually the old code.
Ugly but it works. Better plan: test with Option B above before publishing the draft.
## Retrying a partially published build
Use GitHub Actions **Re-run failed jobs** for the same release run. On retries,
the workflow removes only the current version's installers for that job's target
before Tauri uploads them again. A macOS retry also replaces that architecture's
versionless updater archive. Other versions, sibling platforms, and updater
manifests remain intact. Inventory or deletion permission/network failures stop
the job instead of hiding an upload collision.
+4 -4
View File
@@ -18,7 +18,7 @@ Phase 5 · Productisation ░░░░░░░░░░ 0 / 5
Design track ▓▓▓▓▓▓▓▓▓░ ongoing · 14 primitives + ~67 migrated inline styles · DubTab/Header/Sidebar/CloneDesignTab drained
Performance track ▓▓▓░░░░░░░ underway · profiling, preload, isolated engines + cache-remix I/O
Feature-magic track ░░░░░░░░░░ not started
Feature-magic track ▓▓░░░░░░░░ underway · project-level casting board shipped
Quality track ▓▓░░░░░░░░ 12 smoke tests, 10 error messages rewritten
```
@@ -205,15 +205,15 @@ None on the critical path to world-class. All are answers to real demand.
| Interaction budgets (<50 ms UI, <200 ms preview, <4 s first seg) | 🟡 | `/ws/tts` reports real TTFA, total generation time and RTF; frontend responsiveness instrumentation exists, but no cross-surface budget gate yet. |
| Dedicated dev-week per quarter | ⏳ | Cadence not yet booked. |
### ✨ Feature-magic track _(⏳ not started)_
### ✨ Feature-magic track _(🟡 underway)_
| Feature | Status | Phase gate |
|------|:---:|------|
| Project-level casting view (drag voices to speakers) | | After Phase 3 |
| Project-level casting view (drag voices to speakers) | | Shipped (#1767): the dub CAST strip expands into a casting board — drag voice chips onto speaker rows, keyboard listbox included, same fields as the dropdowns. |
| Voice memory across projects | ⏳ | After Phase 4 |
| Context-aware pipeline (video frames → pipeline decisions) | ⏳ | After Phase 4 |
| On-device learning from corrections (user edits → LoRA) | ⏳ | Research only; possibly Phase 5+ |
| Real-time dub preview (stream TTS as you edit) | | After Phase 4.1 |
| Real-time dub preview (stream TTS as you edit) | | Shipped 2026-09-02 (#1769) — opt-in "Live preview" toggle on the dub segment table streams the edited line over `/ws/tts` with its CAST voice; export path unchanged. |
### 🧪 Quality track _(🟡 underway)_
+4 -2
View File
@@ -204,7 +204,8 @@ ws://gpu-box:3900/ws/transcribe?api_key=<key>
That URL form is retained for non-browser compatibility only. The first-party
UI never constructs it. A bearer administrator session first calls
`POST /api/auth/ws-ticket` and puts only the returned `ws_ticket` in the URL.
Tickets are scoped to `/ws/transcribe` or `/ws/events`, expire after 30 seconds,
Tickets are scoped to one of `/ws/transcribe`, `/ws/events` or `/ws/tts` (the
live dub preview stream), expire after 30 seconds,
return the same bounded `expires_in`/`expires_at` pair, and are consumed
atomically at most once. Same-origin UI WebSockets use the
HttpOnly session cookie and must pass exact `Origin` validation; `null`, missing,
@@ -338,7 +339,8 @@ drives exact-Origin checks and the session cookie's `Secure` attribute.
For a public path prefix such as `/studio`, either strip that prefix before
forwarding or configure the ASGI `root_path` to the same value. WebSocket ticket
validation removes only that trusted, configured prefix; it never accepts an
arbitrary path merely because it ends in `/ws/events` or `/ws/transcribe`.
arbitrary path merely because it ends in `/ws/events`, `/ws/transcribe` or
`/ws/tts`.
## Status codes
+17
View File
@@ -4,3 +4,20 @@ Video exports can contain both the source audio and one or more dubbed tracks.
VoiceStudio marks the selected dubbed language as the default so ordinary video
players and messaging apps play the dub immediately. Choose **Original** in the
Default Track control when the source audio should play first instead.
## Hardsub captions
Turning on **Burn subtitles into picture (hardsub)** re-encodes the video with
captions rendered into the frames. Two caption styles are available:
- **Line** (default) — static line subtitles, unchanged from previous releases.
- **Karaoke (word highlight)** — each word fills with the highlight colour in
sequence across the line. Word timings recorded at transcription time drive
the sweep when they still spell the burned text; otherwise (older jobs, or
translated tracks) the timing is spread evenly across each line. Karaoke is
not available together with the dual-layout (translation + original) style —
switching dual on falls back to the line burn.
Smart Fit exports burn captions after the video retime, so both styles follow
the fitted timeline. The karaoke script can also be downloaded on its own from
`GET /dub/ass/{job_id}` as an `.ass` sidecar.
+1 -1
View File
@@ -54,7 +54,7 @@ approval), [Windows](../install/windows.md), [Linux](../install/linux.md),
| Moonshine | [moonshine](moonshine.md) | CPU | edge/low-power, no timestamps | `pip install` (see guide) |
| FunASR (SenseVoice) | [funasr](funasr.md) | CUDA · CPU | 50+ languages, inline diarization | `pip install funasr` |
| Sherpa-ONNX dictation | [sherpa-onnx-asr](sherpa-onnx-asr.md) | CPU | live streaming dictation | curated model download |
| OpenAI-compatible (remote) | [openai-compatible-asr](openai-compatible-asr.md) | network | offloading to a server (audio leaves the machine) | Model Catalogue |
| OpenAI-compatible (local or remote) | [openai-compatible-asr](openai-compatible-asr.md) | network | a configured endpoint; loopback stays local | Model Catalogue |
Speaker diarization is not an engine registry of its own — the dub pipeline
uses pyannote (HF-gated; see [diarization](../features/diarization.md)) and
+10
View File
@@ -88,3 +88,13 @@ sr = model.sample_rate # 22050
- ✅ **Sidecar logic unit-tested** (`tests/test_confucius4_sidecar.py`):
language normalization, tensor→PCM (mono/stereo/clip), config-path
resolution, clone sys.path injection, wire framing, synthesize dispatch.
## Accelerator routing
The sidecar passes a runtime-available CUDA/ROCm, XPU, or registered NPU
through upstream's device-aware model loading. The engine venv needs a matching
PyTorch/vendor runtime, also noted in the catalogue install hint. XPU/NPU selection is covered by mocked loader and routing
tests; this change does not certify synthesis on physical XPU/NPU hardware.
MPS keeps the existing CPU fallback described in the validation record above.
If modern accelerator detection or the legacy CUDA probe raises, loading falls
back to CPU instead of aborting before model construction.
+6
View File
@@ -115,3 +115,9 @@ dots.tts runs in a dedicated sidecar venv (it pins `transformers==4.57`,
which conflicts with the parent's `transformers>=5.3`). For why that adds
disk and how uv keeps the cost down, see
[Engine venvs & disk usage](disk-usage.md).
The upstream runtime selects CUDA or CPU internally. Automatic precision follows
that selection: bfloat16 on CUDA, float32 otherwise, including XPU/NPU/MPS hosts
where this runtime executes on CPU. `OMNIVOICE_DOTS_TTS_PRECISION` remains an
explicit override. If the CUDA availability probe raises, the automatic precision
default stays float32; upstream remains responsible for its device selection.
+6 -4
View File
@@ -23,10 +23,12 @@ interpreter, so MOSS runs behind
8 GB GPUs when quantized; the bf16 Transformers path used here is ~16 GB
of weights, so a 16 GB+ GPU is the realistic CUDA target. It also runs on
**CPU** (fp32) — correct but slow.
- **Device:** CUDA when present, else CPU. **There is no MPS path**
upstream documents only CUDA/CPU and the custom modelling code is
untested on Apple Silicon, so VoiceStudio never routes MOSS to MPS. On a
Mac it runs on CPU.
- **Device:** the sidecar uses a runtime-available PyTorch CUDA/ROCm, XPU,
or registered NPU backend, otherwise CPU. The isolated engine venv needs the
matching torch/vendor integration. A failed accelerator probe falls back to
CPU, including in older venvs without the unified accelerator API. MPS still uses CPU. XPU/NPU routing is
covered by mocked loader tests; physical-device synthesis has not been
validated by this change.
## Install
+16 -4
View File
@@ -26,7 +26,11 @@ quantized native binary with a much smaller memory footprint.
[#1222](https://github.com/debpalash/VoiceStudio/issues/1222)) the driver
pages to system RAM and a render that should take seconds runs for minutes
until the compute budget kills it. The UI warns before you wait; nothing
hard-blocks, since short inputs can still fit.
hard-blocks, since short inputs can still fit. A CUDA or ROCm card below the
floor is also budgeted as the CPU-class hardware it performs like — the longer
`OMNIVOICE_CPU_GENERATE_TIMEOUT_S` (600 s), not the accelerated 300 s
([#1804](https://github.com/debpalash/VoiceStudio/issues/1804)). Apple Silicon
is excluded: unified memory has no dedicated pool to compare against.
- No extra install — the model ships with the app and downloads its weights
on first use (see [downloading-models.md](../downloading-models.md)).
@@ -88,9 +92,10 @@ The env var overrides the persisted UI choice.
is ignored. Design is trained on English and Chinese and can be unstable
in low-resource languages; for description-driven design in other cases
try [VoxCPM2](voxcpm2.md).
- Below the 6 GB VRAM floor, expect very slow renders or budget timeouts;
prefer [OmniVoice GGUF](omnivoice-gguf.md) or a CPU engine such as
[PocketTTS](pockettts.md).
- Below the 6 GB VRAM floor on a CUDA or ROCm card, expect very slow renders;
they get the longer CPU compute-time budget rather than the accelerated one,
but can still time out. Prefer [OmniVoice GGUF](omnivoice-gguf.md) or a CPU
engine such as [PocketTTS](pockettts.md).
## Troubleshooting
@@ -106,3 +111,10 @@ See also: [benchmarks.md](../benchmarks.md),
[performance.md](../performance.md),
[expressive-speech.md](../expressive-speech.md),
[disk usage](disk-usage.md).
A timed-out subprocess is killed and given a bounded wait to exit before the
request returns, so retrying cannot reuse its closing process. Timeout cleanup
remains tied to the original child and cannot kill a replacement sidecar.
If that wait cannot confirm exit, VoiceStudio retains the process for cleanup
and blocks another attempt until it can be reaped, rather than starting a second
engine alongside it. A later retry or shutdown retries the bounded cleanup.
+16 -12
View File
@@ -1,11 +1,11 @@
# VoiceStudio OpenAI-Compatible Remote ASR
# VoiceStudio: OpenAI-Compatible ASR
Point transcription at **any** server exposing an OpenAI-compatible
`POST /v1/audio/transcriptions` endpoint LM Studio or a llama.cpp-style
local server, a self-hosted Qwen3-ASR/FunASR/SenseVoice box on your network,
Groq, or OpenAI's own Whisper API. Unlike every other ASR engine, this one
runs no model locally: it's a pure network client, so it needs no install
and claims no GPU.
`POST /v1/audio/transcriptions` endpoint: gigastt, LM Studio, or a
llama.cpp-style server on the same machine; a self-hosted
Qwen3-ASR/FunASR/SenseVoice box on your network; Groq; or OpenAI's Whisper
API. VoiceStudio is a pure client in this mode, so the configured server owns
model installation and compute.
## Setup
@@ -39,13 +39,18 @@ picks local engines, and the app works fully with this engine unconfigured.
| Server | Server URL | Model | API key |
| --- | --- | --- | --- |
| [gigastt](https://github.com/ekhodzitsky/gigastt) (local Russian specialist) | `http://127.0.0.1:9876/v1` | `gigaam-v3-rnnt` | none |
| LM Studio (local) | `http://localhost:1234/v1` | the model name shown in LM Studio | none |
| llama.cpp / whisper.cpp server (local) | `http://localhost:8080/v1` | whatever the server loads (often ignored) | none |
| speaches / faster-whisper-server (local) | `http://localhost:8000/v1` | e.g. `Systran/faster-whisper-large-v3` | none |
| Self-hosted Qwen3-ASR / FunASR (LAN box) | `http://<host>:8000/v1` | your deployment's model id | if you enabled auth |
| Self-hosted Qwen3-ASR / FunASR (LAN box) | `https://<host>:8000/v1` | your deployment's model id | if you enabled auth |
| Groq | `https://api.groq.com/openai/v1` | `whisper-large-v3` | required |
| OpenAI | `https://api.openai.com/v1` | `whisper-1` | required |
Plain HTTP is accepted only for exact loopback hosts such as `localhost`,
`127.0.0.1`, and `::1`. Every non-loopback endpoint must use HTTPS. VoiceStudio
does not follow redirects from transcription or connection-probe requests.
Local servers vary in which endpoints they implement — if **Test
connection** reports the server is reachable but doesn't list models,
transcription may still work; run a small dictation or dub-transcribe to
@@ -60,8 +65,7 @@ path returns word-level timestamps — that's not part of this API.
## Privacy note
Unlike every other ASR engine in VoiceStudio, audio sent through this backend
leaves your machine — to whatever server **you** configured, and nowhere
else. If that's a self-hosted server on your own network, nothing leaves
your control; if it's a third-party API (Groq, OpenAI's, or someone
else's), review their data handling before sending anything sensitive.
Audio goes only to the server **you** configure. A loopback URL such as the
gigastt example keeps it on the same machine and may use HTTP. LAN and public
endpoints require HTTPS, and redirects are not followed. Review the configured
server's data handling before sending anything sensitive.
+3
View File
@@ -23,6 +23,9 @@ ignores.
Everything you type in the text box reaches the active engine **verbatim**
the pipeline goes out of its way not to break tags:
- Tilde-separated integer, signed, and decimal ranges get a spoken separator in
English, Korean, Japanese, and Chinese; malformed chains and product codes
are left unchanged.
- The text-normalization pass (numbers, abbreviations) skips every `[…]` span
(`backend/services/text_normalization.py`).
- The long-text chunker never cuts inside a bracket tag
+1 -1
View File
@@ -82,7 +82,7 @@ asr_engines:
- id: sherpa-onnx-asr
readme: "**sherpa-onnx** (live dictation)"
- id: openai-compat-asr
readme: "**OpenAI-compatible** ⚠️ remote"
readme: "**OpenAI-compatible** ⚠️ configured server"
# Doc files that must exist (the install path users are sent to).
docs:
+3 -3
View File
@@ -13,12 +13,12 @@ and [`palashdeb/omnivoice-studio` on Docker Hub](https://hub.docker.com/r/palash
> |-----|--------------|
> | `:latest` | **Rolling preview** — latest commit on `main`, at or ahead of the last release. This is the preview channel; pin `:stable` for production. |
> | `:stable` | Most recent versioned release (updated on every `v*` git tag) |
> | `:0.5.1` | Exact release version |
> | `:0.5.2` | Exact release version |
> | `:0.5` | Latest patch within the 0.5 minor |
> | `:main` | Alias of the same rolling `main` build as `:latest` |
> | `:sha-xxxxxxx` | Specific commit (produced by manual workflow dispatch) |
> | `:rocm` | **AMD GPU (ROCm) build** of the rolling preview — the ROCm analogue of `:latest` |
> | `:stable-rocm`, `:0.5.1-rocm`, `:0.5-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
> | `:stable-rocm`, `:0.5.2-rocm`, `:0.5-rocm`, `:sha-xxxxxxx-rocm` | ROCm builds of the corresponding CUDA tags above |
>
> Versioning rule: preview builds always come from `main` and never
> version-sort below `:stable` — upgrades flow naturally.
@@ -164,7 +164,7 @@ Volume=omnivoice-data:/app/omnivoice_data
Environment=OMNIVOICE_API_KEY=replace-with-a-long-random-key
```
Release pins exist too: `:stable-rocm`, `:0.5.1-rocm`, `:0.5-rocm` mirror
Release pins exist too: `:stable-rocm`, `:0.5.2-rocm`, `:0.5-rocm` mirror
the CUDA tags exactly.
> **Consumer cards and APUs (RX 6000/7000, Strix Point/Halo):** the backend
+2 -2
View File
@@ -7,8 +7,8 @@ working VoiceStudio install on a Debian / Ubuntu / Fedora / Arch host.
### Using the AppImage
- **Linux x86_64** with a desktop session (X11 or Wayland) capable of running
a Tauri / WebKitGTK app.
- **Linux x86_64 with glibc 2.39+** and a desktop session (X11 or Wayland)
capable of running a Tauri / WebKitGTK app.
- **~10 GB free disk** for the app, its Python environment, and model weights.
- Optional: an **NVIDIA driver** for CUDA GPU acceleration — the app runs
CPU-only without one. For AMD GPUs see [AMD GPU (ROCm)](#amd-gpu-rocm).
+17
View File
@@ -190,3 +190,20 @@ Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
The in-app error UI (the React error boundary that fires on backend errors)
includes an **"Open docs for this error"** button — that button deeplinks
back into this docs tree at the right section for the error class.
### Desktop window chrome
The main window uses native macOS traffic lights with an overlay title bar;
window sizing, resize limits, and application file-drop behavior match the
other desktop platforms. The platform configuration repeats the complete window
list because Tauri replaces arrays when merging it with the shared config.
The capture widget remains a separate borderless window created at runtime.
Its window-scoped Tauri capability permits hiding after recording or idle
reconciliation on every desktop platform.
### Fast process shutdown
A process that exits while shutdown is signalling it can report a macOS
permission error. VoiceStudio accepts this only after confirming the original
process exited without being reaped, then still waits for nested operations to
drain. Live-process permission errors and lost process ownership remain failures.
+84 -2
View File
@@ -557,8 +557,21 @@ them to fail faster on a small machine.
CPU-only hosts use a bounded 600-second generation floor because correct CPU
synthesis can take longer than the accelerated five-minute budget. Override it
with `OMNIVOICE_CPU_GENERATE_TIMEOUT_S`; an explicit higher
or lower `OMNIVOICE_GENERATE_TIMEOUT_S` always wins.
with `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` an explicit value here always
governs CPU-family generation, independent of `OMNIVOICE_GENERATE_TIMEOUT_S`.
Setting *only* `OMNIVOICE_GENERATE_TIMEOUT_S` still governs CPU hosts too, the
same as it always has (a quick way to lower the watchdog everywhere with one
var); it only stops doing so once you also set an explicit
`OMNIVOICE_CPU_GENERATE_TIMEOUT_S`, which then takes precedence for CPU jobs.
Both budgets are also editable from **Settings → Performance & Device →
Compute-time budget** — no env var or config file needed. A value saved there
persists across restarts but only takes effect on the *next* backend restart
(the running process already read the old value at startup), which the panel
states — and if an external env var (shell profile, `.env`, Docker `-e`,
systemd unit, …) is already providing the same key, the panel says so instead,
since that external value keeps winning on every future restart too, not just
this one.
**Two things changed here** ([#1190](https://github.com/debpalash/VoiceStudio/issues/1190)):
@@ -641,6 +654,8 @@ the error persistently on a current build, that's section **14** (a wedged GPU
job), section **14d** (the backend never started), or the crash notice above —
not this window.
A startup timeout from an earlier attempt cannot replace the current startup state after **Retry** takes over.
Desktop startup, **Retry**, storage reset, setup re-entry, in-app uninstall,
app shutdown, and automatic crash recovery also share one backend lifecycle
owner. Overlapping start attempts wait and attach to the healthy process,
@@ -829,6 +844,73 @@ unaffected and works normally.
> deep-links the exact OS pane described above. The dictation blocker rechecks
> Accessibility while it is visible and closes as soon as macOS reports the grant.
## 17. Windows: backend won't start with a non-English (CJK) username — `UnicodeDecodeError` in `site`
**Symptom:** the app never gets past first-run setup / "starting_backend", and
the backend log shows the interpreter dying before any VoiceStudio code runs:
```
Fatal Python error: init_import_site: Failed to import the site module
File "<frozen site>", line 188, in addpackage
UnicodeDecodeError: 'gbk' codec can't decode byte 0x80 in position 11: illegal multibyte sequence
```
**Cause:** Python 3.11's `site` module opens every `.pth` file in
`site-packages` using the ANSI code page — even with `PYTHONUTF8=1` set. When
the managed environment lives under a Windows user profile whose account name
contains non-ASCII characters (most commonly CJK), `uv sync`'s editable
install `.pth` embeds that path in UTF-8, which is rarely a valid byte
sequence in the active ANSI code page (`gbk`, `shift_jis`, etc.), and the
interpreter can never start (#1783).
**Fix:** current builds resolve the managed environment through Windows' 8.3
short filename for any path containing non-ASCII bytes (the same trick
already used for the HuggingFace cache — see
[`backend/core/config.py`](../../backend/core/config.py)) whenever there is
no usable environment yet, or an existing one shows exactly this crash — so
a first-time install, and an install already broken by this bug, both land
at an ASCII-safe path automatically with no user action needed. The old
broken environment (if any) is left in place, not deleted, in case manual
recovery is ever needed. An existing environment that already works — ASCII
path or not — is never touched or relocated.
**Prevention, on a brand new install:** on the first-run setup screen (before
clicking through it), the **Change…** button on the "App environment" row
(or "Portable folder" in portable mode) lets you pick an ASCII-only path up
front — nothing below is needed if you do this before setup completes.
If it still happens — most likely because Windows' 8.3 short filenames are
off on the system drive, or the affected folder already existed before this
fix shipped — the app names this cause specifically rather than the generic
"backend never reported ready." By the time this message can appear, setup
has already been confirmed (it's only reached after the first-run screen
hands off to the installer), so the error screen you're actually looking at
offers only **Retry** and **Clean & Retry** — neither changes where the
environment is stored, so both fail identically, and the first-run picker
above is no longer reachable either. The right fix depends on which install
mode you're in:
- **Standard (non-portable) install:** quit VoiceStudio, open (creating it
if it doesn't exist) `%LOCALAPPDATA%\com.debpalash.omnivoice-studio\config.json`
in a text editor, add `"env_dir": "C:/VoiceStudio/env"` (any path using
only English letters/numbers — forward slashes are fine on Windows), save,
and relaunch.
- **Portable install:** the `env_dir` config key above does **not** apply —
portable mode resolves its own environment folder from the portable
location and never consults it. Quit VoiceStudio, then either move the
whole VoiceStudio folder (the app plus its `OmniVoiceStudio-Data` folder)
to an ASCII-only path and run it from there, or create a `portable.path`
text file beside the app containing one line — an absolute ASCII-only path
for the data folder (e.g. `C:\VoiceStudio\Data`) — and relaunch.
Re-enabling 8.3 short filenames (`fsutil 8dot3name`) is deliberately **not**
recommended here: the setting is per-volume and only affects directories
created *after* it's changed, so toggling it does nothing for a folder that
already exists — it would not actually fix this without also recreating the
folder, which the ASCII-path options above already do more reliably.
**Linked issue:** [#1783](https://github.com/debpalash/VoiceStudio/issues/1783) (auto-captured from [#1771](https://github.com/debpalash/VoiceStudio/issues/1771))
## Dub: "translation engine needs the optional … package"
**Symptom:** in the Dub tab, translating fails with e.g. *"The 'google'
+25 -2
View File
@@ -143,7 +143,7 @@ documented below.
Managed or offline deployments can prohibit that network action:
```powershell
msiexec /i VoiceStudio_0.5.1_x64_en-US.msi DISABLEWEBVIEW2BOOTSTRAP=1 AUTOLAUNCHAPP=0 /qn /L*V "%TEMP%\VoiceStudio-install.log"
msiexec /i VoiceStudio_0.5.2_x64_en-US.msi DISABLEWEBVIEW2BOOTSTRAP=1 AUTOLAUNCHAPP=0 /qn /L*V "%TEMP%\VoiceStudio-install.log"
```
The per-user artifact never contains a WebView2 download or installer action.
@@ -151,7 +151,7 @@ It does not require an elevated terminal, and fails closed when the runtime is
absent:
```powershell
msiexec /i VoiceStudio_Current_User_0.5.1_x64_en-US.msi DISABLEWEBVIEW2BOOTSTRAP=1 AUTOLAUNCHAPP=0 /qn /L*V "%TEMP%\VoiceStudio-user-install.log"
msiexec /i VoiceStudio_Current_User_0.5.2_x64_en-US.msi DISABLEWEBVIEW2BOOTSTRAP=1 AUTOLAUNCHAPP=0 /qn /L*V "%TEMP%\VoiceStudio-user-install.log"
```
For the per-machine artifact, `DISABLEWEBVIEW2BOOTSTRAP=1` leaves detection
@@ -297,3 +297,26 @@ See [docs/setup/huggingface-token.md](../setup/huggingface-token.md).
## Troubleshooting
Hit a wall? See [docs/install/troubleshooting.md](troubleshooting.md).
### Building the current-user MSI
Build the system MSI first. `scripts/render-per-user-wix.py` requires
`--system-wxs` pointing to its fully rendered `release/wix/x64/main.wxs`,
in addition to the canonical `--source` template and `--output` destination.
The renderer preserves Tauri resource destinations while assigning distinct,
stable per-user component identities, HKCU registry keypaths, and uninstall
cleanup for nested resource folders. Missing or unrendered resources fail the
build. The canonical system installer retains its per-machine authoring.
The per-user build reuses the system build's frontend output: its config clears
`beforeBuildCommand` so a second Vite build cannot replace the hashed files
referenced by the rendered WiX template. Keep using `tauri build` for the
per-user stage; it still recompiles the shell with its own product configuration.
CI builds both scopes with a tiny executable, an external helper, and nested
resources using the CLI version locked in `bun.lock`. Its frontend-like build
hook rotates resource filenames, verifying the per-user build preserves the
system build's resource snapshot. The Windows MSI authoring
job runs after the test suite and preserves verbose WiX logs and rendered XML.
For focused diagnosis, dispatch CI with `windows_wix_diagnostic=true`; it skips
the other jobs and never signs, publishes, or installs the fixture bundles.
+1
View File
@@ -1,6 +1,7 @@
{
"_comment": "MCP client config for VoiceStudio. See docs/mcp.md for both connection modes.",
"_streamable_http": "If your MCP client speaks Streamable HTTP, point it directly at the running app: http://localhost:3900/mcp — no separate process needed (the server is mounted on the backend). Send an X-OmniVoice-Client-Id header to bind this agent to a specific voice.",
"_output_mode": "To keep audio out of the agent's context, set OMNIVOICE_MCP_OUTPUT_MODE=files and OMNIVOICE_MCP_BASE_PATH=<a directory visible to both backend and agent> on the BACKEND's environment for the mounted endpoint (or on a standalone `python -m backend.mcp_server` entry's env). The agent's working directory is valid only when it is mounted into the backend at the same path. See docs/mcp.md, 'Output mode and file inputs'.",
"mcpServers": {
"omnivoice": {
"command": "python",
+31 -3
View File
@@ -10,12 +10,40 @@ start once VoiceStudio is open.
| Tool | What it does |
|---|---|
| `generate_speech` | text → WAV (base64). Uses the agent's bound voice unless a `profile_id` is passed. |
| `clone_voice` | base64 audio → new voice profile. Returns a `profile_id` for use with `generate_speech`. |
| `transcribe` | base64 audio → text (646 languages). |
| `generate_speech` | text → WAV. Uses the agent's bound voice unless a `profile_id` is passed. Returns base64 by default, or a URL + file in [files mode](#output-mode-and-file-inputs). |
| `clone_voice` | reference audio (base64, or a `ref_audio_path` under the base path) → new voice profile. Returns a `profile_id` for use with `generate_speech`. |
| `transcribe` | audio (base64, or an `audio_path` under the base path) → text (646 languages). |
| `list_voices` / `list_personalities` / `list_languages` | enumerate what's available. |
| `check_health` | backend status + active GPU device. |
## Output mode and file inputs
An LLM agent pays for every byte it receives in context, and a WAV as base64
is a lot of bytes — a short clip already brushes per-result limits, a
paragraph of narration blows them. Two environment variables move the audio
out of the conversation and onto disk, where an agent can hand it to a player
or another tool by path:
| Variable | Values | Effect |
|---|---|---|
| `OMNIVOICE_MCP_OUTPUT_MODE` | `resources` (default) · `files` · `both` | `resources` returns `wav_base64` inline (the original contract). `files` returns `audio_url` (the render served at `/audio/<audio_id>.wav`, which the backend keeps anyway) and, when a base path is set, `output_path` — the WAV written into that directory. `both` returns everything. |
| `OMNIVOICE_MCP_TIMEOUT_S` | seconds (default `120`) | How long a tool waits on the backend. CPU hosts render a paragraph in minutes and serialize generations, so an agent queued behind another render can outlast the default; raise it in step with `OMNIVOICE_GENERATE_TIMEOUT_S`. |
| `OMNIVOICE_MCP_BASE_PATH` | a directory | The **security boundary** for file-shaped traffic. `transcribe(audio_path=…)` and `clone_voice(ref_audio_path=…)` read only from inside it (relative paths resolve against it, absolute paths must already lie within it, symlinks are resolved before the check), and files mode writes only into it. With no base path configured, path arguments are refused with a reason. |
Input files are opened through confined, no-follow descriptors after path
validation, so replacing a checked file or parent directory cannot redirect a
read outside the base path.
Set them on the **backend's** environment for the mounted `/mcp` endpoint
(the launcher, a service file, Docker `-e`), or on the server entry's `env`
when running `python -m backend.mcp_server` standalone. The base path must be
visible to both the backend and the agent. If they run in different containers
or filesystem namespaces, mount one shared directory at the same path in both;
`output_path` is reported in the backend's namespace. The agent's working
directory is suitable only when that shared mount exists. With this setup,
`OMNIVOICE_MCP_OUTPUT_MODE=files` keeps every render out of agent context while
still returning a path the agent can use.
## Connecting
### Streamable HTTP (modern clients)
+25 -6
View File
@@ -92,7 +92,8 @@ None of them are required — the defaults are chosen for the common case.
| `OMNIVOICE_UNIFIED_OFFLOAD_HEADROOM_GB` | `6` | On unified memory (Apple Silicon): if free RAM is below this when a dub needs the transcription model, the TTS model is fully released first (it reloads on the next generation). Raise to be more aggressive about freeing, lower on 32 GB+ machines to avoid the reload. |
| `OMNIVOICE_INDEXTTS_FP16` | `1` | IndexTTS half-precision. Leave on. |
| `OMNIVOICE_ASR_VRAM_PREFLIGHT` | `1` | Downgrade transcription precision instead of crashing when VRAM is short (CUDA). Leave on. |
| `OMNIVOICE_GENERATE_TIMEOUT_S` | `300` | Abandon a generation after this many seconds **of actual compute** — the clock starts when a GPU worker picks the job up, never while it waits in line. It's a floor, not a ceiling: the budget grows with the text (+1 s per 40 characters past the first 1200), so long inputs rarely need this raised. |
| `OMNIVOICE_GENERATE_TIMEOUT_S` | `300` | Abandon a generation after this many seconds **of actual compute** on an accelerated (GPU-family) host — the clock starts when a worker picks the job up, never while it waits in line. It's a floor, not a ceiling: the budget grows with the text (+1 s per 40 characters past the first 1200), so long inputs rarely need this raised. A **CUDA or ROCm** GPU with less dedicated VRAM than the engine declares it needs is the exception — it pages to system RAM and renders slower than the same machine's CPU, so it floors at `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` below instead. Apple Silicon (MPS) is not included: its reported VRAM is a heuristic over a *unified* memory pool, not a dedicated one, so there is no comparable floor to measure it against. Setting **this** var explicitly turns that off — an explicit value here is the base on every device, under-provisioned or not, so lowering it to fail fast still works. Also settable from **Settings → Performance & Device → Compute-time budget** (persists to `prefs.json`; takes effect on the next backend restart, same as `OMNIVOICE_DEVICE` above). |
| `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` | `600` | Same budget, for hosts that render on the CPU — correct CPU synthesis legitimately takes longer than the accelerated floor, so it gets its own, higher one. It is also the floor a CUDA/ROCm GPU below the engine's declared VRAM floor gets, since that is the performance class it actually falls into. An explicit value here always governs CPU-family generation, independent of `OMNIVOICE_GENERATE_TIMEOUT_S` above — that var only doubles as a CPU floor when *this* one is left unset (a legacy shortcut: setting only `OMNIVOICE_GENERATE_TIMEOUT_S` lowers the watchdog everywhere with one var). Also settable from **Settings → Performance & Device**, which flags a row an external env var is already shadowing instead of claiming a save will apply. |
| `OMNIVOICE_ENGINE_IMPORT_PROBE_TIMEOUT_S` | `60` | How long to wait while checking that a sidecar engine's virtualenv can import the engine. Only affects how quickly a *broken* venv is ruled out — a probe that runs out of time is treated as "unproven", and the venv is used anyway, so a slow machine is never told its engine is missing. Per-engine override: `OMNIVOICE_INDEXTTS_IMPORT_PROBE_TIMEOUT_S` (and the same shape for `CONFUCIUS4`, `DOTS_TTS`, `MOSS_TTS_V15`). |
| `OMNIVOICE_GPU_QUEUE_TIMEOUT_S` | `1800` | How long a job may sit in the GPU queue before it's reported as a saturated pool (a retryable condition — nothing ran). Waiting is normal on 1-worker machines; lower this only if you'd rather fail fast than queue. |
@@ -115,14 +116,32 @@ editor, profile previews, and streaming).
| Situation | What you see |
| --- | --- |
| The engine declares a VRAM floor above what this GPU has, or routing fell back to CPU | The routing caveat, naming your card, the engine's floor, and the ways around it |
| The engine declares a VRAM floor above what this GPU has, or routing fell back to CPU | The routing caveat, naming your card, the engine's floor, and the ways around it. A CUDA/ROCm card below the floor is also *budgeted* as the CPU-class hardware it performs like — it gets the larger CPU/accelerated base unless `OMNIVOICE_GENERATE_TIMEOUT_S` is explicitly set |
| The host synthesizes on the CPU **and** the text is over 1200 characters | A heads-up that this generation may exceed the time budget |
| The host synthesizes on Apple Silicon (MPS) **and** the text is over 1200 characters | The same heads-up — MPS gets the accelerated-host budget (`OMNIVOICE_GENERATE_TIMEOUT_S`), which a long render can still legitimately exceed |
**Why 1200 characters:** it is the same figure the budget itself uses. The first
1200 characters get the flat `OMNIVOICE_GENERATE_TIMEOUT_S`, and only past that
does the budget start growing (+1 s per 40 characters). Below the threshold you
are inside a budget the backend already considers generous, so ordinary
sentences on a CPU laptop stay quiet.
1200 characters get the flat base budget, and only past that does the budget
start growing (+1 s per 40 characters). Below the threshold you are inside a
budget the backend already considers generous, so ordinary sentences on a CPU
laptop stay quiet.
**Which base applies:**
| Host | Base budget |
| --- | --- |
| Renders on the CPU | `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` |
| CUDA/ROCm GPU below the engine's declared VRAM floor, when `OMNIVOICE_GENERATE_TIMEOUT_S` is not explicitly set | `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` (whichever of the two is larger) |
| Any other accelerated host, MPS included | `OMNIVOICE_GENERATE_TIMEOUT_S` |
Both rows above can be overridden, and the two vars are independent:
- An explicit `OMNIVOICE_CPU_GENERATE_TIMEOUT_S` always governs CPU-family
generation, even when the accelerated var is also set.
- An explicit `OMNIVOICE_GENERATE_TIMEOUT_S` is used verbatim on every
accelerated host — **including** an under-provisioned one, which then keeps
the value you chose rather than being floored. That is deliberate: it is what
lets you lower the watchdog to fail fast everywhere with one setting.
Both warnings are **advisory** — nothing is blocked. A driver can page to system
RAM, and a short input fits where a long one does not, so the engine still runs
+2 -2
View File
@@ -9,7 +9,7 @@ internet.
```yaml
services:
voicestudio:
image: ghcr.io/debpalash/omnivoice-studio:0.5.1
image: ghcr.io/debpalash/omnivoice-studio:0.5.2
restart: unless-stopped
environment:
OMNIVOICE_API_KEY: ${OMNIVOICE_API_KEY:?set a long random key}
@@ -44,7 +44,7 @@ volume across container recreation.
Pin an exact release tag. `:latest` and `:main` are rolling previews;
`:stable` moves whenever a stable release is published. AMD hosts use the
matching `:0.5.1-rocm` image and the device mapping documented in
matching `:0.5.2-rocm` image and the device mapping documented in
[Docker installation](install/docker.md#pull-and-run-amd-gpu--rocm).
## Network boundary
+4
View File
@@ -245,6 +245,10 @@ grace window to come back, and if it returns carrying a finished result, that
result is used — the task is never run twice just because a network blip
happened. Only when the window expires is the task retried elsewhere.
Each attempt retains the deadline budget granted at dispatch, including after a
worker disconnect or control-plane restart; changed worker availability cannot
shorten an in-flight attempts execution allowance.
**A worker fails repeatedly.** After three consecutive failures that are
actually its fault, it is paused for a minute, then automatically given one
task to prove itself. Repeated trips back off further, up to thirty minutes.
+23 -11
View File
@@ -1,7 +1,7 @@
# Hugging Face Token Setup
VoiceStudio uses a single HF token for every model download, license-gate
check, and `whoami` ping. This page covers the three places VoiceStudio will
check, and an explicit **Test now** action. This page covers the three places VoiceStudio will
look for a token and the recommended path for v0.3+.
## Three sources (cascade)
@@ -14,13 +14,16 @@ call wins:
Set via the in-app **Settings → API Keys** panel.
2. **Env**`HF_TOKEN` (or the legacy `HUGGING_FACE_HUB_TOKEN`) environment
variable visible to the VoiceStudio process.
3. **HF CLI** — the canonical `~/.cache/huggingface/token` file written by
3. **HF CLI** — the local `HF_TOKEN_PATH` file (normally `~/.cache/huggingface/token`) written by
`huggingface-cli login`.
The active source is surfaced live in **Settings → API Keys**: each row shows
set/unset, a masked preview (`hf_…3jw`), the `whoami` username + green check
when valid, and an **"Active"** badge on whichever source is currently
serving the cascade.
Source labels in onboarding and Settings follow the selected UI language;
product names such as HuggingFace CLI remain unchanged.
On opening **Settings → API Keys**, each row shows local set/unset state and
a masked preview (`hf_…3jw`). Tokens remain **Not tested** until you select
**Test now**. That explicit check displays the `whoami` username and a green
check for valid sources, with an **Active** badge on the highest-priority valid source.
## Setting via the app (recommended)
@@ -32,8 +35,7 @@ serving the cascade.
key derived per-install from machine-id) and also written to the
canonical `huggingface_hub` token location so subprocess engines pick it
up automatically.
4. The row's `whoami` indicator flips green and the **Active** badge moves to
"App".
4. Select **Test now** to validate the token with Hugging Face. A successful test turns the indicator green and moves the **Active** badge to "App".
> **Known limitation (honest disclosure):** the encryption key is derived
> per-install from the machine identifier. If you copy `omnivoice_data/`
@@ -79,8 +81,8 @@ huggingface-cli login
# paste token at the prompt
```
That writes to `~/.cache/huggingface/token`. VoiceStudio reads via
`huggingface_hub.get_token()` and picks it up automatically — you'll see the
That normally writes to `~/.cache/huggingface/token`. VoiceStudio reads the
selected local token file directly — you'll see the
**HF CLI** row in **Settings → API Keys** flip to "set".
## Accepting model licenses
@@ -105,7 +107,7 @@ process).
- **HF 401 even though a token is set** — visit the model's HuggingFace page
and accept the license (see above). The token is fine; the *license* gate
is separate.
- **Token row stays red after Save** — the `whoami` call failed. Check the
- **Token row stays red after Test now** — the `whoami` call failed. Check the
token is valid at
[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens)
and has at least the "read" scope.
@@ -113,3 +115,13 @@ process).
the App row. If it's empty, the SQLite store may have been wiped — re-save.
If it's set but the active source is "Env" or "HF CLI", that's the cascade
working as intended (App is highest priority).
Opening onboarding or Settings only reads local token presence and masked previews; it does not contact Hugging Face. Untested tokens are shown as **Not tested**, and onboarding reports where a token was found without claiming it is valid. **Test now** explicitly contacts Hugging Face. Replacing a saved token does not revoke the previous token on Hugging Face.
Windows automatically shortens the model cache path while keeping the normal CLI token location. If only a previous VoiceStudio short-cache token exists, the app continues using that file. An existing normal CLI token takes priority; explicit token or cache overrides remain authoritative. Credentials are never copied between these locations.
The CLI row reads only the selected local file, without OAuth refresh or environment-token fallback. **Also clear saved HuggingFace CLI token files** removes both active and stored-token files at recognized automatic locations, so an older app token cannot reappear on restart. Explicit overrides limit clearing to their selected location. Clearing only the app token preserves CLI files; neither action revokes tokens on Hugging Face or changes Git credentials. A file permission failure is reported instead of claiming the files were cleared.
If onboarding cannot read token state, it shows an error and **Retry**, keeping token entry hidden until discovery succeeds. **Replace token** writes the encrypted app token through the same endpoint as Settings, so it replaces the highest-priority app credential even when an older one exists. The success message confirms saving only; validation remains a separate explicit action.
+9
View File
@@ -158,3 +158,12 @@ One **Export** path for every audio/video asset, surfaced via `<UseInMenu>` →
- **OmniDrive as home?** This spec keeps **Launchpad** as home and OmniDrive as the asset library. If you'd rather OmniDrive *be* the landing hub (everything starts from "your stuff"), that's a Launchpad/OmniDrive merge — say so and it folds into A1.
- **Drag-and-drop vs menu.** v1 ships the `<UseInMenu>` dropdown (discoverable, keyboard-friendly). Drag an asset card onto a rail item is a later additive layer over the same `sendTo`.
- **`GET /assets` endpoint.** v1 derives assets client-side; promote to a backend read model only if OmniDrive paging needs it.
## Workspace control behavior
Conversion keeps its method tab active until the request settles, including failures.
Engine quick-switch triggers and expanded choices use the same formatted model name.
Dubbing fit badges, explanations, shorter-rewrite actions, playback controls,
and engine residency labels are localized in every supported UI language.
Compact engine-family badges retain the standard TTS, ASR, and LLM abbreviations.
+4
View File
@@ -128,3 +128,7 @@ for Chinese — the model auto-fixes mismatches).
- **Case-insensitive**: `"Male"`, `"MALE"`, and `"male"` are all accepted, the code will normalize them to lower case.
- **Accent vs Dialect**: English accents are only applied to English speech, Chinese dialects are only applied to Chinese speech.
Gallery previews reject silent output and near-pure tonal buzz. The quality
check measures short audio frames rather than the whole clip, so longer or
softly voiced speech is not rejected merely for having low spectral flatness.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omnivoice-studio",
"version": "0.5.1",
"version": "0.5.2",
"private": true,
"license": "AGPL-3.0-only",
"type": "module",
+9
View File
@@ -0,0 +1,9 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
export function resolveDialogEsm(frontendRoot) {
return [
path.resolve(frontendRoot, 'node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
path.resolve(frontendRoot, '../node_modules/@tauri-apps/plugin-dialog/dist-js/index.js'),
].find(existsSync);
}
+111 -2
View File
@@ -43,6 +43,12 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "ambient-authority"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
[[package]]
name = "android_log-sys"
version = "0.3.2"
@@ -548,6 +554,36 @@ dependencies = [
"serde_core",
]
[[package]]
name = "cap-primitives"
version = "3.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e0bf07d379916947be6c4a07f43684153d710a2896c31f9e97781362895596c"
dependencies = [
"ambient-authority",
"fs-set-times",
"io-extras",
"io-lifetimes",
"ipnet",
"maybe-owned",
"rustix",
"rustix-linux-procfs",
"windows-sys 0.59.0",
"winx",
]
[[package]]
name = "cap-std"
version = "3.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a59e59fa26472d29680ece6a9f8ee8b0551a719a33df2f5240bde065ecbddfd7"
dependencies = [
"cap-primitives",
"io-extras",
"io-lifetimes",
"rustix",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
@@ -1364,6 +1400,17 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs-set-times"
version = "0.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
dependencies = [
"io-lifetimes",
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "fs4"
version = "0.13.1"
@@ -1393,6 +1440,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -2184,6 +2232,22 @@ dependencies = [
"cfb",
]
[[package]]
name = "io-extras"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65"
dependencies = [
"io-lifetimes",
"windows-sys 0.59.0",
]
[[package]]
name = "io-lifetimes"
version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -2477,6 +2541,12 @@ dependencies = [
"web_atoms",
]
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]]
name = "memchr"
version = "2.8.2"
@@ -2507,6 +2577,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minisign-verify"
version = "0.2.5"
@@ -2964,9 +3044,10 @@ dependencies = [
[[package]]
name = "omnivoice-studio"
version = "0.5.1"
version = "0.5.2"
dependencies = [
"arboard",
"cap-std",
"dirs-next",
"enigo",
"fs4",
@@ -3051,7 +3132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3672,6 +3753,7 @@ dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2",
@@ -3684,6 +3766,7 @@ dependencies = [
"js-sys",
"log",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3818,6 +3901,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustix-linux-procfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
dependencies = [
"once_cell",
"rustix",
]
[[package]]
name = "rustls"
version = "0.23.40"
@@ -5423,6 +5516,12 @@ dependencies = [
"unic-common",
]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -6512,6 +6611,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "winx"
version = "0.36.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
dependencies = [
"bitflags 2.13.0",
"windows-sys 0.59.0",
]
[[package]]
name = "wit-bindgen"
version = "0.51.0"
+9 -4
View File
@@ -4,7 +4,7 @@
# launcher's pkill matches `omnivoice-studio` and must never match a user's
# installed app. Renaming it would collapse that distinction.
name = "omnivoice-studio"
version = "0.5.1"
version = "0.5.2"
description = "VoiceStudio AI voice cloning & dubbing desktop app"
authors = ["Debpalash"]
license = "AGPL-3.0-only"
@@ -23,6 +23,10 @@ tauri-build = { version = "2.6.0", features = [] }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
getrandom = "0.3"
# Capability-scoped fs access for the batch watch folder: scans/reads resolve
# against a directory HANDLE captured at pick time. The selected pathname is
# re-resolved only for liveness/identity checks (#1768).
cap-std = "3"
log = "0.4"
tauri = { version = "2.11.0", features = ["macos-private-api", "protocol-asset", "tray-icon", "image-png"] }
tauri-plugin-log = "2"
@@ -47,8 +51,9 @@ enigo = { version = "0.3", features = ["serde"] }
# bundled pyproject.toml — which installs torch, whisperx, etc.
# ureq is used for HTTP health checks and ffmpeg downloads.
ureq = "2"
# reqwest is used for GitHub Releases API calls (list_releases command).
reqwest = { version = "0.13", features = ["json"] }
# reqwest is used for GitHub Releases API calls and bounded-memory native
# watch-folder uploads to the loopback backend.
reqwest = { version = "0.13", features = ["json", "blocking", "multipart"] }
# semver is used by the channel-aware updater (updater_channel.rs) to rank
# builds across the stable/preview channels (#326); same major as the
# `Version` type tauri-plugin-updater re-exports (already in the lockfile).
@@ -90,7 +95,7 @@ windows-core = "0.61"
# `HWND` type — no second copy of the crate enters the dependency graph.
# Win32_System_Registry: check_microphone reads the CapabilityAccessManager
# ConsentStore mic toggle (RegGetValueW) for the permissions UX.
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp", "Win32_Security"] }
windows = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_UI_WindowsAndMessaging", "Win32_System_Registry", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_System_Diagnostics_ToolHelp", "Win32_Security"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "capture-widget",
"description": "Allows the capture widget to hide when recording ends or an idle window is reconciled",
"windows": ["widget"],
"permissions": ["core:window:allow-hide"]
}
+442 -1
View File
@@ -66,6 +66,45 @@ fn parse_app_version(body: &str) -> Option<String> {
Some(rest[..rest.find('"')?].to_string())
}
/// The `data_dir` the running backend advertises via `/system/info` — the
/// directory `backend/core/config.py::get_app_data_dir()` resolved for
/// itself (honors `OMNIVOICE_DATA_DIR`, else the per-OS default).
///
/// This exists so Tauri's one-shot host-path capability files
/// (`commands::authorize_host_path`) always land where
/// `backend/core/path_authorization.py` actually looks for them. Tauri's own
/// `setup::resolved_data_dir` normally agrees with the backend, but they can
/// diverge: dev mode spawns the backend out-of-process
/// (`scripts/dev-backend.mjs`, which strips `OMNIVOICE_*` from the child
/// env) so it may fall back to a different platform default than Tauri
/// computes, and in a packaged build a custom data folder or portable mode
/// applied after the backend already started can do the same (#1781).
/// Asking the backend directly makes the two processes structurally unable
/// to disagree.
///
/// `None` when nothing VoiceStudio answers at `port` (not started yet,
/// unreachable) or an old backend predating the `data_dir` field — callers
/// fall back to Tauri's own resolution, the historical behavior.
///
/// Only an ABSOLUTE path is accepted. `get_app_data_dir()` returns
/// `OMNIVOICE_DATA_DIR` verbatim, so a relative value (`OMNIVOICE_DATA_DIR=
/// omnivoice_data`, plausible for source/Docker setups) would make the
/// backend resolve the store against ITS working directory while Tauri
/// resolved the same string against its own — silently recreating the very
/// split this function exists to close. A relative advertisement is
/// therefore treated as unusable and the caller falls back, which is also
/// what keeps a foreign responder on the port from steering capability
/// writes to a path of its choosing.
pub fn backend_data_dir(port: u16) -> Option<String> {
let url = format!("http://127.0.0.1:{}/system/info", port);
let body = ureq_get_with_timeout(&url, Duration::from_millis(500)).ok()?;
if !is_omnivoice_body(&body) {
return None;
}
parse_json_string_field(&body, "data_dir")
.filter(|dir| !dir.is_empty() && Path::new(dir).is_absolute())
}
/// Whether a running backend's version matches THIS app build, comparing
/// **base** versions (any `-N` pre-release suffix stripped from both sides) so
/// a preview build `0.3.10-4` still attaches to its `0.3.10` backend.
@@ -83,6 +122,87 @@ pub fn same_app_version(running: &str) -> bool {
!running.is_empty() && base(running) == base(env!("CARGO_PKG_VERSION"))
}
/// `app_version` + `code_fingerprint`, parsed from ONE `/system/info` fetch
/// — see `code_fingerprint_is_current` for how callers interpret
/// `code_fingerprint`.
#[derive(Debug, PartialEq, Eq)]
pub struct BackendIdentity {
pub version: String,
/// `None` when the response has no `code_fingerprint` key at all (an old
/// backend whose `/system/info` schema predates the field, since a
/// *present* field always serializes — even as `""`). `Some("")` when
/// the field is present but blank (current schema, but the process
/// wasn't spawned with `OMNIVOICE_BUILD_FINGERPRINT` set — dev mode's
/// `dev-backend.mjs` strips `OMNIVOICE_*`, and a manually started
/// `uvicorn` never sets it).
pub code_fingerprint: Option<String>,
}
/// Parse both fields out of a single already-fetched `/system/info` body.
/// Pure — no network — so the absent/blank/known distinction is unit-tested
/// directly against fixture bodies, not against a live probe.
fn parse_backend_identity(body: &str) -> Option<BackendIdentity> {
if !is_omnivoice_body(body) {
return None;
}
Some(BackendIdentity {
version: parse_app_version(body).unwrap_or_default(),
code_fingerprint: parse_json_string_field(body, "code_fingerprint"),
})
}
/// `running_backend_version` + a separate `code_fingerprint` fetch used to be
/// two independent `/system/info` round-trips in the attach handshake —
/// besides the redundant probe, that let a transport hiccup on the *second*
/// fetch (a transient timeout, not "no key in the body") come back as
/// `None`, indistinguishable from a successfully parsed body that genuinely
/// lacks `code_fingerprint`. `code_fingerprint_is_current` treats a missing
/// key as stale, so that ambiguity could kill and respawn a perfectly
/// healthy backend on a single flaky probe. One fetch removes the ambiguity
/// structurally: `None` here means "nothing answered at all" (the caller's
/// existing "no VoiceStudio here" branch), full stop — it can never be
/// confused with "answered, but the field was absent from that body".
pub fn running_backend_identity(port: u16) -> Option<BackendIdentity> {
let url = format!("http://127.0.0.1:{}/system/info", port);
let body = ureq_get_with_timeout(&url, Duration::from_millis(500)).ok()?;
parse_backend_identity(&body)
}
/// Whether an already-version-matched running backend's *code* is current
/// enough to attach to, for the `prepare_backend_launch` handshake (#1770).
///
/// `running` is `BackendIdentity::code_fingerprint` from the SAME
/// `/system/info` fetch that established the version match — never a
/// separate probe (see `running_backend_identity`'s doc comment for why);
/// `ours` is `bootstrap::own_backend_code_fingerprint`'s.
///
/// - `running: None` — the backend's `/system/info` has no `code_fingerprint`
/// key at all, meaning its code predates this fingerprinting mechanism
/// outright. Because `main` holds one version string for an entire
/// release cycle (see `same_app_version`'s doc comment), a matching
/// version string does NOT mean matching code — this is exactly the class
/// of bug #1770 reported: a same-version backend running weeks-old code
/// was attached to and adopted as current. Treated as STALE, the same
/// "replace it" outcome a version mismatch already gets.
/// - `running: Some("")` — the field IS present (current schema) but blank:
/// confirmed at-least-this-fix-or-later by schema, just unverifiable
/// further (no env var at spawn time — dev mode, a manually started
/// backend). Accepted rather than hard-failing every dev session or
/// manual-start workflow.
/// - `ours: None` — we failed to compute our own fingerprint (unreadable
/// resource dir / dev root). We can't enforce a check we can't compute
/// either side of, so this degrades to accept too, same as the historical
/// version-only behavior.
/// - both `Some` — must match exactly; a mismatch is STALE.
pub fn code_fingerprint_is_current(running: Option<&str>, ours: Option<&str>) -> bool {
match (running, ours) {
(None, _) => false,
(Some(""), _) => true,
(Some(_), None) => true,
(Some(r), Some(o)) => r == o,
}
}
/// Deep health probe for the attach-to-a-running-backend shortcut.
///
/// `/health` and `/system/info` keep answering from a backend whose install
@@ -138,12 +258,81 @@ pub fn startup_progress(port: u16) -> Option<(String, String, String)> {
/// First `"key": "value"` string field in a JSON body — same dependency-free
/// sniffing style as `parse_app_version`. `None` for absent or non-string
/// (e.g. `null`) values.
///
/// Decodes JSON string escapes properly (`decode_json_string`) rather than
/// substring-slicing to the first `"` byte: a naive slice returns the
/// literal wire form, which breaks on any value containing a backslash or an
/// escaped quote. This matters most for `data_dir` — a Windows path like
/// `C:\Users\x\AppData\Roaming\OmniVoice` serialises as
/// `"C:\\Users\\x\\..."`, and slicing to the first raw `"` would either hand
/// back the doubled-backslash form verbatim or, for a path containing a
/// literal quote, truncate the value outright.
fn parse_json_string_field(body: &str, key: &str) -> Option<String> {
let needle = format!("\"{key}\"");
let rest = &body[body.find(&needle)? + needle.len()..];
let rest = rest[rest.find(':')? + 1..].trim_start();
let rest = rest.strip_prefix('"')?;
Some(rest[..rest.find('"')?].to_string())
decode_json_string(rest)
}
/// Decode a JSON string body starting right after its opening `"`, stopping
/// at the first *unescaped* closing `"`. Handles `\\`, `\"`, `\/`, `\n`,
/// `\r`, `\t`, `\b`, `\f`, and `\uXXXX` (including UTF-16 surrogate pairs for
/// codepoints outside the BMP). Returns `None` on an unterminated string or a
/// malformed escape — matching the previous function's `?`-propagating
/// behavior on absent/malformed input.
fn decode_json_string(rest: &str) -> Option<String> {
fn read_hex4(chars: &mut std::str::Chars) -> Option<u32> {
let mut hex = String::with_capacity(4);
for _ in 0..4 {
hex.push(chars.next()?);
}
u32::from_str_radix(&hex, 16).ok()
}
let mut chars = rest.chars();
let mut out = String::new();
loop {
let c = chars.next()?;
if c == '"' {
return Some(out);
}
if c != '\\' {
out.push(c);
continue;
}
match chars.next()? {
'"' => out.push('"'),
'\\' => out.push('\\'),
'/' => out.push('/'),
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'b' => out.push('\u{0008}'),
'f' => out.push('\u{000C}'),
'u' => {
let code = read_hex4(&mut chars)?;
if (0xD800..=0xDBFF).contains(&code) {
// High surrogate: must be followed by a \uXXXX low
// surrogate to form one codepoint outside the BMP.
if chars.next()? != '\\' || chars.next()? != 'u' {
return None;
}
let low = read_hex4(&mut chars)?;
if !(0xDC00..=0xDFFF).contains(&low) {
return None;
}
let cp = 0x10000 + (((code - 0xD800) << 10) | (low - 0xDC00));
out.push(char::from_u32(cp)?);
} else if (0xDC00..=0xDFFF).contains(&code) {
return None; // lone low surrogate — malformed
} else {
out.push(char::from_u32(code)?);
}
}
_ => return None, // invalid escape
}
}
}
/// Status code from a raw HTTP response ("HTTP/1.1 200 OK" → 200).
@@ -615,6 +804,12 @@ pub(crate) fn spawn_backend<R: tauri::Runtime>(
// Analytics destination (#1123) — see analytics_env() below for why.
env.extend(analytics_env(option_env!("VITE_POSTHOG_KEY"), option_env!("VITE_POSTHOG_HOST")));
if cmd_override.is_none() {
// #1770: lets the attach handshake tell "this build's code" apart
// from "a same-version backend running older code" — see
// `bootstrap::own_backend_code_fingerprint` / `code_fingerprint_is_current`.
if let Some(fingerprint) = crate::bootstrap::own_backend_code_fingerprint(app) {
env.push(("OMNIVOICE_BUILD_FINGERPRINT".into(), fingerprint));
}
let app_data = app.path().app_local_data_dir().unwrap_or_default();
if let Some(ffmpeg_path) = resolve_ffmpeg(app, &app_data) {
env.push(("FFMPEG_PATH".into(), ffmpeg_path.to_string_lossy().into()));
@@ -841,6 +1036,148 @@ mod tests {
port
}
/// Loopback responder that answers `/system/info` with an arbitrary
/// body, for `backend_data_dir` tests.
fn spawn_system_info_stub(body: &'static str) -> u16 {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { break };
let mut buf = [0u8; 512];
let _ = stream.read(&mut buf);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
}
});
port
}
#[test]
fn backend_data_dir_reads_system_info_and_degrades_safely() {
// Reachable backend advertising data_dir → Some(path). This is what
// lets Tauri and the backend agree on where one-shot capability
// files live (#1781) even when they'd otherwise resolve different
// platform defaults (e.g. a dev backend spawned without
// OMNIVOICE_DATA_DIR).
//
// The fixture must be a platform-appropriate ABSOLUTE path:
// `Path::new("/custom/data").is_absolute()` is FALSE on Windows
// (Windows requires a drive prefix like `C:` *and* a root — a
// rooted-but-driveless path is drive-relative and genuinely
// ambiguous), so a Unix-style fixture would spuriously fail the
// `is_absolute()` filter in `backend_data_dir` on that platform.
// The wire body doubles each backslash (JSON escaping); the new
// `decode_json_string` decodes that back to a single backslash, so
// the assertion checks the DECODED form, not the wire form.
#[cfg(windows)]
let (body, expected) = (
r#"{"data_dir": "C:\\custom\\data"}"#,
r"C:\custom\data",
);
#[cfg(not(windows))]
let (body, expected) = (r#"{"data_dir": "/custom/data"}"#, "/custom/data");
let port = spawn_system_info_stub(body);
assert_eq!(backend_data_dir(port), Some(expected.to_string()));
// A rooted-but-driveless path is drive-relative on Windows (which
// drive is "the" drive is ambiguous) and must be REJECTED there —
// `is_absolute()` correctly returns false for it, and the fixture
// fixed above must not silently paper over that. `/custom/data` and
// `\data` are exactly the un-prefixed forms a misbehaving responder
// (or a backend running under an unexpected shell) could send.
#[cfg(windows)]
{
assert_eq!(
backend_data_dir(spawn_system_info_stub(r#"{"data_dir": "/custom/data"}"#)),
None
);
assert_eq!(
backend_data_dir(spawn_system_info_stub(r#"{"data_dir": "\\data"}"#)),
None
);
}
// Old backend body (identifies via model_checkpoint) predating the
// data_dir field → None, so callers fall back to Tauri's own
// resolution instead of trusting a missing field.
let old = spawn_system_info_stub(r#"{"model_checkpoint": "x"}"#);
assert_eq!(backend_data_dir(old), None);
// Explicit empty data_dir must not resolve to a bare/relative root —
// treated the same as absent.
let empty = spawn_system_info_stub(r#"{"data_dir": ""}"#);
assert_eq!(backend_data_dir(empty), None);
// A foreign (non-VoiceStudio) responder must not be trusted either.
let foreign = spawn_system_info_stub(r#"{"hello": "world"}"#);
assert_eq!(backend_data_dir(foreign), None);
// A RELATIVE data_dir is unusable and must not be adopted: the
// backend resolves it against its own working directory, so joining
// the same string onto Tauri's cwd would recreate exactly the split
// #1781 is about. `get_app_data_dir()` hands back
// OMNIVOICE_DATA_DIR verbatim, so this is reachable without any
// malice — a source or Docker setup using a relative override.
let relative = spawn_system_info_stub(r#"{"data_dir": "omnivoice_data"}"#);
assert_eq!(backend_data_dir(relative), None);
let dotted = spawn_system_info_stub(r#"{"data_dir": "./data"}"#);
assert_eq!(backend_data_dir(dotted), None);
// Nothing listening → None, same fallback path.
assert_eq!(backend_data_dir(1), None); // port 1 — never bindable by us
}
#[test]
fn parse_json_string_field_decodes_escape_sequences() {
// Windows data dirs are full of backslashes: the backend serializes
// `C:\Users\x\AppData\Roaming\OmniVoice` as
// `"C:\\Users\\x\\AppData\\Roaming\\OmniVoice"` on the wire. A naive
// substring extract to the first raw `"` would hand back the
// doubled-backslash literal instead of the real path.
assert_eq!(
parse_json_string_field(
r#"{"data_dir": "C:\\Users\\x\\AppData\\Roaming\\OmniVoice"}"#,
"data_dir"
),
Some(r"C:\Users\x\AppData\Roaming\OmniVoice".to_string())
);
// A path containing an escaped quote must not truncate the value at
// that quote — only an UNESCAPED quote terminates the string.
assert_eq!(
parse_json_string_field(r#"{"data_dir": "C:\\a \"weird\" dir"}"#, "data_dir"),
Some("C:\\a \"weird\" dir".to_string())
);
// \uXXXX escapes, including a surrogate pair for a codepoint outside
// the BMP (backend labels are ensure_ascii-encoded JSON, so any
// non-ASCII text — e.g. an ellipsis "…" or an emoji — arrives this
// way, not as raw UTF-8 bytes).
assert_eq!(
parse_json_string_field(r#"{"label": "Loading\u2026"}"#, "label"),
Some("Loading\u{2026}".to_string())
);
assert_eq!(
parse_json_string_field(r#"{"label": "\ud83d\ude00"}"#, "label"),
Some("\u{1F600}".to_string())
);
// The other basic escapes.
assert_eq!(
parse_json_string_field(r#"{"x": "a\nb\tc\rd\/e"}"#, "x"),
Some("a\nb\tc\rd/e".to_string())
);
// Malformed/unterminated input still degrades to None.
assert_eq!(parse_json_string_field(r#"{"x": "unterminated"#, "x"), None);
assert_eq!(parse_json_string_field(r#"{"x": "bad \q escape"}"#, "x"), None);
}
#[test]
fn backend_cmd_override_parses_json_and_whitespace_forms() {
// JSON form (the harness's): paths with spaces survive.
@@ -955,6 +1292,110 @@ mod tests {
assert!(!same_app_version(""));
}
// ── #1770: code fingerprint decision (pure — no AppHandle, per the
// Windows tauri::test::mock_builder abort that broke a PR earlier
// today) ────────────────────────────────────────────────────────────
#[test]
fn code_fingerprint_absent_is_stale() {
// No `code_fingerprint` key at all in /system/info -> the backend's
// code predates the fingerprinting mechanism outright, regardless of
// whether we could compute our own. This is the #1770 bug case: a
// same-version backend running weeks-old code must NOT be adopted.
assert!(!code_fingerprint_is_current(None, Some("abc123")));
assert!(!code_fingerprint_is_current(None, None));
}
#[test]
fn code_fingerprint_present_but_blank_degrades_to_accept() {
// Present-but-blank means current schema, no env var at spawn time
// (dev mode's dev-backend.mjs strips OMNIVOICE_*; a manually started
// uvicorn never sets it) — don't hard-fail every dev/manual-start
// session over an unverifiable-but-plausibly-current backend.
assert!(code_fingerprint_is_current(Some(""), Some("abc123")));
assert!(code_fingerprint_is_current(Some(""), None));
}
#[test]
fn code_fingerprint_matches_and_mismatches() {
// Tracked re-supervision / preview-build reattach: same resource
// dir hashed twice by the same build -> identical value -> accept.
assert!(code_fingerprint_is_current(Some("abc123"), Some("abc123")));
// Different code within the same version string -> stale, replace it.
assert!(!code_fingerprint_is_current(Some("abc123"), Some("def456")));
}
#[test]
fn code_fingerprint_our_side_unknown_degrades_to_accept() {
// We failed to compute our own fingerprint (unreadable resource dir
// / dev root) — can't enforce a check we can't compute either side
// of, so don't block a legitimate attach on our own tooling failure.
assert!(code_fingerprint_is_current(Some("abc123"), None));
}
#[test]
fn parse_backend_identity_distinguishes_absent_from_blank_from_known() {
// Old backend: /system/info has no code_fingerprint key at all.
let old = parse_backend_identity(r#"{"app_version":"0.5.2","data_dir": "/x"}"#).unwrap();
assert_eq!(old.version, "0.5.2");
assert_eq!(old.code_fingerprint, None);
// Current schema, no env var set at spawn time.
let blank = parse_backend_identity(
r#"{"app_version":"0.5.2","data_dir": "/x", "code_fingerprint": ""}"#,
)
.unwrap();
assert_eq!(blank.code_fingerprint, Some(String::new()));
// Current schema, fingerprint present.
let known = parse_backend_identity(
r#"{"app_version":"0.5.2","data_dir": "/x", "code_fingerprint": "abc123"}"#,
)
.unwrap();
assert_eq!(known.code_fingerprint, Some("abc123".to_string()));
// Not our backend at all (no data_dir/model_checkpoint marker) — the
// whole identity is unknown, not just the fingerprint.
assert!(parse_backend_identity(r#"{"code_fingerprint": "abc123"}"#).is_none());
}
#[test]
fn running_backend_identity_reads_both_fields_from_one_fetch() {
let stub = spawn_system_info_stub(
r#"{"app_version":"0.5.2","data_dir": "/x", "code_fingerprint": "abc123"}"#,
);
let identity = running_backend_identity(stub).unwrap();
assert_eq!(identity.version, "0.5.2");
assert_eq!(identity.code_fingerprint, Some("abc123".to_string()));
}
#[test]
fn running_backend_identity_transport_failure_is_not_conflated_with_absent_field() {
// The P1 Greptile caught in review of #1796: when version and
// fingerprint came from two independent /system/info fetches, a
// transport hiccup on the SECOND one (nothing listening, timeout,
// connection reset) returned `None` from
// `running_backend_code_fingerprint` alone — wire-identical to "the
// body parsed fine but the key was genuinely absent", which
// `code_fingerprint_is_current` treats as stale. That could kill and
// respawn a perfectly healthy, externally-owned backend on a single
// flaky probe.
//
// With one fetch, a transport failure can no longer reach that
// branch at all: `running_backend_identity` returns a flat `None`,
// which `prepare_backend_launch`'s `None => {}` arm treats as
// "nothing answered" — a wholly different code path from "answered,
// but predates the fingerprint field" (`Some(identity)` with
// `code_fingerprint: None`). Assert that boundary directly: nothing
// is listening on this port, so the fetch itself fails, and the
// result must be the "nothing answered" `None` — never a `Some`
// that a caller could misread as an absent-field verdict.
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
drop(listener); // frees the port; nothing is listening on it now
assert_eq!(running_backend_identity(port), None);
}
// ── Per-run crash evidence (#1510) ───────────────────────────────────
// The reported failure shape: a crash marker whose stderr tail was the
// REPLACEMENT process's healthy startup, because the shared err log was
File diff suppressed because it is too large Load Diff
+82 -2
View File
@@ -30,9 +30,41 @@ pub struct AuthorizedPathSelection {
path: String,
}
/// Directory for one-shot host-path capability files (and the
/// paired `revealed-paths` ledger) — must agree with what the *running
/// backend* resolves in `backend/core/path_authorization.py`, since the
/// backend is what reads these tokens back over loopback HTTP.
///
/// Prefers the backend's own advertised `data_dir` (`GET /system/info`, see
/// `backend::backend_data_dir`) so the two processes cannot disagree; falls
/// back to Tauri's own resolution (`setup::resolved_data_dir` / historical
/// behavior) when the backend isn't reachable yet, e.g. very early startup.
/// See #1781 for the split this closes: a dev backend spawned without
/// `OMNIVOICE_*` env, or a custom data folder / portable mode applied after
/// the backend already started, previously left Tauri writing capability
/// files the backend could never find, 403ing every export.
pub fn path_authorization_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
crate::setup::resolved_data_dir(app)
.unwrap_or_else(crate::setup::default_data_dir)
authorization_dir_from(crate::backend::backend_data_dir(crate::backend_port()), || {
crate::setup::resolved_data_dir(app).unwrap_or_else(crate::setup::default_data_dir)
})
}
/// The resolution rule itself, with the two inputs passed in rather than
/// fetched, so it is unit-testable without an `AppHandle`.
///
/// Constructing one in a test (`tauri::test::mock_builder`) aborts the whole
/// test binary on the Windows CI runner — it exits before the harness prints
/// a single line — and nothing else in this crate builds a Tauri app in a
/// unit test. Keeping the decision in a plain function means the branch that
/// matters for #1781 is covered on every platform, and the wrapper above is
/// left as two argument expressions with no logic of its own.
fn authorization_dir_from(
advertised: Option<String>,
tauri_fallback: impl FnOnce() -> PathBuf,
) -> PathBuf {
advertised
.map(PathBuf::from)
.unwrap_or_else(tauri_fallback)
.join(".path-authorizations")
}
@@ -261,6 +293,54 @@ mod host_path_authorization_tests {
validate_host_path("dub_export", parent.join("missing-directory/export.wav"),).is_err()
);
}
/// Regression for #1781: a native save dialog succeeded and Tauri wrote
/// the one-shot capability file, but the backend 403'd every export
/// because it scanned a DIFFERENT `.path-authorizations` directory (its
/// own `DATA_DIR`, resolved independently — e.g. the dev backend spawned
/// without `OMNIVOICE_*` env, or a custom data folder / portable mode
/// applied after the backend already started). When a backend is
/// reachable, its advertised `data_dir` must win so the two processes
/// are structurally unable to disagree.
///
/// Exercised through `authorization_dir_from` rather than
/// `path_authorization_dir`: the wrapper needs an `AppHandle`, and
/// building one in a unit test aborts the entire test binary on the
/// Windows runner. The HTTP side (`backend::backend_data_dir`, including
/// its absolute-path filter) has its own tests in `backend.rs`.
#[test]
fn prefers_the_running_backends_advertised_data_dir() {
// Platform-appropriate absolute fixture: a Unix-style path is NOT
// absolute on Windows (no drive prefix), and `PathBuf::join`
// normalizes separators per platform.
#[cfg(windows)]
let advertised = r"C:\backend\advertised\data";
#[cfg(not(windows))]
let advertised = "/backend/advertised/data";
let dir = super::authorization_dir_from(Some(advertised.to_string()), || {
panic!("must not fall back to Tauri's resolution while a backend advertises a dir")
});
assert_eq!(
dir,
PathBuf::from(advertised).join(".path-authorizations"),
"must write into the backend's own data_dir, not Tauri's independent resolution"
);
}
/// When no backend answers (unreachable, not started yet, or advertising
/// something unusable), the resolver must fall back to Tauri's own
/// resolution — the pre-#1781 behavior — rather than erroring or writing
/// somewhere unpredictable.
#[test]
fn falls_back_to_tauris_own_resolution_when_the_backend_is_unreachable() {
let fallback = std::env::temp_dir().join("voicestudio-fallback-fixture");
let dir = super::authorization_dir_from(None, || fallback.clone());
assert_eq!(dir, fallback.join(".path-authorizations"));
}
}
// ── System metrics ────────────────────────────────────────────────────────
+5
View File
@@ -22,6 +22,7 @@ pub mod speech_sidecar;
pub mod tools;
pub mod uninstall;
pub mod updater_channel;
pub mod watch_folder;
#[cfg(target_os = "linux")]
pub mod wayland_shortcut;
@@ -739,6 +740,10 @@ pub fn run() {
reset::reset_purge,
blank_guard::report_render_state,
blank_guard::recover_main_window,
watch_folder::watch_folder_pick,
watch_folder::watch_folder_scan,
watch_folder::watch_folder_enqueue,
watch_folder::watch_folder_forget,
])
.setup(move |app| {
// Blank-window guard: watch the main window and, if nothing ever
+269
View File
@@ -375,6 +375,15 @@ pub fn default_models_dir() -> PathBuf {
/// Root that holds the managed Python project (`<root>/project/.venv`).
/// Single source of truth for bootstrap + clean-retry + backend spawn.
///
/// Deliberately always the TRUE, non-redirected path — never routed through
/// [`ascii_safe_dir`] here. `ensure_venv_ready` (bootstrap.rs) is the only
/// caller allowed to redirect to an ASCII-safe root (#1783), and only after
/// confirming there is nothing usable at this true path (see
/// `resolve_venv_root`): a working venv, ASCII path or not, must never be
/// silently relocated just because this function was called — every other
/// consumer (uninstall size/deletion, disk-space checks, the first-run
/// storage preview) needs the path that actually holds the data on disk.
pub fn env_root<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
let cfg = config::load_config(app);
if cfg.install_mode == "portable" {
@@ -388,6 +397,153 @@ pub fn env_root<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> PathBuf {
app.path().app_local_data_dir().unwrap_or_default()
}
/// True when `path`'s displayed form is pure ASCII — the precondition
/// Windows' ANSI-code-page `.pth` decoding needs (#1783). Byte-identical
/// fast path for the overwhelming majority of installs, which never touch
/// the WinAPI short-name call below.
///
/// `pub(crate)`, not Windows-gated: `bootstrap::ensure_venv_ready` uses this
/// as a cheap pre-check (a string scan, no subprocess) to skip its non-ASCII
/// interpreter probe entirely for an ASCII env root — which is every install
/// on macOS/Linux and the overwhelming majority on Windows too. Without this
/// gate, every launch with an existing venv would pay for one extra
/// subprocess spawn it never needed.
pub(crate) fn is_ascii_path(path: &Path) -> bool {
path.to_string_lossy().is_ascii()
}
/// Split `path` into (longest existing ancestor, remaining components that
/// don't exist yet). `GetShortPathNameW` can only resolve a path that
/// already exists on disk end-to-end, but the managed env root's leaf
/// directories (`<bundle-id>`, `project`, `.venv`) are created by bootstrap
/// itself and won't exist on first run — only the user-profile ancestor
/// (e.g. `%LOCALAPPDATA%`, which Windows always creates) does. The
/// remainder is literal ASCII we chose ourselves, so it's reattached as-is.
#[cfg(any(target_os = "windows", test))]
fn split_existing_ancestor(path: &Path) -> (PathBuf, PathBuf) {
let mut remainder = PathBuf::new();
let mut candidate = path.to_path_buf();
loop {
if candidate.exists() {
return (candidate, remainder);
}
match candidate.file_name().map(|n| n.to_os_string()) {
Some(name) => {
remainder = Path::new(&name).join(&remainder);
candidate.pop();
}
None => return (candidate, remainder), // walked past the root — give up
}
}
}
/// What [`ascii_safe_dir`] should do, decided by [`plan_ascii_safe_dir`].
/// Pure/portable so the DECISION is unit-testable on every platform even
/// though only the Windows build ever executes a `Shorten*` variant.
#[cfg(any(target_os = "windows", test))]
#[derive(Debug, PartialEq, Eq)]
enum AsciiSafePlan {
/// `dir` is already ASCII — return unchanged, no OS calls at all.
NoOp,
/// The non-ASCII bytes are confined to `ancestor`, which already exists
/// (Windows creates a user's profile dirs at account-creation time, so a
/// non-ASCII *username* is always here) — shorten just that and
/// reattach `remainder`, which is literal ASCII we chose ourselves and
/// doesn't exist yet, unchanged.
ShortenAncestor { ancestor: PathBuf, remainder: PathBuf },
/// The not-yet-created remainder ITSELF carries non-ASCII bytes — e.g. a
/// user-chosen custom install folder named in their own language
/// (greptile #1783 review: reattaching a non-ASCII remainder unchanged
/// would silently leave the result non-ASCII for exactly the user this
/// exists to help). `GetShortPathNameW` can't mint a short name for a
/// path that doesn't exist, so `full` must be created on disk first —
/// Windows assigns every new directory an 8.3 short name by default —
/// then the COMPLETE path is shortened in one call.
CreateThenShorten { full: PathBuf },
}
/// True when [`split_existing_ancestor`]'s reattached remainder needs its
/// own short-name resolution — non-empty AND non-ASCII — because the
/// `ShortenAncestor` fast path (reattach unchanged) would silently leave the
/// result non-ASCII otherwise.
#[cfg(any(target_os = "windows", test))]
fn remainder_needs_own_shortening(remainder: &Path) -> bool {
!remainder.as_os_str().is_empty() && !is_ascii_path(remainder)
}
#[cfg(any(target_os = "windows", test))]
fn plan_ascii_safe_dir(dir: &Path) -> AsciiSafePlan {
if is_ascii_path(dir) {
return AsciiSafePlan::NoOp;
}
let (existing, remainder) = split_existing_ancestor(dir);
if remainder_needs_own_shortening(&remainder) {
AsciiSafePlan::CreateThenShorten { full: dir.to_path_buf() }
} else {
AsciiSafePlan::ShortenAncestor { ancestor: existing, remainder }
}
}
/// Windows-only: if `dir` contains a non-ASCII byte, resolve it to an
/// ASCII-safe equivalent via Windows' 8.3 short filenames (the same trick
/// `backend/core/config.py::_ensure_short_hf_cache_on_windows` documents for
/// the HF-cache MAX_PATH problem), so every byte later written into the venv
/// — including `uv sync`'s editable `.pth` — is valid in any single-byte
/// Windows code page (#1783). See [`plan_ascii_safe_dir`] for which of the
/// two non-ASCII cases applies.
///
/// Best-effort throughout: an ASCII `dir` is returned unchanged without ever
/// calling into WinAPI or touching the filesystem. Any failure along the way
/// (short-name call fails or is still non-ASCII — 8.3 generation disabled
/// via `fsutil 8dot3name` — or the `CreateThenShorten` directory creation
/// fails) returns the original path unchanged — no worse than before this
/// fix, and the bootstrap crash-signature diagnosis names the cause if the
/// interpreter still dies in `site`.
#[cfg(target_os = "windows")]
pub fn ascii_safe_dir(dir: &Path) -> PathBuf {
match plan_ascii_safe_dir(dir) {
AsciiSafePlan::NoOp => dir.to_path_buf(),
AsciiSafePlan::ShortenAncestor { ancestor, remainder } => match win_short_path_name(&ancestor) {
Some(short) if is_ascii_path(&short) => short.join(&remainder),
_ => dir.to_path_buf(),
},
AsciiSafePlan::CreateThenShorten { full } => {
if fs::create_dir_all(&full).is_err() {
return dir.to_path_buf();
}
match win_short_path_name(&full) {
Some(short) if is_ascii_path(&short) => short,
_ => dir.to_path_buf(),
}
}
}
}
#[cfg(not(target_os = "windows"))]
pub fn ascii_safe_dir(dir: &Path) -> PathBuf {
dir.to_path_buf()
}
/// Raw `GetShortPathNameW` call. `None` on any failure (path doesn't exist,
/// buffer too small, 8.3 names disabled) — callers fall back to the
/// original path rather than trust a partial/garbled result.
#[cfg(target_os = "windows")]
fn win_short_path_name(path: &Path) -> Option<PathBuf> {
use std::os::windows::ffi::OsStrExt;
use windows::core::PCWSTR;
use windows::Win32::Storage::FileSystem::GetShortPathNameW;
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
let mut buf = vec![0u16; 4096];
// Safety: `wide` is NUL-terminated and outlives the call; `buf` is sized
// and its length is passed as `cchbuffer`, so the call cannot write past it.
let len = unsafe { GetShortPathNameW(PCWSTR(wide.as_ptr()), Some(&mut buf)) } as usize;
if len == 0 || len >= buf.len() {
return None; // 0 = failure (see GetLastError); >= buf.len() = truncated
}
Some(PathBuf::from(String::from_utf16_lossy(&buf[..len])))
}
/// User-chosen backend data dir (voices/projects/db) → `OMNIVOICE_DATA_DIR`.
/// `None` = backend platform default; we deliberately don't set the env var
/// then, so legacy installs keep byte-identical behavior.
@@ -1452,4 +1608,117 @@ mod tests {
assert!(REQUIRED_MODELS_BYTES >= 7 * GIB);
assert!(REQUIRED_DATA_BYTES >= GIB / 2);
}
// ── #1783: ASCII-safe env root ─────────────────────────────────────────
// `\u{...}` escapes (not literal CJK bytes) so this source file itself
// stays outside tests/test_no_hardcoded_cjk.py's scan — those escapes
// still build the real crash-report username at runtime.
fn cjk_username_path() -> PathBuf {
// U+65E5 U+672C U+8A9E — three Japanese characters, matching #1771's
// report. Spelled as escapes, not literal bytes, on purpose.
PathBuf::from(format!("C:\\Users\\{}\\AppData\\Local\\OmniVoice", "\u{65e5}\u{672c}\u{8a9e}"))
}
#[test]
fn is_ascii_path_flags_non_ascii_bytes() {
assert!(is_ascii_path(Path::new("/Users/alice/AppData/Local/OmniVoice")));
assert!(is_ascii_path(Path::new(r"C:\Users\alice\AppData\Local\OmniVoice")));
// The exact byte from the crash report: a CJK Windows username.
assert!(!is_ascii_path(&cjk_username_path()));
assert!(!is_ascii_path(Path::new("/home/\u{443}\u{441}\u{435}\u{440}/.omnivoice")));
}
#[test]
fn split_existing_ancestor_finds_the_deepest_real_directory() {
let root = std::env::temp_dir().join(format!(
"ov-ascii-safe-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
let target = root.join("project").join(".venv");
let (existing, remainder) = split_existing_ancestor(&target);
assert_eq!(existing, root);
assert_eq!(remainder, Path::new("project").join(".venv"));
// A path that exists outright has no remainder.
let (existing2, remainder2) = split_existing_ancestor(&root);
assert_eq!(existing2, root);
assert_eq!(remainder2, Path::new(""));
// Reassembling must reproduce the original path exactly.
assert_eq!(existing.join(&remainder), target);
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn plan_ascii_safe_dir_is_a_noop_for_ascii_paths() {
assert_eq!(plan_ascii_safe_dir(Path::new("/ascii/only/path")), AsciiSafePlan::NoOp);
}
#[test]
fn plan_ascii_safe_dir_shortens_only_the_ancestor_when_the_remainder_is_ascii() {
// The common #1783 case: a non-ASCII Windows username is always
// part of an EXISTING ancestor (Windows creates the profile dir at
// account-creation time) — the not-yet-created subpath bootstrap
// appends (`AppData\Local\OmniVoice`) is ours and always ASCII.
let root = std::env::temp_dir().join(format!(
"ov-plan-ascii-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&root);
let nonascii_ancestor = root.join("\u{65e5}\u{672c}\u{8a9e}"); // exists
fs::create_dir_all(&nonascii_ancestor).unwrap();
let target = nonascii_ancestor.join("AppData").join("Local").join("OmniVoice"); // doesn't exist
match plan_ascii_safe_dir(&target) {
AsciiSafePlan::ShortenAncestor { ancestor, remainder } => {
assert_eq!(ancestor, nonascii_ancestor);
assert_eq!(remainder, Path::new("AppData").join("Local").join("OmniVoice"));
}
other => panic!("expected ShortenAncestor, got {:?}", other),
}
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn plan_ascii_safe_dir_creates_then_shortens_when_the_not_yet_created_leaf_is_nonascii() {
// greptile #1783 review, P1: a user-chosen custom install folder
// named in their own language — the ancestor exists and is ASCII,
// but the NOT-YET-CREATED leaf itself carries the non-ASCII bytes.
// Reattaching it unchanged (the ShortenAncestor fast path) would
// silently leave the result non-ASCII — exactly the bug flagged.
let root = std::env::temp_dir().join(format!(
"ov-plan-ascii-leaf-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap(); // ASCII ancestor, exists
let target = root.join("\u{6211}\u{7684}\u{8f6f}\u{4ef6}").join("env"); // doesn't exist, non-ASCII leaf
match plan_ascii_safe_dir(&target) {
AsciiSafePlan::CreateThenShorten { full } => assert_eq!(full, target),
other => panic!("expected CreateThenShorten, got {:?}", other),
}
fs::remove_dir_all(&root).unwrap();
}
// On every non-Windows target `ascii_safe_dir` never touches the
// filesystem or WinAPI — it exists so `env_root` has one call site
// regardless of platform. The real short-name resolution is
// Windows-only and gated behind `#[cfg(target_os = "windows")]` above
// (untestable on this machine — see #1783 PR notes on what couldn't be
// exercised here).
#[cfg(not(target_os = "windows"))]
#[test]
fn ascii_safe_dir_is_a_byte_identical_noop_off_windows() {
let p = cjk_username_path();
assert_eq!(ascii_safe_dir(&p), p);
}
}
+100 -33
View File
@@ -73,6 +73,33 @@ pub struct OwnedProcessTree {
job: std::os::windows::io::OwnedHandle,
}
// Keep the delivery and post-error ownership probe together: the root may
// exit between any earlier liveness check and either TERM or KILL delivery.
#[cfg(unix)]
fn signal_process_group_with(
signal: libc::c_int,
darwin: bool,
send: impl FnOnce(libc::c_int) -> io::Result<()>,
root_exited_unreaped: impl FnOnce() -> io::Result<bool>,
) -> io::Result<()> {
match send(signal) {
Ok(()) => Ok(()),
Err(error) if error.raw_os_error() == Some(libc::ESRCH) => Ok(()),
Err(error) if darwin && error.raw_os_error() == Some(libc::EPERM) => {
// Darwin can report EPERM when the last signalable member exited
// during delivery. Accept only a newly verified unreaped root:
// live roots, lost identity and probe failures remain errors.
// Callers must still join nested drain before reaping that root.
if root_exited_unreaped()? {
Ok(())
} else {
Err(error)
}
}
Err(error) => Err(error),
}
}
impl OwnedProcessTree {
#[cfg(unix)]
fn root_exited_unreaped(&self) -> io::Result<bool> {
@@ -141,15 +168,18 @@ impl OwnedProcessTree {
#[cfg(unix)]
fn signal_group(&self, signal: libc::c_int) -> io::Result<()> {
if unsafe { libc::kill(-self.process_group, signal) } == 0 {
return Ok(());
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
Ok(())
} else {
Err(error)
}
signal_process_group_with(
signal,
cfg!(target_os = "macos"),
|signal| {
if unsafe { libc::kill(-self.process_group, signal) } == 0 {
Ok(())
} else {
Err(io::Error::last_os_error())
}
},
|| self.root_exited_unreaped(),
)
}
fn force_terminate(&mut self) -> io::Result<()> {
@@ -173,27 +203,6 @@ impl OwnedProcessTree {
Ok(())
}
#[cfg(unix)]
fn force_terminate_after_root_exit(&mut self) -> io::Result<()> {
if self.terminated {
return Ok(());
}
match self.signal_group(libc::SIGKILL) {
Ok(()) => {}
#[cfg(target_os = "macos")]
Err(error) if error.raw_os_error() == Some(libc::EPERM) => {
// XNU excludes zombies when iterating an explicit process
// group, then reports EPERM when it found no signalable live
// member. The unreaped root still reserves this exact group;
// the nested-drain join that follows catches any descendant
// which actually survived the signal attempt.
}
Err(error) => return Err(error),
}
self.terminated = true;
Ok(())
}
fn wait_nested_drain(&mut self, timeout: Duration) -> io::Result<()> {
#[cfg(unix)]
{
@@ -446,7 +455,7 @@ pub fn contained_child_exit(
Ok(true) => {
// Do not reap the root until group cleanup succeeds: the
// zombie is what keeps this process-group ID non-reusable.
tree.force_terminate_after_root_exit()?;
tree.force_terminate()?;
tree.wait_nested_drain(nested_drain_timeout())?;
let status = child.try_wait()?;
return Ok(status);
@@ -495,7 +504,7 @@ pub fn terminate_process_tree(
#[cfg(unix)]
match tree.root_exited_unreaped() {
Ok(true) => {
tree.force_terminate_after_root_exit()?;
tree.force_terminate()?;
tree.wait_nested_drain(nested_drain_timeout())?;
return child.wait();
}
@@ -522,7 +531,7 @@ pub fn terminate_process_tree(
while std::time::Instant::now() < deadline {
#[cfg(unix)]
if tree.root_exited_unreaped()? {
tree.force_terminate_after_root_exit()?;
tree.force_terminate()?;
tree.wait_nested_drain(nested_drain_timeout())?;
return child.wait();
}
@@ -1207,6 +1216,64 @@ mod uv_tests {
use super::*;
use std::ffi::OsStr;
#[cfg(unix)]
#[test]
fn darwin_group_signal_recovers_exit_during_term_or_kill_delivery() {
for signal in [libc::SIGTERM, libc::SIGKILL] {
let exited = std::cell::Cell::new(false);
signal_process_group_with(
signal,
true,
|delivered| {
assert_eq!(delivered, signal);
assert!(!exited.replace(true));
Err(io::Error::from_raw_os_error(libc::EPERM))
},
|| {
assert!(exited.get(), "ownership must be checked after failed delivery");
Ok(true)
},
)
.expect("an exited unreaped root keeps its group reserved until drain completes");
}
}
#[cfg(unix)]
#[test]
fn darwin_group_signal_rejects_live_reaped_or_unverifiable_roots() {
for signal in [libc::SIGTERM, libc::SIGKILL] {
for (probe, expected) in [
(Ok(false), libc::EPERM),
(Err(libc::ECHILD), libc::ECHILD),
(Err(libc::EIO), libc::EIO),
] {
let error = signal_process_group_with(
signal,
true,
|_| Err(io::Error::from_raw_os_error(libc::EPERM)),
|| probe.map_err(io::Error::from_raw_os_error),
)
.expect_err("only a confirmed unreaped exit can explain Darwin EPERM");
assert_eq!(error.raw_os_error(), Some(expected));
}
}
}
#[cfg(unix)]
#[test]
fn group_signal_preserves_other_permission_errors_and_platforms() {
for (darwin, errno) in [(false, libc::EPERM), (true, libc::EACCES)] {
let error = signal_process_group_with(
libc::SIGKILL,
darwin,
|_| Err(io::Error::from_raw_os_error(errno)),
|| panic!("unrelated errors must not use the Darwin exit exception"),
)
.unwrap_err();
assert_eq!(error.raw_os_error(), Some(errno));
}
}
#[cfg(unix)]
#[test]
fn contained_exit_probe_preserves_a_live_child() {
+678
View File
@@ -0,0 +1,678 @@
//! Batch watch-folder IPC: native folder pick, polling scan, and upload.
//!
//! The watcher lives entirely on the client side of the app: the webview asks
//! this module (over Tauri IPC) for directory listings and asks Rust to stream
//! a settled file through the existing `POST /batch/enqueue` multipart route.
//! The Python backend only ever sees uploaded bytes — filesystem paths never
//! ride an HTTP request (same posture as `commands::authorize_host_path`).
//!
//! Access model: the folder is picked in a native dialog inside this process
//! and registered under a random session token, together with a `cap_std`
//! directory HANDLE opened at pick time. Scan/read commands resolve entries
//! relative to that handle — the pathname is never re-resolved for *access*,
//! so swapping the directory (or any component of its path) for a
//! symlink/junction later cannot redirect the watcher, on any OS. The stored
//! pathname is re-resolved only by the liveness/identity check, which stops
//! the watcher loudly when the folder is deleted, moved, or replaced. The
//! webview cannot point the commands at an arbitrary path; reads are confined
//! to files sitting directly in the folder the user explicitly picked this
//! session (non-recursive by design).
//!
//! Holding the handle must not lock the user's folder: `cap_std` opens
//! directories on Windows WITHOUT `FILE_SHARE_DELETE` (it pins the pathname
//! for its own path-based helpers), which would make Explorer refuse to
//! rename or delete a watched folder until the watch is stopped — a
//! Windows-only behaviour the other two platforms don't have. The handle is
//! therefore opened here with the full share mode (`open_dir_handle`), so
//! replacing the folder behaves identically everywhere: the OS allows it, the
//! next poll's identity check fails, and the UI stops the watcher.
use std::collections::HashMap;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::UNIX_EPOCH;
#[cfg(not(windows))]
use cap_std::ambient_authority;
use cap_std::fs::Dir;
use serde::Serialize;
use tauri_plugin_dialog::DialogExt;
/// An authorized watch folder: the directory handle everything resolves
/// against, plus the identity the folder had when the user picked it. The
/// handle is the security boundary (operations can never leave it); the
/// identity check is the LIVENESS signal — when the folder is deleted, moved,
/// or replaced, token resolution fails loudly and the UI stops the watcher
/// instead of polling silently forever.
struct WatchedDir {
/// Fully-resolved directory path captured at pick time.
canonical: PathBuf,
/// Filesystem identity (device, inode) captured at pick time.
#[cfg(unix)]
identity: (u64, u64),
/// Filesystem identity (volume serial, file index) captured at pick time.
#[cfg(windows)]
identity: (u32, u64),
/// Directory handle captured at pick time — all scans/reads go through it.
handle: Dir,
}
#[cfg(unix)]
fn dir_identity(meta: &fs::Metadata) -> (u64, u64) {
use std::os::unix::fs::MetadataExt;
(meta.dev(), meta.ino())
}
#[cfg(windows)]
fn dir_identity(dir: &Dir) -> Result<(u32, u64), String> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
};
let mut info = BY_HANDLE_FILE_INFORMATION::default();
// SAFETY: `dir` owns a live directory handle for the duration of this
// call, and `info` is a valid writable output buffer.
unsafe { GetFileInformationByHandle(HANDLE(dir.as_raw_handle()), &mut info) }
.map_err(|_| "Selected watch folder identity could not be read".to_string())?;
Ok((
info.dwVolumeSerialNumber,
((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64,
))
}
/// Open a directory handle for capability-scoped access.
///
/// Unix: `cap_std`'s own ambient open. Windows: the same
/// `FILE_FLAG_BACKUP_SEMANTICS` directory open `cap_std` performs, but with
/// `FILE_SHARE_DELETE` included so the user can still rename/delete the folder
/// while it is watched (see the module docs). Child opens stay handle-relative
/// (`CreateFileAtW` / `NtCreateFile` with a root directory) so confinement is
/// unaffected; only the liveness check observes the rename, which is the
/// intended signal.
#[cfg(not(windows))]
fn open_dir_handle(dir: &Path) -> io::Result<Dir> {
Dir::open_ambient_dir(dir, ambient_authority())
}
#[cfg(windows)]
fn open_dir_handle(dir: &Path) -> io::Result<Dir> {
use std::os::windows::fs::OpenOptionsExt;
use windows::Win32::Storage::FileSystem::{
FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
let file = fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS.0)
.share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0)
.open(dir)?;
if !file.metadata()?.is_dir() {
return Err(io::Error::other("not a directory"));
}
Ok(Dir::from_std_file(file))
}
fn authorize_watched_dir(dir: &Path) -> Result<WatchedDir, String> {
let canonical = fs::canonicalize(dir)
.map_err(|e| format!("Selected watch folder could not be resolved: {e}"))?;
if !canonical.is_dir() {
return Err("Selected watch folder is not a directory".into());
}
let handle = open_dir_handle(&canonical)
.map_err(|e| format!("Selected watch folder could not be opened: {e}"))?;
#[cfg(unix)]
let identity = dir_identity(
&fs::metadata(&canonical)
.map_err(|e| format!("Selected watch folder could not be inspected: {e}"))?,
);
#[cfg(windows)]
let identity = dir_identity(&handle)?;
Ok(WatchedDir {
canonical,
#[cfg(unix)]
identity,
#[cfg(windows)]
identity,
handle,
})
}
/// Re-verify a watched folder's identity: the stored path must still resolve
/// to the same canonical target (and, on unix, the same device+inode). A
/// deleted, moved, replaced, or recreated directory fails here, which is what
/// stops the watcher loudly in the UI. Reads never depend on this check for
/// confinement — they go through the pinned handle regardless.
fn verify_watched_dir(watched: &WatchedDir) -> Result<(), String> {
let canonical_now = fs::canonicalize(&watched.canonical)
.map_err(|_| "Watched folder is no longer accessible".to_string())?;
if canonical_now != watched.canonical {
return Err("Watched folder changed identity".into());
}
#[cfg(unix)]
{
let meta = fs::metadata(&canonical_now)
.map_err(|_| "Watched folder is no longer accessible".to_string())?;
if dir_identity(&meta) != watched.identity {
return Err("Watched folder changed identity".into());
}
}
#[cfg(windows)]
{
let current = open_dir_handle(&canonical_now)
.map_err(|_| "Watched folder is no longer accessible".to_string())?;
if dir_identity(&current)? != watched.identity {
return Err("Watched folder changed identity".into());
}
}
Ok(())
}
fn registry() -> &'static Mutex<HashMap<String, WatchedDir>> {
static WATCHED: OnceLock<Mutex<HashMap<String, WatchedDir>>> = OnceLock::new();
WATCHED.get_or_init(|| Mutex::new(HashMap::new()))
}
#[derive(Serialize)]
pub struct WatchFolderSelection {
token: String,
path: String,
}
#[derive(Serialize)]
pub struct WatchEntry {
name: String,
size: u64,
/// Modification time in ms since the Unix epoch (0 when unavailable).
mtime: u64,
}
fn new_token() -> Result<String, String> {
let mut random = [0_u8; 32];
getrandom::fill(&mut random).map_err(|e| format!("Secure randomness unavailable: {e}"))?;
Ok(random.iter().map(|b| format!("{b:02x}")).collect())
}
/// Resolve a session token to a clone of its pinned directory handle,
/// re-verifying the folder's liveness/identity on every access.
fn registered_dir(token: &str) -> Result<Dir, String> {
let map = registry()
.lock()
.map_err(|_| "Watch-folder registry poisoned".to_string())?;
let watched = map
.get(token)
.ok_or_else(|| "Watch folder is not authorized".to_string())?;
verify_watched_dir(watched)?;
watched
.handle
.try_clone()
.map_err(|e| format!("Watched folder handle could not be reused: {e}"))
}
/// A directory entry name must be a single plain path component — anything
/// that could climb out of the watched folder is rejected. (The `cap_std`
/// handle would also refuse an escape; this keeps the error crisp and the
/// contract explicit.)
fn validate_entry_name(name: &str) -> Result<(), String> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
|| name.chars().any(|c| c.is_control())
{
return Err("Invalid watch-folder entry name".into());
}
Ok(())
}
fn mtime_ms(meta: &fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn cap_mtime_ms(meta: &cap_std::fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|t| t.into_std().duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Non-recursive listing of the regular files in the watched folder (name,
/// size, mtime), resolved through the pinned handle. Symlinks are skipped
/// outright — the read path cannot follow them out of the folder anyway, so
/// listing them would only produce entries that can never be ingested.
fn scan_dir(dir: &Dir) -> Result<Vec<WatchEntry>, String> {
let mut entries = Vec::new();
let read = dir
.entries()
.map_err(|e| format!("Watched folder is unreadable: {e}"))?;
for item in read.flatten() {
let Ok(file_type) = item.file_type() else {
continue;
};
if !file_type.is_file() {
continue;
}
let Ok(meta) = item.metadata() else { continue };
let Ok(name) = item.file_name().into_string() else {
continue; // non-UTF-8 names can't round-trip through IPC; skip
};
entries.push(WatchEntry {
name,
size: meta.len(),
mtime: cap_mtime_ms(&meta),
});
}
Ok(entries)
}
/// Open a settled watched file through the pinned directory handle. A symlink
/// outside the folder cannot be opened, and the returned reader revalidates
/// size+mtime around every network read so a mutation aborts the upload.
fn open_watched_reader(
dir: &Dir,
name: &str,
expected_size: u64,
expected_mtime: u64,
) -> Result<SnapshotReader, String> {
validate_entry_name(name)?;
let file = dir
.open(name)
.map_err(|e| format!("Watched file could not be opened: {e}"))?
.into_std();
let meta = file
.metadata()
.map_err(|e| format!("Watched file could not be inspected: {e}"))?;
if !meta.is_file() {
return Err("Watched entry is not a regular file".into());
}
if meta.len() != expected_size || mtime_ms(&meta) != expected_mtime {
return Err("Watched file changed after it was scanned".into());
}
Ok(SnapshotReader {
file,
expected_size,
expected_mtime,
})
}
#[derive(Debug)]
struct SnapshotReader {
file: fs::File,
expected_size: u64,
expected_mtime: u64,
}
impl SnapshotReader {
fn validate(&self) -> io::Result<()> {
let meta = self.file.metadata()?;
if !meta.is_file()
|| meta.len() != self.expected_size
|| mtime_ms(&meta) != self.expected_mtime
{
return Err(io::Error::other("watched file changed during upload"));
}
Ok(())
}
}
impl Read for SnapshotReader {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.validate()?;
let read = self.file.read(buf)?;
self.validate()?;
Ok(read)
}
}
/// Open the native folder picker and register the chosen directory for this
/// session. Returns `None` when the user cancels.
#[tauri::command]
pub async fn watch_folder_pick(
app: tauri::AppHandle,
) -> Result<Option<WatchFolderSelection>, String> {
let picked = app
.dialog()
.file()
.blocking_pick_folder()
.and_then(|value| value.into_path().ok());
let Some(dir) = picked else {
return Ok(None);
};
if !dir.is_absolute() || !dir.is_dir() {
return Err("Selected watch folder is not a directory".into());
}
let watched = authorize_watched_dir(&dir)?;
let display = watched.canonical.to_string_lossy().into_owned();
let token = new_token()?;
registry()
.lock()
.map_err(|_| "Watch-folder registry poisoned".to_string())?
.insert(token.clone(), watched);
Ok(Some(WatchFolderSelection {
token,
path: display,
}))
}
/// List the files currently sitting in the watched folder (non-recursive).
#[tauri::command]
pub fn watch_folder_scan(token: String) -> Result<Vec<WatchEntry>, String> {
scan_dir(&registered_dir(&token)?)
}
#[derive(Serialize)]
pub struct WatchFolderUploadReply {
status: u16,
body: serde_json::Value,
}
fn video_mime(name: &str) -> &'static str {
match Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"mkv" => "video/x-matroska",
"webm" => "video/webm",
"avi" => "video/x-msvideo",
"mpg" | "mpeg" => "video/mpeg",
"wmv" => "video/x-ms-wmv",
_ => "application/octet-stream",
}
}
/// Stream one settled watched file directly from its pinned OS handle to the
/// loopback backend. Keeping bytes out of WebView IPC avoids an O(file size)
/// renderer allocation for multi-gigabyte videos.
#[tauri::command]
pub async fn watch_folder_enqueue(
token: String,
name: String,
expected_size: u64,
expected_mtime: u64,
langs: Vec<String>,
voice_id: Option<String>,
preserve_bg: bool,
) -> Result<WatchFolderUploadReply, String> {
let dir = registered_dir(&token)?;
let reader = open_watched_reader(&dir, &name, expected_size, expected_mtime)?;
let mime = video_mime(&name);
let url = format!("http://127.0.0.1:{}/batch/enqueue", crate::backend_port());
tauri::async_runtime::spawn_blocking(move || {
let part = reqwest::blocking::multipart::Part::reader_with_length(reader, expected_size)
.file_name(name)
.mime_str(mime)
.map_err(|_| "Watched file type could not be prepared".to_string())?;
let mut form = reqwest::blocking::multipart::Form::new()
.part("video", part)
.text("langs", langs.join(","))
.text("preserve_bg", preserve_bg.to_string());
if let Some(voice_id) = voice_id.filter(|value| !value.is_empty()) {
form = form.text("voice_id", voice_id);
}
let response = reqwest::blocking::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|_| "Watch-folder upload client could not start".to_string())?
.post(url)
.multipart(form)
.send()
.map_err(|_| "Watch-folder upload failed".to_string())?;
let status = response.status().as_u16();
let body = response
.json::<serde_json::Value>()
.map_err(|_| "Watch-folder backend returned an invalid response".to_string())?;
Ok(WatchFolderUploadReply { status, body })
})
.await
.map_err(|_| "Watch-folder upload task failed".to_string())?
}
/// Drop a watch-folder authorization (watcher stopped or component unmounted).
#[tauri::command]
pub fn watch_folder_forget(token: String) {
if let Ok(mut map) = registry().lock() {
map.remove(&token);
}
}
#[cfg(test)]
mod tests {
use super::{
authorize_watched_dir, mtime_ms, open_dir_handle, open_watched_reader, scan_dir,
validate_entry_name, verify_watched_dir, Dir,
};
use std::fs;
use std::io::Read;
use std::path::PathBuf;
fn temp_watch_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("vs-watch-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
fn open_handle(dir: &std::path::Path) -> Dir {
open_dir_handle(dir).unwrap()
}
fn snapshot(path: &std::path::Path) -> (u64, u64) {
let meta = fs::metadata(path).unwrap();
(meta.len(), mtime_ms(&meta))
}
#[test]
fn entry_names_must_be_single_components() {
assert!(validate_entry_name("clip.mp4").is_ok());
assert!(validate_entry_name("weird name (1).MOV").is_ok());
for bad in [
"",
".",
"..",
"a/b.mp4",
"a\\b.mp4",
"..\\up.mp4",
"x\n.mp4",
] {
assert!(validate_entry_name(bad).is_err(), "accepted {bad:?}");
}
}
#[test]
fn scan_lists_regular_files_with_size_and_mtime_and_skips_dirs() {
let dir = temp_watch_dir("scan");
fs::create_dir_all(dir.join("nested")).unwrap();
fs::write(dir.join("a.mp4"), b"12345").unwrap();
fs::write(dir.join("notes.txt"), b"x").unwrap();
let mut entries = scan_dir(&open_handle(&dir)).unwrap();
entries.sort_by(|a, b| a.name.cmp(&b.name));
let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
// Directories are skipped; filtering to *videos* is the frontend's job.
assert_eq!(names, ["a.mp4", "notes.txt"]);
assert_eq!(entries[0].size, 5);
assert!(entries[0].mtime > 0);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn snapshot_reader_streams_the_exact_bytes() {
let dir = temp_watch_dir("stream");
fs::write(dir.join("clip.mp4"), b"0123456789").unwrap();
let (size, mtime) = snapshot(&dir.join("clip.mp4"));
let handle = open_handle(&dir);
let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap();
let mut whole = Vec::new();
reader.read_to_end(&mut whole).unwrap();
assert_eq!(whole, b"0123456789");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn reads_are_bound_to_the_settled_snapshot() {
let dir = temp_watch_dir("snapshot");
fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap();
let (size, mtime) = snapshot(&dir.join("clip.mp4"));
let handle = open_handle(&dir);
// The file is replaced after the scan settled → the read must refuse
// rather than upload bytes the tracker never saw stabilize.
fs::write(dir.join("clip.mp4"), b"replaced with something longer").unwrap();
let err = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap_err();
assert!(err.contains("changed"), "unexpected error: {err}");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn snapshot_reader_aborts_when_file_changes_during_stream() {
let dir = temp_watch_dir("mid-stream-change");
fs::write(dir.join("clip.mp4"), b"settled bytes").unwrap();
let (size, mtime) = snapshot(&dir.join("clip.mp4"));
let handle = open_handle(&dir);
let mut reader = open_watched_reader(&handle, "clip.mp4", size, mtime).unwrap();
fs::write(dir.join("clip.mp4"), b"different-length bytes").unwrap();
let mut byte = [0_u8; 1];
assert!(reader.read(&mut byte).is_err());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn authorization_pins_the_directory_identity() {
let dir = temp_watch_dir("identity");
let watched = authorize_watched_dir(&dir).unwrap();
// Untouched directory verifies fine…
assert!(verify_watched_dir(&watched).is_ok());
// …and a directory that disappears after authorization is refused.
// The removal itself must succeed WHILE the handle is held: a watch
// that locked the user's folder against deletion (Windows sharing
// violation, OS error 32) would be a Windows-only behaviour.
fs::remove_dir_all(&dir).unwrap();
assert!(verify_watched_dir(&watched).is_err());
}
#[test]
fn a_watched_folder_can_be_renamed_by_the_user_while_watched() {
// Cross-platform contract: holding the pinned handle never blocks the
// user from moving the folder (Explorer/Finder/mv). The liveness
// check is what notices — it must refuse, not the OS.
let dir = temp_watch_dir("rename-while-watched");
let moved = dir.with_extension("moved");
let _ = fs::remove_dir_all(&moved);
let watched = authorize_watched_dir(&dir).unwrap();
fs::rename(&dir, &moved).unwrap();
assert!(verify_watched_dir(&watched).is_err());
// The pinned handle still points at the ORIGINAL directory object.
fs::write(moved.join("clip.mp4"), b"x").unwrap();
let names: Vec<String> = scan_dir(&watched.handle)
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert_eq!(names, ["clip.mp4"]);
drop(watched);
let _ = fs::remove_dir_all(&moved);
}
#[cfg(unix)]
#[test]
fn a_directory_swapped_for_a_symlink_is_refused_and_never_followed() {
let dir = temp_watch_dir("dir-swap");
let elsewhere = temp_watch_dir("dir-swap-target");
fs::write(elsewhere.join("clip.mp4"), b"outside").unwrap();
let (size, mtime) = snapshot(&elsewhere.join("clip.mp4"));
let watched = authorize_watched_dir(&dir).unwrap();
assert!(verify_watched_dir(&watched).is_ok());
// Replace the authorized directory itself with a symlink pointing
// somewhere else. Token resolution refuses (identity check)…
fs::remove_dir_all(&dir).unwrap();
std::os::unix::fs::symlink(&elsewhere, &dir).unwrap();
let err = verify_watched_dir(&watched).unwrap_err();
assert!(err.contains("identity"), "unexpected error: {err}");
// …and even the pinned handle cannot reach the swap target: it still
// points at the ORIGINAL (now unlinked) directory, which is empty.
assert!(scan_dir(&watched.handle).unwrap().is_empty());
assert!(open_watched_reader(&watched.handle, "clip.mp4", size, mtime).is_err());
let _ = fs::remove_file(&dir);
let _ = fs::remove_dir_all(&elsewhere);
}
#[cfg(unix)]
#[test]
fn a_recreated_directory_at_the_same_path_is_refused() {
let dir = temp_watch_dir("dir-recreate");
let watched = authorize_watched_dir(&dir).unwrap();
fs::remove_dir_all(&dir).unwrap();
fs::create_dir_all(&dir).unwrap(); // same path, different inode
assert!(verify_watched_dir(&watched).is_err());
let _ = fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
fn a_replaced_directory_at_the_same_windows_path_is_refused() {
// Same pathname, different directory object (volume serial + file
// index): the pathname check alone would pass, the identity must not.
let dir = temp_watch_dir("windows-dir-replace");
let moved = dir.with_extension("moved");
let _ = fs::remove_dir_all(&moved);
let watched = authorize_watched_dir(&dir).unwrap();
fs::rename(&dir, &moved).unwrap();
fs::create_dir_all(&dir).unwrap();
let err = verify_watched_dir(&watched).unwrap_err();
assert!(err.contains("identity"), "unexpected error: {err}");
drop(watched);
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_dir_all(&moved);
}
#[cfg(unix)]
#[test]
fn symlinks_are_never_followed_out_of_the_folder() {
let dir = temp_watch_dir("symlink");
let secret = std::env::temp_dir().join(format!("vs-secret-{}", std::process::id()));
fs::write(&secret, b"outside the folder").unwrap();
std::os::unix::fs::symlink(&secret, dir.join("evil.mp4")).unwrap();
let meta = fs::metadata(dir.join("evil.mp4")).unwrap();
let handle = open_handle(&dir);
// Even with a "correct" snapshot of the symlink target, opening it
// through the capability handle refuses: resolution may not escape
// the watched folder.
let err =
open_watched_reader(&handle, "evil.mp4", meta.len(), mtime_ms(&meta)).unwrap_err();
assert!(
err.contains("could not be opened"),
"unexpected error: {err}"
);
// And the scanner never lists it in the first place.
assert!(scan_dir(&handle).unwrap().is_empty());
let _ = fs::remove_dir_all(&dir);
let _ = fs::remove_file(&secret);
}
}
+25
View File
@@ -3,10 +3,35 @@
"app": {
"windows": [
{
"title": "VoiceStudio",
"width": 1920,
"height": 1080,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"fullscreen": false,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"maximized": true,
"dragDropEnabled": false,
"transparent": true,
"backgroundColor": "#1d2021"
},
{
"label": "widget",
"title": "Capture",
"width": 300,
"height": 64,
"resizable": false,
"fullscreen": false,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"visible": false,
"skipTaskbar": true,
"center": true,
"create": false
}
]
},
+253 -4
View File
@@ -222,10 +222,29 @@ fn scenario_child() {
} else if progress_only {
"HTTP/1.1 503 X\r\nContent-Length: 0\r\n\r\n".to_string()
} else if req.starts_with("GET /system/info") {
let body = format!(
r#"{{"data_dir": "/x", "app_version": "{}"}}"#,
env!("CARGO_PKG_VERSION")
);
// #1770: this scenario child is a genuine, current
// build of this binary — but launched via the
// OMNIVOICE_BACKEND_CMD fault-injection seam (or as
// a hand-spawned "external" process in the attach
// tests below), never through spawn_backend's normal
// path, so it never receives OMNIVOICE_BUILD_FINGERPRINT.
// That's exactly the "current schema, no env var"
// shape a legitimate external/manually-started
// backend has, so it serves `code_fingerprint: ""`
// by default — the one case OMNIVOICE_SCENARIO_NO_CODE_FINGERPRINT
// opts out of, to model a backend that predates the
// fingerprinting mechanism outright.
let body = if get("OMNIVOICE_SCENARIO_NO_CODE_FINGERPRINT") == "1" {
format!(
r#"{{"data_dir": "/x", "app_version": "{}"}}"#,
env!("CARGO_PKG_VERSION")
)
} else {
format!(
r#"{{"data_dir": "/x", "app_version": "{}", "code_fingerprint": ""}}"#,
env!("CARGO_PKG_VERSION")
)
};
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body)
} else if req.starts_with("GET /profiles")
&& std::env::var_os("OMNIVOICE_SCENARIO_HEALTH_FAIL_FILE")
@@ -279,6 +298,13 @@ struct Scenario<'a> {
serve_ms: Option<u64>,
progress_only: bool,
foreign: bool,
/// #1770: serve `/system/info` with NO `code_fingerprint` key at all —
/// the pre-fingerprinting shape a same-version backend from before this
/// fix has. Default (false) serves `code_fingerprint: ""`, modeling a
/// current-build backend that just wasn't launched with
/// `OMNIVOICE_BUILD_FINGERPRINT` set (every scenario child here, since
/// none go through spawn_backend's normal path).
no_code_fingerprint: bool,
}
impl Default for Scenario<'_> {
@@ -290,6 +316,7 @@ impl Default for Scenario<'_> {
serve_ms: None,
progress_only: false,
foreign: false,
no_code_fingerprint: false,
}
}
}
@@ -302,6 +329,7 @@ const SCENARIO_ENV: &[&str] = &[
"OMNIVOICE_SCENARIO_SERVE_MS",
"OMNIVOICE_SCENARIO_PROGRESS_ONLY",
"OMNIVOICE_SCENARIO_FOREIGN",
"OMNIVOICE_SCENARIO_NO_CODE_FINGERPRINT",
"OMNIVOICE_SCENARIO_START_DELAY_MS",
"OMNIVOICE_SCENARIO_SPAWN_LOG",
"OMNIVOICE_SCENARIO_DESCENDANT",
@@ -317,6 +345,8 @@ const SCENARIO_ENV: &[&str] = &[
"OMNIVOICE_TEST_BEFORE_TRACK_RELEASE",
"OMNIVOICE_TEST_AFTER_TRACK_ENTERED",
"OMNIVOICE_TEST_AFTER_TRACK_RELEASE",
"OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED",
"OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE",
"OMNIVOICE_BACKEND_CMD",
"OMNIVOICE_LOG_DIR",
"OMNIVOICE_PORT",
@@ -389,6 +419,9 @@ impl TestApp {
if scenario.foreign {
std::env::set_var("OMNIVOICE_SCENARIO_FOREIGN", "1");
}
if scenario.no_code_fingerprint {
std::env::set_var("OMNIVOICE_SCENARIO_NO_CODE_FINGERPRINT", "1");
}
let app = tauri::test::mock_builder()
.build(tauri::test::mock_context(tauri::test::noop_assets()))
@@ -661,6 +694,186 @@ fn healthy_external_backend_is_attached_with_supervision_and_replaced_after_deat
assert!(!app_lib::backend::port_in_use(port));
}
/// #1770 at the integration level: a same-version external backend whose
/// `/system/info` has NO `code_fingerprint` key at all — the shape a
/// backend from before this fix has, since a present field always
/// serializes even blank — must NOT be silently attached to. This is the
/// actual bug two independent reports traced to a stale `destination_path`
/// 422: an already-running same-version backend running weeks-old code was
/// attached to instead of refused.
///
/// The outcome is a REFUSAL, not a kill-and-replace: this backend was never
/// tracked or attached (it's a plain external process on the port), and
/// `kill_orphan_on_port` deliberately never signals a PID discovered only
/// through the port — the exact "reuse race" guard
/// `orphan_cleanup_refuses_a_foreign_listener` covers for the pre-existing
/// version-mismatch case. So a stale-fingerprint match takes the SAME path
/// a stale-version match already does: `stop_backend_locked` cannot free
/// the port, and the launch fails with a diagnosis rather than adopting
/// stale code OR terminating an unowned process.
#[test]
fn stale_code_fingerprint_external_backend_is_refused_not_attached() {
let t = TestApp::new(&Scenario {
serve_ms: Some(0),
no_code_fingerprint: true,
..Default::default()
});
let spawn_log = t._logdir.path().join("scenario-stale-fingerprint-spawns.log");
std::env::set_var("OMNIVOICE_SCENARIO_SPAWN_LOG", &spawn_log);
let exe = std::env::current_exe().expect("current test executable");
let mut external = std::process::Command::new(exe)
.args(["scenario_child", "--exact", "--nocapture"])
.spawn()
.expect("spawn external stale-fingerprint backend");
let external_pid = external.id();
let port = std::env::var("OMNIVOICE_PORT").unwrap().parse().unwrap();
assert!(
wait_until(Duration::from_secs(10), || {
app_lib::backend::backend_ready(port) && recorded_spawn_count(&spawn_log) == 1
}),
"external stale-fingerprint backend never became healthy"
);
let bootstrap = t.run_bootstrap();
assert!(
wait_until(Duration::from_secs(10), || {
matches!(t.stage_snapshot(), BootstrapStage::Failed { .. })
}),
"a same-version backend with no code_fingerprint field must be refused (the same outcome \
a version mismatch already gets), not silently attached got {:?} / {:?}",
t.stage_snapshot(),
t.failed_message()
);
assert!(
!t.app.state::<BackendState>().attached.load(std::sync::atomic::Ordering::SeqCst),
"a refused attach must never mark the backend attached"
);
assert_eq!(
recorded_spawn_count(&spawn_log),
1,
"an unowned, untracked process must never be killed by PID — no replacement child may spawn"
);
assert!(
process_is_alive(external_pid),
"the stale-fingerprint external backend must be left running untouched, not killed"
);
join_with_timeout(bootstrap, Duration::from_secs(10), "stale-fingerprint refusal");
external.kill().expect("kill external stale-fingerprint backend");
let _ = external.wait();
}
/// Greptile P1 on #1796: `backend_deep_healthy` (GET /profiles) and
/// `running_backend_identity` (GET /system/info) are two independent
/// requests — never atomic. If the process on the port changed between
/// them, an identity-THEN-health ordering could pair one process's
/// (already stale) identity with a DIFFERENT process's health and attach
/// without ever validating that second process — exactly what this
/// fingerprint check exists to prevent, reached by a different route.
/// `prepare_backend_launch` closes most of that window by probing health
/// FIRST and identity LAST, immediately before the attach decision, so the
/// identity that governs the decision is always the freshest read of
/// whatever currently answers the port.
///
/// A genuine multi-process bind-swap race is non-deterministic to trigger
/// on demand (the second process must win the port back inside a
/// microsecond gap), so this models the same OBSERVABLE property with a
/// single deterministic stub: it serves a MATCHING identity right up until
/// it answers the health probe, then flips to a body with no
/// `code_fingerprint` key at all — "the port changed hands" from the
/// launcher's point of view is indistinguishable from "the same process
/// changed what it reports". With identity probed last, the launcher must
/// read the post-flip (stale) state and refuse. Had identity still been
/// probed FIRST (the pre-fix ordering), it would have captured the
/// pre-flip (matching) identity and attached despite the divergence.
#[test]
fn identity_probed_after_health_reflects_the_port_at_decision_time() {
let t = TestApp::new(&Scenario {
serve_ms: Some(0),
..Default::default()
});
let port: u16 = std::env::var("OMNIVOICE_PORT").unwrap().parse().unwrap();
let flipped = Arc::new(AtomicBool::new(false));
let stop = Arc::new(AtomicBool::new(false));
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("bind identity-flip stub");
listener.set_nonblocking(true).unwrap();
let stub = {
let flipped = flipped.clone();
let stop = stop.clone();
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if stop.load(std::sync::atomic::Ordering::SeqCst) || Instant::now() >= deadline {
return;
}
match listener.accept() {
Ok((mut stream, _)) => {
// Whether an accepted socket inherits the listening
// socket's non-blocking flag is OS-dependent (Linux,
// macOS, and Windows disagree) — this job runs on all
// three, so leave nothing to inheritance. Force
// blocking mode explicitly; the read timeout below
// then bounds it deterministically everywhere.
stream.set_nonblocking(false).expect("accepted stream to blocking mode");
let mut buf = [0u8; 512];
let _ = stream.set_read_timeout(Some(Duration::from_millis(500)));
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let resp = if req.starts_with("GET /profiles") {
// Answering the health probe is the trigger:
// flip identity for whatever comes next,
// modeling the port changing hands right after
// this response.
flipped.store(true, std::sync::atomic::Ordering::SeqCst);
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n[]".to_string()
} else if req.starts_with("GET /system/info") {
let body = if flipped.load(std::sync::atomic::Ordering::SeqCst) {
// Post-flip: same version, but no
// code_fingerprint key at all — the
// pre-fingerprinting shape, as if a
// different (stale) process now answers.
format!(
r#"{{"data_dir": "/x", "app_version": "{}"}}"#,
env!("CARGO_PKG_VERSION")
)
} else {
format!(
r#"{{"data_dir": "/x", "app_version": "{}", "code_fingerprint": ""}}"#,
env!("CARGO_PKG_VERSION")
)
};
format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.len(), body)
} else {
"HTTP/1.1 404 X\r\nContent-Length: 0\r\n\r\n".to_string()
};
let _ = stream.write_all(resp.as_bytes());
}
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
}
})
};
let bootstrap = t.run_bootstrap();
assert!(
wait_until(Duration::from_secs(10), || {
flipped.load(std::sync::atomic::Ordering::SeqCst)
&& matches!(t.stage_snapshot(), BootstrapStage::Failed { .. })
}),
"identity read AFTER the health probe must reflect the post-flip (stale) state and \
refuse the attach got stage {:?}",
t.stage_snapshot()
);
assert!(
!t.app.state::<BackendState>().attached.load(std::sync::atomic::Ordering::SeqCst),
"a post-flip identity mismatch must never be attached to"
);
stop.store(true, std::sync::atomic::Ordering::SeqCst);
join_with_timeout(bootstrap, Duration::from_secs(10), "identity-after-health refusal");
let _ = stub.join();
}
#[test]
fn attached_backend_health_grace_recovers_without_false_crash_or_port_failure() {
let t = TestApp::new(&Scenario {
@@ -1549,3 +1762,39 @@ fn deferred_startup_failure_names_the_step() {
logs.iter().map(|l| &l.line).collect::<Vec<_>>()
);
}
#[test]
fn retry_preempts_launch_before_the_readiness_wait_starts() {
let t = TestApp::new(&Scenario { serve_ms: Some(0), ..Default::default() });
std::env::set_var("OMNIVOICE_SCENARIO_PROGRESS_ONLY", "1");
let entered = t._logdir.path().join("launch-locked");
let release = t._logdir.path().join("release-launch");
std::env::set_var("OMNIVOICE_TEST_LAUNCH_LOCKED_ENTERED", &entered);
std::env::set_var("OMNIVOICE_TEST_LAUNCH_LOCKED_RELEASE", &release);
let bootstrap = t.run_bootstrap();
let entered_launch = wait_until(Duration::from_secs(5), || entered.exists());
if !entered_launch {
// Release and join before asserting: a timeout must not detach a
// bootstrap thread while it still owns the lifecycle mutex.
let released = std::fs::write(&release, b"release");
t.quit();
t.kill_tracked_child();
join_with_timeout(bootstrap, Duration::from_secs(10), "launch gate timeout");
released.expect("release launch gate after timeout");
panic!("bootstrap did not enter the launch gate");
}
app_lib::bootstrap::preempt_backend_wait();
let handle = t.handle();
let retry = std::thread::spawn(move || {
let state = handle.state::<BackendState>();
let _ownership = state.lifecycle.lock().unwrap();
});
std::fs::write(&release, b"release").unwrap();
let acquired = wait_until(Duration::from_secs(3), || retry.is_finished());
t.quit();
t.kill_tracked_child();
join_with_timeout(bootstrap, Duration::from_secs(10), "preempted launch");
join_with_timeout(retry, Duration::from_secs(10), "retry ownership");
assert!(acquired, "old launch swallowed Retry's generation and held lifecycle");
}
+2
View File
@@ -177,7 +177,9 @@
<File Id="UpdateTaskUninstaller" Source="uninstall-task.ps1" KeyPath="yes" Checksum="yes"/>
</Component>
{{/if}}
<!-- BEGIN BUNDLED_RESOURCES -->
{{resources}}
<!-- END BUNDLED_RESOURCES -->
<Component Id="CMP_UninstallShortcut" Guid="*">
<Shortcut Id="UninstallShortcut"
+13 -14
View File
@@ -1,3 +1,4 @@
import { firstSoundRequest } from './utils/firstSound';
import React, {
useState,
useRef,
@@ -107,7 +108,7 @@ import {
setHistoryStarred as apiSetHistoryStarred,
audioUrlWithCacheBust,
} from './api/generate';
import { clearDubHistory as apiClearDubHistory } from './api/dub';
import { clearDubHistory as apiClearDubHistory, dubBurnQuery } from './api/dub';
import { isTauri, doubleClickMaximize, fileToMediaUrl, playBlobAudio } from './utils/media';
import { browserDownload } from './utils/download';
@@ -300,7 +301,7 @@ function App() {
mode === 'settings' ||
mode === 'voice' ||
mode === 'donate' ||
mode === 'queue' ||
mode === 'batch' ||
mode === 'tools' ||
mode === 'projects' ||
mode === 'gallery' ||
@@ -453,6 +454,7 @@ function App() {
// MIC RECORDING
const {
isRecording,
isStartingRecording,
isCleaning,
recordingTime,
audioInputs,
@@ -510,6 +512,7 @@ function App() {
const setGlossaryTerms = useAppStore((s) => s.setGlossaryTerms);
const dualSubs = useAppStore((s) => s.dualSubs);
const burnSubs = useAppStore((s) => s.burnSubs);
const karaokeSubs = useAppStore((s) => s.karaokeSubs);
// UNDO / REDO + SEGMENT EDITING
// Must come before useDubWorkflow because the dub generate handler needs
@@ -697,16 +700,10 @@ function App() {
if (!pending) return;
(async () => {
try {
const fd = new FormData();
fd.append('text', i18n.t('firstrun.first_sound_text'));
// Functional model prompt (not user-facing copy) keeps the demo
// voice warm without depending on seeded profiles.
fd.append('instruct', 'A warm, friendly narrator voice, medium pace');
fd.append('num_step', '16');
const res = await apiFetch(`${API}/generate`, {
method: 'POST',
body: fd,
});
const res = await apiFetch(
`${API}/generate`,
firstSoundRequest(i18n.t('firstrun.first_sound_text')),
);
const blob = await res.blob();
await playBlobAudio(blob, { label: i18n.t('player.generated_audio') });
toast.success(i18n.t('firstrun.first_sound_done'), { duration: 7000 });
@@ -943,7 +940,7 @@ function App() {
if (exportTracks[t] !== false) selected.push(t);
});
const tracksParam = selected.join(',');
const burnParam = burnSubs ? `&burn_subs=1&dual=${dualSubs ? 1 : 0}` : '';
const burnParam = dubBurnQuery(burnSubs, dualSubs, karaokeSubs);
const resolvedDefaultTrack = resolveDubDefaultTrack(defaultTrack, dubLangCode, dubTracks);
triggerDownload(
`${API}/dub/download/${dubJobId}/dubbed_video.mp4?preserve_bg=${preserveBg}&default_track=${resolvedDefaultTrack}&include_tracks=${encodeURIComponent(tracksParam)}${burnParam}`,
@@ -1473,7 +1470,7 @@ function App() {
/>
</Suspense>
</ErrorBoundary>
) : mode === 'queue' ? (
) : mode === 'batch' ? (
<ErrorBoundary name="batch-queue">
<Suspense fallback={<LazyFallback />}>
<BatchQueue onBack={() => setMode('launchpad')} />
@@ -1685,6 +1682,7 @@ function App() {
handlePreviewVoice={handlePreviewVoice}
handleUnlockProfile={handleUnlockProfile}
openVoiceProfile={openVoiceProfile}
selectionDisabled={isStartingRecording || isRecording}
onOpenVoicePreview={(profileId) => {
setVoicePreviewProfileId(profileId || '');
setIsVoicePreviewOpen(true);
@@ -1737,6 +1735,7 @@ function App() {
showSaveProfile={showSaveProfile}
setShowSaveProfile={setShowSaveProfile}
isRecording={isRecording}
isStartingRecording={isStartingRecording}
isCleaning={isCleaning}
recordingTime={recordingTime}
audioInputs={audioInputs}
@@ -81,13 +81,14 @@ describe('administrator credential hygiene static guard', () => {
expect(violations, 'WebSocket URLs may contain ws_ticket, never a master key').toEqual([]);
});
it('keeps both WebSocket consumers behind the authenticated URL boundary', () => {
it('keeps every WebSocket consumer behind the authenticated URL boundary', () => {
const constructors = sources()
.filter(({ source }) => source.includes('new WebSocket('))
.map(({ file, source }) => ({ file, authenticated: source.includes('authenticatedWsUrl') }));
expect(constructors).toEqual([
{ file: 'components/CaptureWidget.jsx', authenticated: true },
{ file: 'hooks/useDubLivePreview.js', authenticated: true },
{ file: 'hooks/useRealtimeEvents.js', authenticated: true },
]);
});
+10 -1
View File
@@ -379,7 +379,10 @@ export async function revokeAdminSession(
}
}
const ALLOWED_WS_PATHS = new Set(['/ws/events', '/ws/transcribe']);
// Mirrors the backend ticket allowlist (`_ALLOWED_WS_PATHS` in
// backend/services/admin_sessions.py). A path listed here but not there mints
// a 422, and the consumer fails silently — keep the two in lockstep.
const ALLOWED_WS_PATHS = new Set(['/ws/events', '/ws/transcribe', '/ws/tts']);
const LOGICAL_WS_ORIGIN = 'http://omnivoice.invalid';
function websocketTarget(path: string, apiBase: string): { url: URL; logicalPath: string } {
@@ -426,6 +429,12 @@ export async function requestWebSocketTicket(
const { logicalPath } = websocketTarget(path, base);
const session = getAdminSession(base, { storage, now });
if (!session) throw new AuthSessionError(401);
// Deliberately no plaintext (`ws:`) refusal: the documented remote-GPU setup
// is plain HTTP over a Tailscale/WireGuard tailnet (docs/remote-gpu.md), and
// the bearer session that mints this ticket already crossed that same
// transport. A one-use, 30 s, path-bound ticket adds no exposure the session
// lacks; refusing it would only cut /ws/events and /ws/transcribe off for
// every remote-backend user.
let response: Response;
const controller = new AbortController();
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ADMIN_SESSION_STORAGE_KEY, AuthSessionError, authenticatedWsUrl } from './authSession';
const SESSION = `ovs_admin_session_${'A'.repeat(43)}`;
const TICKET = `ovs_ws_ticket_${'B'.repeat(43)}`;
const NOW_SECONDS = 1_800_000_000;
const response = () =>
new Response(JSON.stringify({ ticket: TICKET, expires_at: NOW_SECONDS + 30 }), {
status: 201,
headers: { 'content-type': 'application/json' },
});
const storeSession = (apiBase: string) => {
sessionStorage.setItem(
ADMIN_SESSION_STORAGE_KEY,
JSON.stringify({ token: SESSION, expiresAt: NOW_SECONDS + 3600, apiBase }),
);
};
describe('ticketed WebSocket transport', () => {
beforeEach(() => sessionStorage.clear());
it('mints a path-bound /ws/tts ticket for the live dub preview (#1769)', async () => {
const apiBase = 'https://gpu.test:3900';
storeSession(apiBase);
const fetchImpl = vi.fn().mockResolvedValue(response());
await expect(
authenticatedWsUrl('/ws/tts', { apiBase, fetchImpl, now: () => NOW_SECONDS * 1000 }),
).resolves.toBe(`wss://gpu.test:3900/ws/tts?ws_ticket=${TICKET}`);
expect(JSON.parse(fetchImpl.mock.calls[0][1].body)).toEqual({ path: '/ws/tts' });
});
it.each([
'http://gpu-box.your-tailnet.ts.net:3900', // docs/remote-gpu.md tailnet flow
'http://192.168.1.20:3900', // LAN Docker host
'http://127.0.0.2:3900',
])('keeps ticketing the plaintext non-loopback bases the docs support: %s', async (apiBase) => {
// The bearer session that mints the ticket already crossed this same
// transport; refusing plaintext here would only cut /ws/events and
// /ws/transcribe off for every documented remote-backend user.
storeSession(apiBase);
const fetchImpl = vi.fn().mockResolvedValue(response());
await expect(
authenticatedWsUrl('/ws/tts', { apiBase, fetchImpl, now: () => NOW_SECONDS * 1000 }),
).resolves.toBe(`${apiBase.replace('http:', 'ws:')}/ws/tts?ws_ticket=${TICKET}`);
});
it('returns a credential-free URL when no admin session exists (loopback desktop)', async () => {
const fetchImpl = vi.fn();
await expect(
authenticatedWsUrl('/ws/tts', { apiBase: 'http://127.0.0.1:3900', fetchImpl }),
).resolves.toBe('ws://127.0.0.1:3900/ws/tts');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('refuses paths outside the ticket allowlist before any network call', async () => {
const apiBase = 'https://gpu.test:3900';
storeSession(apiBase);
const fetchImpl = vi.fn().mockResolvedValue(response());
await expect(
authenticatedWsUrl('/ws/anything', { apiBase, fetchImpl, now: () => NOW_SECONDS * 1000 }),
).rejects.toBeInstanceOf(AuthSessionError);
expect(fetchImpl).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More