From f832d616f7eb5ee8028b5c8924249a552638634a Mon Sep 17 00:00:00 2001 From: Palash Debnath <4178343+debpalash@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:10:11 -0700 Subject: [PATCH] feat(electron): add full VoiceStudio desktop app --- .github/workflows/ci.yml | 28 + .github/workflows/release.yml | 133 +- .gitignore | 3 + CHANGELOG.md | 2 + backend/api/routers/audiobook.py | 7 +- backend/api/routers/batch.py | 609 ++- backend/api/routers/capture.py | 88 +- backend/api/routers/dictation.py | 15 +- backend/api/routers/dub_core.py | 222 +- backend/api/routers/dub_export.py | 72 +- backend/api/routers/dub_generate.py | 376 +- backend/api/routers/dub_translate.py | 402 +- backend/api/routers/engines.py | 233 +- backend/api/routers/generation.py | 11 +- backend/api/routers/profile_images.py | 182 + backend/api/routers/profiles.py | 116 +- backend/api/routers/settings.py | 60 +- backend/api/routers/setup/download.py | 225 +- backend/api/routers/setup/models.py | 146 +- backend/api/routers/system.py | 182 +- backend/api/routers/voice_convert.py | 8 +- backend/api/routers/workers.py | 12 + backend/api/schemas.py | 9 +- backend/config/models.yaml | 37 +- backend/core/csrf.py | 6 +- backend/core/describe_voice.py | 17 + backend/core/engine_licenses.py | 3 + backend/core/error_docs_map.py | 4 +- backend/core/failure.py | 19 +- backend/core/logging_filter.py | 56 + backend/core/parent_liveness.py | 21 +- backend/core/prefs.py | 11 + backend/core/profile_images.py | 27 + backend/core/run_sentinel.py | 32 + backend/engines/_asr_sidecar/main.py | 13 +- backend/engines/audiocpp/bootstrap.py | 55 +- backend/engines/omnivoice_subprocess/main.py | 38 +- backend/main.py | 106 +- backend/schemas/requests.py | 28 +- backend/services/asr_backend.py | 261 +- backend/services/audio_io.py | 11 +- backend/services/audiocpp_runtime_install.py | 197 + backend/services/diarization_local.py | 37 + backend/services/diarization_native.py | 163 + backend/services/diarization_runtime.py | 118 + backend/services/dub_batching.py | 56 + backend/services/dub_pipeline.py | 46 +- backend/services/gpu_gateway.py | 244 +- backend/services/hf_revisions.py | 2 + backend/services/model_lifecycle.py | 113 +- backend/services/model_manager.py | 97 +- backend/services/performance_profiles.py | 463 +++ backend/services/segment_bundle.py | 78 + backend/services/sherpa_dictation.py | 8 +- backend/services/speech_rate.py | 117 + backend/services/subprocess_asr.py | 10 + backend/services/subprocess_backend.py | 60 +- backend/services/translation_engines.py | 140 +- backend/services/tts_backend.py | 78 +- backend/services/watermark.py | 69 +- backend/tests/test_dub_caption_languages.py | 39 + backend/tests/test_omnivoice_subprocess.py | 53 + backend/tests/test_run_sentinel.py | 37 +- backend/worker/capabilities.py | 87 +- backend/worker/deadlines.py | 2 + backend/worker/errors.py | 23 +- backend/worker/executor.py | 158 +- backend/worker/lifecycle.py | 21 +- backend/worker/protocol/gen/worker_v1_pb2.py | 52 +- backend/worker/protocol/gen/worker_v1_pb2.pyi | 14 +- backend/worker/protocol/worker_v1.proto | 9 + backend/worker/routing.py | 24 +- backend/worker/transport/client.py | 118 +- backend/worker/transport/server.py | 72 +- bun.lock | 1461 ++++++- docs/branding.md | 9 +- docs/electron-batch.md | 27 + docs/electron-connections.md | 50 + docs/electron-dubbing.md | 201 + docs/electron-gallery.md | 30 + docs/electron-launchpad.md | 5 + docs/electron-llm-providers.md | 26 + docs/electron-logs.md | 17 + docs/electron-longform.md | 31 + docs/electron-parity.md | 67 + docs/electron-performance.md | 33 + docs/electron-playback.md | 33 + docs/electron-privacy.md | 11 + docs/electron-projects.md | 26 + docs/electron-repair.md | 113 + docs/electron-runtime.md | 75 + docs/electron-storage.md | 17 + docs/electron-tools.md | 31 + docs/electron-transcriptions.md | 127 + docs/electron-voice-design.md | 15 + docs/engines/audio-cpp.md | 38 +- docs/features/diarization.md | 58 +- docs/install/uninstall.md | 4 + docs/performance.md | 13 +- docs/profile-portraits.md | 95 + docs/specs/voice-studio-unification.md | 2 +- docs/update-channels.md | 33 +- electron/.gitignore | 13 + electron/.oxfmtrc.json | 6 + electron/.oxlintrc.json | 10 + electron/CONTRACT.md | 87 + electron/PARITY.md | 1114 ++++++ electron/README.md | 208 + electron/T3CODE-LICENSE.txt | 21 + electron/bun.lock | 2011 ++++++++++ electron/components.json | 25 + electron/electron-builder.config.mjs | 114 + electron/electron.vite.config.ts | 114 + electron/native-helper-build.mjs | 44 + electron/package.json | 98 + electron/src/main/backend-download.test.ts | 27 + electron/src/main/backend-download.ts | 17 + electron/src/main/backend-setup.test.ts | 366 ++ electron/src/main/backend.ts | 1122 ++++++ electron/src/main/blank-window-guard.test.ts | 115 + electron/src/main/blank-window-guard.ts | 160 + electron/src/main/capture-session.test.ts | 237 ++ electron/src/main/capture-session.ts | 266 ++ electron/src/main/crash-journal.test.ts | 45 + electron/src/main/crash-journal.ts | 77 + electron/src/main/data-relocation.test.ts | 105 + electron/src/main/data-relocation.ts | 288 ++ electron/src/main/dev-backend-proxy.test.ts | 79 + electron/src/main/dev-backend-proxy.ts | 102 + electron/src/main/devtools-policy.test.ts | 10 + electron/src/main/devtools-policy.ts | 5 + electron/src/main/dictation-output.test.ts | 121 + electron/src/main/dictation-output.ts | 173 + electron/src/main/env.d.ts | 2 + electron/src/main/fixtures/LICENSE | 21 + electron/src/main/fixtures/README.md | 4 + electron/src/main/fixtures/sample.doc | Bin 0 -> 22528 bytes electron/src/main/fixtures/sample.docx | Bin 0 -> 13622 bytes electron/src/main/index.ts | 337 ++ electron/src/main/ipc.ts | 626 +++ electron/src/main/main-error-journal.test.ts | 90 + electron/src/main/main-error-journal.ts | 125 + electron/src/main/media-authorization.test.ts | 54 + electron/src/main/media-authorization.ts | 59 + electron/src/main/media-permissions.test.ts | 62 + electron/src/main/media-permissions.ts | 51 + .../models-directory-authorization.test.ts | 43 + electron/src/main/native-capture.ts | 302 ++ electron/src/main/protocol.ts | 190 + electron/src/main/remote-backend.test.ts | 115 + electron/src/main/remote-backend.ts | 249 ++ electron/src/main/repair-agents.test.ts | 227 ++ electron/src/main/repair-agents.ts | 916 +++++ electron/src/main/repair-api-bridge.test.ts | 266 ++ electron/src/main/repair-api-bridge.ts | 338 ++ electron/src/main/repair-mcp-server.ts | 145 + electron/src/main/reset-data.test.ts | 85 + electron/src/main/reset-data.ts | 238 ++ electron/src/main/runtime-project.test.ts | 320 ++ electron/src/main/runtime-project.ts | 429 +++ electron/src/main/save-filters.test.ts | 12 + electron/src/main/save-filters.ts | 10 + electron/src/main/script-import.test.ts | 21 + electron/src/main/script-import.ts | 62 + electron/src/main/setup-progress.test.ts | 73 + electron/src/main/setup-progress.ts | 149 + electron/src/main/shortcut-settings.test.ts | 51 + electron/src/main/shortcut-settings.ts | 82 + electron/src/main/trusted-renderer.ts | 18 + electron/src/main/uninstall-cleanup.ts | 35 + electron/src/main/uninstall-data.test.ts | 69 + electron/src/main/uninstall-data.ts | 114 + electron/src/main/updater.test.ts | 361 ++ electron/src/main/updater.ts | 443 +++ electron/src/main/watch-folders.test.ts | 112 + electron/src/main/watch-folders.ts | 142 + electron/src/main/window-safety.test.ts | 61 + electron/src/main/window-safety.ts | 52 + electron/src/preload/index.d.ts | 402 ++ electron/src/preload/index.ts | 139 + electron/src/renderer/env.d.ts | 4 + electron/src/renderer/index.html | 18 + .../renderer/public/early-error-capture.js | 33 + electron/src/renderer/src/app.tsx | 130 + .../src/components/agent-fix-button.tsx | 21 + .../src/components/analytics-consent.test.tsx | 62 + .../src/components/analytics-consent.tsx | 203 + .../src/components/app-shell/app-shell.tsx | 44 + .../src/components/app-shell/nav-rail.tsx | 29 + .../app-shell/repair-agent-dock.tsx | 479 +++ .../components/app-shell/status-bar.test.ts | 118 + .../src/components/app-shell/status-bar.tsx | 815 ++++ .../components/app-shell/status-runtime.ts | 64 + .../app-shell/system-notifications.test.tsx | 65 + .../app-shell/system-notifications.tsx | 279 ++ .../src/components/app-shell/theme-toggle.tsx | 30 + .../src/components/app-shell/top-bar.tsx | 37 + .../components/app-shell/workspace-header.tsx | 31 + .../components/app-shell/workspace-menu.tsx | 215 ++ .../app-shell/workspace-sidebar.tsx | 184 + .../src/components/audio-preview-button.tsx | 147 + .../src/components/backend-gate.test.tsx | 98 + .../renderer/src/components/backend-gate.tsx | 524 +++ .../src/renderer/src/components/bridge.ts | 16 + .../src/components/command-palette.tsx | 381 ++ .../src/components/compute-target-choices.tsx | 99 + .../src/components/compute-vendor-icon.tsx | 61 + .../src/components/crash-details.test.tsx | 53 + .../renderer/src/components/crash-details.tsx | 43 + .../src/components/dictation-demo.test.tsx | 54 + .../src/components/dictation-demo.tsx | 120 + .../src/components/engine-notice.test.tsx | 211 + .../renderer/src/components/engine-notice.tsx | 212 + .../src/components/error-boundary.test.tsx | 90 + .../src/components/error-boundary.tsx | 129 + .../renderer/src/components/external-link.tsx | 34 + .../src/components/first-sound-handoff.tsx | 63 + .../src/components/keyboard-cheatsheet.tsx | 91 + .../renderer/src/components/markdown-lite.tsx | 66 + .../renderer/src/components/media-player.tsx | 182 + .../src/components/performance-profile.tsx | 369 ++ .../src/components/pipeline-failure.test.tsx | 41 + .../src/components/pipeline-failure.tsx | 104 + .../src/renderer/src/components/popover.tsx | 38 + .../src/components/profile-avatar.test.tsx | 17 + .../src/components/profile-avatar.tsx | 46 + .../src/components/recording-inputs.tsx | 98 + .../src/components/report-bug.test.tsx | 294 ++ .../renderer/src/components/report-bug.tsx | 252 ++ .../src/components/save-audio-button.tsx | 60 + .../src/components/setup-gate.test.tsx | 122 + .../renderer/src/components/setup-gate.tsx | 226 ++ .../src/components/setup-recovery.test.tsx | 51 + .../src/components/setup-recovery.tsx | 42 + .../src/renderer/src/components/ui/alert.tsx | 68 + .../src/renderer/src/components/ui/badge.tsx | 48 + .../src/renderer/src/components/ui/button.tsx | 57 + .../src/renderer/src/components/ui/card.tsx | 99 + .../src/components/ui/collapsible.tsx | 21 + .../src/renderer/src/components/ui/dialog.tsx | 156 + .../src/renderer/src/components/ui/input.tsx | 19 + .../src/renderer/src/components/ui/kbd.tsx | 26 + .../src/renderer/src/components/ui/label.tsx | 19 + .../renderer/src/components/ui/progress.tsx | 63 + .../src/components/ui/scroll-area.tsx | 47 + .../src/renderer/src/components/ui/select.tsx | 205 + .../renderer/src/components/ui/separator.tsx | 20 + .../renderer/src/components/ui/skeleton.tsx | 13 + .../src/renderer/src/components/ui/slider.tsx | 51 + .../src/renderer/src/components/ui/sonner.tsx | 45 + .../renderer/src/components/ui/spinner.tsx | 22 + .../src/renderer/src/components/ui/switch.tsx | 31 + .../src/renderer/src/components/ui/tabs.tsx | 81 + .../renderer/src/components/ui/textarea.tsx | 17 + .../src/components/ui/toggle-group.tsx | 89 + .../src/renderer/src/components/ui/toggle.tsx | 44 + .../renderer/src/components/ui/tooltip.tsx | 70 + .../renderer/src/components/video-player.tsx | 262 ++ .../src/components/waveform-player.tsx | 220 ++ .../src/components/workspace-pane.tsx | 85 + .../src/components/workspace-sidebar.tsx | 146 + .../src/features/batch/batch-page.tsx | 633 +++ .../src/features/batch/enqueue.test.ts | 17 + .../renderer/src/features/batch/enqueue.ts | 30 + .../src/features/batch/watch-folder.test.tsx | 127 + .../src/features/batch/watch-folder.tsx | 204 + .../src/features/clone/action-bar.test.tsx | 160 + .../src/features/clone/action-bar.tsx | 451 +++ .../src/features/clone/clone-demo.tsx | 74 + .../src/features/clone/clone-page.tsx | 335 ++ .../src/features/clone/confirm-dialog.tsx | 61 + .../src/features/clone/edit-profile.tsx | 233 ++ .../src/features/clone/format.test.ts | 17 + .../src/renderer/src/features/clone/format.ts | 46 + .../features/clone/language-picker.test.tsx | 25 + .../src/features/clone/language-picker.tsx | 274 ++ .../src/features/clone/lock-voice.test.tsx | 48 + .../src/features/clone/lock-voice.tsx | 73 + .../src/features/clone/output-panel.tsx | 75 + .../features/clone/persona-export.test.tsx | 53 + .../src/features/clone/persona-export.tsx | 113 + .../src/features/clone/portrait-search.tsx | 88 + .../features/clone/profile-consent.test.tsx | 95 + .../src/features/clone/profile-consent.tsx | 170 + .../clone/profile-image-editor.test.tsx | 57 + .../features/clone/profile-image-editor.tsx | 64 + .../features/clone/profile-preview.test.tsx | 83 + .../src/features/clone/profile-preview.tsx | 156 + .../src/features/clone/profile-save.test.tsx | 69 + .../src/features/clone/profile-usage.test.tsx | 68 + .../src/features/clone/profile-usage.tsx | 121 + .../src/features/clone/reference-panel.tsx | 599 +++ .../src/features/clone/save-take-profile.tsx | 71 + .../features/clone/saved-voices-page.test.tsx | 35 + .../src/features/clone/saved-voices-page.tsx | 66 + .../src/features/clone/script-panel.test.tsx | 47 + .../src/features/clone/script-panel.tsx | 368 ++ .../src/features/clone/section-label.tsx | 15 + .../src/features/clone/take-details.test.tsx | 49 + .../src/features/clone/take-details.tsx | 186 + .../clone/voice-setup-virtual.test.ts | 7 + .../src/features/clone/voice-setup.tsx | 133 + .../clone/voices-sidebar-virtual.test.ts | 8 + .../src/features/clone/voices-sidebar.tsx | 724 ++++ .../src/features/design/demo-presets.tsx | 74 + .../src/features/design/design-draft.test.ts | 48 + .../src/features/design/design-draft.ts | 80 + .../src/features/design/design-page.tsx | 630 +++ .../design/personality-presets.test.tsx | 42 + .../features/design/personality-presets.tsx | 90 + .../features/design/use-description.test.tsx | 57 + .../src/features/design/use-description.ts | 82 + .../src/features/dub/casting-board.tsx | 258 ++ .../src/features/dub/checkpoint-banner.tsx | 93 + .../src/features/dub/dub-draft.test.ts | 183 + .../renderer/src/features/dub/dub-draft.ts | 216 ++ .../src/features/dub/dub-export-panel.tsx | 218 ++ .../src/features/dub/dub-export.test.ts | 49 + .../renderer/src/features/dub/dub-export.ts | 71 + .../renderer/src/features/dub/dub-page.tsx | 2837 ++++++++++++++ .../src/features/dub/dub-session.test.ts | 616 +++ .../renderer/src/features/dub/dub-session.ts | 1829 +++++++++ .../src/features/dub/dub-timeline.tsx | 434 +++ .../src/features/dub/dubbing-demo.tsx | 171 + .../src/features/dub/glossary-panel.tsx | 321 ++ .../src/features/dub/paste-translation.tsx | 211 + .../features/dub/translation-error.test.ts | 27 + .../src/features/dub/translation-error.ts | 14 + .../dub/use-dub-live-preview.test.tsx | 109 + .../src/features/dub/use-dub-live-preview.ts | 205 + .../src/features/dub/use-dub-onsets.ts | 49 + .../features/gallery/community-view.test.tsx | 85 + .../src/features/gallery/community-view.tsx | 264 ++ .../src/features/gallery/favorites.test.ts | 19 + .../src/features/gallery/favorites.ts | 28 + .../src/features/gallery/gallery-page.tsx | 398 ++ .../src/features/gallery/imports-api.test.ts | 33 + .../src/features/gallery/imports-api.ts | 56 + .../features/gallery/imports-view.test.tsx | 62 + .../src/features/gallery/imports-view.tsx | 383 ++ .../src/features/gallery/place-voice.test.ts | 46 + .../src/features/gallery/place-voice.ts | 14 + .../src/features/gallery/trim-audio.test.ts | 27 + .../src/features/gallery/trim-audio.tsx | 313 ++ .../features/gallery/voice-destinations.tsx | 46 + .../renderer/src/features/home/home-page.tsx | 413 ++ .../longform/audiobook-markup-toolbar.tsx | 143 + .../src/features/longform/auto-cast.test.ts | 16 + .../features/longform/book-options.test.ts | 24 + .../src/features/longform/book-options.ts | 39 + .../src/features/longform/book-settings.tsx | 212 + .../src/features/longform/cast-map.ts | 3 + .../src/features/longform/cast-settings.tsx | 70 + .../features/longform/chapter-previews.tsx | 140 + .../features/longform/generation-progress.tsx | 116 + .../src/features/longform/longform-page.tsx | 514 +++ .../longform/longform-session.test.ts | 164 + .../src/features/longform/longform-session.ts | 362 ++ .../features/longform/production-settings.tsx | 133 + .../features/longform/project-library.test.ts | 87 + .../src/features/longform/project-library.ts | 93 + .../features/longform/project-settings.tsx | 197 + .../src/features/longform/story-editor.tsx | 654 ++++ .../features/longform/story-import.test.ts | 18 + .../features/longform/story-inputs.test.ts | 26 + .../src/features/longform/story-inputs.ts | 29 + .../features/longform/story-preview.test.ts | 46 + .../src/features/longform/story-preview.ts | 80 + .../src/features/longform/story-stems.tsx | 163 + .../longform/synced-audiobook-player.tsx | 233 ++ .../features/longform/validation-warnings.tsx | 61 + .../features/projects/project-format.test.ts | 68 + .../src/features/projects/project-format.ts | 122 + .../src/features/projects/projects-page.tsx | 797 ++++ .../src/features/settings/aec-settings.tsx | 26 + .../settings/asr-openai-compat-settings.tsx | 220 ++ .../settings/credential-settings.test.tsx | 80 + .../features/settings/credential-settings.tsx | 257 ++ .../settings/data-directory-settings.test.tsx | 87 + .../settings/data-directory-settings.tsx | 160 + .../settings/diagnostics-settings.tsx | 384 ++ .../features/settings/donation-goal.test.tsx | 40 + .../src/features/settings/donation-goal.tsx | 64 + .../src/features/settings/engine-health.tsx | 107 + .../src/features/settings/engine-install.tsx | 71 + .../src/features/settings/engine-license.tsx | 178 + .../settings/inbound-workers-settings.tsx | 471 +++ .../features/settings/llm-providers.test.tsx | 65 + .../src/features/settings/llm-providers.tsx | 342 ++ .../src/features/settings/llm-skills.tsx | 150 + .../src/features/settings/log-settings.tsx | 240 ++ .../settings/mcp-bindings-settings.tsx | 217 ++ .../src/features/settings/media-tools.tsx | 240 ++ .../src/features/settings/mirror-settings.tsx | 179 + .../settings/model-catalogue-query.ts | 44 + .../src/features/settings/model-family.ts | 29 + .../features/settings/model-library.test.tsx | 607 +++ .../src/features/settings/model-library.tsx | 1293 +++++++ .../features/settings/model-settings.test.tsx | 113 + .../src/features/settings/model-settings.tsx | 592 +++ .../models-directory-settings.test.tsx | 85 + .../settings/models-directory-settings.tsx | 105 + .../features/settings/network-settings.tsx | 94 + .../features/settings/openapi-settings.tsx | 144 + .../src/features/settings/palette-picker.tsx | 68 + .../settings/performance-model-packs.test.ts | 68 + .../settings/performance-model-packs.ts | 44 + .../settings/performance-settings.tsx | 585 +++ .../settings/permissions-settings.tsx | 139 + .../features/settings/privacy-settings.tsx | 209 + .../settings/pronunciation-settings.tsx | 416 ++ .../features/settings/refinement-settings.tsx | 104 + .../settings/remote-backend-settings.test.tsx | 95 + .../settings/remote-backend-settings.tsx | 155 + .../features/settings/reset-settings.test.tsx | 172 + .../src/features/settings/reset-settings.tsx | 425 ++ .../settings/scalar-api-reference.tsx | 45 + .../settings/settings-action-error.tsx | 61 + .../src/features/settings/settings-layout.tsx | 150 + .../src/features/settings/settings-page.tsx | 716 ++++ .../settings/sharing-settings.test.tsx | 112 + .../features/settings/sharing-settings.tsx | 454 +++ .../features/settings/shortcut-settings.tsx | 196 + .../settings/shortcut-verification.test.tsx | 56 + .../features/settings/storage-settings.tsx | 294 ++ .../settings/support-settings.test.tsx | 45 + .../features/settings/support-settings.tsx | 184 + .../settings/system-preflight.test.tsx | 43 + .../features/settings/system-preflight.tsx | 82 + .../settings/translation-settings.test.tsx | 72 + .../settings/translation-settings.tsx | 176 + .../settings/uninstall-settings.test.tsx | 67 + .../features/settings/uninstall-settings.tsx | 245 ++ .../settings/update-settings.test.tsx | 232 ++ .../src/features/settings/update-settings.tsx | 476 +++ .../src/features/settings/usage-settings.tsx | 183 + .../features/settings/use-settings-action.ts | 24 + .../settings/voice-previews-settings.tsx | 120 + .../features/settings/workers-settings.tsx | 729 ++++ .../features/tools/compare-voices.test.tsx | 92 + .../src/features/tools/compare-voices.tsx | 208 + .../src/features/tools/comparison-state.ts | 33 + .../src/features/tools/conversion-state.ts | 17 + .../src/features/tools/convert-voice.test.tsx | 81 + .../src/features/tools/convert-voice.tsx | 258 ++ .../src/features/tools/tools-page.tsx | 289 ++ .../transcriptions/capture-widget.tsx | 212 + .../transcriptions/dictation-setup.test.tsx | 87 + .../transcriptions/dictation-setup.tsx | 233 ++ .../features/transcriptions/history.test.ts | 40 + .../transcriptions/live-dictation.test.ts | 207 + .../features/transcriptions/live-dictation.ts | 302 ++ .../transcriptions/transcriptions-page.tsx | 814 ++++ .../src/renderer/src/hooks/use-appearance.ts | 66 + .../src/hooks/use-backend-status.test.ts | 59 + .../renderer/src/hooks/use-backend-status.ts | 84 + .../src/renderer/src/hooks/use-clone-demo.ts | 8 + .../renderer/src/hooks/use-clone-readiness.ts | 28 + .../src/hooks/use-compute-target.test.tsx | 53 + .../renderer/src/hooks/use-compute-target.ts | 111 + .../src/hooks/use-dictation-selection.ts | 32 + .../src/renderer/src/hooks/use-engines.ts | 62 + .../renderer/src/hooks/use-generate.test.tsx | 243 ++ .../src/renderer/src/hooks/use-generate.ts | 363 ++ .../src/renderer/src/hooks/use-history.ts | 70 + .../src/hooks/use-model-install-sync.test.tsx | 49 + .../src/hooks/use-model-install-sync.tsx | 91 + .../src/hooks/use-native-dictation.ts | 39 + .../src/renderer/src/hooks/use-pane-resize.ts | 94 + .../hooks/use-performance-profile.test.tsx | 89 + .../src/hooks/use-performance-profile.ts | 110 + .../src/renderer/src/hooks/use-profiles.ts | 77 + .../src/hooks/use-realtime-events.test.tsx | 130 + .../src/hooks/use-realtime-events.tsx | 112 + .../renderer/src/hooks/use-recording.test.ts | 76 + .../src/renderer/src/hooks/use-recording.ts | 276 ++ .../hooks/use-reference-transcript.test.ts | 108 + .../src/hooks/use-reference-transcript.ts | 59 + .../src/hooks/use-review-mode.test.ts | 23 + .../src/renderer/src/hooks/use-review-mode.ts | 39 + electron/src/renderer/src/hooks/use-theme.ts | 90 + .../src/hooks/use-tts-readiness.test.tsx | 147 + .../renderer/src/hooks/use-tts-readiness.ts | 51 + electron/src/renderer/src/i18n/index.ts | 131 + .../src/i18n/initial-language.test.tsx | 41 + .../renderer/src/i18n/language-switch.test.ts | 36 + .../renderer/src/i18n/locale-match.test.ts | 20 + .../src/renderer/src/i18n/locale-match.ts | 22 + .../src/renderer/src/i18n/locales/ar.json | 2581 +++++++++++++ .../src/renderer/src/i18n/locales/de.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/en.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/es.json | 2575 +++++++++++++ .../src/renderer/src/i18n/locales/fr.json | 2575 +++++++++++++ .../src/renderer/src/i18n/locales/hi.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/id.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/it.json | 2575 +++++++++++++ .../src/renderer/src/i18n/locales/ja.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/ko.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/nl.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/pl.json | 2577 +++++++++++++ .../src/renderer/src/i18n/locales/pt.json | 2575 +++++++++++++ .../src/renderer/src/i18n/locales/ru.json | 2577 +++++++++++++ .../src/renderer/src/i18n/locales/sv.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/th.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/tr.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/uk.json | 2577 +++++++++++++ .../src/renderer/src/i18n/locales/vi.json | 2573 +++++++++++++ .../src/renderer/src/i18n/locales/zh-CN.json | 2577 +++++++++++++ .../src/renderer/src/i18n/locales/zh-TW.json | 2573 +++++++++++++ electron/src/renderer/src/lib/api/audio.ts | 17 + .../src/renderer/src/lib/api/client.test.ts | 154 + electron/src/renderer/src/lib/api/client.ts | 132 + .../src/renderer/src/lib/api/convert.test.ts | 24 + electron/src/renderer/src/lib/api/convert.ts | 32 + electron/src/renderer/src/lib/api/engines.ts | 10 + .../renderer/src/lib/api/event-stream.test.ts | 43 + .../src/renderer/src/lib/api/event-stream.ts | 70 + electron/src/renderer/src/lib/api/failure.ts | 31 + .../src/renderer/src/lib/api/generate.test.ts | 279 ++ electron/src/renderer/src/lib/api/generate.ts | 388 ++ electron/src/renderer/src/lib/api/history.ts | 27 + .../src/renderer/src/lib/api/profiles.test.ts | 41 + electron/src/renderer/src/lib/api/profiles.ts | 50 + electron/src/renderer/src/lib/api/types.ts | 160 + .../src/renderer/src/lib/api/websocket.ts | 25 + electron/src/renderer/src/lib/app-activity.ts | 53 + .../src/renderer/src/lib/audio/object-url.ts | 18 + .../renderer/src/lib/audio/playback-clock.ts | 77 + .../src/renderer/src/lib/audio/playback.ts | 83 + electron/src/renderer/src/lib/audio/probe.ts | 38 + .../src/renderer/src/lib/audio/recorder.ts | 197 + .../src/lib/audio/streaming-preview.ts | 120 + electron/src/renderer/src/lib/brand.ts | 3 + .../renderer/src/lib/clone-readiness.test.ts | 33 + .../src/renderer/src/lib/clone-readiness.ts | 29 + .../src/lib/detached-renderer-tasks.test.ts | 19 + .../src/lib/engine-selection-feedback.test.ts | 47 + .../src/lib/engine-selection-feedback.ts | 38 + .../src/renderer/src/lib/export-history.ts | 42 + electron/src/renderer/src/lib/first-sound.ts | 1 + .../src/lib/global-error-recovery.test.ts | 88 + .../renderer/src/lib/global-error-recovery.ts | 83 + electron/src/renderer/src/lib/i18n-text.ts | 11 + .../src/renderer/src/lib/import-script.ts | 34 + electron/src/renderer/src/lib/languages.json | 649 ++++ electron/src/renderer/src/lib/languages.ts | 36 + .../src/renderer/src/lib/local-export.test.ts | 24 + electron/src/renderer/src/lib/local-export.ts | 18 + .../src/renderer/src/lib/log-format.test.ts | 19 + electron/src/renderer/src/lib/log-format.ts | 36 + .../renderer/src/lib/preload-recovery.test.ts | 46 + .../src/renderer/src/lib/preload-recovery.ts | 47 + electron/src/renderer/src/lib/query.ts | 27 + .../renderer/src/lib/repair-agent-events.ts | 24 + .../src/lib/repair-log-analysis.test.ts | 24 + .../renderer/src/lib/repair-log-analysis.ts | 36 + .../src/lib/report-breadcrumb.test.ts | 22 + .../src/renderer/src/lib/report-breadcrumb.ts | 98 + .../renderer/src/lib/settings-route.test.ts | 23 + .../src/renderer/src/lib/settings-route.ts | 45 + .../src/renderer/src/lib/setup-progress.ts | 52 + .../renderer/src/lib/status-polling.test.ts | 25 + .../src/renderer/src/lib/status-polling.ts | 19 + .../src/lib/store/clone-settings.test.ts | 95 + .../renderer/src/lib/store/clone-settings.ts | 145 + .../src/lib/store/dictation-settings.ts | 46 + electron/src/renderer/src/lib/store/output.ts | 28 + .../renderer/src/lib/store/reference.test.ts | 108 + .../src/renderer/src/lib/store/reference.ts | 88 + .../src/renderer/src/lib/store/takes.test.ts | 34 + electron/src/renderer/src/lib/store/takes.ts | 67 + .../src/renderer/src/lib/store/workspace.ts | 43 + .../src/renderer/src/lib/synthesis-lock.ts | 11 + .../src/renderer/src/lib/textarea-caret.ts | 37 + .../renderer/src/lib/themes/t3-palettes.ts | 774 ++++ .../renderer/src/lib/themes/tauri-palette.ts | 83 + .../src/lib/themes/theme-preferences.test.ts | 68 + .../src/lib/themes/theme-preferences.ts | 63 + electron/src/renderer/src/lib/utils.ts | 1 + .../renderer/src/lib/vercel-oidc-browser.ts | 16 + electron/src/renderer/src/main.tsx | 25 + electron/src/renderer/src/qrcode.d.ts | 6 + electron/src/renderer/src/router.tsx | 20 + electron/src/renderer/src/routes/index.ts | 263 ++ electron/src/renderer/src/styles/globals.css | 629 +++ electron/src/renderer/src/styles/t3-theme.css | 179 + electron/src/renderer/src/test/setup.ts | 1 + electron/src/shared/repair-request.test.ts | 17 + electron/src/shared/repair-request.ts | 9 + electron/tests/accessibility-smoke.mjs | 197 + electron/tests/asr-isolation-real-smoke.mjs | 88 + electron/tests/batch-smoke.mjs | 80 + electron/tests/capture-target.cjs | 14 + electron/tests/clone-demo-smoke.mjs | 51 + electron/tests/community-real-smoke.mjs | 61 + electron/tests/compare-voices-real-smoke.mjs | 60 + electron/tests/connection-settings-smoke.mjs | 142 + electron/tests/convert-live-smoke.mjs | 77 + electron/tests/design-demo-real-smoke.mjs | 24 + .../tests/design-profile-edit-real-smoke.mjs | 107 + electron/tests/dictation-demo-real-smoke.mjs | 31 + electron/tests/dub-smoke.mjs | 581 +++ electron/tests/error-recovery-smoke.mjs | 105 + electron/tests/fake-health-backend.mjs | 67 + electron/tests/gallery-import-real-smoke.mjs | 94 + .../tests/gallery-preview-fallback-smoke.mjs | 51 + electron/tests/gallery-smoke.mjs | 131 + electron/tests/gallery-trim-real-smoke.mjs | 65 + electron/tests/live-dictation-real-smoke.mjs | 38 + electron/tests/live-dictation-smoke.mjs | 108 + electron/tests/live-engine-state-smoke.mjs | 136 + electron/tests/live-route-health.mjs | 161 + electron/tests/llm-providers-smoke.mjs | 73 + electron/tests/llm-skills-smoke.mjs | 85 + electron/tests/locale-coverage.mjs | 37 + electron/tests/locale-encoding.mjs | 101 + electron/tests/locale-layout-smoke.mjs | 119 + electron/tests/locale-source-keys.mjs | 60 + electron/tests/logs-smoke.mjs | 50 + electron/tests/longform-live.mjs | 100 + electron/tests/longform-smoke.mjs | 229 ++ electron/tests/media-tools-smoke.mjs | 50 + electron/tests/mirror-settings-smoke.mjs | 68 + .../tests/model-recommendations-smoke.mjs | 91 + electron/tests/model-selection-real-smoke.mjs | 103 + electron/tests/native-capture-smoke.mjs | 163 + electron/tests/native-crash-journal-smoke.mjs | 87 + .../tests/native-dev-dictation-real-smoke.mjs | 63 + .../tests/native-global-dictation-smoke.mjs | 128 + electron/tests/native-insertion-smoke.mjs | 170 + electron/tests/native-local-export-smoke.mjs | 46 + electron/tests/native-media-picker-smoke.mjs | 107 + electron/tests/native-output-smoke.ts | 21 + .../tests/native-persona-export-smoke.mjs | 182 + electron/tests/native-repair-agent-smoke.mjs | 75 + electron/tests/native-report-bug-smoke.mjs | 55 + electron/tests/native-shortcut-smoke.ts | 46 + .../tests/native-translation-agent-smoke.mjs | 64 + electron/tests/native-watch-folder-smoke.ts | 81 + electron/tests/native-watch-ui-smoke.mjs | 102 + electron/tests/native-wayland-output-smoke.ts | 30 + .../tests/packaged-repair-agent-smoke.mjs | 97 + .../packaged-repair-operation-acceptance.mjs | 82 + electron/tests/packaged-smoke.mjs | 521 +++ electron/tests/packaging-contract.mjs | 134 + electron/tests/performance-settings-smoke.mjs | 231 ++ .../tests/persona-roundtrip-real-smoke.mjs | 97 + .../tests/personality-presets-real-smoke.mjs | 38 + electron/tests/playback-smoke.mjs | 113 + electron/tests/privacy-settings-smoke.mjs | 103 + electron/tests/profile-consent-real-smoke.mjs | 67 + .../tests/profile-image-search-real-smoke.mjs | 91 + electron/tests/projects-smoke.mjs | 148 + electron/tests/renderer-smoke.vite.config.mjs | 70 + electron/tests/route-layout-smoke.mjs | 117 + electron/tests/runtime-health-smoke.mjs | 75 + electron/tests/runtime-install-smoke.mjs | 55 + .../tests/save-take-profile-real-smoke.mjs | 61 + electron/tests/secondary-sidebar-smoke.mjs | 220 ++ electron/tests/serve-renderer.mjs | 7 + electron/tests/setup-gate-smoke.mjs | 128 + electron/tests/sidebar-layout-smoke.mjs | 91 + electron/tests/storage-settings-smoke.mjs | 128 + electron/tests/stories-live-smoke.mjs | 59 + electron/tests/support-smoke.mjs | 30 + .../tests/system-preflight-real-smoke.mjs | 31 + electron/tests/test-wave.mjs | 13 + electron/tests/tools-smoke.mjs | 54 + electron/tests/transcriptions-real-smoke.mjs | 93 + electron/tests/transcriptions-smoke.mjs | 182 + electron/tests/update-package-contract.mjs | 110 + electron/tests/workspace-shell-smoke.mjs | 63 + electron/tsconfig.json | 7 + electron/tsconfig.node.json | 20 + electron/tsconfig.web.json | 43 + electron/vite.config.ts | 32 + frontend/src/api/archetypes-types.d.ts | 50 + frontend/src/api/batch-types.d.ts | 29 + frontend/src/api/community-types.d.ts | 36 + frontend/src/api/donation.ts | 12 + frontend/src/api/gallery-types.d.ts | 31 + frontend/src/api/setup-types.d.ts | 28 + frontend/src/api/types.ts | 9 +- frontend/src/components/gallery/facets.d.ts | 1 + frontend/src/components/gallery/facets.js | 33 + .../components/settings/models/format.d.ts | 2 + .../src/components/settings/refineStatus.d.ts | 1 + frontend/src/config/sponsors.d.ts | 3 + frontend/src/data/sampleAudiobook.d.ts | 1 + frontend/src/data/sampleStory.d.ts | 13 + frontend/src/utils/aec/farEndBus.d.ts | 3 + frontend/src/utils/aec/micCapture.d.ts | 3 + frontend/src/utils/aec/pcm.d.ts | 5 + frontend/src/utils/aec/playbackTap.d.ts | 4 + frontend/src/utils/audioTrim.d.ts | 3 + frontend/src/utils/audiobookLyrics.d.ts | 49 + frontend/src/utils/audiobookScript.d.ts | 5 + frontend/src/utils/autoCast.d.ts | 1 + frontend/src/utils/autoCast.js | 23 + frontend/src/utils/breadcrumbs.d.ts | 9 + frontend/src/utils/bugReportDocument.d.ts | 10 + frontend/src/utils/bugReportDocument.js | 95 + frontend/src/utils/captureProtocol.d.ts | 6 + frontend/src/utils/captureProtocol.js | 78 + frontend/src/utils/consoleBuffer.d.ts | 3 + frontend/src/utils/constants.d.ts | 6 + frontend/src/utils/contactLinks.d.ts | 2 + frontend/src/utils/contactLinks.js | 15 + frontend/src/utils/cookieExport.ts | 17 + frontend/src/utils/crashReport.d.ts | 2 + frontend/src/utils/crashReport.js | 48 + frontend/src/utils/dictationScripts.d.ts | 1 + frontend/src/utils/dictationScripts.js | 23 + frontend/src/utils/dictationShortcut.d.ts | 20 + frontend/src/utils/donateLinks.d.ts | 1 + frontend/src/utils/errorDocsMap.test.ts | 4 +- frontend/src/utils/errorDocsMap.ts | 65 +- frontend/src/utils/foreignErrors.d.ts | 6 + frontend/src/utils/foreignErrors.js | 41 + frontend/src/utils/importStory.d.ts | 2 + frontend/src/utils/indexedDbLongformStore.ts | 3 +- frontend/src/utils/languages.d.ts | 1 + frontend/src/utils/longformOverrides.d.ts | 3 + frontend/src/utils/longformOverrides.js | 36 + frontend/src/utils/longformStream.d.ts | 1 + frontend/src/utils/markdownLite.d.ts | 12 + frontend/src/utils/multiLang.d.ts | 20 + frontend/src/utils/networkPreferences.d.ts | 2 + frontend/src/utils/networkPreferences.js | 5 + frontend/src/utils/pasteTranslations.d.ts | 41 + frontend/src/utils/runCrashRecord.ts | 55 + frontend/src/utils/scrub.d.ts | 2 + frontend/src/utils/seed.d.ts | 2 + frontend/src/utils/segmentParts.d.ts | 49 + frontend/src/utils/segments.d.ts | 8 + frontend/src/utils/shortcutAccelerator.d.ts | 2 + frontend/src/utils/shortcutAccelerator.js | 32 + frontend/src/utils/splitStoryText.d.ts | 1 + frontend/src/utils/splitStoryText.js | 39 + frontend/src/utils/sseParse.d.ts | 2 + frontend/src/utils/storageWarnings.d.ts | 2 + frontend/src/utils/storageWarnings.js | 32 + frontend/src/utils/storyExport.d.ts | 5 + frontend/src/utils/storyExport.js | 11 +- frontend/src/utils/storyReorder.d.ts | 1 + frontend/src/utils/storyToSpans.d.ts | 1 + frontend/src/utils/timeline.d.ts | 38 + frontend/src/utils/transcriptionFormat.d.ts | 4 + frontend/src/utils/transcriptionFormat.js | 26 + frontend/src/utils/transcriptionsStore.d.ts | 23 + frontend/src/utils/transcriptionsStore.js | 41 +- .../src/utils/translationProviderFields.d.ts | 1 + .../src/utils/translationProviderFields.js | 28 + frontend/src/utils/translatorPrivacy.d.ts | 1 + frontend/src/utils/translatorPrivacy.js | 5 + frontend/src/utils/voiceInstruct.d.ts | 6 + frontend/src/utils/watchFolderTracker.d.ts | 11 + frontend/src/utils/watchFolderTracker.js | 101 + native/desktop-bridge/.cargo/config.toml | 6 + native/desktop-bridge/.gitignore | 1 + native/desktop-bridge/Cargo.lock | 3430 +++++++++++++++++ native/desktop-bridge/Cargo.toml | 30 + native/desktop-bridge/README.md | 41 + native/desktop-bridge/src/cleanup.rs | 197 + native/desktop-bridge/src/main.rs | 351 ++ native/desktop-bridge/src/shortcuts.rs | 192 + package.json | 6 +- scripts/check_electron_release_assets.py | 130 + scripts/smoke_dub_url_captions.py | 57 + scripts/smoke_sortformer.py | 152 + tests/backend/api/test_engines_route_shape.py | 68 + .../backend/api/test_system_gpu_detection.py | 34 + tests/backend/core/test_error_docs_map.py | 2 + tests/backend/core/test_logging_filter.py | 62 + tests/backend/core/test_parent_liveness.py | 19 +- tests/backend/services/test_audio_io.py | 12 + tests/backend/services/test_subprocess_asr.py | 37 + tests/fixtures/api_routes.txt | 19 + tests/scripts/test_stamp_preview_version.py | 12 + tests/test_api.py | 6 + tests/test_audiocpp_runtime_install.py | 93 + tests/test_autofit_838.py | 49 + .../test_broken_dependency_diagnosis_1415.py | 12 + tests/test_capture_ws.py | 28 + tests/test_csrf_origin.py | 7 + tests/test_diarization_native_contract.py | 70 + tests/test_diarization_runtime_status.py | 89 + tests/test_dub_batch_engine_selection.py | 61 + tests/test_dub_export_unique.py | 51 + tests/test_dub_onsets_route.py | 4 +- tests/test_dub_remote.py | 95 +- tests/test_dub_transcribe.py | 47 + tests/test_dub_translate.py | 245 +- tests/test_electron_model_management.py | 115 + tests/test_electron_parity_inventory.py | 221 ++ tests/test_electron_release_assets.py | 97 + tests/test_gpu_gateway.py | 151 + tests/test_hf_cache_fallback.py | 21 + tests/test_hf_revisions.py | 8 +- tests/test_install_disk_space.py | 8 + tests/test_media_tools.py | 4 + tests/test_mm2_lifecycle.py | 32 +- tests/test_model_manager_preload.py | 2 + tests/test_model_status_events.py | 21 + tests/test_no_hardcoded_cjk.py | 3 +- tests/test_perf_operation_budgets.py | 45 +- tests/test_performance_profiles.py | 235 ++ tests/test_profile_images.py | 129 + tests/test_profile_storage_recovery.py | 30 + tests/test_profile_usage.py | 36 + tests/test_segmented_cache_pointer.py | 16 + tests/test_setup_recommendations.py | 16 + tests/test_sherpa_dictation.py | 44 + tests/test_startup_progress_early_bind.py | 35 +- tests/test_transcribe_reference.py | 8 + tests/test_worker_capability_refresh.py | 68 +- tests/test_worker_client_liveness.py | 61 + tests/test_worker_errors.py | 23 + tests/test_worker_lifecycle.py | 25 + tests/test_worker_protocol_contract.py | 5 + tests/test_worker_protocol_gen.py | 7 + tests/test_worker_routing.py | 7 +- tests/test_worker_service_api.py | 102 +- tests/test_worker_transport.py | 9 + 824 files changed, 152701 insertions(+), 771 deletions(-) create mode 100644 backend/api/routers/profile_images.py create mode 100644 backend/core/engine_licenses.py create mode 100644 backend/core/profile_images.py create mode 100644 backend/services/audiocpp_runtime_install.py create mode 100644 backend/services/diarization_local.py create mode 100644 backend/services/diarization_native.py create mode 100644 backend/services/diarization_runtime.py create mode 100644 backend/services/dub_batching.py create mode 100644 backend/services/performance_profiles.py create mode 100644 backend/services/segment_bundle.py create mode 100644 backend/tests/test_dub_caption_languages.py create mode 100644 docs/electron-batch.md create mode 100644 docs/electron-connections.md create mode 100644 docs/electron-dubbing.md create mode 100644 docs/electron-gallery.md create mode 100644 docs/electron-launchpad.md create mode 100644 docs/electron-llm-providers.md create mode 100644 docs/electron-logs.md create mode 100644 docs/electron-longform.md create mode 100644 docs/electron-parity.md create mode 100644 docs/electron-performance.md create mode 100644 docs/electron-playback.md create mode 100644 docs/electron-privacy.md create mode 100644 docs/electron-projects.md create mode 100644 docs/electron-repair.md create mode 100644 docs/electron-runtime.md create mode 100644 docs/electron-storage.md create mode 100644 docs/electron-tools.md create mode 100644 docs/electron-transcriptions.md create mode 100644 docs/electron-voice-design.md create mode 100644 docs/profile-portraits.md create mode 100644 electron/.gitignore create mode 100644 electron/.oxfmtrc.json create mode 100644 electron/.oxlintrc.json create mode 100644 electron/CONTRACT.md create mode 100644 electron/PARITY.md create mode 100644 electron/README.md create mode 100644 electron/T3CODE-LICENSE.txt create mode 100644 electron/bun.lock create mode 100644 electron/components.json create mode 100644 electron/electron-builder.config.mjs create mode 100644 electron/electron.vite.config.ts create mode 100644 electron/native-helper-build.mjs create mode 100644 electron/package.json create mode 100644 electron/src/main/backend-download.test.ts create mode 100644 electron/src/main/backend-download.ts create mode 100644 electron/src/main/backend-setup.test.ts create mode 100644 electron/src/main/backend.ts create mode 100644 electron/src/main/blank-window-guard.test.ts create mode 100644 electron/src/main/blank-window-guard.ts create mode 100644 electron/src/main/capture-session.test.ts create mode 100644 electron/src/main/capture-session.ts create mode 100644 electron/src/main/crash-journal.test.ts create mode 100644 electron/src/main/crash-journal.ts create mode 100644 electron/src/main/data-relocation.test.ts create mode 100644 electron/src/main/data-relocation.ts create mode 100644 electron/src/main/dev-backend-proxy.test.ts create mode 100644 electron/src/main/dev-backend-proxy.ts create mode 100644 electron/src/main/devtools-policy.test.ts create mode 100644 electron/src/main/devtools-policy.ts create mode 100644 electron/src/main/dictation-output.test.ts create mode 100644 electron/src/main/dictation-output.ts create mode 100644 electron/src/main/env.d.ts create mode 100644 electron/src/main/fixtures/LICENSE create mode 100644 electron/src/main/fixtures/README.md create mode 100644 electron/src/main/fixtures/sample.doc create mode 100644 electron/src/main/fixtures/sample.docx create mode 100644 electron/src/main/index.ts create mode 100644 electron/src/main/ipc.ts create mode 100644 electron/src/main/main-error-journal.test.ts create mode 100644 electron/src/main/main-error-journal.ts create mode 100644 electron/src/main/media-authorization.test.ts create mode 100644 electron/src/main/media-authorization.ts create mode 100644 electron/src/main/media-permissions.test.ts create mode 100644 electron/src/main/media-permissions.ts create mode 100644 electron/src/main/models-directory-authorization.test.ts create mode 100644 electron/src/main/native-capture.ts create mode 100644 electron/src/main/protocol.ts create mode 100644 electron/src/main/remote-backend.test.ts create mode 100644 electron/src/main/remote-backend.ts create mode 100644 electron/src/main/repair-agents.test.ts create mode 100644 electron/src/main/repair-agents.ts create mode 100644 electron/src/main/repair-api-bridge.test.ts create mode 100644 electron/src/main/repair-api-bridge.ts create mode 100644 electron/src/main/repair-mcp-server.ts create mode 100644 electron/src/main/reset-data.test.ts create mode 100644 electron/src/main/reset-data.ts create mode 100644 electron/src/main/runtime-project.test.ts create mode 100644 electron/src/main/runtime-project.ts create mode 100644 electron/src/main/save-filters.test.ts create mode 100644 electron/src/main/save-filters.ts create mode 100644 electron/src/main/script-import.test.ts create mode 100644 electron/src/main/script-import.ts create mode 100644 electron/src/main/setup-progress.test.ts create mode 100644 electron/src/main/setup-progress.ts create mode 100644 electron/src/main/shortcut-settings.test.ts create mode 100644 electron/src/main/shortcut-settings.ts create mode 100644 electron/src/main/trusted-renderer.ts create mode 100644 electron/src/main/uninstall-cleanup.ts create mode 100644 electron/src/main/uninstall-data.test.ts create mode 100644 electron/src/main/uninstall-data.ts create mode 100644 electron/src/main/updater.test.ts create mode 100644 electron/src/main/updater.ts create mode 100644 electron/src/main/watch-folders.test.ts create mode 100644 electron/src/main/watch-folders.ts create mode 100644 electron/src/main/window-safety.test.ts create mode 100644 electron/src/main/window-safety.ts create mode 100644 electron/src/preload/index.d.ts create mode 100644 electron/src/preload/index.ts create mode 100644 electron/src/renderer/env.d.ts create mode 100644 electron/src/renderer/index.html create mode 100644 electron/src/renderer/public/early-error-capture.js create mode 100644 electron/src/renderer/src/app.tsx create mode 100644 electron/src/renderer/src/components/agent-fix-button.tsx create mode 100644 electron/src/renderer/src/components/analytics-consent.test.tsx create mode 100644 electron/src/renderer/src/components/analytics-consent.tsx create mode 100644 electron/src/renderer/src/components/app-shell/app-shell.tsx create mode 100644 electron/src/renderer/src/components/app-shell/nav-rail.tsx create mode 100644 electron/src/renderer/src/components/app-shell/repair-agent-dock.tsx create mode 100644 electron/src/renderer/src/components/app-shell/status-bar.test.ts create mode 100644 electron/src/renderer/src/components/app-shell/status-bar.tsx create mode 100644 electron/src/renderer/src/components/app-shell/status-runtime.ts create mode 100644 electron/src/renderer/src/components/app-shell/system-notifications.test.tsx create mode 100644 electron/src/renderer/src/components/app-shell/system-notifications.tsx create mode 100644 electron/src/renderer/src/components/app-shell/theme-toggle.tsx create mode 100644 electron/src/renderer/src/components/app-shell/top-bar.tsx create mode 100644 electron/src/renderer/src/components/app-shell/workspace-header.tsx create mode 100644 electron/src/renderer/src/components/app-shell/workspace-menu.tsx create mode 100644 electron/src/renderer/src/components/app-shell/workspace-sidebar.tsx create mode 100644 electron/src/renderer/src/components/audio-preview-button.tsx create mode 100644 electron/src/renderer/src/components/backend-gate.test.tsx create mode 100644 electron/src/renderer/src/components/backend-gate.tsx create mode 100644 electron/src/renderer/src/components/bridge.ts create mode 100644 electron/src/renderer/src/components/command-palette.tsx create mode 100644 electron/src/renderer/src/components/compute-target-choices.tsx create mode 100644 electron/src/renderer/src/components/compute-vendor-icon.tsx create mode 100644 electron/src/renderer/src/components/crash-details.test.tsx create mode 100644 electron/src/renderer/src/components/crash-details.tsx create mode 100644 electron/src/renderer/src/components/dictation-demo.test.tsx create mode 100644 electron/src/renderer/src/components/dictation-demo.tsx create mode 100644 electron/src/renderer/src/components/engine-notice.test.tsx create mode 100644 electron/src/renderer/src/components/engine-notice.tsx create mode 100644 electron/src/renderer/src/components/error-boundary.test.tsx create mode 100644 electron/src/renderer/src/components/error-boundary.tsx create mode 100644 electron/src/renderer/src/components/external-link.tsx create mode 100644 electron/src/renderer/src/components/first-sound-handoff.tsx create mode 100644 electron/src/renderer/src/components/keyboard-cheatsheet.tsx create mode 100644 electron/src/renderer/src/components/markdown-lite.tsx create mode 100644 electron/src/renderer/src/components/media-player.tsx create mode 100644 electron/src/renderer/src/components/performance-profile.tsx create mode 100644 electron/src/renderer/src/components/pipeline-failure.test.tsx create mode 100644 electron/src/renderer/src/components/pipeline-failure.tsx create mode 100644 electron/src/renderer/src/components/popover.tsx create mode 100644 electron/src/renderer/src/components/profile-avatar.test.tsx create mode 100644 electron/src/renderer/src/components/profile-avatar.tsx create mode 100644 electron/src/renderer/src/components/recording-inputs.tsx create mode 100644 electron/src/renderer/src/components/report-bug.test.tsx create mode 100644 electron/src/renderer/src/components/report-bug.tsx create mode 100644 electron/src/renderer/src/components/save-audio-button.tsx create mode 100644 electron/src/renderer/src/components/setup-gate.test.tsx create mode 100644 electron/src/renderer/src/components/setup-gate.tsx create mode 100644 electron/src/renderer/src/components/setup-recovery.test.tsx create mode 100644 electron/src/renderer/src/components/setup-recovery.tsx create mode 100644 electron/src/renderer/src/components/ui/alert.tsx create mode 100644 electron/src/renderer/src/components/ui/badge.tsx create mode 100644 electron/src/renderer/src/components/ui/button.tsx create mode 100644 electron/src/renderer/src/components/ui/card.tsx create mode 100644 electron/src/renderer/src/components/ui/collapsible.tsx create mode 100644 electron/src/renderer/src/components/ui/dialog.tsx create mode 100644 electron/src/renderer/src/components/ui/input.tsx create mode 100644 electron/src/renderer/src/components/ui/kbd.tsx create mode 100644 electron/src/renderer/src/components/ui/label.tsx create mode 100644 electron/src/renderer/src/components/ui/progress.tsx create mode 100644 electron/src/renderer/src/components/ui/scroll-area.tsx create mode 100644 electron/src/renderer/src/components/ui/select.tsx create mode 100644 electron/src/renderer/src/components/ui/separator.tsx create mode 100644 electron/src/renderer/src/components/ui/skeleton.tsx create mode 100644 electron/src/renderer/src/components/ui/slider.tsx create mode 100644 electron/src/renderer/src/components/ui/sonner.tsx create mode 100644 electron/src/renderer/src/components/ui/spinner.tsx create mode 100644 electron/src/renderer/src/components/ui/switch.tsx create mode 100644 electron/src/renderer/src/components/ui/tabs.tsx create mode 100644 electron/src/renderer/src/components/ui/textarea.tsx create mode 100644 electron/src/renderer/src/components/ui/toggle-group.tsx create mode 100644 electron/src/renderer/src/components/ui/toggle.tsx create mode 100644 electron/src/renderer/src/components/ui/tooltip.tsx create mode 100644 electron/src/renderer/src/components/video-player.tsx create mode 100644 electron/src/renderer/src/components/waveform-player.tsx create mode 100644 electron/src/renderer/src/components/workspace-pane.tsx create mode 100644 electron/src/renderer/src/components/workspace-sidebar.tsx create mode 100644 electron/src/renderer/src/features/batch/batch-page.tsx create mode 100644 electron/src/renderer/src/features/batch/enqueue.test.ts create mode 100644 electron/src/renderer/src/features/batch/enqueue.ts create mode 100644 electron/src/renderer/src/features/batch/watch-folder.test.tsx create mode 100644 electron/src/renderer/src/features/batch/watch-folder.tsx create mode 100644 electron/src/renderer/src/features/clone/action-bar.test.tsx create mode 100644 electron/src/renderer/src/features/clone/action-bar.tsx create mode 100644 electron/src/renderer/src/features/clone/clone-demo.tsx create mode 100644 electron/src/renderer/src/features/clone/clone-page.tsx create mode 100644 electron/src/renderer/src/features/clone/confirm-dialog.tsx create mode 100644 electron/src/renderer/src/features/clone/edit-profile.tsx create mode 100644 electron/src/renderer/src/features/clone/format.test.ts create mode 100644 electron/src/renderer/src/features/clone/format.ts create mode 100644 electron/src/renderer/src/features/clone/language-picker.test.tsx create mode 100644 electron/src/renderer/src/features/clone/language-picker.tsx create mode 100644 electron/src/renderer/src/features/clone/lock-voice.test.tsx create mode 100644 electron/src/renderer/src/features/clone/lock-voice.tsx create mode 100644 electron/src/renderer/src/features/clone/output-panel.tsx create mode 100644 electron/src/renderer/src/features/clone/persona-export.test.tsx create mode 100644 electron/src/renderer/src/features/clone/persona-export.tsx create mode 100644 electron/src/renderer/src/features/clone/portrait-search.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-consent.test.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-consent.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-image-editor.test.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-image-editor.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-preview.test.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-preview.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-save.test.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-usage.test.tsx create mode 100644 electron/src/renderer/src/features/clone/profile-usage.tsx create mode 100644 electron/src/renderer/src/features/clone/reference-panel.tsx create mode 100644 electron/src/renderer/src/features/clone/save-take-profile.tsx create mode 100644 electron/src/renderer/src/features/clone/saved-voices-page.test.tsx create mode 100644 electron/src/renderer/src/features/clone/saved-voices-page.tsx create mode 100644 electron/src/renderer/src/features/clone/script-panel.test.tsx create mode 100644 electron/src/renderer/src/features/clone/script-panel.tsx create mode 100644 electron/src/renderer/src/features/clone/section-label.tsx create mode 100644 electron/src/renderer/src/features/clone/take-details.test.tsx create mode 100644 electron/src/renderer/src/features/clone/take-details.tsx create mode 100644 electron/src/renderer/src/features/clone/voice-setup-virtual.test.ts create mode 100644 electron/src/renderer/src/features/clone/voice-setup.tsx create mode 100644 electron/src/renderer/src/features/clone/voices-sidebar-virtual.test.ts create mode 100644 electron/src/renderer/src/features/clone/voices-sidebar.tsx create mode 100644 electron/src/renderer/src/features/design/demo-presets.tsx create mode 100644 electron/src/renderer/src/features/design/design-draft.test.ts create mode 100644 electron/src/renderer/src/features/design/design-draft.ts create mode 100644 electron/src/renderer/src/features/design/design-page.tsx create mode 100644 electron/src/renderer/src/features/design/personality-presets.test.tsx create mode 100644 electron/src/renderer/src/features/design/personality-presets.tsx create mode 100644 electron/src/renderer/src/features/design/use-description.test.tsx create mode 100644 electron/src/renderer/src/features/design/use-description.ts create mode 100644 electron/src/renderer/src/features/dub/casting-board.tsx create mode 100644 electron/src/renderer/src/features/dub/checkpoint-banner.tsx create mode 100644 electron/src/renderer/src/features/dub/dub-draft.test.ts create mode 100644 electron/src/renderer/src/features/dub/dub-draft.ts create mode 100644 electron/src/renderer/src/features/dub/dub-export-panel.tsx create mode 100644 electron/src/renderer/src/features/dub/dub-export.test.ts create mode 100644 electron/src/renderer/src/features/dub/dub-export.ts create mode 100644 electron/src/renderer/src/features/dub/dub-page.tsx create mode 100644 electron/src/renderer/src/features/dub/dub-session.test.ts create mode 100644 electron/src/renderer/src/features/dub/dub-session.ts create mode 100644 electron/src/renderer/src/features/dub/dub-timeline.tsx create mode 100644 electron/src/renderer/src/features/dub/dubbing-demo.tsx create mode 100644 electron/src/renderer/src/features/dub/glossary-panel.tsx create mode 100644 electron/src/renderer/src/features/dub/paste-translation.tsx create mode 100644 electron/src/renderer/src/features/dub/translation-error.test.ts create mode 100644 electron/src/renderer/src/features/dub/translation-error.ts create mode 100644 electron/src/renderer/src/features/dub/use-dub-live-preview.test.tsx create mode 100644 electron/src/renderer/src/features/dub/use-dub-live-preview.ts create mode 100644 electron/src/renderer/src/features/dub/use-dub-onsets.ts create mode 100644 electron/src/renderer/src/features/gallery/community-view.test.tsx create mode 100644 electron/src/renderer/src/features/gallery/community-view.tsx create mode 100644 electron/src/renderer/src/features/gallery/favorites.test.ts create mode 100644 electron/src/renderer/src/features/gallery/favorites.ts create mode 100644 electron/src/renderer/src/features/gallery/gallery-page.tsx create mode 100644 electron/src/renderer/src/features/gallery/imports-api.test.ts create mode 100644 electron/src/renderer/src/features/gallery/imports-api.ts create mode 100644 electron/src/renderer/src/features/gallery/imports-view.test.tsx create mode 100644 electron/src/renderer/src/features/gallery/imports-view.tsx create mode 100644 electron/src/renderer/src/features/gallery/place-voice.test.ts create mode 100644 electron/src/renderer/src/features/gallery/place-voice.ts create mode 100644 electron/src/renderer/src/features/gallery/trim-audio.test.ts create mode 100644 electron/src/renderer/src/features/gallery/trim-audio.tsx create mode 100644 electron/src/renderer/src/features/gallery/voice-destinations.tsx create mode 100644 electron/src/renderer/src/features/home/home-page.tsx create mode 100644 electron/src/renderer/src/features/longform/audiobook-markup-toolbar.tsx create mode 100644 electron/src/renderer/src/features/longform/auto-cast.test.ts create mode 100644 electron/src/renderer/src/features/longform/book-options.test.ts create mode 100644 electron/src/renderer/src/features/longform/book-options.ts create mode 100644 electron/src/renderer/src/features/longform/book-settings.tsx create mode 100644 electron/src/renderer/src/features/longform/cast-map.ts create mode 100644 electron/src/renderer/src/features/longform/cast-settings.tsx create mode 100644 electron/src/renderer/src/features/longform/chapter-previews.tsx create mode 100644 electron/src/renderer/src/features/longform/generation-progress.tsx create mode 100644 electron/src/renderer/src/features/longform/longform-page.tsx create mode 100644 electron/src/renderer/src/features/longform/longform-session.test.ts create mode 100644 electron/src/renderer/src/features/longform/longform-session.ts create mode 100644 electron/src/renderer/src/features/longform/production-settings.tsx create mode 100644 electron/src/renderer/src/features/longform/project-library.test.ts create mode 100644 electron/src/renderer/src/features/longform/project-library.ts create mode 100644 electron/src/renderer/src/features/longform/project-settings.tsx create mode 100644 electron/src/renderer/src/features/longform/story-editor.tsx create mode 100644 electron/src/renderer/src/features/longform/story-import.test.ts create mode 100644 electron/src/renderer/src/features/longform/story-inputs.test.ts create mode 100644 electron/src/renderer/src/features/longform/story-inputs.ts create mode 100644 electron/src/renderer/src/features/longform/story-preview.test.ts create mode 100644 electron/src/renderer/src/features/longform/story-preview.ts create mode 100644 electron/src/renderer/src/features/longform/story-stems.tsx create mode 100644 electron/src/renderer/src/features/longform/synced-audiobook-player.tsx create mode 100644 electron/src/renderer/src/features/longform/validation-warnings.tsx create mode 100644 electron/src/renderer/src/features/projects/project-format.test.ts create mode 100644 electron/src/renderer/src/features/projects/project-format.ts create mode 100644 electron/src/renderer/src/features/projects/projects-page.tsx create mode 100644 electron/src/renderer/src/features/settings/aec-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/asr-openai-compat-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/credential-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/credential-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/data-directory-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/data-directory-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/diagnostics-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/donation-goal.test.tsx create mode 100644 electron/src/renderer/src/features/settings/donation-goal.tsx create mode 100644 electron/src/renderer/src/features/settings/engine-health.tsx create mode 100644 electron/src/renderer/src/features/settings/engine-install.tsx create mode 100644 electron/src/renderer/src/features/settings/engine-license.tsx create mode 100644 electron/src/renderer/src/features/settings/inbound-workers-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/llm-providers.test.tsx create mode 100644 electron/src/renderer/src/features/settings/llm-providers.tsx create mode 100644 electron/src/renderer/src/features/settings/llm-skills.tsx create mode 100644 electron/src/renderer/src/features/settings/log-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/mcp-bindings-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/media-tools.tsx create mode 100644 electron/src/renderer/src/features/settings/mirror-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/model-catalogue-query.ts create mode 100644 electron/src/renderer/src/features/settings/model-family.ts create mode 100644 electron/src/renderer/src/features/settings/model-library.test.tsx create mode 100644 electron/src/renderer/src/features/settings/model-library.tsx create mode 100644 electron/src/renderer/src/features/settings/model-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/model-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/models-directory-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/models-directory-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/network-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/openapi-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/palette-picker.tsx create mode 100644 electron/src/renderer/src/features/settings/performance-model-packs.test.ts create mode 100644 electron/src/renderer/src/features/settings/performance-model-packs.ts create mode 100644 electron/src/renderer/src/features/settings/performance-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/permissions-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/privacy-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/pronunciation-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/refinement-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/remote-backend-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/remote-backend-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/reset-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/reset-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/scalar-api-reference.tsx create mode 100644 electron/src/renderer/src/features/settings/settings-action-error.tsx create mode 100644 electron/src/renderer/src/features/settings/settings-layout.tsx create mode 100644 electron/src/renderer/src/features/settings/settings-page.tsx create mode 100644 electron/src/renderer/src/features/settings/sharing-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/sharing-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/shortcut-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/shortcut-verification.test.tsx create mode 100644 electron/src/renderer/src/features/settings/storage-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/support-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/support-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/system-preflight.test.tsx create mode 100644 electron/src/renderer/src/features/settings/system-preflight.tsx create mode 100644 electron/src/renderer/src/features/settings/translation-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/translation-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/uninstall-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/uninstall-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/update-settings.test.tsx create mode 100644 electron/src/renderer/src/features/settings/update-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/usage-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/use-settings-action.ts create mode 100644 electron/src/renderer/src/features/settings/voice-previews-settings.tsx create mode 100644 electron/src/renderer/src/features/settings/workers-settings.tsx create mode 100644 electron/src/renderer/src/features/tools/compare-voices.test.tsx create mode 100644 electron/src/renderer/src/features/tools/compare-voices.tsx create mode 100644 electron/src/renderer/src/features/tools/comparison-state.ts create mode 100644 electron/src/renderer/src/features/tools/conversion-state.ts create mode 100644 electron/src/renderer/src/features/tools/convert-voice.test.tsx create mode 100644 electron/src/renderer/src/features/tools/convert-voice.tsx create mode 100644 electron/src/renderer/src/features/tools/tools-page.tsx create mode 100644 electron/src/renderer/src/features/transcriptions/capture-widget.tsx create mode 100644 electron/src/renderer/src/features/transcriptions/dictation-setup.test.tsx create mode 100644 electron/src/renderer/src/features/transcriptions/dictation-setup.tsx create mode 100644 electron/src/renderer/src/features/transcriptions/history.test.ts create mode 100644 electron/src/renderer/src/features/transcriptions/live-dictation.test.ts create mode 100644 electron/src/renderer/src/features/transcriptions/live-dictation.ts create mode 100644 electron/src/renderer/src/features/transcriptions/transcriptions-page.tsx create mode 100644 electron/src/renderer/src/hooks/use-appearance.ts create mode 100644 electron/src/renderer/src/hooks/use-backend-status.test.ts create mode 100644 electron/src/renderer/src/hooks/use-backend-status.ts create mode 100644 electron/src/renderer/src/hooks/use-clone-demo.ts create mode 100644 electron/src/renderer/src/hooks/use-clone-readiness.ts create mode 100644 electron/src/renderer/src/hooks/use-compute-target.test.tsx create mode 100644 electron/src/renderer/src/hooks/use-compute-target.ts create mode 100644 electron/src/renderer/src/hooks/use-dictation-selection.ts create mode 100644 electron/src/renderer/src/hooks/use-engines.ts create mode 100644 electron/src/renderer/src/hooks/use-generate.test.tsx create mode 100644 electron/src/renderer/src/hooks/use-generate.ts create mode 100644 electron/src/renderer/src/hooks/use-history.ts create mode 100644 electron/src/renderer/src/hooks/use-model-install-sync.test.tsx create mode 100644 electron/src/renderer/src/hooks/use-model-install-sync.tsx create mode 100644 electron/src/renderer/src/hooks/use-native-dictation.ts create mode 100644 electron/src/renderer/src/hooks/use-pane-resize.ts create mode 100644 electron/src/renderer/src/hooks/use-performance-profile.test.tsx create mode 100644 electron/src/renderer/src/hooks/use-performance-profile.ts create mode 100644 electron/src/renderer/src/hooks/use-profiles.ts create mode 100644 electron/src/renderer/src/hooks/use-realtime-events.test.tsx create mode 100644 electron/src/renderer/src/hooks/use-realtime-events.tsx create mode 100644 electron/src/renderer/src/hooks/use-recording.test.ts create mode 100644 electron/src/renderer/src/hooks/use-recording.ts create mode 100644 electron/src/renderer/src/hooks/use-reference-transcript.test.ts create mode 100644 electron/src/renderer/src/hooks/use-reference-transcript.ts create mode 100644 electron/src/renderer/src/hooks/use-review-mode.test.ts create mode 100644 electron/src/renderer/src/hooks/use-review-mode.ts create mode 100644 electron/src/renderer/src/hooks/use-theme.ts create mode 100644 electron/src/renderer/src/hooks/use-tts-readiness.test.tsx create mode 100644 electron/src/renderer/src/hooks/use-tts-readiness.ts create mode 100644 electron/src/renderer/src/i18n/index.ts create mode 100644 electron/src/renderer/src/i18n/initial-language.test.tsx create mode 100644 electron/src/renderer/src/i18n/language-switch.test.ts create mode 100644 electron/src/renderer/src/i18n/locale-match.test.ts create mode 100644 electron/src/renderer/src/i18n/locale-match.ts create mode 100644 electron/src/renderer/src/i18n/locales/ar.json create mode 100644 electron/src/renderer/src/i18n/locales/de.json create mode 100644 electron/src/renderer/src/i18n/locales/en.json create mode 100644 electron/src/renderer/src/i18n/locales/es.json create mode 100644 electron/src/renderer/src/i18n/locales/fr.json create mode 100644 electron/src/renderer/src/i18n/locales/hi.json create mode 100644 electron/src/renderer/src/i18n/locales/id.json create mode 100644 electron/src/renderer/src/i18n/locales/it.json create mode 100644 electron/src/renderer/src/i18n/locales/ja.json create mode 100644 electron/src/renderer/src/i18n/locales/ko.json create mode 100644 electron/src/renderer/src/i18n/locales/nl.json create mode 100644 electron/src/renderer/src/i18n/locales/pl.json create mode 100644 electron/src/renderer/src/i18n/locales/pt.json create mode 100644 electron/src/renderer/src/i18n/locales/ru.json create mode 100644 electron/src/renderer/src/i18n/locales/sv.json create mode 100644 electron/src/renderer/src/i18n/locales/th.json create mode 100644 electron/src/renderer/src/i18n/locales/tr.json create mode 100644 electron/src/renderer/src/i18n/locales/uk.json create mode 100644 electron/src/renderer/src/i18n/locales/vi.json create mode 100644 electron/src/renderer/src/i18n/locales/zh-CN.json create mode 100644 electron/src/renderer/src/i18n/locales/zh-TW.json create mode 100644 electron/src/renderer/src/lib/api/audio.ts create mode 100644 electron/src/renderer/src/lib/api/client.test.ts create mode 100644 electron/src/renderer/src/lib/api/client.ts create mode 100644 electron/src/renderer/src/lib/api/convert.test.ts create mode 100644 electron/src/renderer/src/lib/api/convert.ts create mode 100644 electron/src/renderer/src/lib/api/engines.ts create mode 100644 electron/src/renderer/src/lib/api/event-stream.test.ts create mode 100644 electron/src/renderer/src/lib/api/event-stream.ts create mode 100644 electron/src/renderer/src/lib/api/failure.ts create mode 100644 electron/src/renderer/src/lib/api/generate.test.ts create mode 100644 electron/src/renderer/src/lib/api/generate.ts create mode 100644 electron/src/renderer/src/lib/api/history.ts create mode 100644 electron/src/renderer/src/lib/api/profiles.test.ts create mode 100644 electron/src/renderer/src/lib/api/profiles.ts create mode 100644 electron/src/renderer/src/lib/api/types.ts create mode 100644 electron/src/renderer/src/lib/api/websocket.ts create mode 100644 electron/src/renderer/src/lib/app-activity.ts create mode 100644 electron/src/renderer/src/lib/audio/object-url.ts create mode 100644 electron/src/renderer/src/lib/audio/playback-clock.ts create mode 100644 electron/src/renderer/src/lib/audio/playback.ts create mode 100644 electron/src/renderer/src/lib/audio/probe.ts create mode 100644 electron/src/renderer/src/lib/audio/recorder.ts create mode 100644 electron/src/renderer/src/lib/audio/streaming-preview.ts create mode 100644 electron/src/renderer/src/lib/brand.ts create mode 100644 electron/src/renderer/src/lib/clone-readiness.test.ts create mode 100644 electron/src/renderer/src/lib/clone-readiness.ts create mode 100644 electron/src/renderer/src/lib/detached-renderer-tasks.test.ts create mode 100644 electron/src/renderer/src/lib/engine-selection-feedback.test.ts create mode 100644 electron/src/renderer/src/lib/engine-selection-feedback.ts create mode 100644 electron/src/renderer/src/lib/export-history.ts create mode 100644 electron/src/renderer/src/lib/first-sound.ts create mode 100644 electron/src/renderer/src/lib/global-error-recovery.test.ts create mode 100644 electron/src/renderer/src/lib/global-error-recovery.ts create mode 100644 electron/src/renderer/src/lib/i18n-text.ts create mode 100644 electron/src/renderer/src/lib/import-script.ts create mode 100644 electron/src/renderer/src/lib/languages.json create mode 100644 electron/src/renderer/src/lib/languages.ts create mode 100644 electron/src/renderer/src/lib/local-export.test.ts create mode 100644 electron/src/renderer/src/lib/local-export.ts create mode 100644 electron/src/renderer/src/lib/log-format.test.ts create mode 100644 electron/src/renderer/src/lib/log-format.ts create mode 100644 electron/src/renderer/src/lib/preload-recovery.test.ts create mode 100644 electron/src/renderer/src/lib/preload-recovery.ts create mode 100644 electron/src/renderer/src/lib/query.ts create mode 100644 electron/src/renderer/src/lib/repair-agent-events.ts create mode 100644 electron/src/renderer/src/lib/repair-log-analysis.test.ts create mode 100644 electron/src/renderer/src/lib/repair-log-analysis.ts create mode 100644 electron/src/renderer/src/lib/report-breadcrumb.test.ts create mode 100644 electron/src/renderer/src/lib/report-breadcrumb.ts create mode 100644 electron/src/renderer/src/lib/settings-route.test.ts create mode 100644 electron/src/renderer/src/lib/settings-route.ts create mode 100644 electron/src/renderer/src/lib/setup-progress.ts create mode 100644 electron/src/renderer/src/lib/status-polling.test.ts create mode 100644 electron/src/renderer/src/lib/status-polling.ts create mode 100644 electron/src/renderer/src/lib/store/clone-settings.test.ts create mode 100644 electron/src/renderer/src/lib/store/clone-settings.ts create mode 100644 electron/src/renderer/src/lib/store/dictation-settings.ts create mode 100644 electron/src/renderer/src/lib/store/output.ts create mode 100644 electron/src/renderer/src/lib/store/reference.test.ts create mode 100644 electron/src/renderer/src/lib/store/reference.ts create mode 100644 electron/src/renderer/src/lib/store/takes.test.ts create mode 100644 electron/src/renderer/src/lib/store/takes.ts create mode 100644 electron/src/renderer/src/lib/store/workspace.ts create mode 100644 electron/src/renderer/src/lib/synthesis-lock.ts create mode 100644 electron/src/renderer/src/lib/textarea-caret.ts create mode 100644 electron/src/renderer/src/lib/themes/t3-palettes.ts create mode 100644 electron/src/renderer/src/lib/themes/tauri-palette.ts create mode 100644 electron/src/renderer/src/lib/themes/theme-preferences.test.ts create mode 100644 electron/src/renderer/src/lib/themes/theme-preferences.ts create mode 100644 electron/src/renderer/src/lib/utils.ts create mode 100644 electron/src/renderer/src/lib/vercel-oidc-browser.ts create mode 100644 electron/src/renderer/src/main.tsx create mode 100644 electron/src/renderer/src/qrcode.d.ts create mode 100644 electron/src/renderer/src/router.tsx create mode 100644 electron/src/renderer/src/routes/index.ts create mode 100644 electron/src/renderer/src/styles/globals.css create mode 100644 electron/src/renderer/src/styles/t3-theme.css create mode 100644 electron/src/renderer/src/test/setup.ts create mode 100644 electron/src/shared/repair-request.test.ts create mode 100644 electron/src/shared/repair-request.ts create mode 100644 electron/tests/accessibility-smoke.mjs create mode 100644 electron/tests/asr-isolation-real-smoke.mjs create mode 100644 electron/tests/batch-smoke.mjs create mode 100644 electron/tests/capture-target.cjs create mode 100644 electron/tests/clone-demo-smoke.mjs create mode 100644 electron/tests/community-real-smoke.mjs create mode 100644 electron/tests/compare-voices-real-smoke.mjs create mode 100644 electron/tests/connection-settings-smoke.mjs create mode 100644 electron/tests/convert-live-smoke.mjs create mode 100644 electron/tests/design-demo-real-smoke.mjs create mode 100644 electron/tests/design-profile-edit-real-smoke.mjs create mode 100644 electron/tests/dictation-demo-real-smoke.mjs create mode 100644 electron/tests/dub-smoke.mjs create mode 100644 electron/tests/error-recovery-smoke.mjs create mode 100644 electron/tests/fake-health-backend.mjs create mode 100644 electron/tests/gallery-import-real-smoke.mjs create mode 100644 electron/tests/gallery-preview-fallback-smoke.mjs create mode 100644 electron/tests/gallery-smoke.mjs create mode 100644 electron/tests/gallery-trim-real-smoke.mjs create mode 100644 electron/tests/live-dictation-real-smoke.mjs create mode 100644 electron/tests/live-dictation-smoke.mjs create mode 100644 electron/tests/live-engine-state-smoke.mjs create mode 100644 electron/tests/live-route-health.mjs create mode 100644 electron/tests/llm-providers-smoke.mjs create mode 100644 electron/tests/llm-skills-smoke.mjs create mode 100644 electron/tests/locale-coverage.mjs create mode 100644 electron/tests/locale-encoding.mjs create mode 100644 electron/tests/locale-layout-smoke.mjs create mode 100644 electron/tests/locale-source-keys.mjs create mode 100644 electron/tests/logs-smoke.mjs create mode 100644 electron/tests/longform-live.mjs create mode 100644 electron/tests/longform-smoke.mjs create mode 100644 electron/tests/media-tools-smoke.mjs create mode 100644 electron/tests/mirror-settings-smoke.mjs create mode 100644 electron/tests/model-recommendations-smoke.mjs create mode 100644 electron/tests/model-selection-real-smoke.mjs create mode 100644 electron/tests/native-capture-smoke.mjs create mode 100644 electron/tests/native-crash-journal-smoke.mjs create mode 100644 electron/tests/native-dev-dictation-real-smoke.mjs create mode 100644 electron/tests/native-global-dictation-smoke.mjs create mode 100644 electron/tests/native-insertion-smoke.mjs create mode 100644 electron/tests/native-local-export-smoke.mjs create mode 100644 electron/tests/native-media-picker-smoke.mjs create mode 100644 electron/tests/native-output-smoke.ts create mode 100644 electron/tests/native-persona-export-smoke.mjs create mode 100644 electron/tests/native-repair-agent-smoke.mjs create mode 100644 electron/tests/native-report-bug-smoke.mjs create mode 100644 electron/tests/native-shortcut-smoke.ts create mode 100644 electron/tests/native-translation-agent-smoke.mjs create mode 100644 electron/tests/native-watch-folder-smoke.ts create mode 100644 electron/tests/native-watch-ui-smoke.mjs create mode 100644 electron/tests/native-wayland-output-smoke.ts create mode 100644 electron/tests/packaged-repair-agent-smoke.mjs create mode 100644 electron/tests/packaged-repair-operation-acceptance.mjs create mode 100644 electron/tests/packaged-smoke.mjs create mode 100644 electron/tests/packaging-contract.mjs create mode 100644 electron/tests/performance-settings-smoke.mjs create mode 100644 electron/tests/persona-roundtrip-real-smoke.mjs create mode 100644 electron/tests/personality-presets-real-smoke.mjs create mode 100644 electron/tests/playback-smoke.mjs create mode 100644 electron/tests/privacy-settings-smoke.mjs create mode 100644 electron/tests/profile-consent-real-smoke.mjs create mode 100644 electron/tests/profile-image-search-real-smoke.mjs create mode 100644 electron/tests/projects-smoke.mjs create mode 100644 electron/tests/renderer-smoke.vite.config.mjs create mode 100644 electron/tests/route-layout-smoke.mjs create mode 100644 electron/tests/runtime-health-smoke.mjs create mode 100644 electron/tests/runtime-install-smoke.mjs create mode 100644 electron/tests/save-take-profile-real-smoke.mjs create mode 100644 electron/tests/secondary-sidebar-smoke.mjs create mode 100644 electron/tests/serve-renderer.mjs create mode 100644 electron/tests/setup-gate-smoke.mjs create mode 100644 electron/tests/sidebar-layout-smoke.mjs create mode 100644 electron/tests/storage-settings-smoke.mjs create mode 100644 electron/tests/stories-live-smoke.mjs create mode 100644 electron/tests/support-smoke.mjs create mode 100644 electron/tests/system-preflight-real-smoke.mjs create mode 100644 electron/tests/test-wave.mjs create mode 100644 electron/tests/tools-smoke.mjs create mode 100644 electron/tests/transcriptions-real-smoke.mjs create mode 100644 electron/tests/transcriptions-smoke.mjs create mode 100644 electron/tests/update-package-contract.mjs create mode 100644 electron/tests/workspace-shell-smoke.mjs create mode 100644 electron/tsconfig.json create mode 100644 electron/tsconfig.node.json create mode 100644 electron/tsconfig.web.json create mode 100644 electron/vite.config.ts create mode 100644 frontend/src/api/archetypes-types.d.ts create mode 100644 frontend/src/api/batch-types.d.ts create mode 100644 frontend/src/api/community-types.d.ts create mode 100644 frontend/src/api/gallery-types.d.ts create mode 100644 frontend/src/api/setup-types.d.ts create mode 100644 frontend/src/components/gallery/facets.d.ts create mode 100644 frontend/src/components/gallery/facets.js create mode 100644 frontend/src/components/settings/models/format.d.ts create mode 100644 frontend/src/components/settings/refineStatus.d.ts create mode 100644 frontend/src/config/sponsors.d.ts create mode 100644 frontend/src/data/sampleAudiobook.d.ts create mode 100644 frontend/src/data/sampleStory.d.ts create mode 100644 frontend/src/utils/aec/farEndBus.d.ts create mode 100644 frontend/src/utils/aec/micCapture.d.ts create mode 100644 frontend/src/utils/aec/pcm.d.ts create mode 100644 frontend/src/utils/aec/playbackTap.d.ts create mode 100644 frontend/src/utils/audioTrim.d.ts create mode 100644 frontend/src/utils/audiobookLyrics.d.ts create mode 100644 frontend/src/utils/audiobookScript.d.ts create mode 100644 frontend/src/utils/autoCast.d.ts create mode 100644 frontend/src/utils/autoCast.js create mode 100644 frontend/src/utils/breadcrumbs.d.ts create mode 100644 frontend/src/utils/bugReportDocument.d.ts create mode 100644 frontend/src/utils/bugReportDocument.js create mode 100644 frontend/src/utils/captureProtocol.d.ts create mode 100644 frontend/src/utils/captureProtocol.js create mode 100644 frontend/src/utils/consoleBuffer.d.ts create mode 100644 frontend/src/utils/constants.d.ts create mode 100644 frontend/src/utils/contactLinks.d.ts create mode 100644 frontend/src/utils/contactLinks.js create mode 100644 frontend/src/utils/cookieExport.ts create mode 100644 frontend/src/utils/crashReport.d.ts create mode 100644 frontend/src/utils/crashReport.js create mode 100644 frontend/src/utils/dictationScripts.d.ts create mode 100644 frontend/src/utils/dictationScripts.js create mode 100644 frontend/src/utils/dictationShortcut.d.ts create mode 100644 frontend/src/utils/donateLinks.d.ts create mode 100644 frontend/src/utils/foreignErrors.d.ts create mode 100644 frontend/src/utils/foreignErrors.js create mode 100644 frontend/src/utils/importStory.d.ts create mode 100644 frontend/src/utils/languages.d.ts create mode 100644 frontend/src/utils/longformOverrides.d.ts create mode 100644 frontend/src/utils/longformOverrides.js create mode 100644 frontend/src/utils/longformStream.d.ts create mode 100644 frontend/src/utils/markdownLite.d.ts create mode 100644 frontend/src/utils/multiLang.d.ts create mode 100644 frontend/src/utils/networkPreferences.d.ts create mode 100644 frontend/src/utils/networkPreferences.js create mode 100644 frontend/src/utils/pasteTranslations.d.ts create mode 100644 frontend/src/utils/runCrashRecord.ts create mode 100644 frontend/src/utils/scrub.d.ts create mode 100644 frontend/src/utils/seed.d.ts create mode 100644 frontend/src/utils/segmentParts.d.ts create mode 100644 frontend/src/utils/segments.d.ts create mode 100644 frontend/src/utils/shortcutAccelerator.d.ts create mode 100644 frontend/src/utils/shortcutAccelerator.js create mode 100644 frontend/src/utils/splitStoryText.d.ts create mode 100644 frontend/src/utils/splitStoryText.js create mode 100644 frontend/src/utils/sseParse.d.ts create mode 100644 frontend/src/utils/storageWarnings.d.ts create mode 100644 frontend/src/utils/storageWarnings.js create mode 100644 frontend/src/utils/storyExport.d.ts create mode 100644 frontend/src/utils/storyReorder.d.ts create mode 100644 frontend/src/utils/storyToSpans.d.ts create mode 100644 frontend/src/utils/timeline.d.ts create mode 100644 frontend/src/utils/transcriptionFormat.d.ts create mode 100644 frontend/src/utils/transcriptionFormat.js create mode 100644 frontend/src/utils/transcriptionsStore.d.ts create mode 100644 frontend/src/utils/translationProviderFields.d.ts create mode 100644 frontend/src/utils/translationProviderFields.js create mode 100644 frontend/src/utils/translatorPrivacy.d.ts create mode 100644 frontend/src/utils/translatorPrivacy.js create mode 100644 frontend/src/utils/voiceInstruct.d.ts create mode 100644 frontend/src/utils/watchFolderTracker.d.ts create mode 100644 frontend/src/utils/watchFolderTracker.js create mode 100644 native/desktop-bridge/.cargo/config.toml create mode 100644 native/desktop-bridge/.gitignore create mode 100644 native/desktop-bridge/Cargo.lock create mode 100644 native/desktop-bridge/Cargo.toml create mode 100644 native/desktop-bridge/README.md create mode 100644 native/desktop-bridge/src/cleanup.rs create mode 100644 native/desktop-bridge/src/main.rs create mode 100644 native/desktop-bridge/src/shortcuts.rs create mode 100644 scripts/check_electron_release_assets.py create mode 100644 scripts/smoke_dub_url_captions.py create mode 100644 scripts/smoke_sortformer.py create mode 100644 tests/backend/api/test_system_gpu_detection.py create mode 100644 tests/test_audiocpp_runtime_install.py create mode 100644 tests/test_diarization_native_contract.py create mode 100644 tests/test_diarization_runtime_status.py create mode 100644 tests/test_electron_model_management.py create mode 100644 tests/test_electron_parity_inventory.py create mode 100644 tests/test_electron_release_assets.py create mode 100644 tests/test_model_status_events.py create mode 100644 tests/test_performance_profiles.py create mode 100644 tests/test_profile_images.py create mode 100644 tests/test_profile_storage_recovery.py create mode 100644 tests/test_profile_usage.py create mode 100644 tests/test_segmented_cache_pointer.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3dcefd..f81389ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,6 +172,12 @@ jobs: working-directory: frontend run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs + # Electron used to be built only after a release started, so renderer, + # preload and packaging regressions could pass the required PR gate. + # Keep this command shared with release.yml through the root script. + - name: Electron typecheck, tests and production contract + run: bun run check:electron + # Production-bundle blank-screen gate. Everything above runs UN-minified # (dev server + Vitest/jsdom), so a crash that exists ONLY in the minified # release bundle — a TDZ reorder that throws before React mounts — passes @@ -186,6 +192,28 @@ jobs: - name: Production-bundle smoke — no blank screen working-directory: frontend run: bun run test:prod-bundle + - name: Electron renderer workflow smokes + shell: bash + run: | + set -euo pipefail + export OMNIVOICE_PORT=3999 + export VOICESTUDIO_UI_URL=http://localhost:3912 + export PLAYWRIGHT_CHANNEL=chromium + bun run --cwd electron smoke:server > /tmp/voicestudio-electron-smoke.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true' EXIT + for _ in {1..60}; do + if curl --fail --silent --show-error "$VOICESTUDIO_UI_URL" >/dev/null; then + break + fi + sleep 0.25 + done + curl --fail --silent --show-error "$VOICESTUDIO_UI_URL" >/dev/null || { + cat /tmp/voicestudio-electron-smoke.log + exit 1 + } + node electron/tests/playback-smoke.mjs + node electron/tests/dub-smoke.mjs # ── Cross-platform Tauri shell check ──────────────────────────────────── # Catches platform-specific Rust regressions on PR (cfg(target_os=...) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b6bd239..4a0c50e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -142,6 +142,9 @@ jobs: working-directory: frontend run: node --experimental-strip-types --no-warnings --test ../tests/frontend/*.test.mjs + - name: Electron typecheck, tests and production contract + run: bun run check:electron + # Decide preview-vs-stable, and for nightly runs whether `main` actually # moved in the last day. Outputs gate the expensive matrix (`build`) and the # `preview-notes` job, so a no-commit night costs only this ~30s job. @@ -311,7 +314,7 @@ jobs: libwebkit2gtk-4.1-dev \ build-essential curl wget file libxdo-dev libssl-dev \ libayatana-appindicator3-dev librsvg2-dev \ - libasound2-dev ffmpeg + libasound2-dev ffmpeg xvfb # ── Frontend build ───────────────────────────────────────────────── - name: Cache bun deps @@ -344,7 +347,7 @@ jobs: - name: Bundle uv (${{ matrix.rust_target }}) shell: bash env: - UV_VERSION: "0.11.7" + UV_VERSION: "0.12.13" TRIPLE: ${{ matrix.rust_target }} run: | set -euo pipefail @@ -663,6 +666,83 @@ jobs: updaterJsonPreferNsis: false includeUpdaterJson: true + - name: Build + publish Electron desktop + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} + IS_PREVIEW: ${{ needs.preview-gate.outputs.is_preview }} + VOICESTUDIO_RUST_TARGET: ${{ matrix.rust_target }} + run: | + set -euo pipefail + case "${{ matrix.rust_target }}" in + aarch64-apple-darwin) ELECTRON_OS=darwin; ELECTRON_ARCH=arm64; FLAGS="--mac --arm64" ;; + x86_64-apple-darwin) ELECTRON_OS=darwin; ELECTRON_ARCH=x64; FLAGS="--mac --x64" ;; + x86_64-pc-windows-msvc) ELECTRON_OS=win32; ELECTRON_ARCH=x64; FLAGS="--win --x64" ;; + x86_64-unknown-linux-gnu) ELECTRON_OS=linux; ELECTRON_ARCH=x64; FLAGS="--linux --x64" ;; + *) echo "Unsupported Electron target: ${{ matrix.rust_target }}"; exit 1 ;; + esac + if [ "$IS_PREVIEW" = "true" ]; then + export VOICESTUDIO_UPDATE_CHANNEL="electron-preview-${ELECTRON_OS}-${ELECTRON_ARCH}" + else + export VOICESTUDIO_UPDATE_CHANNEL="electron-stable-${ELECTRON_OS}-${ELECTRON_ARCH}" + fi + if [ -n "${APPLE_CERTIFICATE:-}" ]; then + export CSC_LINK="$APPLE_CERTIFICATE" + export CSC_KEY_PASSWORD="${APPLE_CERTIFICATE_PASSWORD:-}" + fi + + bun install --frozen-lockfile + ( + cd electron + bun run build + node tests/packaging-contract.mjs + bun x electron-builder \ + --config electron-builder.config.mjs $FLAGS --publish never + node tests/packaging-contract.mjs --artifact + if [ "$RUNNER_OS" = "Linux" ]; then + xvfb-run -a node tests/packaged-smoke.mjs --setup + else + node tests/packaged-smoke.mjs --setup + fi + node tests/update-package-contract.mjs \ + --channel "$VOICESTUDIO_UPDATE_CHANNEL" \ + --platform "$ELECTRON_OS" \ + --arch "$ELECTRON_ARCH" + ) + + # A rolling preview reuses one release. Remove only this platform / + # architecture's older Electron artifacts before publishing the new + # version; sibling matrix legs own different names and metadata. + if [ "$IS_PREVIEW" = "true" ]; then + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' > electron-assets.txt + case "${{ runner.os }}" in + Windows) OS_TOKEN=win ;; + macOS) OS_TOKEN=mac ;; + Linux) OS_TOKEN=linux ;; + esac + while IFS= read -r asset; do + case "$asset" in + VoiceStudio-Electron-*-${OS_TOKEN}-${ELECTRON_ARCH}.*|${VOICESTUDIO_UPDATE_CHANNEL}*.yml) + gh release delete-asset "$RELEASE_TAG" "$asset" --yes --repo "$GITHUB_REPOSITORY" + ;; + esac + done < electron-assets.txt + fi + + electron_artifact_count=0 + while IFS= read -r artifact; do + gh release upload "$RELEASE_TAG" "$artifact" --clobber --repo "$GITHUB_REPOSITORY" + electron_artifact_count=$((electron_artifact_count + 1)) + done < <(find electron/release -maxdepth 1 -type f \ + \( -name 'VoiceStudio-Electron-*' -o -name "${VOICESTUDIO_UPDATE_CHANNEL}*.yml" \) | sort) + if [ "$electron_artifact_count" -eq 0 ]; then + echo "FAIL — Electron build produced no publishable artifacts" + find electron/release -maxdepth 1 -type f -print || true + exit 1 + fi + - name: Build per-user Windows MSI if: runner.os == 'Windows' shell: bash @@ -914,6 +994,10 @@ jobs: -o -name "*.msi" -o -name "*.msi.sig" \ -o -name "*.AppImage" -o -name "*.AppImage.sig" \ -o -name "*.deb" \) 2>/dev/null | sort) + while IFS= read -r artifact; do + ARTIFACTS+=("$artifact") + done < <(find electron/release -maxdepth 1 -type f \ + \( -name 'VoiceStudio-Electron-*' -o -name 'electron-*.yml' \) 2>/dev/null | sort) if [ ${#ARTIFACTS[@]} -eq 0 ]; then echo "FAIL — no artifacts found under $BUNDLE_DIR" @@ -1031,6 +1115,51 @@ jobs: # This job runs once after the whole matrix as the single final writer: # it makes the manifest's linux signature agree with the .sig asset that # actually shipped, and refuses to leave a mismatch behind. + # The matrix validates each Electron package before upload. This final read-only + # check validates the other half of the contract: GitHub must actually serve + # all four manifests and every payload they name. Without it a green release + # can leave the in-app updater with four 404 feeds. + electron-publish-contract: + needs: [build, preview-gate] + if: >- + needs.build.result == 'success' && + (needs.preview-gate.outputs.is_preview == 'true' || startsWith(github.ref, 'refs/tags/v')) + runs-on: ubuntu-22.04 + permissions: + contents: read + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || github.ref_name }} + CHANNEL: ${{ (needs.preview-gate.outputs.is_preview == 'true') && 'preview' || 'stable' }} + STABLE_TAG: ${{ needs.preview-gate.outputs.stable_tag }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Verify published Electron updater assets + shell: bash + run: | + set -euo pipefail + WORK="$(mktemp -d)" + mkdir -p "$WORK/manifests" + gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \ + --json tagName,isPrerelease,assets > "$WORK/release.json" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern "electron-${CHANNEL}-*.yml" --dir "$WORK/manifests" + if [ "$CHANNEL" = "preview" ]; then + VERSION=$(python3 scripts/stamp-preview-version.py \ + --package-json frontend/package.json \ + --stable-tag "$STABLE_TAG" \ + --run-number "${{ github.run_number }}") + else + VERSION=$(python3 -c 'import json; print(json.load(open("frontend/package.json"))["version"])') + fi + python3 scripts/check_electron_release_assets.py \ + --release-json "$WORK/release.json" \ + --manifest-dir "$WORK/manifests" \ + --channel "$CHANNEL" \ + --version "$VERSION" + repair-updater-manifest: needs: [build, preview-gate] runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 86efe985..256ba51d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ node_modules .turbo/ bun.lockb frontend/src-tauri/target/ +electron/.tmp-native-target/ +native/**/target*/ # ───────────────────────────────────────────────────────────────────────── # Secrets & env @@ -55,6 +57,7 @@ memxt.db-wal !.claude/agents/** /.cache* /.tmp/ +/.tmp-* # ───────────────────────────────────────────────────────────────────────── # Research clones — upstream repos used as reference, not shipped diff --git a/CHANGELOG.md b/CHANGELOG.md index c52baa68..77e2dd83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ the frozen-backend fallback mirror it for their toolchains. **Highlights** +- Electron now ships as a complete cross-platform VoiceStudio desktop app with local-first cloning, production workspaces, model packs, repair agents, native integrations, updates, parity checks, and the shared backend contracts required by those workflows (#1823) + - The Model Catalogue is one page: what you use now on top, then each family's engines and weights (#2013) - VoxCPM2 installs in one click into its own environment, with the CUDA build of PyTorch on NVIDIA GPUs (#2021) - MOSS-TTS-Nano installs in one click into its own environment, pinned to a reviewed upstream commit it works with (#2022) diff --git a/backend/api/routers/audiobook.py b/backend/api/routers/audiobook.py index 752ad1cb..fbe704a0 100644 --- a/backend/api/routers/audiobook.py +++ b/backend/api/routers/audiobook.py @@ -420,8 +420,11 @@ def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict: today exactly: num_step 32, guidance 2.0, and NO temperature/postprocess kwargs (the model keeps its own defaults). Emotion is never forwarded — the VoiceStudio config rejects unknown kwargs.""" + from services.performance_profiles import tts_defaults + + defaults = tts_defaults() kw = { - "num_step": opts.num_step if opts.num_step is not None else LONGFORM_NUM_STEP, + "num_step": opts.num_step if opts.num_step is not None else defaults.get("num_step", LONGFORM_NUM_STEP), "guidance_scale": ( opts.guidance_scale if opts.guidance_scale is not None else LONGFORM_GUIDANCE_SCALE ), @@ -432,6 +435,8 @@ def _omnivoice_sampling_kwargs(opts: ExpressiveOptions) -> dict: kw["class_temperature"] = opts.class_temperature if opts.postprocess_output is not None: kw["postprocess_output"] = opts.postprocess_output + elif "postprocess_output" in defaults: + kw["postprocess_output"] = defaults["postprocess_output"] return kw diff --git a/backend/api/routers/batch.py b/backend/api/routers/batch.py index e8911073..cae82ff1 100644 --- a/backend/api/routers/batch.py +++ b/backend/api/routers/batch.py @@ -9,6 +9,8 @@ the SQLite `jobs` table for history, but the queue itself restarts empty on backend restart — intentional, since GPU jobs can't be safely resumed. """ import os +import json +import shutil import uuid import time import asyncio @@ -16,20 +18,42 @@ import logging from typing import Optional, List from fastapi import APIRouter, File, UploadFile, HTTPException, Form +from fastapi.responses import JSONResponse from pydantic import BaseModel from core.config import DATA_DIR from core import failure from core.logging_utils import log_safe from core.file_cleanup import FileCleanupError, unlink_if_present +from services.dub_batching import ( + BATCH_WIDTH_ENV, + batch_timeout_s as _batch_timeout_s, + native_batch_width as _native_batch_width, +) +from services import gpu_gateway +from services.segment_bundle import extract_segment_wavs, remove_segment_wavs +from services.tts_backend import active_backend_id, resolve_generation_backend router = APIRouter() logger = logging.getLogger("omnivoice.batch") +# Compatibility values emitted by the established Tauri Batch picker. They +# are taxonomy tokens, not arbitrary prose, and are resolved server-side so +# native watch-folder uploads and both desktop clients use the same voice. +_BATCH_PRESET_INSTRUCT = { + "narrator": "male, middle-aged, low pitch, british accent", + "excited_child": "child, high pitch", + "anxious_whisper": "young adult, whisper", + "surprised_woman": "female, young adult, high pitch", + "elderly_story": "male, elderly, very low pitch", + "sichuan": "female, young adult, moderate pitch, \u56db\u5ddd\u8bdd", +} + # ── In-memory queue ───────────────────────────────────────────────────── _queue: asyncio.Queue = None # Lazily initialised _worker_task: asyncio.Task = None # Background consumer +_processing_job_ids: set[str] = set() _jobs: dict = {} # job_id → status dict @@ -40,11 +64,15 @@ class BatchJobStatus(BaseModel): langs: List[str] voice_id: Optional[str] = None preserve_bg: bool = True + translation_provider: Optional[str] = None created_at: float started_at: Optional[float] = None finished_at: Optional[float] = None error: Optional[str] = None progress: Optional[dict] = None + attempts: int = 1 + retry_ready: bool = True + setup_required: Optional[dict] = None def _ensure_queue(): @@ -66,6 +94,7 @@ async def _worker(): job["status"] = "running" job["started_at"] = time.time() + _processing_job_ids.add(job_id) logger.info("Batch job %s starting: %s", job_id, job["filename"]) try: @@ -95,6 +124,9 @@ async def _worker(): job["finished_at"] = time.time() logger.error("Batch job %s failed: %s", job_id, e, exc_info=True) finally: + _processing_job_ids.discard(job_id) + if job["status"] == "cancelled": + job["retry_ready"] = True _queue.task_done() @@ -104,18 +136,62 @@ def _set_progress(job, stage, percent=0, **extra): #: Override for the native dub batch width. Set to 1 to disable batching. -BATCH_WIDTH_ENV = "OMNIVOICE_DUB_BATCH_WIDTH" #: Hard ceiling on the override — a batch this wide is already amortizing #: almost all of the per-call setup, and beyond it the failure mode is an OOM #: that costs more than the saving. -_MAX_BATCH_WIDTH = 16 # Bound each allocation while persisting multipart uploads. Video inputs can # be many gigabytes; `await UploadFile.read()` with no size used to mirror the # entire file in process memory before writing it back out. _UPLOAD_CHUNK_BYTES = 1024 * 1024 +_REMOTE_BATCH_OPERATION = "batch_segments" + + +async def _resolve_batch_execution(voice: dict): + """Resolve Batch's TTS target without loading local weights remotely.""" + engine_id = active_backend_id() + decision = gpu_gateway.decide("batch") + if decision.remote: + await gpu_gateway.preflight( + engine_id, + decision, + operation=_REMOTE_BATCH_OPERATION, + ) + return engine_id, decision, None + backend = await resolve_generation_backend( + require_cloning=voice["requires_cloning"], + cloning_purpose="this batch job's pinned voice", + ) + return engine_id, decision, backend + + +def _decode_remote_batch( + result: gpu_gateway.RemoteResult, + batch_dir: str, + expected: set[int], +) -> tuple[dict[int, str], int]: + """Validate and unpack one worker result before accepting remote success.""" + import soundfile as sf + + target = os.path.join(batch_dir, ".remote", result.task_id) + paths = extract_segment_wavs(result.path or "", target) + try: + if set(paths) != expected: + missing = sorted(expected - set(paths)) + extra = sorted(set(paths) - expected) + raise ValueError( + f"segment bundle mismatch (missing={missing}, extra={extra})" + ) + rates = {int(sf.info(path).samplerate) for path in paths.values()} + if len(rates) != 1 or next(iter(rates), 0) <= 0: + raise ValueError("segment bundle has inconsistent sample rates") + return paths, rates.pop() + except BaseException: + remove_segment_wavs(paths) + raise + async def _save_upload(upload: UploadFile, destination: str) -> None: try: @@ -130,64 +206,63 @@ async def _save_upload(upload: UploadFile, destination: str) -> None: raise -def _native_batch_width(backend) -> int: - """How many segments to render in one native batch on THIS host. +def _batch_voice(voice_id: str | None) -> dict: + """Resolve one queue-wide voice into concrete generation inputs. - A native batch widens the forward pass, so the width cannot be a constant. - The default engine declares ``min_vram_gb = 6.0`` for a SINGLE job; an - unconditional 8-wide batch would OOM the 4-8 GB CUDA cards and the MPS - Macs where the per-segment path succeeds today — turning a throughput - optimization into a regression on exactly the hardware that already - struggles (#1616 is a 4 GB card reporting capacity failures). Default - behaviour must not get riskier on a host, so the width is derived from - measured headroom and falls back to 1 (no batching) when unknown. - - CPU hosts get 1: batching there buys no kernel amortization and only - multiplies peak RAM. + Clone profiles contribute their reference; designed profiles contribute + their healed instruction and seed. Legacy ``preset:`` selections become + the same instruction used by Dubbing instead of falling through to the + engine default. """ - override = os.environ.get(BATCH_WIDTH_ENV, "").strip() - if override: - try: - return max(1, min(_MAX_BATCH_WIDTH, int(override))) - except (TypeError, ValueError): - logger.warning( - "%s=%r is not an integer — deriving the batch width from the host instead.", - BATCH_WIDTH_ENV, override, - ) - try: - from core.device_caps import detect_host_caps - caps = detect_host_caps() - except Exception: # noqa: BLE001 — an unprobeable host takes the safe path - return 1 - if caps.family == "cpu" or not caps.vram_gb: - return 1 - headroom = caps.vram_gb - float(getattr(backend, "min_vram_gb", 0.0) or 0.0) - if headroom < 2.0: - return 1 - if headroom < 6.0: - return 2 - if headroom < 12.0: - return 4 - return 8 + resolved = { + "ref_audio": None, + "ref_text": None, + "instruct": "", + "seed": None, + "requires_cloning": False, + } + if not voice_id: + return resolved + if voice_id.startswith("preset:"): + preset_id = voice_id.removeprefix("preset:") + instruct = _BATCH_PRESET_INSTRUCT.get(preset_id) + if instruct is None: + raise ValueError("That built-in voice preset no longer exists") + from omnivoice.utils.voice_design import sanitize_instruct + resolved["instruct"] = sanitize_instruct(instruct) + return resolved -def _batch_timeout_s(texts: list[str], backend) -> float: - """Execution budget for one native batch. + from core.config import VOICES_DIR + from core.db import db_conn - Not the sum of the per-item budgets: ``generate_timeout_s`` returns a - floor (300s GPU / 600s CPU) plus per-length overage, so summing it across - eight items yields a ~2400s budget — and a wedged batch would hold a - GPU-pool worker for forty minutes before the reset this file depends on - (#730). One floor covers wedge detection for the whole call; only the - length-driven overage is genuinely additive. - """ - from services.model_manager import generate_timeout_s + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM voice_profiles WHERE id=?", + (voice_id,), + ).fetchone() + if row is None: + raise ValueError("That saved voice no longer exists") - floor = generate_timeout_s("", engine=backend) - overage = sum( - max(0.0, generate_timeout_s(text, engine=backend) - floor) for text in texts - ) - return floor + overage + if row["kind"] == "design": + from omnivoice.utils.voice_design import heal_design_instruct + + resolved["instruct"] = heal_design_instruct(row["instruct"], row["vd_states"]) + resolved["seed"] = int(row["seed"]) if row["seed"] is not None else None + return resolved + + relative = row["locked_audio_path"] if row["is_locked"] else row["ref_audio_path"] + if not relative: + raise ValueError("That saved voice has no reference audio") + ref_audio = os.path.join(VOICES_DIR, relative) + if not os.path.isfile(ref_audio): + raise ValueError("That saved voice's reference audio is missing") + resolved.update({ + "ref_audio": ref_audio, + "ref_text": row["ref_text"], + "requires_cloning": True, + }) + return resolved async def _run_batch_pipeline(job_id: str, job: dict): @@ -280,19 +355,30 @@ async def _run_batch_pipeline(job_id: str, job: dict): return # ── Engine resolution (issue #312 class) ──────────────────────────── - # Batch used to hardcode VoiceStudio via get_model() regardless of the - # engine selected in Model Catalogue. require_cloning only when a - # specific voice is pinned (job["voice_id"]) — an unpinned job is fine on - # any active engine. Resolved ONCE for the whole job (every language + # Batch used to hardcode VoiceStudio regardless of the engine selected in + # Model Catalogue. Clone profiles require a cloning-capable engine; presets + # and designed voices use instruction mode. Resolve once for the whole job. # below shares the same active engine); an uncaught ValueError here # propagates to _worker()'s existing except-Exception handling, which # already records a structured job failure via core.failure.build_failure. - from services.tts_backend import resolve_generation_backend - backend = await resolve_generation_backend( - require_cloning=bool(job.get("voice_id")), - cloning_purpose="this batch job's pinned voice", - ) - sr = backend.sample_rate + voice = _batch_voice(job.get("voice_id")) + engine_id, execution_target, backend = await _resolve_batch_execution(voice) + sr = backend.sample_rate if backend is not None else 0 + from services.performance_profiles import tts_defaults + _profile_defaults = tts_defaults(engine_id) + _batch_num_step = _profile_defaults.get("num_step", 16) + _batch_postprocess = _profile_defaults.get("postprocess_output", True) + batch_run = gpu_gateway.JobRun("batch") + + async def _prepare_local_batch() -> gpu_gateway.LocalCall: + nonlocal backend, sr + if backend is None: + backend = await resolve_generation_backend( + require_cloning=voice["requires_cloning"], + cloning_purpose="this batch job's pinned voice", + ) + sr = backend.sample_rate + return gpu_gateway.LocalCall(fn=lambda: None, what="Batch TTS fallback") # ── 3. Translate + Generate per language ─────────────────────────── total_langs = len(langs) @@ -311,40 +397,65 @@ async def _run_batch_pipeline(job_id: str, job: dict): translated_segments = list(segments) # copy if target_lang != source_lang: - try: - def _translate_batch(segs, src, tgt): - """Translate segment texts via Google Translate.""" - from deep_translator import GoogleTranslator - TRANSLATE_CODES = { - "en": "en", "es": "es", "fr": "fr", "de": "de", - "it": "it", "pt": "pt", "ru": "ru", "ja": "ja", - "ko": "ko", "zh": "zh-CN", "ar": "ar", "hi": "hi", - "tr": "tr", "pl": "pl", "nl": "nl", "sv": "sv", - } - src_code = TRANSLATE_CODES.get(src, src) or "auto" - tgt_code = TRANSLATE_CODES.get(tgt, tgt) - translator = GoogleTranslator(source=src_code, target=tgt_code) - out = [] - for s in segs: - s_copy = dict(s) - text = s.get("text", "").strip() - if text: - try: - s_copy["text"] = translator.translate(text) or text - except Exception as e: - logger.warning("Translate seg failed: %s", e) - out.append(s_copy) - return out + # Use the same provider dispatch as interactive Dubbing. The old + # batch-only implementation hardcoded Google and silently kept the + # source text on failure, which could make an English track labelled + # "es" while also sending text online despite an offline selection. + from api.routers.dub_translate import dub_translate + from schemas.requests import TranslateRequest - translated_segments = await loop.run_in_executor( - _cpu_pool, _translate_batch, - segments, source_lang, target_lang, + from core import prefs + + provider = job.get("translation_provider") or prefs.get("translation_backend", "argos") + translation = await dub_translate(TranslateRequest( + segments=[ + { + "id": str(segment["id"]), + "text": segment.get("text", ""), + "start": segment.get("start"), + "end": segment.get("end"), + } + for segment in segments + ], + source_lang=source_lang, + target_lang=target_lang, + provider=provider, + quality="fast", + )) + if isinstance(translation, JSONResponse): + try: + payload = json.loads(translation.body) + detail = payload.get("error") or payload.get("detail") + if payload.get("code") == "argos_pack_missing": + job["setup_required"] = { + "kind": "argos_packs", + "source_lang": source_lang, + "target_langs": [ + pair["target_lang"] + for pair in payload.get("pairs", []) + if isinstance(pair, dict) and pair.get("target_lang") + ], + } + except Exception: # noqa: BLE001 — retain the stable fallback + detail = None + raise RuntimeError( + detail or f"{provider} could not translate this batch" ) - except ImportError: - logger.warning("deep_translator not installed, skipping translation for %s", target_lang) - except Exception as e: - logger.warning("Translation failed for %s: %s, using original", target_lang, e) - translated_segments = segments + rows = { + str(row.get("id")): row + for row in translation.get("translated", []) + if isinstance(row, dict) + } + failed = [row for row in rows.values() if row.get("error")] + if failed or len(rows) != len(segments): + raise RuntimeError( + f"{provider} translation failed for " + f"{len(failed) or len(segments) - len(rows)} segment(s)" + ) + translated_segments = [ + {**segment, "text": rows[str(segment["id"])]["text"]} + for segment in segments + ] if job["status"] == "cancelled": return @@ -362,6 +473,90 @@ async def _run_batch_pipeline(job_id: str, job: dict): from services.audio_io import atomic_save_wav import torch + remote_segments: dict[int, str] = {} + valid_rows = [ + (i, segment) + for i, segment in enumerate(translated_segments) + if segment.get("end", 0) - segment.get("start", 0) > 0.05 + and segment.get("text", "").strip() + ] + if execution_target.remote and valid_rows: + remote_rows = [ + { + "index": i, + "text": segment.get("text", "").strip(), + "language": target_lang, + "ref_text": voice["ref_text"], + "instruct": voice["instruct"] or None, + "duration": segment.get("end", 0) - segment.get("start", 0), + "num_step": _batch_num_step, + "postprocess_output": _batch_postprocess, + "guidance_scale": 2.0, + "speed": 1.0, + "effect_preset": "batch", + "seed": ( + voice["seed"] + i if voice["seed"] is not None else None + ), + # The assembled track receives one watermark below. Marking + # each line here would double-process remote output. + "watermark": False, + } + for i, segment in valid_rows + ] + expected = {row["index"] for row in remote_rows} + + def _remote_state(state: dict) -> None: + fraction = max(0.0, min(1.0, float(state.get("progress") or 0.0))) + _set_progress( + job, + "generate", + percent=int(((lang_idx + fraction) / total_langs) * 100), + current_lang=target_lang, + current_segment=min(len(remote_rows), round(fraction * len(remote_rows))), + total_segments=len(remote_rows), + execution_target=execution_target.label, + execution_phase=state.get("phase"), + ) + + route_task = asyncio.create_task( + gpu_gateway.run( + "batch", + local=gpu_gateway.LocalCall(prepare=_prepare_local_batch), + remote=gpu_gateway.RemoteCall( + engine=engine_id, + operation=_REMOTE_BATCH_OPERATION, + params={ + "segments": remote_rows, + "ref_audio": [voice["ref_audio"] for _ in remote_rows], + "input_seconds": sum( + float(row.get("duration") or 0.0) for row in remote_rows + ), + }, + idempotency_key=f"batch:{job_id}:{target_lang}", + decode=lambda result: _decode_remote_batch( + result, batch_dir, expected + ), + ), + decision=execution_target, + job=batch_run, + on_state=_remote_state, + ) + ) + while not route_task.done(): + await asyncio.wait({route_task}, timeout=0.25) + if job["status"] == "cancelled": + route_task.cancel() + try: + await route_task + except asyncio.CancelledError: + pass + return + routed = route_task.result() + if routed is not None: + remote_segments, sr = routed + + # A remote-only empty transcript still needs a valid silent-track rate. + sr = sr or 24_000 total_samples = int(duration * sr) full_audio = torch.zeros(1, total_samples) total_segs = len(translated_segments) @@ -372,26 +567,15 @@ async def _run_batch_pipeline(job_id: str, job: dict): # the established one-segment behavior below. from services.tts_backend import TTSBackend batched_audio: dict[int, torch.Tensor] = {} - has_native_batch = type(backend).generate_batch is not TTSBackend.generate_batch + has_native_batch = ( + backend is not None + and type(backend).generate_batch is not TTSBackend.generate_batch + ) if has_native_batch: from services.text_normalization import normalize_for_tts - batch_ref_audio = None - batch_ref_text = None - if job.get("voice_id"): - from core.db import db_conn - from core.config import VOICES_DIR as _VD - with db_conn() as conn: - row = conn.execute( - "SELECT * FROM voice_profiles WHERE id=?", - (job["voice_id"],), - ).fetchone() - if row: - if row["is_locked"] and row["locked_audio_path"]: - batch_ref_audio = os.path.join(_VD, row["locked_audio_path"]) - elif row["ref_audio_path"]: - batch_ref_audio = os.path.join(_VD, row["ref_audio_path"]) - batch_ref_text = row["ref_text"] + batch_ref_audio = voice["ref_audio"] + batch_ref_text = voice["ref_text"] batch_width = _native_batch_width(backend) @@ -428,17 +612,20 @@ async def _run_batch_pipeline(job_id: str, job: dict): ] def _render_native_batch(): + if voice["seed"] is not None: + torch.manual_seed(voice["seed"]) generated = backend.generate_batch( batch_texts, language=target_lang, ref_audio=batch_ref_audio, ref_text=batch_ref_text, + instruct=voice["instruct"] or None, duration=batch_durations, - num_step=16, + num_step=_batch_num_step, guidance_scale=2.0, speed=1.0, denoise=True, - postprocess_output=True, + postprocess_output=_batch_postprocess, ) if len(generated) != len(batch_indices): raise RuntimeError( @@ -473,6 +660,7 @@ async def _run_batch_pipeline(job_id: str, job: dict): for i, seg in enumerate(translated_segments): if job["status"] == "cancelled": + remove_segment_wavs(remote_segments) return _set_progress( @@ -499,32 +687,18 @@ async def _run_batch_pipeline(job_id: str, job: dict): from services.text_normalization import normalize_for_tts text = normalize_for_tts(text, lang) - ref_audio = None - ref_text = None - - # Use voice_id if provided - if job.get("voice_id"): - from core.db import db_conn - from core.config import VOICES_DIR as _VD - with db_conn() as conn: - row = conn.execute( - "SELECT * FROM voice_profiles WHERE id=?", - (job["voice_id"],), - ).fetchone() - if row: - if row["is_locked"] and row["locked_audio_path"]: - ref_audio = os.path.join(_VD, row["locked_audio_path"]) - elif row["ref_audio_path"]: - ref_audio = os.path.join(_VD, row["ref_audio_path"]) - ref_text = row.get("ref_text") - try: + if backend is None: + raise RuntimeError("the local TTS fallback was not prepared") + if voice["seed"] is not None: + torch.manual_seed(voice["seed"] + i) audio_out = backend.generate( text=text, language=lang, - ref_audio=ref_audio, ref_text=ref_text, - duration=dur, num_step=16, + ref_audio=voice["ref_audio"], ref_text=voice["ref_text"], + instruct=voice["instruct"] or None, + duration=dur, num_step=_batch_num_step, guidance_scale=2.0, speed=1.0, - denoise=True, postprocess_output=True, + denoise=True, postprocess_output=_batch_postprocess, ) if not getattr(backend, "applies_own_mastering", False): audio_out = apply_mastering(audio_out, sample_rate=sr) @@ -548,15 +722,43 @@ async def _run_batch_pipeline(job_id: str, job: dict): # Budget is the shared length-scaled one (#1190): a long segment # on CPU-class hardware no longer dies on the flat 300s. from services.model_manager import generate_timeout_s - if has_native_batch and i not in batched_audio: - await _prefetch_batch(i) - if i in batched_audio: - audio_tensor = batched_audio.pop(i) + remote_path = remote_segments.pop(i, None) + if remote_path is not None: + import soundfile as sf + + try: + audio_array, remote_sr = sf.read( + remote_path, + dtype="float32", + always_2d=True, + ) + if int(remote_sr) != sr: + raise ValueError( + f"remote segment sample rate changed from {sr} to {remote_sr}" + ) + audio_tensor = torch.from_numpy(audio_array.T).mean( + dim=0, + keepdim=True, + ) + finally: + remove_segment_wavs({i: remote_path}) else: - audio_tensor = await run_on_gpu_pool_guarded( - _gen, what="Batch generate", - timeout=generate_timeout_s(seg_text, engine=backend), - ) + if backend is None: + await _prepare_local_batch() + # This path means a validated remote bundle lost a row + # after dispatch. Recover only that row; native batches + # were not planned for this language. + has_native_batch = False + if has_native_batch and i not in batched_audio: + await _prefetch_batch(i) + if i in batched_audio: + audio_tensor = batched_audio.pop(i) + else: + audio_tensor = await run_on_gpu_pool_guarded( + _gen, + what="Batch generate", + timeout=generate_timeout_s(seg_text, engine=backend), + ) # Fit to slot target_samples_seg = int(seg_duration * sr) @@ -604,6 +806,8 @@ async def _run_batch_pipeline(job_id: str, job: dict): f"left silent: {e}" ) + remove_segment_wavs(remote_segments) + # ── 3c. Save dubbed audio track ─────────────────────────────── # Invisible provenance mark on the assembled track (#1169), tensor # stage, before the WAV write / aac mux — batch dubs used to ship @@ -670,6 +874,7 @@ async def _run_batch_pipeline(job_id: str, job: dict): outputs[target_lang] = output_path job["outputs"] = outputs + job.pop("setup_required", None) _set_progress(job, "done", 100) @@ -681,6 +886,7 @@ async def enqueue_batch_job( langs: str = Form("es"), # comma-separated lang codes voice_id: Optional[str] = Form(None), preserve_bg: bool = Form(True), + translation_provider: Optional[str] = Form(None), ): """Enqueue a video for batch dubbing. @@ -694,6 +900,14 @@ async def enqueue_batch_job( if not lang_list: raise HTTPException(400, "At least one target language is required") + # Validate the snapshot before persisting a potentially large upload. + # Resolve it again in the worker so deleting or editing a queued profile + # cannot silently fall back to the engine's default voice. + try: + await asyncio.to_thread(_batch_voice, voice_id) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + # TTS-only install: no ASR model on disk → typed 409 with a download CTA # now, instead of accepting the job and having the transcribe stage # silently auto-download multi-GB whisper weights (or fail) in the worker. @@ -702,6 +916,19 @@ async def enqueue_batch_job( if missing is not None: raise HTTPException(409, {**missing, "message": asr_model_missing_detail(missing)}) + # Snapshot the selected translation engine when the user enqueues the job, + # so a later Settings change cannot alter work already waiting in the queue. + from core import prefs + from services import translation_engines + + provider = translation_provider or prefs.get("translation_backend", "argos") + if not translation_engines.get_engine(provider): + raise HTTPException(400, "Unknown translation engine") + if not translation_engines.is_installed(provider): + raise HTTPException(409, "Install the selected translation engine before adding this batch") + if not translation_engines.is_ready(provider): + raise HTTPException(409, "Configure the selected translation provider before adding this batch") + # Save the uploaded video batch_dir = os.path.join(DATA_DIR, "batch") os.makedirs(batch_dir, exist_ok=True) @@ -718,7 +945,9 @@ async def enqueue_batch_job( "langs": lang_list, "voice_id": voice_id, "preserve_bg": preserve_bg, + "translation_provider": provider, "created_at": time.time(), + "attempts": 1, "started_at": None, "finished_at": None, "error": None, @@ -741,6 +970,8 @@ def list_batch_jobs(status: Optional[str] = None, limit: int = 50): if status: if status == "active": jobs = [j for j in jobs if j["status"] in ("queued", "running")] + elif status == "retryable": + jobs = [j for j in jobs if j["status"] in ("failed", "cancelled")] else: jobs = [j for j in jobs if j["status"] == status] jobs.sort(key=lambda j: j["created_at"], reverse=True) @@ -764,14 +995,88 @@ def cancel_batch_job(job_id: str): raise HTTPException(404, "Job not found") if job["status"] in ("done", "failed", "cancelled"): return {"already": job["status"]} + was_running = job["status"] == "running" or job_id in _processing_job_ids job["status"] = "cancelled" + job["retry_ready"] = not was_running job["finished_at"] = time.time() return {"cancelled": True} +@router.post("/batch/jobs/{job_id}/retry") +async def retry_batch_job(job_id: str): + """Retry a terminal job using its original app-owned upload and settings.""" + job = _jobs.get(job_id) + if not job: + raise HTTPException(404, "Job not found") + if job["status"] not in ("failed", "cancelled"): + raise HTTPException(409, f"Job is {job['status']}, not retryable") + if job_id in _processing_job_ids or not job.get("retry_ready", True): + raise HTTPException(409, "The cancelled job is still stopping") + if not os.path.isfile(job.get("video_path") or ""): + raise HTTPException(409, "The original batch input is no longer available") + + try: + await asyncio.to_thread(_batch_voice, job.get("voice_id")) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + from services.asr_backend import asr_model_missing_detail, asr_model_missing_error + missing = await asyncio.to_thread(asr_model_missing_error) + if missing is not None: + raise HTTPException(409, {**missing, "message": asr_model_missing_detail(missing)}) + + from services import translation_engines + provider = job.get("translation_provider") or "argos" + if not translation_engines.is_ready(provider): + raise HTTPException(409, "Configure the selected translation provider before retrying") + if provider == "argos" and job.get("source_lang"): + status = await asyncio.to_thread( + translation_engines.argos_pack_status, + job["source_lang"], + job["langs"], + ) + if any(not pair["installed"] for pair in status["pairs"]): + raise HTTPException(409, "Install the required Argos language packs before retrying") + + batch_root = os.path.realpath(os.path.join(DATA_DIR, "batch")) + output_dir = os.path.realpath(os.path.join(batch_root, job_id)) + if os.path.dirname(output_dir) != batch_root: + raise HTTPException(status_code=400, detail="Invalid batch job path") + try: + if os.path.isdir(output_dir): + await asyncio.to_thread(shutil.rmtree, output_dir) + except OSError as exc: + raise HTTPException( + status_code=500, + detail="Could not reset the batch output files. Close any app using them and retry.", + ) from exc + + for key in ( + "duration", + "segments", + "source_lang", + "outputs", + "warnings", + "setup_required", + "retry_ready", + ): + job.pop(key, None) + job.update({ + "status": "queued", + "started_at": None, + "finished_at": None, + "error": None, + "progress": None, + "attempts": int(job.get("attempts", 1)) + 1, + }) + _ensure_queue() + await _queue.put(job_id) + return {"job_id": job_id, "status": "queued", "queue_position": _queue.qsize()} + + @router.delete("/batch/jobs/{job_id}") def delete_batch_job(job_id: str): - """Delete a batch job record and its video file.""" + """Delete a batch job record and every app-owned input/output file.""" job = _jobs.get(job_id) if not job: raise HTTPException(404, "Job not found") @@ -783,6 +1088,18 @@ def delete_batch_job(job_id: str): status_code=500, detail="Could not delete the batch video file. Close any app using it and retry.", ) from exc + batch_root = os.path.realpath(os.path.join(DATA_DIR, "batch")) + output_dir = os.path.realpath(os.path.join(batch_root, job_id)) + if os.path.dirname(output_dir) != batch_root: + raise HTTPException(status_code=400, detail="Invalid batch job path") + try: + if os.path.isdir(output_dir): + shutil.rmtree(output_dir) + except OSError as exc: + raise HTTPException( + status_code=500, + detail="Could not delete the batch output files. Close any app using them and retry.", + ) from exc _jobs.pop(job_id, None) return {"deleted": True} diff --git a/backend/api/routers/capture.py b/backend/api/routers/capture.py index e9b0a48b..7d763f89 100644 --- a/backend/api/routers/capture.py +++ b/backend/api/routers/capture.py @@ -60,7 +60,8 @@ async def transcribe_audio( language: Optional language hint (not currently used; auto-detected). model: Whisper model size (legacy; ignored in dual-mode architecture). mode: 'fast' (default) uses MLX Turbo for speed; 'accurate' uses - WhisperX with forced alignment for word-level timing. + the selected ASR engine with word-level timing. 'reference' uses + the selected ASR engine without word-level timing. refine: Opt-in local-LLM cleanup of the final text (disfluencies, self-corrections, punctuation) — same pipeline the live dictation socket uses. Off by default so MCP/CLI callers don't @@ -91,7 +92,9 @@ async def transcribe_audio( tmp.write(content) tmp.close() - use_accurate = (mode or "").strip().lower() == "accurate" + requested_mode = (mode or "").strip().lower() + use_accurate = requested_mode == "accurate" + use_active_asr = requested_mode in {"accurate", "reference"} # TTS-only install: no ASR model on disk → typed 409 with a download # CTA, BEFORE any backend is constructed (the whisper backends @@ -99,7 +102,8 @@ async def transcribe_audio( from services.asr_backend import asr_model_missing_detail, asr_model_missing_error missing = await asyncio.to_thread( asr_model_missing_error, - purpose="transcribe" if use_accurate else "dictation", + purpose="transcribe" if use_active_asr else "dictation", + require_installed=requested_mode == "reference", ) if missing is not None: raise HTTPException( @@ -108,7 +112,7 @@ async def transcribe_audio( ) def _run(): - if use_accurate: + if use_active_asr: # Accurate mode: full WhisperX with forced alignment — # for when the user explicitly wants word-level timing. # `load_*`, not `get_*`: the selector alone hands back an @@ -116,8 +120,8 @@ async def transcribe_audio( # chain is broken, which then 500s at `.transcribe()`. The # loader degrades to the next healthy engine (#1185). from services.asr_backend import load_active_asr_backend - backend = load_active_asr_backend() - result = backend.transcribe(tmp.name, word_timestamps=True) + backend = load_active_asr_backend(require_installed=True) if requested_mode == "reference" else load_active_asr_backend() + result = backend.transcribe(tmp.name, word_timestamps=use_accurate) else: # Fast mode (default): use the fastest available engine # (MLX Turbo on Apple Silicon). Skip word_timestamps for @@ -125,7 +129,8 @@ async def transcribe_audio( from services.asr_backend import get_capture_asr_backend backend = get_capture_asr_backend() result = backend.transcribe(tmp.name, word_timestamps=False) - return result, backend.id + sherpa_model_id = getattr(getattr(backend, "spec", None), "id", None) + return result, backend.id, sherpa_model_id from services.model_manager import _gpu_pool from services.asr_backend import ( @@ -135,7 +140,7 @@ async def transcribe_audio( ) t0 = time.perf_counter() try: - result, engine_id = await run_transcribe_guarded( + result, engine_id, sherpa_model_id = await run_transcribe_guarded( _gpu_pool, _run, what="Dictation", ) except ASRTimeoutError as e: @@ -151,6 +156,69 @@ async def transcribe_audio( status_code=409, detail={**e.payload, "message": asr_model_missing_detail(e.payload)}, ) + + # Some sherpa-onnx NeMo-TDT builds load successfully but decode an + # entire spoken clip to no tokens. Live dictation already recovers + # from that failure; the shared file endpoint must do the same because + # it also powers uploaded transcription and automatic profile text. + # Retry only through an already-installed fallback, and demote the + # silent model only when the second recognizer actually heard words. + initial_text = str(result.get("text") or "").strip() + if not initial_text and result.get("segments"): + initial_text = " ".join( + str(segment.get("text") or "") + for segment in result["segments"] + if isinstance(segment, dict) + ).strip() + recovered_from = None + if not use_active_asr and sherpa_model_id and not initial_text: + fallback_missing = await asyncio.to_thread( + asr_model_missing_error, + purpose="dictation", + skip_sherpa=True, + require_installed=True, + ) + if fallback_missing is None: + def _run_fallback(): + from services.asr_backend import get_capture_asr_backend + + fallback = get_capture_asr_backend(skip_sherpa=True) + return ( + fallback.transcribe(tmp.name, word_timestamps=False), + fallback.id, + ) + + try: + fallback_result, fallback_engine_id = await run_transcribe_guarded( + _gpu_pool, + _run_fallback, + what="Dictation fallback", + ) + fallback_text = str(fallback_result.get("text") or "").strip() + if not fallback_text and fallback_result.get("segments"): + fallback_text = " ".join( + str(segment.get("text") or "") + for segment in fallback_result["segments"] + if isinstance(segment, dict) + ).strip() + if fallback_text: + from services.sherpa_dictation import demote_model + + await asyncio.to_thread(demote_model, sherpa_model_id) + result = fallback_result + engine_id = fallback_engine_id + recovered_from = sherpa_model_id + logger.warning( + "File transcription recovered from silent dictation model %s " + "through installed engine %s", + sherpa_model_id, + fallback_engine_id, + ) + except Exception: + logger.exception( + "Installed fallback failed after dictation model %s returned no text", + sherpa_model_id, + ) elapsed = round(time.perf_counter() - t0, 2) # Normalize result shape @@ -202,7 +270,7 @@ async def transcribe_audio( logger.info( "Capture transcription done: engine=%s, elapsed=%.2fs, duration=%.1fs, mode=%s, refined=%s", - engine_id, elapsed, duration, "accurate" if use_accurate else "fast", + engine_id, elapsed, duration, requested_mode if use_active_asr else "fast", refined_text is not None, ) @@ -223,6 +291,8 @@ async def transcribe_audio( } if refined_text is not None: response["refined_text"] = refined_text + if recovered_from is not None: + response["model_silent"] = recovered_from return response finally: try: diff --git a/backend/api/routers/dictation.py b/backend/api/routers/dictation.py index ce852ff2..c30aac9b 100644 --- a/backend/api/routers/dictation.py +++ b/backend/api/routers/dictation.py @@ -21,7 +21,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from typing import Optional +from typing import Literal, Optional from api.dependencies import require_local from api.public_engine_metadata import public_unavailability @@ -87,13 +87,18 @@ def list_dictation_models(): @router.get("/dictation/readiness", dependencies=[Depends(require_local)]) -def dictation_readiness(model_id: str | None = None) -> dict: - """Check capture's model selection without loading or downloading weights.""" +def dictation_readiness( + model_id: str | None = None, + purpose: Literal["dictation", "transcribe"] = "dictation", +) -> dict: + """Check a selected ASR path without loading or downloading weights.""" from services.asr_backend import asr_model_missing_error missing = asr_model_missing_error( - purpose="dictation", - sherpa_model_id=model_id or _read_prefs()["model_id"], + purpose=purpose, + sherpa_model_id=(model_id or _read_prefs()["model_id"]) + if purpose == "dictation" + else None, ) return {"ready": missing is None, "missing": missing} diff --git a/backend/api/routers/dub_core.py b/backend/api/routers/dub_core.py index 2450823b..74f9ae1b 100644 --- a/backend/api/routers/dub_core.py +++ b/backend/api/routers/dub_core.py @@ -355,6 +355,138 @@ async def dub_import_srt(job_id: str, file: UploadFile = File(...)): } +def _select_downloaded_caption_track( + tracks: dict[str, list[dict]], preferred: str | None, +) -> str | None: + """Choose the closest original-language caption track deterministically.""" + available = [key for key, cues in tracks.items() if isinstance(cues, list) and cues] + if not available: + return None + preferred_tag = (preferred or "").strip().lower().replace("_", "-") + preferred_base = preferred_tag.split("-", 1)[0] + + def rank(key: str) -> tuple[int, int, int, str]: + tag = key.strip().lower().replace("_", "-") + base = tag.split("-", 1)[0] + if preferred_tag: + language_rank = 0 if tag == preferred_tag else 1 if base == preferred_base else 2 + else: + language_rank = 0 + return ( + language_rank, + 0 if tag.endswith("-orig") else 1, + 0 if "-" not in tag else 1, + tag, + ) + + return min(available, key=rank) + + +def _prepare_downloaded_caption_segments(cues: list[dict], duration: float) -> list[dict]: + """Normalize downloaded VTT cues into safe, sequential Dub segments.""" + def cue_start(cue: dict) -> float: + try: + return float(cue.get("start") or 0.0) + except (TypeError, ValueError): + return 0.0 + + def remove_repeated_prefix(previous: str, current: str) -> str: + previous_words = previous.split() + current_words = current.split() + folded_previous = [word.casefold() for word in previous_words] + folded_current = [word.casefold() for word in current_words] + for count in range(min(len(previous_words), len(current_words)), 0, -1): + if folded_previous[-count:] == folded_current[:count]: + return " ".join(current_words[count:]) + return current + + prepared: list[dict] = [] + previous_end = 0.0 + ordered = sorted((cue for cue in cues if isinstance(cue, dict)), key=cue_start) + for index, cue in enumerate(ordered): + try: + raw_start = max(0.0, float(cue.get("start") or 0.0)) + end = float(cue.get("end") or raw_start) + except (TypeError, ValueError): + continue + text = " ".join(str(cue.get("text") or "").split()) + if duration > 0: + if raw_start >= duration: + continue + end = min(end, duration) + if prepared and raw_start < previous_end: + text = remove_repeated_prefix(prepared[-1]["text"], text) + if not text: + prepared[-1]["end"] = round(max(previous_end, end), 3) + previous_end = max(previous_end, end) + continue + # Caption hosts commonly emit slightly overlapping cues. Dubbing needs + # a monotonic timeline, so trim the later cue rather than manufacture + # overlapping speech slots. + start = max(raw_start, previous_end) + if not text or end <= start: + continue + prepared.append({ + "id": str(index), + "start": round(start, 3), + "end": round(end, 3), + "text": text, + "speaker_id": "Speaker 1", + }) + previous_end = end + + cleaned = clean_up_segments(prepared) + return [ + { + **segment, + "id": index, + "text_original": segment.get("text", ""), + } + for index, segment in enumerate(cleaned) + ] + + +@router.post("/dub/use-downloaded-captions/{job_id}") +def dub_use_downloaded_captions(job_id: str): + """Seed a prepared Dub job from its downloaded caption track.""" + job = _get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found") + tracks = job.get("youtube_subs") + if not isinstance(tracks, dict): + raise HTTPException(status_code=404, detail="No downloaded captions are available") + caption_lang = _select_downloaded_caption_track( + tracks, + job.get("source_lang_override") or job.get("source_lang"), + ) + if caption_lang is None: + raise HTTPException(status_code=404, detail="No downloaded captions are available") + segments = _prepare_downloaded_caption_segments( + tracks[caption_lang], + float(job.get("duration") or 0.0), + ) + if not segments: + raise HTTPException(status_code=422, detail="Downloaded captions contain no usable cues") + + source_lang = job.get("source_lang_override") or _detected_source_lang(caption_lang) + job["segments"] = segments + job["source_lang"] = source_lang + job["full_transcript"] = " ".join(segment["text"] for segment in segments) + # Caption files contain timing and text, but no trustworthy speaker or + # reference-audio attribution. Never retain stale clone maps from a prior + # transcript on the same job. + job["segment_clones"] = {} + job["speaker_clones"] = {} + job.pop("cast_sources", None) + _save_job(job_id, job) + return { + "segments": segments, + "source_lang": source_lang, + "caption_lang": caption_lang, + "available": sorted(tracks.keys()), + } + + @router.post("/dub/cleanup-segments/{job_id}") def dub_cleanup_segments(job_id: str): """Re-run merge/stitch passes on a job's existing segments to drop fragments.""" @@ -375,8 +507,7 @@ def dub_abort(job_id: str): had_procs = bool(_active_procs.get(job_id)) _kill_job_procs(job_id) try: - if task_manager.cancel_task(job_id) is False: - raise RuntimeError("task cancellation was declined") + had_task = task_manager.cancel_task(job_id) except Exception as exc: logger.warning("Dub task cancellation failed") raise HTTPException( @@ -386,7 +517,13 @@ def dub_abort(job_id: str): job = _dub_jobs.get(job_id) if job is not None: job["aborted"] = True - return {"aborted": True, "had_active_procs": had_procs} + # Cancellation is idempotent: a missing active task means it already + # stopped between the renderer aborting its stream and this request. + return { + "aborted": True, + "had_active_procs": had_procs, + "had_active_task": had_task, + } @router.get("/dub/history") @@ -615,8 +752,19 @@ async def dub_upload( os.makedirs(job_dir, exist_ok=True) video_path = os.path.join(job_dir, f"original{ext}") - with open(video_path, "wb") as f: - f.write(await video.read()) + + def _stream_upload_to_disk() -> None: + # UploadFile is already a spooled file. Copy it in bounded chunks on a + # worker thread instead of materialising a multi-GB video in RAM and + # blocking every API request while the event loop writes it. + video.file.seek(0) + with open(video_path, "wb") as output: + shutil.copyfileobj(video.file, output, length=1024 * 1024) + + try: + await asyncio.to_thread(_stream_upload_to_disk) + finally: + await video.close() filename = video.filename or f"video{ext}" task_id = f"prep_{job_id}" @@ -959,6 +1107,23 @@ async def dub_transcribe_stream( job = _get_job(job_id) + # The durable job is written before the terminal SSE events below. If + # the renderer, proxy, or backend connection drops in that narrow + # window, reconnecting must replay the completed result instead of + # running a second whole-file ASR pass. This is deliberately gated by + # an explicit completion marker so partial work and imported subtitle + # rows still take their established paths. + if job and job.get("transcription_complete") and isinstance(job.get("segments"), list): + yield _sse_event("final", { + "segments": job["segments"], + "source_lang": job.get("source_lang") or "en", + "full_transcript": job.get("full_transcript") or "", + "speaker_clones": job.get("cast_sources", {}), + "cast_sources": job.get("cast_sources", {}), + }) + yield _sse_event("done", {}) + return + preflight_error: Optional[str] = None # Extra machine-readable fields merged into the preflight `error` SSE event # (e.g. the typed asr_model_missing payload → download-CTA in the UI). @@ -1453,6 +1618,7 @@ async def dub_transcribe_stream( from services.model_manager import ( DIARIZATION_ERR_LICENSE, DIARIZATION_ERR_NO_TOKEN, + DIARIZATION_ERR_MISSING, ) from core import error_docs_map @@ -1545,7 +1711,23 @@ async def dub_transcribe_stream( from services import token_resolver resolved = token_resolver.resolve() - if err_sentinel == DIARIZATION_ERR_NO_TOKEN or not resolved: + if err_sentinel == DIARIZATION_ERR_MISSING: + from services.diarization_runtime import SORTFORMER, selected_backend + native_selected = selected_backend() == SORTFORMER + detail = ( + "Native Sortformer files are missing. Install audiocpp_cli beside " + "the audio.cpp native bundle in Settings > Models > " + "Diarisation, then retry transcription. " + "Using silence gaps for now; rapid speaker turns may be merged." + ) if native_selected else ( + "Speaker diarization files are missing or incomplete. " + "Install or repair pyannote in Settings > Models > Diarisation, " + "then retry transcription. No models were downloaded during " + "this job. Using silence gaps for now; rapid speaker turns " + "may be merged." + ) + error_class = "DIARIZATION_MODEL_MISSING" + elif err_sentinel == DIARIZATION_ERR_NO_TOKEN: detail = ( "Speaker diarization is disabled because no HuggingFace token " "was found in any source (Settings → API Keys, the HF_TOKEN " @@ -1558,12 +1740,12 @@ async def dub_transcribe_stream( ) error_class = "HF_AUTH_FAILED" elif err_sentinel == DIARIZATION_ERR_LICENSE: - who = resolved.username or "(whoami suppressed)" + who = resolved.username if resolved else "(not signed in)" detail = ( f"Speaker diarization model is gated — the " f"pyannote/speaker-diarization-3.1 license has not been " f"accepted on HuggingFace by this account " - f"(source={resolved.source}, user={who}). Visit " + f"(user={who}). Visit " f"huggingface.co/pyannote/speaker-diarization-3.1 AND " f"huggingface.co/pyannote/segmentation-3.0 while signed " f"in and click 'Agree and access repository' on both, " @@ -1575,17 +1757,13 @@ async def dub_transcribe_stream( else: # err_sentinel == DIARIZATION_ERR_LOAD (or unexpected None # with a resolved token — historical safety net). - who = resolved.username or "(whoami suppressed)" detail = ( - f"Speaker diarization model failed to load even though an HF " - f"token was found (source={resolved.source}, user={who}). " - f"Most common causes: the pyannote/speaker-diarization-3.1 " - f"license has not been accepted on HuggingFace, or there is " - f"a pyannote/torch version mismatch. See backend logs for " + f"The installed speaker diarization model failed to load. " + f"See Settings > Logs > Backend for " f"the underlying error. Falling back to a silence-gap " f"heuristic; rapid speaker turns may be merged." ) - error_class = "PYANNOTE_LICENSE_REQUIRED" + error_class = "DIARIZATION_LOAD_FAILED" warning = { "detail": detail + _hint_suffix(), "error_class": error_class, @@ -1606,7 +1784,13 @@ async def dub_transcribe_stream( # provided (#274). pyannote's apply() accepts num_speakers; # omit it entirely when None so we don't depend on the kwarg # existing in every pyannote build. - if num_speakers: + from services.diarization_native import NativeSortformer + if isinstance(diar_pipe, NativeSortformer): + diar = diar_pipe( + asr_audio_target, num_speakers=num_speakers, job_id=job_id, + cancel_check=lambda: bool(job.get("aborted")) or task_manager.is_cancelled(job_id), + ) + elif num_speakers: logger.info("Diarizing with num_speakers=%d (user hint)", num_speakers) diar = diar_pipe(asr_audio_target, num_speakers=num_speakers) else: @@ -1632,7 +1816,7 @@ async def dub_transcribe_stream( len(asr_phrase_segments), separation, ) return recovered_segments, None, "phrase_embeddings" - return resplit, None, "pyannote" + return resplit, None, "audiocpp-sortformer" if isinstance(diar_pipe, NativeSortformer) else "pyannote" except Exception as e: logger.exception("Diarization failed") # Inline ASR turns beat the silence-gap heuristic as a crash @@ -1681,6 +1865,9 @@ async def dub_transcribe_stream( final_segs, diar_warning, labels_source = done.pop().result() break yield _sse_event("ping", {}) + if job.get("aborted") or task_manager.is_cancelled(job_id): + yield _sse_event("aborted", {}) + return if diar_warning: logger.warning("diarization fallback: %s", diar_warning.get("detail")) payload = { @@ -1845,6 +2032,7 @@ async def dub_transcribe_stream( detected_lang ) job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs) + job["transcription_complete"] = True _save_job(job_id, job) # Restore TTS model to GPU now that ASR is done. unload() blocks diff --git a/backend/api/routers/dub_export.py b/backend/api/routers/dub_export.py index 63087d46..23901f49 100644 --- a/backend/api/routers/dub_export.py +++ b/backend/api/routers/dub_export.py @@ -15,7 +15,7 @@ from core.http_headers import content_disposition from core.logging_utils import log_safe from core.path_security import UnsafePath, resolve_within from core.tasks import task_manager -from fastapi import APIRouter, Header, HTTPException, Query, Response +from fastapi import APIRouter, Header, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, StreamingResponse from services.ffmpeg_utils import ( bed_mix_filter, @@ -1050,8 +1050,8 @@ _MEDIA_TYPES = { } -@router.get("/dub/media/{job_id}") -async def dub_get_media(job_id: str): +@router.api_route("/dub/media/{job_id}", methods=["GET", "HEAD"]) +async def dub_get_media(job_id: str, request: Request): _job_dir_or_400(job_id) job = _get_job(job_id) if not job: @@ -1064,7 +1064,15 @@ async def dub_get_media(job_id: str): # silent black box. Default to video/mp4 because the ingest pipeline # remuxes URL downloads to mp4 (dub_pipeline.yt_download_sync). ext = os.path.splitext(video_path)[1].lower() - return FileResponse(video_path, media_type=_MEDIA_TYPES.get(ext, "video/mp4")) + media_type = _MEDIA_TYPES.get(ext, "video/mp4") + headers = { + "Cache-Control": "private, max-age=31536000, immutable", + "Accept-Ranges": "bytes", + } + if request.method == "HEAD": + headers["Content-Length"] = str(os.path.getsize(video_path)) + return Response(media_type=media_type, headers=headers) + return FileResponse(video_path, media_type=media_type, headers=headers) # One mux at a time per preview file. Without this, two overlapping requests # (e.g. the