Showing preview only (3,538K chars total). Download the full file or copy to clipboard to get everything.
Repository: jamiepine/voicebox
Branch: main
Commit: d70b878b71d4
Files: 453
Total size: 3.3 MB
Directory structure:
gitextract_61mm52up/
├── .agents/
│ └── skills/
│ ├── add-tts-engine/
│ │ └── SKILL.md
│ ├── draft-release-notes/
│ │ └── SKILL.md
│ └── release-bump/
│ └── SKILL.md
├── .biomeignore
├── .bumpversion.cfg
├── .dockerignore
├── .github/
│ └── workflows/
│ ├── build-windows.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── app/
│ ├── components.json
│ ├── index.html
│ ├── package.json
│ ├── plugins/
│ │ └── changelog.ts
│ ├── src/
│ │ ├── App.tsx
│ │ ├── components/
│ │ │ ├── AppFrame/
│ │ │ │ └── AppFrame.tsx
│ │ │ ├── AudioPlayer/
│ │ │ │ └── AudioPlayer.tsx
│ │ │ ├── AudioStudio/
│ │ │ │ └── .gitkeep
│ │ │ ├── AudioTab/
│ │ │ │ └── AudioTab.tsx
│ │ │ ├── Effects/
│ │ │ │ ├── EffectsChainEditor.tsx
│ │ │ │ └── GenerationPicker.tsx
│ │ │ ├── EffectsTab/
│ │ │ │ ├── EffectsDetail.tsx
│ │ │ │ ├── EffectsList.tsx
│ │ │ │ └── EffectsTab.tsx
│ │ │ ├── Generation/
│ │ │ │ ├── EngineModelSelector.tsx
│ │ │ │ ├── FloatingGenerateBox.tsx
│ │ │ │ ├── GenerationForm.tsx
│ │ │ │ └── ParalinguisticInput.tsx
│ │ │ ├── History/
│ │ │ │ └── HistoryTable.tsx
│ │ │ ├── MainEditor/
│ │ │ │ └── MainEditor.tsx
│ │ │ ├── ModelsTab/
│ │ │ │ └── ModelsTab.tsx
│ │ │ ├── ServerSettings/
│ │ │ │ ├── ConnectionForm.tsx
│ │ │ │ ├── GenerationSettings.tsx
│ │ │ │ ├── GpuAcceleration.tsx
│ │ │ │ ├── ModelManagement.tsx
│ │ │ │ ├── ModelProgress.tsx
│ │ │ │ ├── ServerStatus.tsx
│ │ │ │ └── UpdateStatus.tsx
│ │ │ ├── ServerTab/
│ │ │ │ ├── AboutPage.tsx
│ │ │ │ ├── ChangelogPage.tsx
│ │ │ │ ├── GeneralPage.tsx
│ │ │ │ ├── GenerationPage.tsx
│ │ │ │ ├── GpuPage.tsx
│ │ │ │ ├── LogsPage.tsx
│ │ │ │ ├── ServerTab.tsx
│ │ │ │ └── SettingRow.tsx
│ │ │ ├── ShinyText.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ ├── StoriesTab/
│ │ │ │ ├── StoriesTab.tsx
│ │ │ │ ├── StoryChatItem.tsx
│ │ │ │ ├── StoryContent.tsx
│ │ │ │ ├── StoryList.tsx
│ │ │ │ └── StoryTrackEditor.tsx
│ │ │ ├── TitleBarDragRegion.tsx
│ │ │ ├── VoiceProfiles/
│ │ │ │ ├── AudioSampleRecording.tsx
│ │ │ │ ├── AudioSampleSystem.tsx
│ │ │ │ ├── AudioSampleUpload.tsx
│ │ │ │ ├── ProfileCard.tsx
│ │ │ │ ├── ProfileForm.tsx
│ │ │ │ ├── ProfileList.tsx
│ │ │ │ ├── SampleList.tsx
│ │ │ │ └── SampleUpload.tsx
│ │ │ ├── VoicesTab/
│ │ │ │ ├── VoiceInspector.tsx
│ │ │ │ └── VoicesTab.tsx
│ │ │ └── ui/
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── circle-button.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── multi-select.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── slider.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── toast.tsx
│ │ │ ├── toaster.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── use-toast.ts
│ │ ├── global.d.ts
│ │ ├── hooks/
│ │ │ ├── useAutoUpdater.ts
│ │ │ └── useAutoUpdater.tsx
│ │ ├── index.css
│ │ ├── lib/
│ │ │ ├── api/
│ │ │ │ ├── .gitkeep
│ │ │ │ ├── client.ts
│ │ │ │ ├── core/
│ │ │ │ │ ├── ApiError.ts
│ │ │ │ │ ├── ApiRequestOptions.ts
│ │ │ │ │ ├── ApiResult.ts
│ │ │ │ │ ├── CancelablePromise.ts
│ │ │ │ │ ├── OpenAPI.ts
│ │ │ │ │ └── request.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models/
│ │ │ │ │ ├── Body_add_profile_sample_profiles__profile_id__samples_post.ts
│ │ │ │ │ ├── Body_transcribe_audio_transcribe_post.ts
│ │ │ │ │ ├── GenerationRequest.ts
│ │ │ │ │ ├── GenerationResponse.ts
│ │ │ │ │ ├── HTTPValidationError.ts
│ │ │ │ │ ├── HealthResponse.ts
│ │ │ │ │ ├── HistoryListResponse.ts
│ │ │ │ │ ├── HistoryResponse.ts
│ │ │ │ │ ├── ModelDownloadRequest.ts
│ │ │ │ │ ├── ModelStatus.ts
│ │ │ │ │ ├── ModelStatusListResponse.ts
│ │ │ │ │ ├── ProfileSampleResponse.ts
│ │ │ │ │ ├── TranscriptionResponse.ts
│ │ │ │ │ ├── ValidationError.ts
│ │ │ │ │ ├── VoiceProfileCreate.ts
│ │ │ │ │ └── VoiceProfileResponse.ts
│ │ │ │ ├── schemas/
│ │ │ │ │ ├── $Body_add_profile_sample_profiles__profile_id__samples_post.ts
│ │ │ │ │ ├── $Body_transcribe_audio_transcribe_post.ts
│ │ │ │ │ ├── $GenerationRequest.ts
│ │ │ │ │ ├── $GenerationResponse.ts
│ │ │ │ │ ├── $HTTPValidationError.ts
│ │ │ │ │ ├── $HealthResponse.ts
│ │ │ │ │ ├── $HistoryListResponse.ts
│ │ │ │ │ ├── $HistoryResponse.ts
│ │ │ │ │ ├── $ModelDownloadRequest.ts
│ │ │ │ │ ├── $ModelStatus.ts
│ │ │ │ │ ├── $ModelStatusListResponse.ts
│ │ │ │ │ ├── $ProfileSampleResponse.ts
│ │ │ │ │ ├── $TranscriptionResponse.ts
│ │ │ │ │ ├── $ValidationError.ts
│ │ │ │ │ ├── $VoiceProfileCreate.ts
│ │ │ │ │ └── $VoiceProfileResponse.ts
│ │ │ │ ├── services/
│ │ │ │ │ └── DefaultService.ts
│ │ │ │ └── types.ts
│ │ │ ├── constants/
│ │ │ │ ├── languages.ts
│ │ │ │ └── ui.ts
│ │ │ ├── hooks/
│ │ │ │ ├── useAudioPlayer.ts
│ │ │ │ ├── useAudioRecording.ts
│ │ │ │ ├── useGeneration.ts
│ │ │ │ ├── useGenerationForm.ts
│ │ │ │ ├── useGenerationProgress.ts
│ │ │ │ ├── useHistory.ts
│ │ │ │ ├── useModelDownloadToast.tsx
│ │ │ │ ├── useProfiles.ts
│ │ │ │ ├── useRestoreActiveTasks.tsx
│ │ │ │ ├── useServer.ts
│ │ │ │ ├── useStories.ts
│ │ │ │ ├── useStoryPlayback.ts
│ │ │ │ ├── useSystemAudioCapture.ts
│ │ │ │ └── useTranscription.ts
│ │ │ └── utils/
│ │ │ ├── .gitkeep
│ │ │ ├── audio.ts
│ │ │ ├── cn.ts
│ │ │ ├── debug.ts
│ │ │ ├── format.ts
│ │ │ └── parseChangelog.ts
│ │ ├── main.tsx
│ │ ├── platform/
│ │ │ ├── PlatformContext.tsx
│ │ │ └── types.ts
│ │ ├── router.tsx
│ │ ├── stores/
│ │ │ ├── audioChannelStore.ts
│ │ │ ├── effectsStore.ts
│ │ │ ├── generationStore.ts
│ │ │ ├── logStore.ts
│ │ │ ├── playerStore.ts
│ │ │ ├── serverStore.ts
│ │ │ ├── storyStore.ts
│ │ │ └── uiStore.ts
│ │ └── types/
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── backend/
│ ├── README.md
│ ├── STYLE_GUIDE.md
│ ├── __init__.py
│ ├── app.py
│ ├── backends/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── chatterbox_backend.py
│ │ ├── chatterbox_turbo_backend.py
│ │ ├── hume_backend.py
│ │ ├── kokoro_backend.py
│ │ ├── luxtts_backend.py
│ │ ├── mlx_backend.py
│ │ └── pytorch_backend.py
│ ├── build_binary.py
│ ├── config.py
│ ├── database/
│ │ ├── __init__.py
│ │ ├── migrations.py
│ │ ├── models.py
│ │ ├── seed.py
│ │ └── session.py
│ ├── main.py
│ ├── models.py
│ ├── pyproject.toml
│ ├── requirements-mlx.txt
│ ├── requirements.txt
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── audio.py
│ │ ├── channels.py
│ │ ├── cuda.py
│ │ ├── effects.py
│ │ ├── generations.py
│ │ ├── health.py
│ │ ├── history.py
│ │ ├── models.py
│ │ ├── profiles.py
│ │ ├── stories.py
│ │ ├── tasks.py
│ │ └── transcription.py
│ ├── server.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── channels.py
│ │ ├── cuda.py
│ │ ├── effects.py
│ │ ├── export_import.py
│ │ ├── generation.py
│ │ ├── history.py
│ │ ├── profiles.py
│ │ ├── stories.py
│ │ ├── task_queue.py
│ │ ├── transcribe.py
│ │ ├── tts.py
│ │ └── versions.py
│ ├── tests/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── test_cors.py
│ │ ├── test_generation_download.py
│ │ ├── test_profile_duplicate_names.py
│ │ ├── test_progress.py
│ │ ├── test_qwen_download.py
│ │ └── test_whisper_download.py
│ └── utils/
│ ├── __init__.py
│ ├── audio.py
│ ├── cache.py
│ ├── chunked_tts.py
│ ├── dac_shim.py
│ ├── effects.py
│ ├── hf_offline_patch.py
│ ├── hf_progress.py
│ ├── images.py
│ ├── platform_detect.py
│ ├── progress.py
│ └── tasks.py
├── biome.json
├── data/
│ └── .gitkeep
├── docker-compose.yml
├── docs/
│ ├── .gitignore
│ ├── README.md
│ ├── app/
│ │ ├── [[...slug]]/
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── api/
│ │ │ └── search/
│ │ │ └── route.ts
│ │ ├── global.css
│ │ ├── layout.tsx
│ │ ├── llms-full.txt/
│ │ │ └── route.ts
│ │ ├── llms.mdx/
│ │ │ └── docs/
│ │ │ └── [[...slug]]/
│ │ │ └── route.ts
│ │ └── og/
│ │ └── docs/
│ │ └── [...slug]/
│ │ └── route.tsx
│ ├── cli.json
│ ├── components/
│ │ ├── ai/
│ │ │ └── page-actions.tsx
│ │ ├── api-page.client.tsx
│ │ ├── api-page.tsx
│ │ └── ui/
│ │ ├── button.tsx
│ │ └── popover.tsx
│ ├── content/
│ │ └── docs/
│ │ ├── README.md
│ │ ├── TROUBLESHOOTING.md
│ │ ├── api-reference/
│ │ │ ├── general/
│ │ │ │ ├── health_health_get.mdx
│ │ │ │ ├── meta.json
│ │ │ │ └── root__get.mdx
│ │ │ ├── generation/
│ │ │ │ ├── generate_speech_generate_post.mdx
│ │ │ │ ├── get_audio_audio__generation_id__get.mdx
│ │ │ │ ├── meta.json
│ │ │ │ └── transcribe_audio_transcribe_post.mdx
│ │ │ ├── history/
│ │ │ │ ├── delete_generation_history__generation_id__delete.mdx
│ │ │ │ ├── get_generation_history__generation_id__get.mdx
│ │ │ │ ├── get_stats_history_stats_get.mdx
│ │ │ │ ├── list_history_history_get.mdx
│ │ │ │ └── meta.json
│ │ │ ├── meta.json
│ │ │ ├── models/
│ │ │ │ ├── get_model_progress_models_progress__model_name__get.mdx
│ │ │ │ ├── get_model_status_models_status_get.mdx
│ │ │ │ ├── load_model_models_load_post.mdx
│ │ │ │ ├── meta.json
│ │ │ │ ├── trigger_model_download_models_download_post.mdx
│ │ │ │ └── unload_model_models_unload_post.mdx
│ │ │ └── profiles/
│ │ │ ├── add_profile_sample_profiles__profile_id__samples_post.mdx
│ │ │ ├── create_profile_profiles_post.mdx
│ │ │ ├── delete_profile_profiles__profile_id__delete.mdx
│ │ │ ├── delete_profile_sample_profiles_samples__sample_id__delete.mdx
│ │ │ ├── get_profile_profiles__profile_id__get.mdx
│ │ │ ├── get_profile_samples_profiles__profile_id__samples_get.mdx
│ │ │ ├── list_profiles_profiles_get.mdx
│ │ │ ├── meta.json
│ │ │ └── update_profile_profiles__profile_id__put.mdx
│ │ ├── developer/
│ │ │ ├── architecture.mdx
│ │ │ ├── audio-channels.mdx
│ │ │ ├── autoupdater.mdx
│ │ │ ├── building.mdx
│ │ │ ├── contributing.mdx
│ │ │ ├── effects-pipeline.mdx
│ │ │ ├── history.mdx
│ │ │ ├── meta.json
│ │ │ ├── model-management.mdx
│ │ │ ├── setup.mdx
│ │ │ ├── stories.mdx
│ │ │ ├── transcription.mdx
│ │ │ ├── tts-engines.mdx
│ │ │ ├── tts-generation.mdx
│ │ │ └── voice-profiles.mdx
│ │ ├── index.mdx
│ │ ├── meta.json
│ │ └── overview/
│ │ ├── building-stories.mdx
│ │ ├── creating-voice-profiles.mdx
│ │ ├── docker.mdx
│ │ ├── generating-speech.mdx
│ │ ├── generation-history.mdx
│ │ ├── installation.mdx
│ │ ├── introduction.mdx
│ │ ├── meta.json
│ │ ├── quick-start.mdx
│ │ ├── recording-transcription.mdx
│ │ ├── remote-mode.mdx
│ │ ├── stories-editor.mdx
│ │ ├── troubleshooting.mdx
│ │ └── voice-cloning.mdx
│ ├── lib/
│ │ ├── cn.ts
│ │ ├── layout.shared.tsx
│ │ ├── openapi.ts
│ │ └── source.ts
│ ├── mdx-components.tsx
│ ├── next.config.mjs
│ ├── notes/
│ │ ├── BACKEND_CODE_REVIEW.md
│ │ ├── MIGRATION.md
│ │ ├── PROJECT_STATUS.md
│ │ ├── RELEASE_v0.2.0.md
│ │ └── issue-pain-points.md
│ ├── openapi.json
│ ├── package.json
│ ├── plans/
│ │ ├── API_REFACTOR_PLAN.md
│ │ ├── CUDA_LIBS_ADDON.md
│ │ ├── DOCKER_DEPLOYMENT.md
│ │ └── OPENAI_SUPPORT.md
│ ├── postcss.config.mjs
│ ├── scripts/
│ │ └── generate-openapi.ts
│ ├── source.config.ts
│ └── tsconfig.json
├── justfile
├── landing/
│ ├── .gitignore
│ ├── README.md
│ ├── components.json
│ ├── next.config.js
│ ├── nixpacks.toml
│ ├── package.json
│ ├── postcss.config.js
│ ├── public/
│ │ ├── audio/
│ │ │ ├── fireship.webm
│ │ │ ├── jarvis.webm
│ │ │ ├── linus.webm
│ │ │ ├── morganfreeman.webm
│ │ │ ├── samaltman.webm
│ │ │ └── samjackson.webm
│ │ └── voicebox-demo.webm
│ ├── src/
│ │ ├── app/
│ │ │ ├── api/
│ │ │ │ ├── releases/
│ │ │ │ │ └── route.ts
│ │ │ │ └── stars/
│ │ │ │ └── route.ts
│ │ │ ├── download/
│ │ │ │ └── [platform]/
│ │ │ │ └── route.ts
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── linux-install/
│ │ │ │ └── page.tsx
│ │ │ ├── og/
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── components/
│ │ │ ├── Banner.tsx
│ │ │ ├── ControlUI.tsx
│ │ │ ├── DownloadSection.tsx
│ │ │ ├── Features.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── LandingAudioPlayer.tsx
│ │ │ ├── Navbar.tsx
│ │ │ ├── PlatformIcons.tsx
│ │ │ ├── VoiceCreator.tsx
│ │ │ └── ui/
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── feature-card.tsx
│ │ │ ├── hero.tsx
│ │ │ ├── section.tsx
│ │ │ └── separator.tsx
│ │ └── lib/
│ │ ├── constants.ts
│ │ ├── releases.ts
│ │ └── utils.ts
│ ├── tailwind.config.js
│ └── tsconfig.json
├── package.json
├── requirements.txt
├── scripts/
│ ├── build-server.sh
│ ├── convert-assets.sh
│ ├── generate-api.sh
│ ├── package_cuda.py
│ ├── prepare-release.sh
│ ├── setup-dev-sidecar.js
│ ├── test_download_progress.py
│ └── update-icons.sh
├── tauri/
│ ├── assets/
│ │ └── voicebox.icon/
│ │ └── icon.json
│ ├── index.html
│ ├── package.json
│ ├── src/
│ │ ├── main.tsx
│ │ └── platform/
│ │ ├── audio.ts
│ │ ├── filesystem.ts
│ │ ├── index.ts
│ │ ├── lifecycle.ts
│ │ ├── metadata.ts
│ │ └── updater.ts
│ ├── src-tauri/
│ │ ├── Cargo.toml
│ │ ├── Entitlements.plist
│ │ ├── Info.plist
│ │ ├── build.rs
│ │ ├── capabilities/
│ │ │ └── default.json
│ │ ├── gen/
│ │ │ └── schemas/
│ │ │ ├── acl-manifests.json
│ │ │ ├── capabilities.json
│ │ │ ├── desktop-schema.json
│ │ │ ├── macOS-schema.json
│ │ │ └── windows-schema.json
│ │ ├── icons/
│ │ │ ├── android/
│ │ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ │ └── ic_launcher.xml
│ │ │ │ └── values/
│ │ │ │ └── ic_launcher_background.xml
│ │ │ └── icon.icns
│ │ ├── src/
│ │ │ ├── audio_capture/
│ │ │ │ ├── linux.rs
│ │ │ │ ├── macos.rs
│ │ │ │ ├── mod.rs
│ │ │ │ └── windows.rs
│ │ │ ├── audio_output.rs
│ │ │ ├── lib.rs
│ │ │ └── main.rs
│ │ ├── tauri.conf.json
│ │ └── tests/
│ │ └── audio_capture_test.rs
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── web/
├── index.html
├── package.json
├── src/
│ ├── main.tsx
│ └── platform/
│ ├── audio.ts
│ ├── filesystem.ts
│ ├── index.ts
│ ├── lifecycle.ts
│ ├── metadata.ts
│ └── updater.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .agents/skills/add-tts-engine/SKILL.md
================================================
---
name: add-tts-engine
description: Use this skill to add a new TTS engine to Voicebox. It walks through dependency research, backend implementation, frontend wiring, PyInstaller bundling, and frozen-build testing. Always start with Phase 0 (dependency audit) before writing any code.
---
# Add TTS Engine
## Goal
Integrate a new text-to-speech engine into Voicebox end-to-end: dependency research, backend protocol implementation, frontend UI wiring, PyInstaller bundling, and frozen-build verification. The user should only need to test the final build locally.
## Reference Doc
The full phased guide lives at `docs/content/docs/developer/tts-engines.mdx`. **Read this file in its entirety before starting.** It contains:
- Phase 0: Dependency research (mandatory before writing code)
- Phase 1: Backend implementation (`TTSBackend` protocol)
- Phase 2: Route and service integration (usually zero changes)
- Phase 3: Frontend integration (5 files)
- Phase 4: Dependencies (`requirements.txt`, justfile, CI, Docker)
- Phase 5: PyInstaller bundling (`build_binary.py` + `server.py`)
- Phase 6: Common upstream workarounds
- Implementation checklist (gate between phases)
## Workflow
### 1. Read the guide
```bash
# Read the full TTS engines doc
cat docs/content/docs/developer/tts-engines.mdx
```
Internalize all phases, especially Phase 0 and Phase 5. The v0.2.3 release was three patch releases because Phase 0 was skipped.
### 2. Dependency research (Phase 0)
Clone the model library into a temporary directory and audit it. Do NOT skip this.
```bash
mkdir /tmp/engine-research && cd /tmp/engine-research
git clone <model-library-url>
```
Run the grep searches from Phase 0.2 in the guide against the cloned source and its transitive dependencies. Produce a written dependency audit covering:
1. PyPI vs non-PyPI packages
2. PyInstaller directives needed (`--collect-all`, `--copy-metadata`, `--hidden-import`)
3. Runtime data files that must be bundled
4. Native library paths that need env var overrides in frozen builds
5. Monkey-patches needed (`torch.load`, float64, MPS, HF token)
6. Sample rate
7. Model download method (`from_pretrained` vs `snapshot_download` + `from_local`)
Test model loading and generation on CPU in the throwaway venv before proceeding.
### 3. Implement (Phases 1–4)
Follow the guide's phases in order. Key files to modify:
**Backend (Phase 1):**
- Create `backend/backends/<engine>_backend.py`
- Register in `backend/backends/__init__.py` (ModelConfig + TTS_ENGINES + factory)
- Update regex in `backend/models.py`
**Frontend (Phase 3):**
- `app/src/lib/api/types.ts` — engine union type
- `app/src/lib/constants/languages.ts` — ENGINE_LANGUAGES
- `app/src/components/Generation/EngineModelSelector.tsx` — ENGINE_OPTIONS, ENGINE_DESCRIPTIONS
- `app/src/lib/hooks/useGenerationForm.ts` — Zod schema, model-name mapping
- `app/src/components/ServerSettings/ModelManagement.tsx` — MODEL_DESCRIPTIONS
**Dependencies (Phase 4):**
- `backend/requirements.txt`
- `justfile` (setup-python, setup-python-release targets)
- `.github/workflows/release.yml`
- `Dockerfile` (if applicable)
### 4. PyInstaller bundling (Phase 5)
Register the engine in `backend/build_binary.py`:
- `--hidden-import` for the backend module and model package
- `--collect-all` for packages using `inspect.getsource`, shipping data files, or native libraries
- `--copy-metadata` for packages using `importlib.metadata`
If the engine has native data paths, add `os.environ.setdefault()` in `backend/server.py` inside the `if getattr(sys, 'frozen', False):` block.
### 5. Verify in dev mode
```bash
just dev
```
Test the full chain: model download → load → generate → voice cloning.
### 6. Use the checklist
Walk through the Implementation Checklist at the bottom of `tts-engines.mdx`. Every item must be checked before handing the build to the user.
## Key Lessons (from v0.2.3)
These are the most common failure modes. Phase 0 research catches all of them:
| Pattern | Symptom in Frozen Build | Fix |
|---------|------------------------|-----|
| `@typechecked` / `inspect.getsource()` | "could not get source code" | `--collect-all <package>` |
| Package ships pretrained model files | `FileNotFoundError` for `.pth.tar`, `.yaml` | `--collect-all <package>` |
| C library with hardcoded system paths | `FileNotFoundError` for `/usr/share/...` | `--collect-all` + env var in `server.py` |
| `importlib.metadata.version()` | "No package metadata found" | `--copy-metadata <package>` |
| `torch.load` without `map_location` | CUDA device not available on CPU build | Monkey-patch `torch.load` |
| `torch.from_numpy` on float64 data | dtype mismatch RuntimeError | Cast to `.float()` |
| `token=True` in HF download calls | Auth failure without stored HF token | Use `snapshot_download(token=None)` + `from_local()` |
## Notes
- The route and service layers have zero per-engine dispatch points. `main.py` requires zero changes.
- The model config registry in `backends/__init__.py` handles all dispatch automatically.
- Use `get_torch_device()` and `model_load_progress()` from `backends/base.py` — don't reimplement device detection or progress tracking.
- Always test with a **clean HuggingFace cache** (no pre-downloaded models from dev).
- Do NOT push or create a release. Hand the build to the user for local testing.
================================================
FILE: .agents/skills/draft-release-notes/SKILL.md
================================================
---
name: draft-release-notes
description: Use this skill to draft or update the [Unreleased] section of CHANGELOG.md from the actual changes since the last tag. Run this at any point during development to keep a working copy of the release narrative. Does NOT bump versions or create tags.
---
# Draft Release Notes
## Goal
Update the `[Unreleased]` section at the top of `CHANGELOG.md` with a narrative release story based on the real changes since the last tag. This is a **non-destructive working copy** — run it as many times as you want during development.
## Workflow
1. **Identify the last release tag and gather changes.**
```bash
LAST_TAG=$(git tag --list "v*" --sort=-v:refname | head -n 1)
echo "Last tag: $LAST_TAG"
```
Then collect raw material from three sources:
a. **Commit log since last tag:**
```bash
git log --oneline "$LAST_TAG"..HEAD
```
b. **GitHub-generated release notes preview** (PR titles, new contributors):
```bash
gh api repos/:owner/:repo/releases/generate-notes \
-f tag_name="vNEXT" \
-f target_commitish="$(git rev-parse HEAD)" \
-f previous_tag_name="$LAST_TAG" \
--jq '.body'
```
c. **Diff stat for theme analysis:**
```bash
git diff --stat "$LAST_TAG"..HEAD
```
2. **Draft the release narrative.**
Write markdown for the `[Unreleased]` section following the format below. Do not include the `## [Unreleased]` heading itself — just the body content.
3. **Update CHANGELOG.md.**
Replace everything between `## [Unreleased]` and the next `## [` heading with the new draft. Preserve the HTML comment header and all existing release sections below.
The `[Unreleased]` section must always exist and always be the first section after the header comments.
4. **Do NOT commit, tag, or bump versions.** Just leave the file modified in the working tree.
## Release Story Format
Structure the `[Unreleased]` section like this:
```markdown
## [Unreleased]
<One strong opening paragraph: what this release is about and why it matters.
Tie it to concrete shipped changes. No vague hype.>
<One paragraph on major technical shifts, if applicable.>
### <Feature/Theme Group>
- Bullet points with specifics
- Reference PRs where available: ([#123](https://github.com/jamiepine/voicebox/pull/123))
### <Another Group>
- ...
### Bug Fixes
- ...
```
### Style Guidelines
- **Factual and specific.** Every claim should trace to a real commit or PR.
- **Narrative over list.** Lead with paragraphs that tell the story, then support with bullets.
- **Group by theme, not by commit.** Cluster related changes under descriptive headings.
- **Reference PRs** where they exist, but don't fabricate them.
- **Skip trivial chores** (typo fixes, CI tweaks) unless they're the bulk of the release.
- **Match the voice of existing releases** — look at the v0.2.1 and v0.2.3 entries in CHANGELOG.md for tone reference.
## When There Are No Changes
If `git log "$LAST_TAG"..HEAD` is empty, leave the `[Unreleased]` section empty (just the heading) and tell the user there's nothing to draft.
## Notes
- This skill only touches the `[Unreleased]` section. It never modifies stamped release sections.
- The agent can be asked to run this skill at any point — mid-feature, before a PR, or right before cutting a release.
- The `release-bump` skill depends on this draft being up to date before it finalizes.
================================================
FILE: .agents/skills/release-bump/SKILL.md
================================================
---
name: release-bump
description: Use this skill to finalize a release. It stamps the [Unreleased] changelog section with a version and date, runs bumpversion to update all version files, and creates the release commit and tag. Only run this when you're ready to ship.
---
# Release Bump
## Goal
Finalize the changelog draft, bump the version across all tracked files, and create a tagged release commit. After this skill runs, the repo has a clean release commit and tag ready to push.
## Prerequisites
- `gh` CLI installed and authenticated (`gh auth status`).
- `bumpversion` installed (`pip install bumpversion` or available in the project venv).
- The `[Unreleased]` section of `CHANGELOG.md` should already contain the release narrative. If it's empty or stale, run the `draft-release-notes` skill first.
## Workflow
1. **Verify the working tree is clean** (except `CHANGELOG.md` which may have the draft).
```bash
git status --porcelain
```
Only `CHANGELOG.md` (and optionally `.agents/` files) should be modified. If there are other uncommitted changes, stop and ask the user to commit or stash them first.
2. **Determine the bump level.**
Ask the user if not specified: `patch`, `minor`, or `major`. Check the current version:
```bash
grep '^current_version' .bumpversion.cfg
```
3. **Stamp the changelog.**
Read the current `[Unreleased]` content from `CHANGELOG.md`. Compute the new version (based on bump level and current version). Then:
a. Replace the `## [Unreleased]` section body with an empty placeholder.
b. Insert a new stamped section immediately after `## [Unreleased]`:
```markdown
## [Unreleased]
## [X.Y.Z] - YYYY-MM-DD
<the content that was in [Unreleased]>
```
c. Update the reference links at the bottom of the file:
- Change the `[Unreleased]` link to compare against the new tag
- Add a new link for the new version
```markdown
[Unreleased]: https://github.com/jamiepine/voicebox/compare/vX.Y.Z...HEAD
[X.Y.Z]: https://github.com/jamiepine/voicebox/compare/vPREVIOUS...vX.Y.Z
```
4. **Stage the changelog.**
```bash
git add CHANGELOG.md
```
5. **Run bumpversion.**
```bash
bumpversion --allow-dirty <patch|minor|major>
```
The `--allow-dirty` flag is needed because `CHANGELOG.md` is already staged. bumpversion will:
- Update version strings in all tracked files (see `.bumpversion.cfg`)
- Create a commit with message `Bump version: X.Y.Z -> A.B.C`
- Create a tag `vA.B.C`
The staged `CHANGELOG.md` will be included in this commit automatically.
6. **Verify results.**
```bash
git show --name-only --stat HEAD
git tag --list "v*" --sort=-v:refname | head -n 5
```
Confirm the commit contains:
- `CHANGELOG.md`
- `.bumpversion.cfg`
- `tauri/src-tauri/tauri.conf.json`
- `tauri/src-tauri/Cargo.toml`
- `package.json`
- `app/package.json`
- `tauri/package.json`
- `landing/package.json`
- `web/package.json`
- `backend/__init__.py`
Confirm the new tag exists.
7. **Do NOT push** unless the user explicitly asks. Report the tag name and suggest:
```
Ready to push. When you're ready:
git push origin main --follow-tags
```
## Version Calculation Reference
Given current version `X.Y.Z`:
- `patch` -> `X.Y.(Z+1)`
- `minor` -> `X.(Y+1).0`
- `major` -> `(X+1).0.0`
## Error Recovery
- If bumpversion fails, the tag won't exist. Fix the issue and re-run — bumpversion is idempotent as long as the tag doesn't already exist.
- If you need to undo a release commit (before pushing): `git tag -d vX.Y.Z && git reset --soft HEAD~1`
- Never amend a release commit that has been pushed.
## Notes
- When the tag is pushed, the release CI (`.github/workflows/release.yml`) automatically extracts the matching version section from `CHANGELOG.md` and uses it as the GitHub Release body. No manual copy-paste needed.
- The release commit message is controlled by `.bumpversion.cfg` (`Bump version: X.Y.Z -> A.B.C`). Do not override it.
- If you need to manually update the GitHub Release body after the fact: `gh release edit vX.Y.Z --notes-file <(sed -n '/## \[X.Y.Z\]/,/## \[/p' CHANGELOG.md | head -n -1)`
================================================
FILE: .biomeignore
================================================
# Dependencies
node_modules
bun.lockb
# Build outputs
dist
target
.tauri
# Generated files
app/src/lib/api
# Config files (don't lint/format)
*.config.js
*.config.ts
# Tailwind CSS files (contains @tailwind directives)
**/index.css
================================================
FILE: .bumpversion.cfg
================================================
[bumpversion]
current_version = 0.3.1
commit = True
tag = True
tag_name = v{new_version}
tag_message = Release v{new_version}
message = Bump version: {current_version} → {new_version}
[bumpversion:file:tauri/src-tauri/tauri.conf.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:tauri/src-tauri/Cargo.toml]
search = version = "{current_version}"
replace = version = "{new_version}"
[bumpversion:file:package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:app/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:tauri/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:landing/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:web/package.json]
search = "version": "{current_version}"
replace = "version": "{new_version}"
[bumpversion:file:backend/__init__.py]
search = __version__ = "{current_version}"
replace = __version__ = "{new_version}"
================================================
FILE: .dockerignore
================================================
# Version control
.git
.github
.gitignore
# Desktop-only (not needed in web container)
tauri/
landing/
docs/
mlx-test/
scripts/
# Dependencies & build artifacts (rebuilt in Docker)
node_modules/
__pycache__/
*.pyc
*.pyo
*.egg-info/
dist/
build/
*.spec
# Data (will be bind-mounted)
data/
backend/data/
# IDE & OS
.vscode/
.idea/
*.swp
*.swo
.DS_Store
Thumbs.db
# Config files not needed in container
biome.json
.biomeignore
.bumpversion.cfg
.npmrc
Makefile
CHANGELOG.md
CONTRIBUTING.md
SECURITY.md
LICENSE
README.md
backend/README.md
================================================
FILE: .github/workflows/build-windows.yml
================================================
name: Build Windows
on:
workflow_dispatch:
jobs:
build-windows:
permissions:
contents: write
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
- name: Build Python server
shell: bash
run: |
cd backend
python build_binary.py
PLATFORM=$(rustc --print host-tuple)
mkdir -p ../tauri/src-tauri/binaries
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./tauri/src-tauri -> target"
- name: Install dependencies
run: bun install
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
projectPath: tauri
tagName: v__VERSION__
releaseName: "voicebox v__VERSION__ (test build)"
releaseBody: "Test build for audio export fix"
releaseDraft: true
prerelease: true
args: ""
includeUpdaterJson: false
================================================
FILE: .github/workflows/release.yml
================================================
name: Release
on:
workflow_dispatch:
push:
tags:
- "v*"
jobs:
release:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
include:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
python-version: "3.12"
backend: "mlx"
- platform: "macos-15-intel"
args: "--target x86_64-apple-darwin"
python-version: "3.12"
backend: "pytorch"
- platform: "windows-latest"
args: ""
python-version: "3.12"
backend: "pytorch"
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies (ubuntu only)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf llvm-dev libasound2-dev
- name: Install LLVM (macOS)
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
run: |
brew install llvm@20
echo "$(brew --prefix llvm@20)/bin" >> $GITHUB_PATH
echo "LLVM_CONFIG=$(brew --prefix llvm@20)/bin/llvm-config" >> $GITHUB_ENV
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
- name: Install CPU-only PyTorch (Linux)
if: contains(matrix.platform, 'ubuntu') || contains(matrix.platform, 'namespace')
run: |
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
- name: Install MLX dependencies (Apple Silicon only)
if: matrix.backend == 'mlx'
run: |
pip install -r backend/requirements-mlx.txt
- name: Build Python server (Linux/macOS)
if: matrix.platform != 'windows-latest'
run: |
chmod +x scripts/build-server.sh
./scripts/build-server.sh
- name: Build Python server (Windows)
if: matrix.platform == 'windows-latest'
shell: bash
run: |
cd backend
python build_binary.py
# Get platform tuple
PLATFORM=$(rustc --print host-tuple)
# Create binaries directory
mkdir -p ../tauri/src-tauri/binaries
# Copy with platform suffix
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
echo "Built voicebox-server-${PLATFORM}.exe"
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ (matrix.platform == 'macos-latest' && 'aarch64-apple-darwin') || (matrix.platform == 'macos-15-intel' && 'x86_64-apple-darwin') || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./tauri/src-tauri -> target"
- name: Install dependencies
run: bun install
- name: Install Apple API key
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
run: |
mkdir -p ~/.appstoreconnect/private_keys/
cd ~/.appstoreconnect/private_keys/
echo ${{ secrets.APPLE_API_KEY_BASE64 }} >> AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
base64 --decode -i AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64 -o AuthKey_${{ secrets.APPLE_API_KEY }}.p8
rm AuthKey_${{ secrets.APPLE_API_KEY }}.p8.base64
- name: Install Codesigning Certificate
if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-15-intel'
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Extract release notes from CHANGELOG.md
id: changelog
shell: bash
run: |
# Get the version from the tag (strip leading 'v')
VERSION="${GITHUB_REF_NAME#v}"
# Extract the section for this version from CHANGELOG.md
# Matches from "## [X.Y.Z]" until the next "## [" heading
NOTES=$(sed -n "/^## \[${VERSION}\]/,/^## \[/{/^## \[${VERSION}\]/d;/^## \[/d;p;}" CHANGELOG.md)
# Fall back to a placeholder if the version isn't in the changelog
if [ -z "$(echo "$NOTES" | tr -d '[:space:]')" ]; then
NOTES="See the assets below to download and install this version."
fi
# Use multiline output syntax
{
echo "notes<<CHANGELOG_EOF"
echo "$NOTES"
echo "CHANGELOG_EOF"
} >> "$GITHUB_OUTPUT"
- uses: tauri-apps/tauri-action@v0.6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
ENABLE_CODE_SIGNING: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_PROVIDER_SHORT_NAME: ${{ secrets.APPLE_PROVIDER_SHORT_NAME }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
with:
projectPath: tauri
tagName: v__VERSION__
releaseName: "voicebox v__VERSION__"
releaseBody: ${{ steps.changelog.outputs.notes }}
releaseDraft: true
prerelease: false
args: ${{ matrix.args }}
includeUpdaterJson: true
build-cuda-windows:
runs-on: windows-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
pip install pyinstaller
pip install -r backend/requirements.txt
pip install --no-deps chatterbox-tts
pip install --no-deps hume-tada
- name: Install PyTorch with CUDA 12.8
run: |
pip install torch --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
pip install torchaudio --index-url https://download.pytorch.org/whl/cu128 --force-reinstall --no-deps
- name: Verify CUDA support in torch
run: |
python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')"
- name: Build CUDA server binary (onedir)
shell: bash
working-directory: backend
run: python build_binary.py --cuda
- name: Package into server core + CUDA libs archives
shell: bash
run: |
python scripts/package_cuda.py \
backend/dist/voicebox-server-cuda/ \
--output release-assets/ \
--cuda-libs-version cu128-v1 \
--torch-compat ">=2.7.0,<2.11.0"
- name: Upload archives to GitHub Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
release-assets/voicebox-server-cuda.tar.gz
release-assets/voicebox-server-cuda.tar.gz.sha256
release-assets/cuda-libs-cu128-v1.tar.gz
release-assets/cuda-libs-cu128-v1.tar.gz.sha256
release-assets/cuda-libs.json
draft: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload onedir as workflow artifact
uses: actions/upload-artifact@v4
with:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda/
retention-days: 7
================================================
FILE: .gitignore
================================================
# Dependencies
node_modules/
bun.lockb
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
*.prompt
# Build outputs
dist/
build/
*.egg-info/
*.egg
target/
*.app
*.dmg
*.exe
*.msi
*.deb
*.AppImage
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Data (user-generated)
data/
!data/.gitkeep
# Logs
*.log
logs/
# Environment
.env
.env.local
# Generated files
app/openapi.json
tauri/src-tauri/binaries/*
tauri/src-tauri/gen/Assets.car
tauri/src-tauri/gen/voicebox.icns
tauri/src-tauri/gen/partial.plist
# PyInstaller
*.spec
# Windows artifacts
nul
# Temporary
tmp/
temp/
*.tmp
================================================
FILE: .npmrc
================================================
# Force bun usage
engine-strict=true
================================================
FILE: CHANGELOG.md
================================================
<!-- This file is compiled automatically during the release workflow. -->
<!-- Do not edit manually — your changes will be overwritten. -->
<!-- To update the draft: ask the agent to use the draft-release-notes skill. -->
<!-- To finalize a release: ask the agent to use the release-bump skill. -->
# Changelog
## [Unreleased]
## [0.3.0] - 2026-03-17
This release rewrites the backend into a modular architecture, overhauls the settings UI into routed sub-pages, fixes audio player freezing, migrates documentation to Fumadocs, and ships a batch of bug fixes targeting the most-reported issues from the tracker.
The backend's 3,000-line monolith `main.py` has been decomposed into domain routers, a services layer, and a proper database package. A style guide and ruff configuration now enforce consistency. On the frontend, settings have been split into dedicated routed pages with server logs, a changelog viewer, and an about page. The audio player no longer freezes mid-playback, and model loading status is now visible in the UI. Seven user-reported bugs have been fixed, including server crashes during sample uploads, generation list staleness, cryptic error messages, and CUDA support for RTX 50-series GPUs.
### Settings Overhaul ([#294](https://github.com/jamiepine/voicebox/pull/294))
- Split settings into routed sub-tabs: General, Generation, GPU, Logs, Changelog, About
- Added live server log viewer with auto-scroll
- Added in-app changelog page that parses `CHANGELOG.md` at build time
- Added About page with version info, license, and generation folder quick-open
- Extracted reusable `SettingRow` component for consistent setting layouts
### Audio Player Fix ([#293](https://github.com/jamiepine/voicebox/pull/293))
- Fixed audio player freezing during playback
- Improved playback UX with better state management and listener cleanup
- Fixed restart race condition during regeneration
- Added stable keys for audio element re-rendering
- Improved accessibility across player controls
### Backend Refactor ([#285](https://github.com/jamiepine/voicebox/pull/285))
- Extracted all routes from `main.py` into 13 domain routers under `backend/routes/` — `main.py` dropped from ~3,100 lines to ~10
- Moved CRUD and service modules into `backend/services/`, platform detection into `backend/utils/`
- Split monolithic `database.py` into a `database/` package with separate `models`, `session`, `migrations`, and `seed` modules
- Added `backend/STYLE_GUIDE.md` and `pyproject.toml` with ruff linting config
- Removed dead code: unused `_get_cuda_dll_excludes`, stale `studio.py`, `example_usage.py`, old `Makefile`
- Deduplicated shared logic across TTS backends into `backends/base.py`
- Improved startup logging with version, platform, data directory, and database stats
- Fixed startup database session leak — sessions now rollback and close in `finally` block
- Isolated shutdown unload calls so one backend failure doesn't block the others
- Handled null duration in `story_items` migration
- Reject model migration when target is a subdirectory of source cache
### Documentation Rewrite ([#288](https://github.com/jamiepine/voicebox/pull/288))
- Migrated docs site from Mintlify to Fumadocs (Next.js-based)
- Rewrote introduction and root page with content from README
- Added "Edit on GitHub" links and last-updated timestamps on all pages
- Generated OpenAPI spec and auto-generated API reference pages
- Removed stale planning docs (`CUDA_BACKEND_SWAP`, `EXTERNAL_PROVIDERS`, `MLX_AUDIO`, `TTS_PROVIDER_ARCHITECTURE`, etc.)
- Sidebar groups now expand by default; root redirects to `/docs`
- Added OG image metadata and `/og` preview page
### UI & Frontend
- Added model loading status indicator and effects preset dropdown ([3187344](https://github.com/jamiepine/voicebox/commit/3187344))
- Fixed take-label race condition during regeneration
- Added accessible focus styling to select component
- Softened select focus indicator opacity
- Addressed 4 critical and 12 major issues from CodeRabbit review
### Bug Fixes ([#295](https://github.com/jamiepine/voicebox/pull/295))
- Fixed sample uploads crashing the server — audio decoding now runs in a thread pool instead of blocking the async event loop ([#278](https://github.com/jamiepine/voicebox/issues/278))
- Fixed generation list not updating when a generation completes — switched to `refetchQueries` for reliable cache busting, added SSE error fallback, and page reset on completion ([#231](https://github.com/jamiepine/voicebox/issues/231))
- Fixed error toasts showing `[object Object]` instead of the actual error message ([#290](https://github.com/jamiepine/voicebox/issues/290))
- Added Whisper model selection (`base`, `small`, `medium`, `large`, `turbo`) and expanded language support to the `/transcribe` endpoint ([#233](https://github.com/jamiepine/voicebox/issues/233))
- Upgraded CUDA backend build from cu121 to cu126 for RTX 50-series (Blackwell) GPU support ([#289](https://github.com/jamiepine/voicebox/issues/289))
- Handled client disconnects in SSE and streaming endpoints to suppress `[Errno 32] Broken Pipe` errors ([#248](https://github.com/jamiepine/voicebox/issues/248))
- Fixed Docker build failure from pip hash mismatch on Qwen3-TTS dependencies ([#286](https://github.com/jamiepine/voicebox/issues/286))
- Added 50 MB upload size limit with chunked reads to prevent unbounded memory allocation on sample uploads
- Eliminated redundant double audio decode in sample processing pipeline
### Platform Fixes
- Replaced `netstat` with `TcpStream` + PowerShell for Windows port detection ([#277](https://github.com/jamiepine/voicebox/pull/277))
- Fixed Docker frontend build and cleaned up Docker docs
- Fixed macOS download links to use `.dmg` instead of `.app.tar.gz`
- Added dynamic download redirect routes to landing site
### Release Tooling
- Added `draft-release-notes` and `release-bump` agent skills
- Wired CI release workflow to extract notes from `CHANGELOG.md` for GitHub Releases
- Backfilled changelog with all historical releases
## [0.2.3] - 2026-03-15
The "it works in dev but not in prod" release. This version fixes a series of PyInstaller bundling issues that prevented model downloading, loading, generation, and progress tracking from working in production builds.
### Model Downloads Now Actually Work
The v0.2.1/v0.2.2 builds could not download or load models that weren't already cached from a dev install. This release fixes the entire chain:
- **Chatterbox, Chatterbox Turbo, and LuxTTS** all download, load, and generate correctly in bundled builds
- **Real-time download progress** — byte-level progress bars now work in production. The root cause: `huggingface_hub` silently disables tqdm progress bars based on logger level, which prevented our progress tracker from receiving byte updates. We now force-enable the internal counter regardless.
- **Fixed Python 3.12.0 `code.replace()` bug** — the macOS build was on Python 3.12.0, which has a [known CPython bug](https://github.com/pyinstaller/pyinstaller/issues/7992) that corrupts bytecode when PyInstaller rewrites code objects. This caused `NameError: name 'obj' is not defined` crashes during scipy/torch imports. Upgraded to Python 3.12.13.
### PyInstaller Fixes
- Collect all `inflect` files — `typeguard`'s `@typechecked` decorator calls `inspect.getsource()` at import time, which needs `.py` source files, not just bytecode. Fixes LuxTTS "could not get source code" error.
- Collect all `perth` files — bundles the pretrained watermark model (`hparams.yaml`, `.pth.tar`) needed by Chatterbox at runtime
- Collect all `piper_phonemize` files — bundles `espeak-ng-data/` (phoneme tables, language dicts) needed by LuxTTS for text-to-phoneme conversion
- Set `ESPEAK_DATA_PATH` in frozen builds so the espeak-ng C library finds the bundled data instead of looking at `/usr/share/espeak-ng-data/`
- Collect all `linacodec` files — fixes `inspect.getsource` error in Vocos codec
- Collect all `zipvoice` files — fixes source code lookup in LuxTTS voice cloning
- Copy metadata for `requests`, `transformers`, `huggingface-hub`, `tokenizers`, `safetensors`, `tqdm` — fixes `importlib.metadata` lookups in frozen binary
- Add hidden imports for `chatterbox`, `chatterbox_turbo`, `luxtts`, `zipvoice` backends
- Add `multiprocessing.freeze_support()` to fix resource_tracker subprocess crash in frozen binary
- `--noconsole` now only applied on Windows — macOS/Linux need stdout/stderr for Tauri sidecar log capture
- Hardened `sys.stdout`/`sys.stderr` devnull redirect to test writability, not just `None` check
### Updater
- Fixed updater artifact generation with `v1Compatible` for `tauri-action` signature files
- Updated `tauri-action` to v0.6 to fix updater JSON and `.sig` generation
### Other Fixes
- Full traceback logging on all backend model loading errors (was just `str(e)` before)
## [0.2.2] - 2026-03-15
- Fix Chatterbox model support in bundled builds
- Fix LuxTTS/ZipVoice support in bundled builds
- Auto-update CUDA binary when app version changes
- CUDA download progress bar
- Fix server process staying alive on macOS (SIGHUP handling, watchdog grace period)
- Hide console window when running CUDA binary on Windows
## [0.2.1] - 2026-03-15
Voicebox v0.1.x was a single-engine voice cloning app built around Qwen3-TTS. v0.2.0 is a ground-up rethink: four TTS engines, 23 languages, paralinguistic emotion controls, a post-processing effects pipeline, unlimited generation length, an async generation queue, and support for every major GPU vendor. Plus Docker.
### New TTS Engines
#### Multi-Engine Architecture
Voicebox now runs **four independent TTS engines** behind a thread-safe per-engine backend registry. Switch engines per-generation from a single dropdown — no restart required.
| Engine | Languages | Size | Key Strengths |
| --------------------------- | --------- | ------- | --------------------------------------------- |
| **Qwen3-TTS 1.7B** | 10 | ~3.5 GB | Highest quality, delivery instructions |
| **Qwen3-TTS 0.6B** | 10 | ~1.2 GB | Lighter, faster variant |
| **LuxTTS** | English | ~300 MB | CPU-friendly, 48 kHz output, 150x realtime |
| **Chatterbox Multilingual** | 23 | ~3.2 GB | Broadest language coverage, zero-shot cloning |
| **Chatterbox Turbo** | English | ~1.5 GB | 350M params, low latency, paralinguistic tags |
#### Chatterbox Multilingual — 23 Languages ([#257](https://github.com/jamiepine/voicebox/pull/257))
Zero-shot voice cloning in Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, and Turkish.
#### LuxTTS — Lightweight English TTS ([#254](https://github.com/jamiepine/voicebox/pull/254))
A fast, CPU-friendly English engine. ~300 MB download, 48 kHz output, runs at 150x realtime on CPU.
#### Chatterbox Turbo — Expressive English ([#258](https://github.com/jamiepine/voicebox/pull/258))
A fast 350M-parameter English model with inline paralinguistic tags.
#### Paralinguistic Tags Autocomplete ([#265](https://github.com/jamiepine/voicebox/pull/265))
Type `/` in the text input with Chatterbox Turbo selected to open an autocomplete for **9 expressive tags**: `[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
### Generation
#### Unlimited Generation Length — Auto-Chunking ([#266](https://github.com/jamiepine/voicebox/pull/266))
Long text is now automatically split at sentence boundaries, generated per-chunk, and crossfaded back together. Engine-agnostic.
- Auto-chunking limit slider — 100–5,000 chars (default 800)
- Crossfade slider — 0–200ms (default 50ms)
- Max text length raised to 50,000 characters
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
#### Asynchronous Generation Queue ([#269](https://github.com/jamiepine/voicebox/pull/269))
Generation is now fully non-blocking. Serial execution queue prevents GPU contention. Real-time SSE status streaming.
#### Generation Versions
Every generation now supports multiple versions with provenance tracking — original, effects versions, takes, source tracking, version pinning in stories, and favorites.
### Post-Processing Effects ([#271](https://github.com/jamiepine/voicebox/pull/271))
A full audio effects system powered by Spotify's `pedalboard` library: Pitch Shift, Reverb, Delay, Chorus/Flanger, Compressor, Gain, High-Pass Filter, Low-Pass Filter. 4 built-in presets, custom presets, per-profile default effects, and live preview.
### Platform Support
- **Windows Support** ([#272](https://github.com/jamiepine/voicebox/pull/272)) — Full Windows support with CUDA GPU detection
- **Linux** ([#262](https://github.com/jamiepine/voicebox/pull/262)) — AMD ROCm, NVIDIA GBM fix, WebKitGTK mic access (build from source)
- **NVIDIA CUDA Backend Swap** ([#252](https://github.com/jamiepine/voicebox/pull/252)) — Download and swap in CUDA backend from within the app
- **Intel Arc (XPU) and DirectML** — PyTorch backend supports Intel Arc and DirectML
- **Docker + Web Deployment** ([#161](https://github.com/jamiepine/voicebox/pull/161)) — 3-stage build, non-root runtime, health checks
- **Whisper Turbo** — Added `openai/whisper-large-v3-turbo` as a transcription model option
### Model Management ([#268](https://github.com/jamiepine/voicebox/pull/268))
Per-model unload, custom models directory, model folder migration, download cancel/clear UI ([#238](https://github.com/jamiepine/voicebox/pull/238)), restructured settings UI.
### Security & Reliability
- CORS hardening ([#88](https://github.com/jamiepine/voicebox/pull/88))
- Network access toggle ([#133](https://github.com/jamiepine/voicebox/pull/133))
- Offline crash fix ([#152](https://github.com/jamiepine/voicebox/pull/152))
- Atomic audio saves ([#263](https://github.com/jamiepine/voicebox/pull/263))
- Filesystem health endpoint
- Chatterbox float64 dtype fix ([#264](https://github.com/jamiepine/voicebox/pull/264))
### Accessibility ([#243](https://github.com/jamiepine/voicebox/pull/243))
Screen reader support, keyboard navigation, state-aware `aria-label` attributes on all interactive controls.
### UI Polish
- Redesigned landing page ([#274](https://github.com/jamiepine/voicebox/pull/274))
- Voices tab overhaul with inline inspector
- Responsive layout improvements
- Duplicate profile name validation ([#175](https://github.com/jamiepine/voicebox/pull/175))
### Community Contributors
[@haosenwang1018](https://github.com/haosenwang1018), [@Balneario-de-Cofrentes](https://github.com/Balneario-de-Cofrentes), [@ageofalgo](https://github.com/ageofalgo), [@mikeswann](https://github.com/mikeswann), [@rayl15](https://github.com/rayl15), [@mpecanha](https://github.com/mpecanha), [@ways2read](https://github.com/ways2read), [@ieguiguren](https://github.com/ieguiguren), [@Vaibhavee89](https://github.com/Vaibhavee89), [@pandego](https://github.com/pandego), [@luminest-llc](https://github.com/luminest-llc)
## [0.1.13] - 2026-02-23
### Stability and reliability
- [#95](https://github.com/jamiepine/voicebox/pull/95) Fix: selecting 0.6B model still downloads and uses 1.7B
- [#93](https://github.com/jamiepine/voicebox/pull/93) fix(mlx): bundle native libs and broaden error handling for Apple Silicon
- [#79](https://github.com/jamiepine/voicebox/pull/79) fix: handle non-ASCII filenames in Content-Disposition headers
- [#78](https://github.com/jamiepine/voicebox/pull/78) fix: guard getUserMedia call against undefined mediaDevices in non-secure contexts
- [#77](https://github.com/jamiepine/voicebox/pull/77) fix: await for confirmation before deleting voices and channels
- [#128](https://github.com/jamiepine/voicebox/pull/128) fix: resolve multiple issues (#96, #119, #111, #108, #121, #125, #127)
- [#40](https://github.com/jamiepine/voicebox/pull/40) Fix: audio export path resolution
### Build and packaging
- [#122](https://github.com/jamiepine/voicebox/pull/122) fix(web): add @tailwindcss/vite plugin to web config
- [#126](https://github.com/jamiepine/voicebox/pull/126) Create requirements.txt
### UX and docs
- [#44](https://github.com/jamiepine/voicebox/pull/44) Enhances floating generate box UX
- [#57](https://github.com/jamiepine/voicebox/pull/57) chore: updates repo URL in README
- [#146](https://github.com/jamiepine/voicebox/pull/146) Add Spacebot banner to landing page
- [#1](https://github.com/jamiepine/voicebox/pull/1) Improvements
## [0.1.12] - 2026-01-31
### Model Download UX Overhaul
- Real-time download progress tracking with accurate percentage and speed info
- No more downloading notifications during generation even when its not downloading
- Better error handling and status reporting throughout the download process
### Other Improvements
- Enhanced health check endpoint with GPU type information
- Improved model caching verification
- More reliable SSE progress updates
- Actual update notifications — no need to manually check in settings anymore
## [0.1.11] - 2026-01-30
- Fixed transcriptions on MLX
- Fixed model download progress (finally)
## [0.1.10] - 2026-01-30
### Faster generation on Apple Silicon
Massive speed gains, from around 20s per generation to 2-3s. Added native MLX backend support for Apple Silicon, providing significantly faster TTS and STT generation on M-series macOS machines.
- **MLX Backend** — New backend implementation optimized for Apple Silicon using MLX framework
- **Dynamic Backend Selection** — Automatically detects platform and selects between MLX (macOS) and PyTorch (other platforms)
- Refactored TTS and STT logic into modular backend implementations
- Updated build process to include MLX-specific dependencies for macOS builds
## [0.1.9] - 2026-01-30
### Improved voice profile creation flow
- Voice create drafts: No longer lose work if you close the modal
- Fixed whisper only transcribing English or Chinese, now has support for all languages
### Improved Stories editor
- Added spacebar for play/pause
- Timeline now auto-scrolls to follow playhead during playback
- Fixed misalignment of the items with mouse when picking up
- Fixed hitbox for selecting an item
- Fixed playhead jumping forward when pressing play
### Generation box improvements
- Instruct mode no longer wipes prompt text
- Improved UI cleanliness
### Misc
- Fixed "Model downloading" toast during generation when model is already downloaded
## [0.1.8] - 2026-01-29
### Model Download Timeout Issues
Fixed critical issue where model downloads would fail with "Failed to fetch" errors on Windows. Refactored download endpoints to return immediately and continue downloads in background.
### Cross-Platform Cache Path Issues
Fixed hardcoded `~/.cache/huggingface/hub` paths that don't work on Windows. All cache paths now use `hf_constants.HF_HUB_CACHE` for proper cross-platform support.
### Windows Process Management
- Added `/shutdown` endpoint for graceful server shutdown on Windows
- Added `gpu_type` field to health check response
## [0.1.7] - 2026-01-29
- Trim and split audio clips in Story Editor
- Auto-activation of stories in Story Editor with visible playhead
- Conditional auto-play support in AudioPlayer for better user control
- Refactored audio loading across HistoryTable, SampleList, and generation forms
- Audio now only auto-plays when explicitly intended, preventing unexpected playback
## [0.1.6] - 2026-01-29
### Introducing Stories
A full voice editor for composing podcasts and generated conversations.
- **Stories Editor** — Create multi-voice narratives, podcasts, or conversations with a timeline-based editor
- Compose tracks with different voices
- Edit and arrange audio segments inline
- Build generated conversations with multiple participants
- **Improved Voice Generation UI** — Auto-resizing input, default voice selection, better layout
- **Track Editor Integration** — Inline track editing within story items
## [0.1.5] - 2026-01-28
Fixed recording length limit at 0:29 to auto stop instead of passing the limit and getting an error, which would cause users to lose their recording.
## [0.1.4] - 2026-01-28
- Audio channel management system
- Native audio playback handling in AudioPlayer component
- Refactored ConnectionForm and Checkbox components
- Improved layout consistency and responsiveness
- Added safe area constants for better responsive design
## [0.1.3] - 2026-01-27
- Improved the generate textbox
- Maybe fixed Windows autoupdate restarting entire computer
## [0.1.2] - 2026-01-27
### Audio Capture & Format Conversion
- Added audio format conversion util
- Enhanced system audio capture on macOS and Windows
- Improved audio recording hooks
- Added audio input entitlement for macOS
- Added audio capture tests
### Update System
- Enhanced auto-updater functionality and update status display
## [0.1.1] - 2026-01-27
### Platform Support
- **macOS Audio Capture** — Native audio capture support for sample creation
- **Windows Audio Capture** — WASAPI implementation with improved thread safety
- **Linux Support** — Temporarily removed builds due to runner disk space constraints
### Audio Features
- Play/pause for audio samples across all components
- Three new sample components: Recording, System capture, Upload with drag-and-drop
- Audio validation, error handling, and consistent cleanup
### Voice Profile Management
- Profile import with file size validation (100MB limit)
- Enhanced profile form with new audio sample components
- Drag-and-drop support for audio file uploads
### Server Management
- Changed default URL from `localhost:8000` to `127.0.0.1:17493`
- Server reuse logic, "keep server running" preference, orphaned process handling
### Build & Release
- Added `.bumpversion.cfg` for automated version management
- Enhanced icon generation script for multi-size Windows icons
### Bug Fixes
- Fixed date formatting for timezone-less date strings
- Fixed getLatestRelease file filtering
- Improved audio duration metadata on Windows
## [0.1.0] - 2026-01-27
The first public release of Voicebox — an open-source voice synthesis studio powered by Qwen3-TTS.
### Voice Cloning with Qwen3-TTS
- Automatic model download from HuggingFace
- Multiple model sizes (1.7B and 0.6B)
- Voice prompt caching for instant regeneration
- English and Chinese support
### Voice Profile Management
- Create profiles from audio files or record directly in the app
- Multiple samples per profile for higher quality cloning
- Import/Export profiles
- Automatic transcription via Whisper
### Speech Generation
- Simple text-to-speech with profile selection
- Seed control for reproducible generations
- Long-form support up to 5,000 characters
### Generation History
- Full history with metadata
- Search by text content
- Inline playback and download
### Flexible Deployment
- Local mode with bundled backend
- Remote mode for GPU servers on your network
- One-click server setup
### Desktop Experience
- Built with Tauri v2 (Rust) — native performance, not Electron
- Cross-platform: macOS and Windows
- No Python installation required
### Tech Stack
Tauri v2, React, TypeScript, Tailwind CSS, FastAPI, Qwen3-TTS, Whisper, SQLite
[Unreleased]: https://github.com/jamiepine/voicebox/compare/v0.2.3...HEAD
[0.2.3]: https://github.com/jamiepine/voicebox/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/jamiepine/voicebox/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/jamiepine/voicebox/compare/v0.1.13...v0.2.1
[0.1.13]: https://github.com/jamiepine/voicebox/compare/v0.1.12...v0.1.13
[0.1.12]: https://github.com/jamiepine/voicebox/compare/v0.1.11...v0.1.12
[0.1.11]: https://github.com/jamiepine/voicebox/compare/v0.1.10...v0.1.11
[0.1.10]: https://github.com/jamiepine/voicebox/compare/v0.1.9...v0.1.10
[0.1.9]: https://github.com/jamiepine/voicebox/compare/v0.1.8...v0.1.9
[0.1.8]: https://github.com/jamiepine/voicebox/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/jamiepine/voicebox/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/jamiepine/voicebox/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/jamiepine/voicebox/compare/v0.1.4...v0.1.5
[0.1.4]: https://github.com/jamiepine/voicebox/compare/v0.1.3...v0.1.4
[0.1.3]: https://github.com/jamiepine/voicebox/compare/v0.1.2...v0.1.3
[0.1.2]: https://github.com/jamiepine/voicebox/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/jamiepine/voicebox/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/jamiepine/voicebox/releases/tag/v0.1.0
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Voicebox
Thank you for your interest in contributing to Voicebox! This document provides guidelines and instructions for contributing.
## Code of Conduct
- Be respectful and inclusive
- Welcome newcomers and help them learn
- Focus on constructive feedback
- Respect different viewpoints and experiences
## Getting Started
### Prerequisites
- **[Bun](https://bun.sh)** - Fast JavaScript runtime and package manager
```bash
curl -fsSL https://bun.sh/install | bash
```
- **[Python 3.11+](https://python.org)** - For backend development
```bash
python --version # Should be 3.11 or higher
```
- **[Rust](https://rustup.rs)** - For Tauri desktop app (installed automatically by Tauri CLI)
```bash
rustc --version # Check if installed
```
- **[Tauri Prerequisites](https://v2.tauri.app/start/prerequisites)** - Tauri-specific system dependencies (varies by OS).
- **Git** - Version control
### Development Setup
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
```bash
git clone https://github.com/YOUR_USERNAME/voicebox.git
cd voicebox
just setup # creates venv, installs Python + JS deps
just dev # starts backend + desktop app
```
`just setup` handles everything automatically, including:
- Creating a Python virtual environment
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
- Installing MLX dependencies on Apple Silicon
- Installing JavaScript dependencies
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
Other useful commands:
```bash
just dev-web # backend + web app (no Tauri/Rust build)
just dev-backend # backend only
just dev-frontend # Tauri app only (backend must be running)
just kill # stop all dev processes
just clean-all # nuke everything and start fresh
just --list # see all available commands
```
> **Note:** In dev mode, the app connects to a manually-started Python server.
> The bundled server binary is only used in production builds.
#### Windows Notes
The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
### Model Downloads
Models are automatically downloaded from HuggingFace Hub on first use:
- **Whisper** (transcription): Auto-downloads on first transcription
- **Qwen3-TTS** (voice cloning): Auto-downloads on first generation (~2-4GB)
First-time usage will be slower due to model downloads, but subsequent runs will use cached models.
### Building
**Build production app:**
```bash
just build # Build CPU server binary + Tauri installer
```
On Windows, to build with CUDA support for local testing:
```bash
just build-local # Build CPU + CUDA server binaries + Tauri installer
```
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
**Individual build targets:**
```bash
just build-server # CPU server binary only
just build-server-cuda # CUDA server binary only (Windows)
just build-tauri # Tauri desktop app only
just build-web # Web app only
```
**Building with local Qwen3-TTS development version:**
If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_TTS_PATH` environment variable to point to your local clone:
```bash
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
just build-server
```
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
### Generate OpenAPI Client
After starting the backend server:
```bash
./scripts/generate-api.sh
```
This downloads the OpenAPI schema and generates the TypeScript client in `app/src/lib/api/`
### Convert Assets to Web Formats
To optimize images and videos for the web, run:
```bash
bun run convert:assets
```
This script:
- Converts PNG → WebP (better compression, same quality)
- Converts MOV → WebM (VP9 codec, smaller file size)
- Processes files in `landing/public/` and `docs/public/`
- **Deletes original files** after successful conversion
**Requirements:** Install `webp` and `ffmpeg`:
```bash
brew install webp ffmpeg
```
> **Note:** Run this before committing new images or videos to keep the repository size small.
## Development Workflow
### 1. Create a Branch
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
```
### 2. Make Your Changes
- Write clean, readable code
- Follow existing code style
- Add comments for complex logic
- Update documentation as needed
### 3. Test Your Changes
- Test manually in the app
- Ensure backend API endpoints work
- Check for TypeScript/Python errors
- Verify UI components render correctly
### 4. Commit Your Changes
Write clear, descriptive commit messages:
```bash
git commit -m "Add feature: voice profile export"
git commit -m "Fix: audio playback stops after 30 seconds"
```
### 5. Push and Create Pull Request
```bash
git push origin feature/your-feature-name
```
Then create a pull request on GitHub with:
- Clear description of changes
- Screenshots (for UI changes)
- Reference to related issues
## Code Style
### TypeScript/React
- Use TypeScript strict mode
- Follow React best practices
- Use functional components with hooks
- Prefer named exports
- Format with Biome (runs automatically)
```typescript
// Good
export function ProfileCard({ profile }: { profile: Profile }) {
return <div>{profile.name}</div>;
}
// Avoid
export const ProfileCard = (props) => { ... }
```
### Python
- Follow PEP 8 style guide
- Use type hints
- Use async/await for I/O operations
- Format with Black (if configured)
```python
# Good
async def create_profile(name: str, language: str) -> Profile:
"""Create a new voice profile."""
...
# Avoid
def create_profile(name, language):
...
```
### Rust
- Follow Rust conventions
- Use meaningful variable names
- Handle errors explicitly
- Format with `rustfmt`
## Project Structure
```
voicebox/
├── app/ # Shared React frontend
│ └── src/
│ ├── components/ # UI components
│ ├── lib/ # Utilities and API client
│ └── hooks/ # React hooks
├── backend/ # Python FastAPI server
│ ├── main.py # API routes
│ ├── tts.py # Voice synthesis
│ └── ...
├── tauri/ # Desktop app wrapper
│ └── src-tauri/ # Rust backend
└── scripts/ # Build scripts
```
## Areas for Contribution
### 🐛 Bug Fixes
- Check existing issues for bugs to fix
- Test your fix thoroughly
- Add tests if possible
### ✨ New Features
- Check the roadmap in README.md
- Discuss major features in an issue first
- Keep features focused and well-scoped
### 📚 Documentation
- Improve README clarity
- Add code comments
- Write API documentation
- Create tutorials or guides
### 🎨 UI/UX Improvements
- Improve accessibility
- Enhance visual design
- Optimize performance
- Add animations/transitions
### 🔧 Infrastructure
- Improve build process
- Add CI/CD improvements
- Optimize bundle size
- Add testing infrastructure
## API Development
When adding new API endpoints:
1. **Add route in `backend/main.py`**
2. **Create Pydantic models in `backend/models.py`**
3. **Implement business logic in appropriate module**
4. **Update OpenAPI schema** (automatic with FastAPI)
5. **Regenerate TypeScript client:**
```bash
bun run generate:api
```
6. **Update `backend/README.md`** with endpoint documentation
## Testing
Currently, testing is primarily manual. When adding tests:
- **Backend**: Use pytest for Python tests
- **Frontend**: Use Vitest for React component tests
- **E2E**: Use Playwright for end-to-end tests (future)
## Pull Request Process
1. **Update documentation** if needed
2. **Ensure code follows style guidelines**
3. **Test your changes thoroughly**
4. **Update CHANGELOG.md** with your changes
5. **Request review** from maintainers
### PR Checklist
- [ ] Code follows style guidelines
- [ ] Documentation updated
- [ ] Changes tested
- [ ] No breaking changes (or documented)
- [ ] CHANGELOG.md updated
## Release Process
Releases are managed by maintainers:
1. **Bump version using bumpversion:**
```bash
# Install bumpversion (if not already installed)
pip install bumpversion
# Bump patch version (0.1.0 -> 0.1.1)
bumpversion patch
# Or bump minor version (0.1.0 -> 0.2.0)
bumpversion minor
# Or bump major version (0.1.0 -> 1.0.0)
bumpversion major
```
This automatically:
- Updates version numbers in all files (`tauri.conf.json`, `Cargo.toml`, all `package.json` files, `backend/main.py`)
- Creates a git commit with the version bump
- Creates a git tag (e.g., `v0.1.1`, `v0.2.0`)
2. **Update CHANGELOG.md** with release notes
3. **Push commits and tags:**
```bash
git push
git push --tags
```
4. **GitHub Actions builds and releases** automatically when tags are pushed
## Troubleshooting
See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and solutions.
**Quick fixes:**
- **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies
- **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean`
- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json`
## Questions?
- Open an issue for bugs or feature requests
- Check existing issues and discussions
- Review the codebase to understand patterns
- See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues
## Additional Resources
- [README.md](README.md) - Project overview
- [backend/README.md](backend/README.md) - API documentation
- [docs/AUTOUPDATER_QUICKSTART.md](docs/AUTOUPDATER_QUICKSTART.md) - Auto-updater setup
- [SECURITY.md](SECURITY.md) - Security policy
- [CHANGELOG.md](CHANGELOG.md) - Version history
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
---
Thank you for contributing to Voicebox! 🎉
================================================
FILE: Dockerfile
================================================
# ============================================================
# Voicebox — Local TTS Server with Web UI (CPU)
# 3-stage build: Frontend → Python deps → Runtime
# ============================================================
# === Stage 1: Build frontend ===
FROM oven/bun:1 AS frontend
WORKDIR /build
# Copy workspace config and frontend source
COPY package.json bun.lock ./
COPY app/ ./app/
COPY web/ ./web/
# Strip workspaces not needed for web build, and fix trailing comma
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
sed -i -z 's/,\n ]/\n ]/' package.json
RUN bun install --no-save
# Build frontend (skip tsc — upstream has pre-existing type errors)
RUN cd web && bunx --bun vite build
# === Stage 2: Build Python dependencies ===
FROM python:3.11-slim AS backend-builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir --upgrade pip
COPY backend/requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
RUN pip install --no-cache-dir --prefix=/install \
git+https://github.com/QwenLM/Qwen3-TTS.git
# === Stage 3: Runtime ===
FROM python:3.11-slim
# Create non-root user for security
RUN groupadd -r voicebox && \
useradd -r -g voicebox -m -s /bin/bash voicebox
WORKDIR /app
# Install only runtime system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from builder stage
COPY --from=backend-builder /install /usr/local
# Copy backend application code
COPY --chown=voicebox:voicebox backend/ /app/backend/
# Copy built frontend from frontend stage
COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
# Create data directories owned by non-root user
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
&& chown -R voicebox:voicebox /app/data
# Switch to non-root user
USER voicebox
# Expose the API port
EXPOSE 17493
# Health check — auto-restart if the server hangs
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
CMD curl -f http://localhost:17493/health || exit 1
# Start the FastAPI server
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2026 Voicebox Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<p align="center">
<img src=".github/assets/icon-dark.webp" alt="Voicebox" width="120" height="120" />
</p>
<h1 align="center">Voicebox</h1>
<p align="center">
<strong>The open-source voice synthesis studio.</strong><br/>
Clone voices. Generate speech. Apply effects. Build voice-powered apps.<br/>
All running locally on your machine.
</p>
<p align="center">
<a href="https://github.com/jamiepine/voicebox/releases">
<img src="https://img.shields.io/github/downloads/jamiepine/voicebox/total?style=flat&color=blue" alt="Downloads" />
</a>
<a href="https://github.com/jamiepine/voicebox/releases/latest">
<img src="https://img.shields.io/github/v/release/jamiepine/voicebox?style=flat" alt="Release" />
</a>
<a href="https://github.com/jamiepine/voicebox/stargazers">
<img src="https://img.shields.io/github/stars/jamiepine/voicebox?style=flat" alt="Stars" />
</a>
<a href="https://github.com/jamiepine/voicebox/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/jamiepine/voicebox?style=flat" alt="License" />
</a>
</p>
<p align="center">
<a href="https://voicebox.sh">voicebox.sh</a> •
<a href="https://docs.voicebox.sh">Docs</a> •
<a href="#download">Download</a> •
<a href="#features">Features</a> •
<a href="#api">API</a>
</p>
<br/>
<p align="center">
<a href="https://voicebox.sh">
<img src="landing/public/assets/app-screenshot-1.webp" alt="Voicebox App Screenshot" width="800" />
</a>
</p>
<p align="center">
<em>Click the image above to watch the demo video on <a href="https://voicebox.sh">voicebox.sh</a></em>
</p>
<br/>
<p align="center">
<img src="landing/public/assets/app-screenshot-2.webp" alt="Voicebox Screenshot 2" width="800" />
</p>
<p align="center">
<img src="landing/public/assets/app-screenshot-3.webp" alt="Voicebox Screenshot 3" width="800" />
</p>
<br/>
## What is Voicebox?
Voicebox is a **local-first voice cloning studio** — a free and open-source alternative to ElevenLabs. Clone voices from a few seconds of audio, generate speech in 23 languages across 5 TTS engines, apply post-processing effects, and compose multi-voice projects with a timeline editor.
- **Complete privacy** — models and voice data stay on your machine
- **5 TTS engines** — Qwen3-TTS, LuxTTS, Chatterbox Multilingual, Chatterbox Turbo, and HumeAI TADA
- **23 languages** — from English to Arabic, Japanese, Hindi, Swahili, and more
- **Post-processing effects** — pitch shift, reverb, delay, chorus, compression, and filters
- **Expressive speech** — paralinguistic tags like `[laugh]`, `[sigh]`, `[gasp]` via Chatterbox Turbo
- **Unlimited length** — auto-chunking with crossfade for scripts, articles, and chapters
- **Stories editor** — multi-track timeline for conversations, podcasts, and narratives
- **API-first** — REST API for integrating voice synthesis into your own projects
- **Native performance** — built with Tauri (Rust), not Electron
- **Runs everywhere** — macOS (MLX/Metal), Windows (CUDA), Linux, AMD ROCm, Intel Arc, Docker
---
## Download
| Platform | Download |
| --------------------- | ------------------------------------------------------ |
| macOS (Apple Silicon) | [Download DMG](https://voicebox.sh/download/mac-arm) |
| macOS (Intel) | [Download DMG](https://voicebox.sh/download/mac-intel) |
| Windows | [Download MSI](https://voicebox.sh/download/windows) |
| Docker | `docker compose up` |
> **[View all binaries →](https://github.com/jamiepine/voicebox/releases/latest)**
> **Linux** — Pre-built binaries are not yet available. See [voicebox.sh/linux-install](https://voicebox.sh/linux-install) for build-from-source instructions.
---
## Features
### Multi-Engine Voice Cloning
Five TTS engines with different strengths, switchable per-generation:
| Engine | Languages | Strengths |
| --------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Qwen3-TTS** (0.6B / 1.7B) | 10 | High-quality multilingual cloning, delivery instructions ("speak slowly", "whisper") |
| **LuxTTS** | English | Lightweight (~1GB VRAM), 48kHz output, 150x realtime on CPU |
| **Chatterbox Multilingual** | 23 | Broadest language coverage — Arabic, Danish, Finnish, Greek, Hebrew, Hindi, Malay, Norwegian, Polish, Swahili, Swedish, Turkish and more |
| **Chatterbox Turbo** | English | Fast 350M model with paralinguistic emotion/sound tags |
| **TADA** (1B / 3B) | 10 | HumeAI speech-language model — 700s+ coherent audio, text-acoustic dual alignment |
### Emotions & Paralinguistic Tags
Type `/` in the text input to insert expressive tags that the model synthesizes inline with speech (Chatterbox Turbo):
`[laugh]` `[chuckle]` `[gasp]` `[cough]` `[sigh]` `[groan]` `[sniff]` `[shush]` `[clear throat]`
### Post-Processing Effects
8 audio effects powered by Spotify's `pedalboard` library. Apply after generation, preview in real time, build reusable presets.
| Effect | Description |
| ---------------- | --------------------------------------------- |
| Pitch Shift | Up or down by up to 12 semitones |
| Reverb | Configurable room size, damping, wet/dry mix |
| Delay | Echo with adjustable time, feedback, and mix |
| Chorus / Flanger | Modulated delay for metallic or lush textures |
| Compressor | Dynamic range compression |
| Gain | Volume adjustment (-40 to +40 dB) |
| High-Pass Filter | Remove low frequencies |
| Low-Pass Filter | Remove high frequencies |
Ships with 4 built-in presets (Robotic, Radio, Echo Chamber, Deep Voice) and supports custom presets. Effects can be assigned per-profile as defaults.
### Unlimited Generation Length
Text is automatically split at sentence boundaries and each chunk is generated independently, then crossfaded together. Works with all engines.
- Configurable auto-chunking limit (100–5,000 chars)
- Crossfade slider (0–200ms) for smooth transitions
- Max text length: 50,000 characters
- Smart splitting respects abbreviations, CJK punctuation, and `[tags]`
### Generation Versions
Every generation supports multiple versions with provenance tracking:
- **Original** — clean TTS output, always preserved
- **Effects versions** — apply different effects chains from any source version
- **Takes** — regenerate with a new seed for variation
- **Source tracking** — each version records its lineage
- **Favorites** — star generations for quick access
### Async Generation Queue
Generation is non-blocking. Submit and immediately start typing the next one.
- Serial execution queue prevents GPU contention
- Real-time SSE status streaming
- Failed generations can be retried
- Stale generations from crashes auto-recover on startup
### Voice Profile Management
- Create profiles from audio files or record directly in-app
- Import/export profiles to share or back up
- Multi-sample support for higher quality cloning
- Per-profile default effects chains
- Organize with descriptions and language tags
### Stories Editor
Multi-voice timeline editor for conversations, podcasts, and narratives.
- Multi-track composition with drag-and-drop
- Inline audio trimming and splitting
- Auto-playback with synchronized playhead
- Version pinning per track clip
### Recording & Transcription
- In-app recording with waveform visualization
- System audio capture (macOS and Windows)
- Automatic transcription powered by Whisper (including Whisper Turbo)
- Export recordings in multiple formats
### Model Management
- Per-model unload to free GPU memory without deleting downloads
- Custom models directory via `VOICEBOX_MODELS_DIR`
- Model folder migration with progress tracking
- Download cancel/clear UI
### GPU Support
| Platform | Backend | Notes |
| ------------------------ | -------------- | ---------------------------------------------- |
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
| Windows (any GPU) | DirectML | Universal Windows GPU support |
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
| Any | CPU | Works everywhere, just slower |
---
## API
Voicebox exposes a full REST API for integrating voice synthesis into your own apps.
```bash
# Generate speech
curl -X POST http://localhost:17493/generate \
-H "Content-Type: application/json" \
-d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}'
# List voice profiles
curl http://localhost:17493/profiles
# Create a profile
curl -X POST http://localhost:17493/profiles \
-H "Content-Type: application/json" \
-d '{"name": "My Voice", "language": "en"}'
```
**Use cases:** game dialogue, podcast production, accessibility tools, voice assistants, content automation.
Full API documentation available at `http://localhost:17493/docs`.
---
## Tech Stack
| Layer | Technology |
| ------------- | ------------------------------------------------- |
| Desktop App | Tauri (Rust) |
| Frontend | React, TypeScript, Tailwind CSS |
| State | Zustand, React Query |
| Backend | FastAPI (Python) |
| TTS Engines | Qwen3-TTS, LuxTTS, Chatterbox, Chatterbox Turbo, TADA |
| Effects | Pedalboard (Spotify) |
| Transcription | Whisper / Whisper Turbo (PyTorch or MLX) |
| Inference | MLX (Apple Silicon) / PyTorch (CUDA/ROCm/XPU/CPU) |
| Database | SQLite |
| Audio | WaveSurfer.js, librosa |
---
## Roadmap
| Feature | Description |
| ----------------------- | ---------------------------------------------- |
| **Real-time Streaming** | Stream audio as it generates, word by word |
| **Voice Design** | Create new voices from text descriptions |
| **More Models** | XTTS, Bark, and other open-source voice models |
| **Plugin Architecture** | Extend with custom models and effects |
| **Mobile Companion** | Control Voicebox from your phone |
---
## Development
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines.
### Quick Start
```bash
git clone https://github.com/jamiepine/voicebox.git
cd voicebox
just setup # creates Python venv, installs all deps
just dev # starts backend + desktop app
```
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
### Building Locally
```bash
just build # Build CPU server binary + Tauri app
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
```
### Adding New Voice Models
The multi-engine architecture makes adding new TTS engines straightforward. A [step-by-step guide](docs/content/docs/developer/tts-engines.mdx) covers the full process: dependency research, backend protocol implementation, frontend wiring, and PyInstaller bundling.
The guide is optimized for AI coding agents. An [agent skill](.agents/skills/add-tts-engine/SKILL.md) can pick up a model name and handle the entire integration autonomously — you just test the build locally.
### Project Structure
```
voicebox/
├── app/ # Shared React frontend
├── tauri/ # Desktop app (Tauri + Rust)
├── web/ # Web deployment
├── backend/ # Python FastAPI server
├── landing/ # Marketing website
└── scripts/ # Build & release scripts
```
---
## Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
1. Fork the repo
2. Create a feature branch
3. Make your changes
4. Submit a PR
## Security
Found a security vulnerability? Please report it responsibly. See [SECURITY.md](SECURITY.md) for details.
---
## License
MIT License — see [LICENSE](LICENSE) for details.
---
<p align="center">
<a href="https://voicebox.sh">voicebox.sh</a>
</p>
================================================
FILE: SECURITY.md
================================================
# Security Policy
## Supported Versions
We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating:
| Version | Supported |
| ------- | ------------------ |
| 0.1.x | :white_check_mark: |
| < 0.1 | :x: |
## Reporting a Vulnerability
If you discover a security vulnerability, please report it responsibly:
1. **Do not** open a public GitHub issue
2. Email security details to: [security@voicebox.sh](mailto:security@voicebox.sh)
3. Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
We will:
- Acknowledge receipt within 48 hours
- Provide a timeline for addressing the issue
- Keep you informed of progress
- Credit you in the security advisory (if desired)
## Security Best Practices
### For Users
- **Keep Voicebox updated** - Updates include security patches
- **Verify downloads** - Only download from official releases
- **Local processing** - Voice data stays on your machine
- **Network security** - Use HTTPS when connecting to remote servers
### For Developers
- **Dependencies** - Keep all dependencies up to date
- **Code review** - All PRs require review before merging
- **Secrets** - Never commit API keys or signing keys
- **Signing** - All releases are cryptographically signed
## Known Security Considerations
### Local Processing
Voicebox processes all audio locally by default. Your voice data never leaves your machine unless you explicitly enable remote server mode.
### Remote Server Mode
When connecting to a remote server:
- Ensure the server is on a trusted network
- Use HTTPS for remote connections
- Verify server identity before connecting
### Auto-Updates
- Updates are cryptographically signed
- Signature verification happens before installation
- Only HTTPS endpoints are allowed
### Python Server
The embedded Python server:
- Runs locally by default (localhost only)
- Can be configured for remote access
- Uses standard FastAPI security practices
## Disclosure Timeline
- **Day 0**: Vulnerability reported
- **Day 1-2**: Initial assessment and acknowledgment
- **Day 3-7**: Investigation and fix development
- **Day 8-14**: Testing and release preparation
- **Day 15+**: Public disclosure (if applicable)
Timeline may vary based on severity and complexity.
## Security Updates
Security updates will be:
- Released as patch versions (e.g., 0.1.1)
- Documented in CHANGELOG.md
- Announced via GitHub releases
- Automatically delivered via auto-updater
---
Thank you for helping keep Voicebox secure! 🔒
================================================
FILE: app/components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/lib/hooks"
}
}
================================================
FILE: app/index.html
================================================
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>voicebox</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
================================================
FILE: app/package.json
================================================
{
"name": "@voicebox/app",
"version": "0.3.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "biome lint src",
"lint:fix": "biome lint --write src",
"format": "biome format --write src",
"check": "biome check --write src"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query-devtools": "^5.0.0",
"@tanstack/react-router": "^1.157.16",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-fs": "^2.0.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.9.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^12.29.0",
"lucide-react": "^0.454.0",
"motion": "^12.29.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-hook-form": "^7.53.0",
"react-sound-visualizer": "^1.4.0",
"tailwind-merge": "^2.5.4",
"wavesurfer.js": "^7.0.0",
"zod": "^3.23.8",
"zustand": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
================================================
FILE: app/plugins/changelog.ts
================================================
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { Plugin } from 'vite';
/** Vite plugin that exposes CHANGELOG.md as `virtual:changelog`. */
export function changelogPlugin(repoRoot: string): Plugin {
const virtualId = 'virtual:changelog';
const resolvedId = '\0' + virtualId;
const changelogPath = path.resolve(repoRoot, 'CHANGELOG.md');
return {
name: 'changelog',
resolveId(id) {
if (id === virtualId) return resolvedId;
},
load(id) {
if (id === resolvedId) {
const raw = readFileSync(changelogPath, 'utf-8');
return `export default ${JSON.stringify(raw)};`;
}
},
};
}
================================================
FILE: app/src/App.tsx
================================================
import { RouterProvider } from '@tanstack/react-router';
import { useEffect, useRef, useState } from 'react';
import voiceboxLogo from '@/assets/voicebox-logo.png';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { router } from '@/router';
import { useLogStore } from '@/stores/logStore';
import { useServerStore } from '@/stores/serverStore';
const LOADING_MESSAGES = [
'Warming up tensors...',
'Calibrating synthesizer engine...',
'Initializing voice models...',
'Loading neural networks...',
'Preparing audio pipelines...',
'Optimizing waveform generators...',
'Tuning frequency analyzers...',
'Building voice embeddings...',
'Configuring text-to-speech cores...',
'Syncing audio buffers...',
'Establishing model connections...',
'Preprocessing training data...',
'Validating voice samples...',
'Compiling inference engines...',
'Mapping phoneme sequences...',
'Aligning prosody parameters...',
'Activating speech synthesis...',
'Fine-tuning acoustic models...',
'Preparing voice cloning matrices...',
'Initializing Qwen TTS framework...',
];
function App() {
const platform = usePlatform();
const [serverReady, setServerReady] = useState(false);
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
const serverStartingRef = useRef(false);
// Automatically check for app updates on startup and show toast notifications
useAutoUpdater({ checkOnMount: true, showToast: true });
// Sync stored setting to Rust on startup
useEffect(() => {
if (platform.metadata.isTauri) {
const keepRunning = useServerStore.getState().keepServerRunningOnClose;
platform.lifecycle.setKeepServerRunning(keepRunning).catch((error) => {
console.error('Failed to sync initial setting to Rust:', error);
});
}
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.metadata.isTauri, platform.lifecycle]);
// Setup lifecycle callbacks
useEffect(() => {
platform.lifecycle.onServerReady = () => {
setServerReady(true);
};
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.lifecycle]);
// Subscribe to server logs
useEffect(() => {
const unsubscribe = platform.lifecycle.subscribeToServerLogs((entry) => {
useLogStore.getState().addEntry(entry);
});
return unsubscribe;
}, [platform.lifecycle]);
// Setup window close handler and auto-start server when running in Tauri (production only)
useEffect(() => {
if (!platform.metadata.isTauri) {
setServerReady(true); // Web assumes server is running
return;
}
// Setup window close handler to check setting and stop server if needed
// This works in both dev and prod, but will only stop server if it was started by the app
platform.lifecycle.setupWindowCloseHandler().catch((error) => {
console.error('Failed to setup window close handler:', error);
});
// Only auto-start server in production mode
// In dev mode, user runs server separately
if (!import.meta.env?.PROD) {
console.log('Dev mode: Skipping auto-start of server (run it separately)');
setServerReady(true); // Mark as ready so UI doesn't show loading screen
// Mark that server was not started by app (so we don't try to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
return;
}
// Auto-start server in production
if (serverStartingRef.current) {
return;
}
serverStartingRef.current = true;
const isRemote = useServerStore.getState().mode === 'remote';
const customModelsDir = useServerStore.getState().customModelsDir;
console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`);
platform.lifecycle
.startServer(isRemote, customModelsDir)
.then((serverUrl) => {
console.log('Server is ready at:', serverUrl);
// Update the server URL in the store with the dynamically assigned port
useServerStore.getState().setServerUrl(serverUrl);
setServerReady(true);
// Mark that we started the server (so we know to stop it on close)
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = true;
})
.catch((error) => {
console.error('Failed to auto-start server:', error);
serverStartingRef.current = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
// Note: Window close is handled separately in Tauri Rust code
return () => {
// Window close event handles server shutdown based on setting
serverStartingRef.current = false;
};
// Empty dependency array - platform is stable from context, only run once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform.metadata.isTauri, platform.lifecycle]);
// Cycle through loading messages every 3 seconds
useEffect(() => {
if (!platform.metadata.isTauri || serverReady) {
return;
}
const interval = setInterval(() => {
setLoadingMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length);
}, 3000);
return () => clearInterval(interval);
}, [serverReady, platform.metadata.isTauri]);
// Show loading screen while server is starting in Tauri
if (platform.metadata.isTauri && !serverReady) {
return (
<div
className={cn(
'min-h-screen bg-background flex items-center justify-center',
TOP_SAFE_AREA_PADDING,
)}
>
<TitleBarDragRegion />
<div className="text-center space-y-6">
<div className="flex justify-center relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-48 h-48 rounded-full bg-accent/20 blur-3xl" />
</div>
<img
src={voiceboxLogo}
alt="Voicebox"
className="w-48 h-48 object-contain animate-fade-in-scale relative z-10"
/>
</div>
<div className="animate-fade-in-delayed">
<ShinyText
text={LOADING_MESSAGES[loadingMessageIndex]}
className="text-lg font-medium text-muted-foreground"
speed={2}
color="hsl(var(--muted-foreground))"
shineColor="hsl(var(--foreground))"
/>
</div>
</div>
</div>
);
}
return <RouterProvider router={router} />;
}
export default App;
================================================
FILE: app/src/components/AppFrame/AppFrame.tsx
================================================
import { useRouterState } from '@tanstack/react-router';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { AudioPlayer } from '@/components/AudioPlayer/AudioPlayer';
import { StoryTrackEditor } from '@/components/StoriesTab/StoryTrackEditor';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
import { useStory } from '@/lib/hooks/useStories';
interface AppFrameProps {
children: React.ReactNode;
}
export function AppFrame({ children }: AppFrameProps) {
const routerState = useRouterState();
const isStoriesRoute = routerState.location.pathname === '/stories';
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const { data: story } = useStory(selectedStoryId);
// Show track editor when on stories route with a selected story that has items
const showTrackEditor = isStoriesRoute && selectedStoryId && story && story.items.length > 0;
return (
<div className={cn('h-screen bg-background flex flex-col overflow-hidden', TOP_SAFE_AREA_PADDING)}>
<TitleBarDragRegion />
{children}
{showTrackEditor ? (
<StoryTrackEditor storyId={story.id} items={story.items} />
) : (
<AudioPlayer />
)}
</div>
);
}
================================================
FILE: app/src/components/AudioPlayer/AudioPlayer.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import WaveSurfer from 'wavesurfer.js';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client';
import { formatAudioDuration } from '@/lib/utils/audio';
import { debug } from '@/lib/utils/debug';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
export function AudioPlayer() {
const platform = usePlatform();
const volumeLabelId = useId();
const {
audioUrl,
audioId,
profileId,
isPlaying,
currentTime,
duration,
volume,
isLooping,
shouldRestart,
setIsPlaying,
setCurrentTime,
setDuration,
setVolume,
toggleLoop,
clearRestartFlag,
reset,
} = usePlayerStore();
// Check if profile has assigned channels (for native audio routing)
const { data: profileChannels } = useQuery({
queryKey: ['profile-channels', profileId],
queryFn: () => {
if (!profileId) return { channel_ids: [] };
return apiClient.getProfileChannels(profileId);
},
enabled: !!profileId && platform.metadata.isTauri,
});
const { data: channels } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
enabled: !!profileChannels && profileChannels.channel_ids.length > 0,
});
// Determine if we should use native playback
const useNativePlayback = useMemo(() => {
if (!platform.metadata.isTauri || !profileChannels || !channels) {
return false;
}
const assignedChannels = channels.filter((ch) => profileChannels.channel_ids.includes(ch.id));
// Use native playback if any assigned channel has non-default devices
const shouldUseNative = assignedChannels.some(
(ch) => ch.device_ids.length > 0 && !ch.is_default,
);
return shouldUseNative;
}, [profileChannels, channels, platform.metadata.isTauri]);
const waveformRef = useRef<HTMLDivElement>(null);
const wavesurferRef = useRef<WaveSurfer | null>(null);
const loadingRef = useRef(false);
const previousAudioIdRef = useRef<string | null>(null);
const hasInitializedRef = useRef(false);
const isUsingNativePlaybackRef = useRef(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [wsReady, setWsReady] = useState(false);
// Create WaveSurfer once when the player becomes visible (audioUrl is set).
// This instance is reused for all subsequent audio loads - never destroyed until unmount.
useEffect(() => {
if (!audioUrl) return;
if (wavesurferRef.current) return; // already created
const initWaveSurfer = () => {
const container = waveformRef.current;
if (!container) {
setTimeout(initWaveSurfer, 50);
return;
}
const rect = container.getBoundingClientRect();
const style = window.getComputedStyle(container);
const isVisible =
rect.width > 0 &&
rect.height > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden';
if (!isVisible) {
setTimeout(initWaveSurfer, 50);
return;
}
debug.log('Creating WaveSurfer instance', {
width: rect.width,
height: rect.height,
});
try {
const root = document.documentElement;
const getCSSVar = (varName: string) => {
const value = getComputedStyle(root).getPropertyValue(varName).trim();
return value ? `hsl(${value})` : '';
};
const wavesurfer = WaveSurfer.create({
container,
waveColor: getCSSVar('--muted'),
progressColor: getCSSVar('--accent'),
cursorColor: getCSSVar('--accent'),
cursorWidth: 3,
barWidth: 2,
barRadius: 2,
height: 80,
normalize: true,
interact: true,
dragToSeek: { debounceTime: 0 },
mediaControls: false,
backend: 'WebAudio',
});
// Wire up event handlers (these persist for the lifetime of the instance)
wavesurfer.on('timeupdate', (time) => {
const dur = usePlayerStore.getState().duration;
if (dur > 0 && time >= dur) {
setCurrentTime(dur);
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
} else {
wavesurfer.pause();
setIsPlaying(false);
}
return;
}
setCurrentTime(time);
});
wavesurfer.on('ready', () => {
const dur = wavesurfer.getDuration();
setDuration(dur);
loadingRef.current = false;
setIsLoading(false);
setError(null);
debug.log('Audio ready, duration:', dur);
wavesurfer.setVolume(usePlayerStore.getState().volume);
wavesurfer.setMuted(false);
// Auto-play if the flag is set (story mode advance or explicit play)
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
if (shouldAutoPlayNow) {
usePlayerStore.getState().clearAutoPlayFlag();
wavesurfer.play().catch((err) => {
debug.error('Failed to autoplay:', err);
});
} else {
debug.log('Skipping auto-play - shouldAutoPlay is false');
}
});
wavesurfer.on('play', () => setIsPlaying(true));
wavesurfer.on('pause', () => {
setIsPlaying(false);
setCurrentTime(wavesurfer.getCurrentTime());
});
wavesurfer.on('seeking', (time) => setCurrentTime(time));
// Mute audio during drag-to-seek to prevent popping from the WebAudio
// backend's hard stop/start cycle on each seek. Unmute with a short
// fade-in when the drag ends.
const seekMedia = wavesurfer.getMediaElement() as any;
const seekGain: GainNode | null = seekMedia?.getGainNode?.() ?? null;
if (seekGain) {
const ctx = seekGain.context as AudioContext;
wavesurfer.on('dragstart', () => {
seekGain.gain.cancelScheduledValues(ctx.currentTime);
seekGain.gain.setTargetAtTime(0, ctx.currentTime, 0.002);
});
wavesurfer.on('dragend', () => {
seekGain.gain.cancelScheduledValues(ctx.currentTime);
seekGain.gain.setTargetAtTime(1, ctx.currentTime, 0.01);
});
}
wavesurfer.on('finish', () => {
const loop = usePlayerStore.getState().isLooping;
if (loop) {
wavesurfer.seekTo(0);
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
} else {
setIsPlaying(false);
const onFinish = usePlayerStore.getState().onFinish;
if (onFinish) onFinish();
}
});
wavesurfer.on('error', (err) => {
debug.error('WaveSurfer error:', err);
setIsLoading(false);
setError(`Audio error: ${err instanceof Error ? err.message : String(err)}`);
});
wavesurfer.on('loading', (percent) => {
setIsLoading(true);
if (percent === 100) setIsLoading(false);
});
wavesurferRef.current = wavesurfer;
setWsReady(true);
debug.log('WaveSurfer created successfully');
} catch (err) {
debug.error('Failed to create WaveSurfer:', err);
setError(
`Failed to initialize waveform: ${err instanceof Error ? err.message : String(err)}`,
);
}
};
let rafId: number;
rafId = requestAnimationFrame(() => {
initWaveSurfer();
});
return () => {
cancelAnimationFrame(rafId);
};
// Only run on mount-like conditions. audioUrl is here so we create the instance
// when the player first appears, but we guard against re-creation above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [audioUrl, setIsPlaying, setDuration, setCurrentTime]);
// Destroy WaveSurfer only on unmount
useEffect(() => {
return () => {
if (wavesurferRef.current) {
debug.log('Destroying WaveSurfer instance (unmount)');
try {
wavesurferRef.current.destroy();
} catch (err) {
debug.error('Error destroying WaveSurfer:', err);
}
wavesurferRef.current = null;
setWsReady(false);
}
};
}, []);
// Load audio when URL changes (reuses the existing WaveSurfer instance)
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !wsReady) return;
if (!audioUrl) {
// No audio - pause and reset
wavesurfer.pause();
wavesurfer.seekTo(0);
loadingRef.current = false;
setIsLoading(false);
setDuration(0);
setCurrentTime(0);
setError(null);
isUsingNativePlaybackRef.current = false;
return;
}
// Reset native playback state
isUsingNativePlaybackRef.current = false;
wavesurfer.setMuted(false);
wavesurfer.setVolume(usePlayerStore.getState().volume);
// Stop current playback and reset position before loading new audio.
// With the WebAudio backend, pause() accumulates playedDuration internally.
// seekTo(0) resets it so the new track starts from the beginning.
debug.log('Loading new audio URL:', audioUrl);
try {
if (wavesurfer.isPlaying()) {
wavesurfer.pause();
}
wavesurfer.seekTo(0);
} catch (err) {
debug.error('Error resetting before load:', err);
}
loadingRef.current = true;
setIsLoading(true);
setError(null);
setCurrentTime(0);
setDuration(0);
wavesurfer
.load(audioUrl)
.then(() => {
debug.log('Audio loaded into WaveSurfer');
loadingRef.current = false;
})
.catch((err) => {
debug.error('Failed to load audio:', err);
loadingRef.current = false;
setIsLoading(false);
setError(`Failed to load audio: ${err instanceof Error ? err.message : String(err)}`);
});
}, [audioUrl, wsReady, setCurrentTime, setDuration]);
// Sync play/pause state (only when user clicks play/pause button, not auto-sync)
// This effect is kept for external state changes but should be minimal
useEffect(() => {
if (!wavesurferRef.current || duration === 0) return;
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
wavesurferRef.current.play().catch((error) => {
debug.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
} else if (!isPlaying && wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
}
}, [isPlaying, setIsPlaying, duration]);
// Sync volume
useEffect(() => {
if (wavesurferRef.current) {
wavesurferRef.current.setVolume(volume);
}
}, [volume]);
// Mark as initialized when audio is ready, reset when audioId changes
useEffect(() => {
if (duration > 0 && audioId) {
hasInitializedRef.current = true;
}
// Reset initialization flag when audioId changes to a new audio
if (audioId !== previousAudioIdRef.current && previousAudioIdRef.current !== null) {
hasInitializedRef.current = false;
}
if (audioId !== null) {
previousAudioIdRef.current = audioId;
}
}, [duration, audioId]);
// Handle restart flag - when history item is clicked again, restart from beginning
useEffect(() => {
const wavesurfer = wavesurferRef.current;
if (!wavesurfer || !shouldRestart || duration === 0) {
return;
}
debug.log('Restarting current audio from beginning');
wavesurfer.seekTo(0);
wavesurfer.play().catch((error) => {
debug.error('Failed to play after restart:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
clearRestartFlag();
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
// Auto-play is handled exclusively in the WaveSurfer 'ready' event handler.
// A separate effect here would race with the ready event since the WebAudio
// backend needs to fully decode the audio before play() works correctly.
// Spacebar to play/pause (capture phase so it fires before focused elements)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== 'Space') return;
// Ignore if user is typing in an input/textarea
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) {
return;
}
if (audioUrl && duration > 0 && wavesurferRef.current) {
e.preventDefault();
e.stopPropagation();
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
wavesurferRef.current.play().catch((err) => debug.error('Spacebar play failed:', err));
}
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [audioUrl, duration]);
const handlePlayPause = async () => {
// Standard WaveSurfer playback (works for both normal and native playback modes)
// When using native playback, WaveSurfer is muted but still controls visualization
if (!wavesurferRef.current) {
debug.error('WaveSurfer not initialized');
return;
}
// Check if audio is loaded
if (duration === 0 && !isLoading) {
debug.error('Audio not loaded yet');
setError('Audio not loaded. Please wait...');
return;
}
// If using native playback
if (useNativePlayback && audioUrl && profileChannels && channels) {
if (isPlaying) {
// Pause: stop native playback and pause WaveSurfer visualization
try {
platform.audio.stopPlayback();
debug.log('Stopped native audio playback');
} catch (error) {
debug.error('Failed to stop native playback:', error);
}
wavesurferRef.current.pause();
return;
}
// Play: trigger native playback
try {
// Stop any existing native playback first
try {
platform.audio.stopPlayback();
} catch (_error) {
// Ignore errors when stopping (might not be playing)
debug.log('No existing playback to stop');
}
// Collect all device IDs from assigned channels
const assignedChannels = channels.filter((ch) =>
profileChannels.channel_ids.includes(ch.id),
);
const deviceIds = assignedChannels.flatMap((ch) => ch.device_ids);
if (deviceIds.length > 0) {
// Fetch audio data
const response = await fetch(audioUrl);
const audioData = new Uint8Array(await response.arrayBuffer());
// Play via native audio
await platform.audio.playToDevices(audioData, deviceIds);
// Mark that we're using native playback
isUsingNativePlaybackRef.current = true;
// Mute WaveSurfer and start it for visualization
wavesurferRef.current.setVolume(0);
wavesurferRef.current.setMuted(true);
// Start WaveSurfer for visualization (muted)
wavesurferRef.current.play().catch((error) => {
debug.error('Failed to start WaveSurfer visualization:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
return;
}
} catch (error) {
debug.error('Native playback failed, falling back to WaveSurfer:', error);
// Fall through to WaveSurfer playback
isUsingNativePlaybackRef.current = false;
}
}
// Standard WaveSurfer playback (or fallback from native playback failure)
if (wavesurferRef.current.isPlaying()) {
wavesurferRef.current.pause();
} else {
// Ensure WaveSurfer is not muted if not using native playback
if (!isUsingNativePlaybackRef.current) {
wavesurferRef.current.setMuted(false);
wavesurferRef.current.setVolume(volume);
}
wavesurferRef.current.play().catch((error) => {
debug.error('Failed to play:', error);
setIsPlaying(false);
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
});
}
};
const handleSeek = (value: number[]) => {
if (!wavesurferRef.current || duration === 0) return;
const progress = value[0] / 100;
wavesurferRef.current.seekTo(progress);
};
const handleVolumeChange = (value: number[]) => {
setVolume(value[0] / 100);
};
const handleClose = () => {
// Stop any native playback
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
try {
platform.audio.stopPlayback();
} catch (error) {
debug.error('Failed to stop native playback:', error);
}
}
// Stop WaveSurfer
if (wavesurferRef.current) {
wavesurferRef.current.pause();
wavesurferRef.current.seekTo(0);
}
// Reset player state
reset();
};
// Don't render if no audio
if (!audioUrl) {
return null;
}
return (
<div className="fixed bottom-0 left-0 right-0 border-t bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 z-50">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center gap-4">
{/* Play/Pause Button */}
<Button
variant="ghost"
size="icon"
onClick={handlePlayPause}
disabled={isLoading || duration === 0}
className={`shrink-0 -mt-2 ${isPlaying ? 'bg-accent text-accent-foreground' : ''}`}
title={duration === 0 && !isLoading ? 'Audio not loaded' : ''}
aria-label={
duration === 0 && !isLoading ? 'Audio not loaded' : isPlaying ? 'Pause' : 'Play'
}
>
{isPlaying ? (
<Pause className="h-5 w-5 fill-current" />
) : (
<Play className="h-5 w-5 fill-current" />
)}
</Button>
{/* Waveform */}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<div ref={waveformRef} className="w-full min-h-[80px] select-none" />
<Slider
value={duration > 0 ? [(currentTime / duration) * 100] : [0]}
onValueChange={handleSeek}
max={100}
step={0.1}
className="w-full"
aria-label="Playback position"
aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`}
/>
{error && <div className="text-xs text-destructive text-center py-2">{error}</div>}
</div>
{/* Time Display */}
<div className="flex items-center gap-2 text-sm text-muted-foreground shrink-0 min-w-[100px]">
<span className="font-mono">{formatAudioDuration(currentTime)}</span>
<span>/</span>
<span className="font-mono">{formatAudioDuration(duration)}</span>
</div>
{/* Loop Button */}
<Button
variant="ghost"
size="icon"
onClick={toggleLoop}
className={isLooping ? 'bg-accent text-accent-foreground' : ''}
title="Toggle loop"
aria-label={isLooping ? 'Stop looping' : 'Loop'}
>
<Repeat className="h-4 w-4" />
</Button>
{/* Volume Control */}
<div
className="flex items-center gap-2 shrink-0 w-[120px]"
role="group"
aria-label="Volume"
>
<Button
variant="ghost"
size="icon"
onClick={() => setVolume(volume > 0 ? 0 : 1)}
className="h-8 w-8"
aria-label={volume > 0 ? 'Mute' : 'Unmute'}
>
{volume > 0 ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</Button>
<span id={volumeLabelId} className="sr-only">
Volume level, {Math.round(volume * 100)}%
</span>
<Slider
value={[volume * 100]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-labelledby={volumeLabelId}
aria-valuetext={`${Math.round(volume * 100)}%`}
/>
</div>
{/* Close Button */}
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="shrink-0"
title="Close player"
aria-label="Close player"
>
<X className="h-5 w-5" />
</Button>
</div>
</div>
</div>
);
}
================================================
FILE: app/src/components/AudioStudio/.gitkeep
================================================
# Audio studio timeline editing components
================================================
FILE: app/src/components/AudioTab/AudioTab.tsx
================================================
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, CheckCircle2, Edit, Plus, Speaker, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiClient } from '@/lib/api/client';
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
import { usePlayerStore } from '@/stores/playerStore';
interface AudioDevice {
id: string;
name: string;
is_default: boolean;
}
export function AudioTab() {
const platform = usePlatform();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] = useState<string | null>(null);
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const queryClient = useQueryClient();
const audioUrl = usePlayerStore((state) => state.audioUrl);
const isPlayerVisible = !!audioUrl;
const { data: channels, isLoading: channelsLoading } = useQuery({
queryKey: ['channels'],
queryFn: () => apiClient.listChannels(),
});
const { data: devices, isLoading: devicesLoading } = useQuery({
queryKey: ['audio-devices'],
queryFn: async () => {
if (!platform.metadata.isTauri) {
return [];
}
try {
return await platform.audio.listOutputDevices();
} catch (error) {
console.error('Failed to list audio devices:', error);
return [];
}
},
enabled: platform.metadata.isTauri,
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const createChannel = useMutation({
mutationFn: (data: { name: string; device_ids: string[] }) => apiClient.createChannel(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
setCreateDialogOpen(false);
},
});
const updateChannel = useMutation({
mutationFn: ({
channelId,
data,
}: {
channelId: string;
data: { name?: string; device_ids?: string[] };
}) => apiClient.updateChannel(channelId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
setEditingChannel(null);
},
});
const deleteChannel = useMutation({
mutationFn: (channelId: string) => apiClient.deleteChannel(channelId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channels'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
const { data: channelVoices } = useQuery({
queryKey: ['channel-voices', editingChannel],
queryFn: async () => {
if (!editingChannel) return { profile_ids: [] };
return apiClient.getChannelVoices(editingChannel);
},
enabled: !!editingChannel,
});
const setChannelVoices = useMutation({
mutationFn: ({ channelId, profileIds }: { channelId: string; profileIds: string[] }) =>
apiClient.setChannelVoices(channelId, profileIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['channel-voices'] });
queryClient.invalidateQueries({ queryKey: ['profile-channels'] });
},
});
if (channelsLoading || devicesLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-muted-foreground">Loading...</div>
</div>
);
}
const handleChannelDelete = async (e, channelId) => {
e.stopPropagation();
if (await confirm('Delete this channel?')) {
deleteChannel.mutate(channelId);
}
};
const allChannels = channels || [];
const allDevices = devices || [];
const selectedChannel = selectedChannelId
? allChannels.find((c) => c.id === selectedChannelId)
: null;
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between mb-6 shrink-0">
<h2 className="text-2xl font-bold">Audio Channels</h2>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
New Channel
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-full min-h-0">
{/* Left Column - Channels */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
{allChannels.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<Speaker className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
No audio channels yet. Create your first channel to route voices to specific
devices.
</p>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Channel
</Button>
</div>
) : (
<div className="space-y-3">
{allChannels.map((channel) => {
const isSelected = selectedChannelId === channel.id;
return (
<button
key={channel.id}
type="button"
className={cn(
'group border rounded-lg p-4 transition-colors cursor-pointer text-left w-full',
isSelected && 'ring-2 ring-primary bg-primary/5 border-primary',
)}
onClick={() => setSelectedChannelId(isSelected ? null : channel.id)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-3">
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
<Speaker className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-0">
<h3 className="font-semibold text-base truncate">{channel.name}</h3>
</div>
</div>
<div className="space-y-2.5 ml-10">
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Output Devices
</div>
<div className="flex flex-wrap gap-1.5">
{channel.device_ids.length > 0
? channel.device_ids.map((deviceId) => {
const device = allDevices.find((d) => d.id === deviceId);
return (
<Badge
key={deviceId}
variant="outline"
className="text-xs font-normal"
>
{device?.name || deviceId}
</Badge>
);
})
: (() => {
const defaultDevice = allDevices.find((d) => d.is_default);
return defaultDevice ? (
<Badge variant="outline" className="text-xs font-normal">
{defaultDevice.name}
</Badge>
) : null;
})()}
</div>
</div>
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">
Assigned Voices
</div>
<ChannelVoicesList channelId={channel.id} />
</div>
</div>
</div>
{!channel.is_default && (
<div className="flex gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
setEditingChannel(channel.id);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => handleChannelDelete(e, channel.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
</button>
);
})}
</div>
)}
</div>
{/* Right Column - Available Devices */}
<div
className={cn(
'flex flex-col min-h-0 overflow-y-auto',
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
)}
>
<div className="shrink-0 mb-4">
<h3 className="text-lg font-semibold">Available Devices</h3>
<p className="text-sm text-muted-foreground mt-1">
{selectedChannelId
? selectedChannel?.is_default
? 'Default channel uses system default device'
: 'Click devices to add or remove them from the selected channel'
: 'Select a channel to assign devices'}
</p>
</div>
{allDevices.length > 0 ? (
<div className="space-y-2">
{allDevices.map((device) => {
const isConnected =
selectedChannelId &&
selectedChannel &&
(selectedChannel.device_ids.length === 0
? device.is_default
: selectedChannel.device_ids.includes(device.id));
const canToggle =
selectedChannelId && selectedChannel && !selectedChannel.is_default;
const handleDeviceClick = () => {
if (!canToggle || !selectedChannel) return;
const currentDeviceIds = selectedChannel.device_ids;
const newDeviceIds = isConnected
? currentDeviceIds.filter((id) => id !== device.id)
: [...currentDeviceIds, device.id];
updateChannel.mutate({
channelId: selectedChannelId,
data: { device_ids: newDeviceIds },
});
};
return (
<button
key={device.id}
type="button"
onClick={handleDeviceClick}
disabled={!canToggle}
className={cn(
'flex items-center gap-2 text-sm p-3 rounded-lg border transition-colors text-left w-full',
isConnected
? 'bg-primary/10 border-primary ring-1 ring-primary/20'
: 'hover:bg-muted/50',
!canToggle && 'cursor-default opacity-60',
canToggle && 'cursor-pointer',
)}
>
{canToggle ? (
<div
className={cn(
'h-4 w-4 rounded border-2 flex items-center justify-center shrink-0',
isConnected ? 'bg-accent border-accent' : 'border-muted-foreground/30',
)}
>
{isConnected && <Check className="h-3 w-3 text-accent-foreground" />}
</div>
) : device.is_default ? (
<CheckCircle2 className="h-4 w-4 text-primary shrink-0" />
) : null}
<span className={cn('truncate flex-1', device.is_default && 'font-medium')}>
{device.name}
</span>
</button>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 border-2 border-dashed border-muted rounded-md">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">
{platform.metadata.isTauri
? 'No audio devices found'
: 'Audio device selection requires Tauri'}
</p>
</div>
)}
</div>
</div>
{/* Create Channel Dialog */}
<CreateChannelDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
devices={devices || []}
onCreate={(name, deviceIds) => {
createChannel.mutate({ name, device_ids: deviceIds });
}}
/>
{/* Edit Channel Dialog */}
{editingChannel &&
(() => {
const channel = channels?.find((c) => c.id === editingChannel);
return channel ? (
<EditChannelDialog
open={!!editingChannel}
onOpenChange={(open) => !open && setEditingChannel(null)}
channel={channel}
devices={devices || []}
profiles={profiles || []}
channelVoices={channelVoices?.profile_ids || []}
onUpdate={(name, deviceIds) => {
updateChannel.mutate({
channelId: editingChannel,
data: { name, device_ids: deviceIds },
});
}}
onSetVoices={(profileIds) => {
setChannelVoices.mutate({
channelId: editingChannel,
profileIds,
});
}}
/>
) : null;
})()}
</div>
);
}
function ChannelVoicesList({ channelId }: { channelId: string }) {
const { data: voices } = useQuery({
queryKey: ['channel-voices', channelId],
queryFn: () => apiClient.getChannelVoices(channelId),
});
const { data: profiles } = useQuery({
queryKey: ['profiles'],
queryFn: () => apiClient.listProfiles(),
});
const voiceNames =
voices?.profile_ids.map((id) => profiles?.find((p) => p.id === id)?.name).filter(Boolean) || [];
return (
<div className="flex flex-wrap gap-1.5">
{voiceNames.length > 0 ? (
voiceNames.map((name) => (
<Badge key={name} variant="outline" className="text-xs font-normal">
{name}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">No voices assigned</span>
)}
</div>
);
}
interface CreateChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
devices: AudioDevice[];
onCreate: (name: string, deviceIds: string[]) => void;
}
function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: CreateChannelDialogProps) {
const [name, setName] = useState('');
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const handleSubmit = () => {
if (name.trim()) {
onCreate(name.trim(), selectedDevices);
setName('');
setSelectedDevices([]);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Audio Channel</DialogTitle>
<DialogDescription>
Create a new audio channel (bus) to route voices to specific output devices.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="channel-name">Channel Name</Label>
<Input
id="channel-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Virtual Cable, Broadcast"
/>
</div>
<div>
<Label>Output Devices</Label>
<Select
value={selectedDevices[0] || ''}
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Select device" />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface EditChannelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
channel: {
id: string;
name: string;
device_ids: string[];
};
devices: AudioDevice[];
profiles: Array<{ id: string; name: string }>;
channelVoices: string[];
onUpdate: (name: string, deviceIds: string[]) => void;
onSetVoices: (profileIds: string[]) => void;
}
function EditChannelDialog({
open,
onOpenChange,
channel,
devices,
profiles,
channelVoices,
onUpdate,
onSetVoices,
}: EditChannelDialogProps) {
const [name, setName] = useState(channel.name);
const [selectedDevices, setSelectedDevices] = useState<string[]>(channel.device_ids);
const [selectedVoices, setSelectedVoices] = useState<string[]>(channelVoices);
const handleSubmit = () => {
if (name.trim()) {
onUpdate(name.trim(), selectedDevices);
onSetVoices(selectedVoices);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Channel</DialogTitle>
<DialogDescription>Update channel settings and voice assignments.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-channel-name">Channel Name</Label>
<Input id="edit-channel-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div>
<Label>Output Devices</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedDevices.includes(value)) {
setSelectedDevices([...selectedDevices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Add device" />
</SelectTrigger>
<SelectContent>
{devices.map((device) => (
<SelectItem key={device.id} value={device.id}>
{device.name} {device.is_default && '(default)'}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedDevices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedDevices.map((deviceId) => {
const device = devices.find((d) => d.id === deviceId);
return (
<div
key={deviceId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{device?.name || deviceId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedDevices(selectedDevices.filter((id) => id !== deviceId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
<div>
<Label>Assigned Voices</Label>
<Select
value=""
onValueChange={(value) => {
if (value && !selectedVoices.includes(value)) {
setSelectedVoices([...selectedVoices, value]);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Add voice" />
</SelectTrigger>
<SelectContent>
{profiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedVoices.length > 0 && (
<div className="mt-2 space-y-1">
{selectedVoices.map((profileId) => {
const profile = profiles.find((p) => p.id === profileId);
return (
<div
key={profileId}
className="flex items-center justify-between text-sm bg-muted p-2 rounded"
>
<span>{profile?.name || profileId}</span>
<Button
variant="ghost"
size="sm"
onClick={() =>
setSelectedVoices(selectedVoices.filter((id) => id !== profileId))
}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
================================================
FILE: app/src/components/Effects/EffectsChainEditor.tsx
================================================
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { apiClient } from '@/lib/api/client';
import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
// Each effect in the chain gets a stable ID for dnd-kit
interface EffectWithId extends EffectConfig {
_id: string;
}
let nextId = 0;
function makeId() {
return `fx-${++nextId}`;
}
interface EffectsChainEditorProps {
value: EffectConfig[];
onChange: (chain: EffectConfig[]) => void;
compact?: boolean;
showPresets?: boolean;
}
export function EffectsChainEditor({
value,
onChange,
compact = false,
showPresets = true,
}: EffectsChainEditorProps) {
const [expandedId, setExpandedId] = useState<string | null>(null);
// Maintain stable IDs for each effect across renders.
// We use a ref to map value items to IDs, rebuilding when length changes.
const idsRef = useRef<string[]>([]);
const items: EffectWithId[] = useMemo(() => {
// Grow ID array if effects were added
while (idsRef.current.length < value.length) {
idsRef.current.push(makeId());
}
// Shrink if effects were removed
if (idsRef.current.length > value.length) {
idsRef.current = idsRef.current.slice(0, value.length);
}
return value.map((e, i) => ({ ...e, _id: idsRef.current[i] }));
}, [value]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const { data: availableEffects } = useQuery({
queryKey: ['available-effects'],
queryFn: () => apiClient.getAvailableEffects(),
staleTime: Infinity,
});
const { data: presets } = useQuery({
queryKey: ['effect-presets'],
queryFn: () => apiClient.listEffectPresets(),
staleTime: 30_000,
});
const effectsMap = useMemo(() => {
const m = new Map<string, AvailableEffect>();
if (availableEffects) {
for (const e of availableEffects.effects) {
m.set(e.type, e);
}
}
return m;
}, [availableEffects]);
function addEffect(type: string) {
const def = effectsMap.get(type);
if (!def) return;
const params: Record<string, number> = {};
for (const [key, p] of Object.entries(def.params)) {
params[key] = p.default;
}
const newEffect: EffectConfig = { type, enabled: true, params };
const newId = makeId();
idsRef.current = [...idsRef.current, newId];
onChange([...value, newEffect]);
setExpandedId(newId);
}
const removeEffect = useCallback(
(index: number) => {
const removedId = idsRef.current[index];
idsRef.current = idsRef.current.filter((_, i) => i !== index);
onChange(value.filter((_, i) => i !== index));
if (expandedId === removedId) setExpandedId(null);
},
[value, onChange, expandedId],
);
const toggleEnabled = useCallback(
(index: number) => {
onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e)));
},
[value, onChange],
);
const updateParam = useCallback(
(index: number, paramName: string, paramValue: number) => {
onChange(
value.map((e, i) =>
i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e,
),
);
},
[value, onChange],
);
function loadPreset(preset: EffectPresetResponse) {
idsRef.current = preset.effects_chain.map(() => makeId());
onChange(preset.effects_chain);
setExpandedId(null);
}
function clearAll() {
idsRef.current = [];
onChange([]);
setExpandedId(null);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = idsRef.current.indexOf(active.id as string);
const newIndex = idsRef.current.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
onChange(arrayMove([...value], oldIndex, newIndex));
}
return (
<div className={cn('space-y-2', compact && 'text-sm')}>
{/* Preset selector row */}
{showPresets && (
<div className="flex items-center gap-2">
<Select
onValueChange={(id) => {
const preset = presets?.find((p) => p.id === id);
if (preset) loadPreset(preset);
}}
>
<SelectTrigger className="h-8 flex-1 text-xs focus:ring-0 focus:ring-offset-0">
<SelectValue placeholder="Load preset..." />
</SelectTrigger>
<SelectContent>
{presets?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
{p.description && (
<span className="ml-1 text-muted-foreground">- {p.description}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{value.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs text-muted-foreground"
onClick={clearAll}
>
Clear
</Button>
)}
</div>
)}
{/* Sortable effects chain */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i._id)} strategy={verticalListSortingStrategy}>
{items.map((effect, index) => (
<SortableEffectItem
key={effect._id}
id={effect._id}
effect={effect}
index={index}
effectDef={effectsMap.get(effect.type)}
isExpanded={expandedId === effect._id}
onToggleExpand={() => setExpandedId(expandedId === effect._id ? null : effect._id)}
onRemove={() => removeEffect(index)}
onToggleEnabled={() => toggleEnabled(index)}
onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)}
/>
))}
</SortableContext>
</DndContext>
{/* Add effect */}
{availableEffects && (
<Select onValueChange={addEffect}>
<SelectTrigger className="h-8 border-dashed text-xs text-muted-foreground focus:ring-0 focus:ring-offset-0">
<Plus className="mr-1 h-3.5 w-3.5" />
<SelectValue placeholder="Add effect..." />
</SelectTrigger>
<SelectContent>
{availableEffects.effects.map((e) => (
<SelectItem key={e.type} value={e.type}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Sortable effect item
// ---------------------------------------------------------------------------
interface SortableEffectItemProps {
id: string;
effect: EffectConfig;
index: number;
effectDef?: AvailableEffect;
isExpanded: boolean;
onToggleExpand: () => void;
onRemove: () => void;
onToggleEnabled: () => void;
onUpdateParam: (paramName: string, paramValue: number) => void;
}
function SortableEffectItem({
id,
effect,
effectDef,
isExpanded,
onToggleExpand,
onRemove,
onToggleEnabled,
onUpdateParam,
}: SortableEffectItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
};
const label = effectDef?.label ?? effect.type;
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-md border',
effect.enabled ? 'border-border bg-card' : 'border-border/50 bg-muted/30',
isDragging && 'opacity-80 shadow-lg',
)}
>
{/* Header */}
<div className="flex items-center gap-1 px-2 py-1.5">
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggleExpand}
>
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
className="p-0.5 text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing touch-none"
{...attributes}
{...listeners}
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<span
className={cn('flex-1 text-xs font-medium', !effect.enabled && 'text-muted-foreground')}
>
{label}
</span>
<button
type="button"
className={cn(
'p-0.5 transition-colors',
effect.enabled ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
onClick={onToggleEnabled}
title={effect.enabled ? 'Disable' : 'Enable'}
>
<Power className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="p-0.5 text-muted-foreground hover:text-destructive"
onClick={onRemove}
title="Remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{/* Params */}
{isExpanded && effectDef && (
<div className="space-y-3 border-t px-3 py-2.5">
{Object.entries(effectDef.params).map(([paramName, paramDef]) => {
const currentValue = effect.params[paramName] ?? paramDef.default;
return (
<div key={paramName} className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[11px] text-muted-foreground">
{paramDef.description}
</Label>
<span className="text-[11px] font-mono tabular-nums text-foreground">
{currentValue.toFixed(
paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0,
)}
</span>
</div>
<Slider
min={paramDef.min}
max={paramDef.max}
step={paramDef.step}
value={[currentValue]}
onValueChange={([v]) => onUpdateParam(paramName, v)}
/>
</div>
);
})}
</div>
)}
</div>
);
}
================================================
FILE: app/src/components/Effects/GenerationPicker.tsx
================================================
import { ChevronDown, Search } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { HistoryResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { cn } from '@/lib/utils/cn';
interface GenerationPickerProps {
selectedId: string | null;
onSelect: (generation: HistoryResponse) => void;
className?: string;
}
export function GenerationPicker({ selectedId, onSelect, className }: GenerationPickerProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const { data: historyData } = useHistory({ limit: 50 });
const completedGenerations = useMemo(() => {
if (!historyData?.items) return [];
return historyData.items.filter((gen) => gen.status === 'completed');
}, [historyData]);
const filtered = useMemo(() => {
if (!searchQuery) return completedGenerations;
const q = searchQuery.toLowerCase();
return completedGenerations.filter(
(gen) => gen.text.toLowerCase().includes(q) || gen.profile_name.toLowerCase().includes(q),
);
}, [completedGenerations, searchQuery]);
const selectedGeneration = completedGenerations.find((g) => g.id === selectedId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn('h-8 justify-between gap-2 text-xs font-normal', className)}
>
{selectedGeneration ? (
<span className="truncate">
<span className="font-medium">{selectedGeneration.profile_name}</span>
<span className="text-muted-foreground ml-1.5">
{selectedGeneration.text.length > 30
? `${selectedGeneration.text.substring(0, 30)}...`
: selectedGeneration.text}
</span>
</span>
) : (
<span className="text-muted-foreground">Select a generation...</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by voice or text..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
<div className="max-h-60 overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No generations found
</div>
) : (
filtered.map((gen) => (
<button
key={gen.id}
type="button"
className={cn(
'w-full text-left px-3 py-2 hover:bg-muted/50 transition-colors border-b border-border/30 last:border-0',
gen.id === selectedId && 'bg-accent/10',
)}
onClick={() => {
onSelect(gen);
setOpen(false);
setSearchQuery('');
}}
>
<div className="font-medium text-sm">{gen.profile_name}</div>
<div className="text-xs text-muted-foreground truncate">
{gen.text.length > 60 ? `${gen.text.substring(0, 60)}...` : gen.text}
</div>
</button>
))
)}
</div>
</PopoverContent>
</Popover>
);
}
================================================
FILE: app/src/components/EffectsTab/EffectsDetail.tsx
================================================
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Play, Save, Trash2, Wand2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
import { GenerationPicker } from '@/components/Effects/GenerationPicker';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { useToast } from '@/components/ui/use-toast';
import { apiClient } from '@/lib/api/client';
import type { HistoryResponse } from '@/lib/api/types';
import { useHistory } from '@/lib/hooks/useHistory';
import { useEffectsStore } from '@/stores/effectsStore';
import { usePlayerStore } from '@/stores/playerStore';
export function EffectsDetail() {
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const workingChain = useEffectsStore((s) => s.workingChain);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
// "Save as Custom" dialog state
const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false);
const [saveAsName, setSaveAsName] = useState('');
const [saveAsDescription, setSaveAsDescription] = useState('');
// Preview state
const [previewGenId, setPreviewGenId] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const blobUrlRef = useRef<string | null>(null);
const setAudioWithAutoPlay = usePlayerStore((s) => s.setAudioWithAutoPlay);
const { toast } = useToast();
const queryClient = useQueryClient();
// Auto-select the most recent generation as preview source
const { data: historyData } = useHistory({ limit: 1 });
useEffect(() => {
if (!previewGenId && historyData?.items?.length) {
const first = historyData.items.find((g) => g.status === 'completed');
if (first) setPreviewGenId(first.id);
}
}, [historyData, previewGenId]);
const { data: preset } = useQuery({
queryKey: ['effect-preset', selectedPresetId],
queryFn: () =>
selectedPresetId
? apiClient
.listEffectPresets()
.then((all) => all.find((p) => p.id === selectedPresetId) ?? null)
: null,
enabled: !!selectedPresetId,
staleTime: 30_000,
});
// Sync name/description when selecting a preset
useEffect(() => {
if (preset) {
setName(preset.name);
setDescription(preset.description ?? '');
} else if (isCreatingNew) {
setName('');
setDescription('');
}
}, [preset, isCreatingNew]);
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const isEditing = !!selectedPresetId || isCreatingNew;
const isBuiltIn = preset?.is_builtin ?? false;
async function handlePreview() {
if (!previewGenId || workingChain.length === 0) return;
setPreviewLoading(true);
try {
const blob = await apiClient.previewEffects(previewGenId, workingChain);
// Revoke old blob URL
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
// Play through the main audio player
setAudioWithAutoPlay(url, `preview-${Date.now()}`, null, 'Effects Preview');
} catch (error) {
toast({
title: 'Preview failed',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setPreviewLoading(false);
}
}
function handleSelectGeneration(gen: HistoryResponse) {
setPreviewGenId(gen.id);
}
async function handleSaveNew() {
if (!name.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: name.trim(),
description: description.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setIsCreatingNew(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleSaveExisting() {
if (!selectedPresetId || !name.trim()) return;
setSaving(true);
try {
await apiClient.updateEffectPreset(selectedPresetId, {
name: name.trim(),
description: description.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
queryClient.invalidateQueries({ queryKey: ['effect-preset', selectedPresetId] });
toast({ title: 'Preset updated' });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
function handleSaveAsNew() {
// Open the dialog with a suggested name based on the current preset
setSaveAsName(`${name} (Copy)`);
setSaveAsDescription(description);
setSaveAsDialogOpen(true);
}
async function handleSaveAsConfirm() {
if (!saveAsName.trim()) {
toast({ title: 'Name required', variant: 'destructive' });
return;
}
setSaving(true);
try {
const created = await apiClient.createEffectPreset({
name: saveAsName.trim(),
description: saveAsDescription.trim() || undefined,
effects_chain: workingChain,
});
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSaveAsDialogOpen(false);
setSelectedPresetId(created.id);
toast({ title: 'Preset saved', description: `"${created.name}" has been created.` });
} catch (error) {
toast({
title: 'Failed to save',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!selectedPresetId) return;
setDeleting(true);
try {
await apiClient.deleteEffectPreset(selectedPresetId);
queryClient.invalidateQueries({ queryKey: ['effect-presets'] });
setSelectedPresetId(null);
setWorkingChain([]);
toast({ title: 'Preset deleted' });
} catch (error) {
toast({
title: 'Failed to delete',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
} finally {
setDeleting(false);
}
}
if (!isEditing) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center space-y-2">
<Wand2 className="h-10 w-10 mx-auto opacity-30" />
<p className="text-sm">Select a preset or create a new one</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">
{isCreatingNew ? 'New Preset' : isBuiltIn ? preset?.name : 'Edit Preset'}
</h2>
<div className="flex items-center gap-2">
{!isBuiltIn && !isCreatingNew && (
<>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive hover:text-destructive gap-1.5"
onClick={handleDelete}
disabled={deleting}
>
<Trash2 className="h-3.5 w-3.5" />
{deleting ? 'Deleting...' : 'Delete'}
</Button>
<Button
size="sm"
className="h-8 gap-1.5"
onClick={handleSaveExisting}
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save'}
</Button>
</>
)}
{isCreatingNew && (
<Button
size="sm"
className="h-8 gap-1.5"
onClick={handleSaveNew}
disabled={saving || workingChain.length === 0}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save Preset'}
</Button>
)}
{isBuiltIn && (
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={handleSaveAsNew}
disabled={saving}
>
<Save className="h-3.5 w-3.5" />
{saving ? 'Saving...' : 'Save as Custom'}
</Button>
)}
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-5 pr-1">
{/* Name & description */}
{(isCreatingNew || !isBuiltIn) && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My preset..."
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
)}
{/* Built-in description (read-only) */}
{isBuiltIn && preset?.description && (
<p className="text-sm text-muted-foreground">{preset.description}</p>
)}
{/* Effects chain editor */}
<EffectsChainEditor value={workingChain} onChange={setWorkingChain} showPresets={false} />
<Separator />
{/* Preview section */}
<div className="space-y-3">
<Label className="text-xs">Preview</Label>
<div className="flex items-center gap-2">
<GenerationPicker
selectedId={previewGenId}
onSelect={handleSelectGeneration}
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 shrink-0"
onClick={handlePreview}
disabled={!previewGenId || workingChain.length === 0 || previewLoading}
>
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Processing...
</>
) : (
<>
<Play className="h-3.5 w-3.5" />
Preview
</>
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Preview applies effects to the clean version without saving.
</p>
</div>
</div>
{/* Save as Custom dialog */}
<Dialog open={saveAsDialogOpen} onOpenChange={setSaveAsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Save as Custom Preset</DialogTitle>
<DialogDescription>
Create a new custom preset based on the current effects chain.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1.5">
<Label className="text-xs">Name</Label>
<Input
value={saveAsName}
onChange={(e) => setSaveAsName(e.target.value)}
placeholder="My preset..."
className="h-9"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && saveAsName.trim()) {
handleSaveAsConfirm();
}
}}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Description</Label>
<Textarea
value={saveAsDescription}
onChange={(e) => setSaveAsDescription(e.target.value)}
placeholder="Describe what this preset does..."
className="min-h-[60px] resize-none"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveAsDialogOpen(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSaveAsConfirm} disabled={saving || !saveAsName.trim()}>
<Save className="h-3.5 w-3.5 mr-1.5" />
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
================================================
FILE: app/src/components/EffectsTab/EffectsList.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiClient } from '@/lib/api/client';
import type { EffectPresetResponse } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
import { useEffectsStore } from '@/stores/effectsStore';
export function EffectsList() {
const selectedPresetId = useEffectsStore((s) => s.selectedPresetId);
const setSelectedPresetId = useEffectsStore((s) => s.setSelectedPresetId);
const setWorkingChain = useEffectsStore((s) => s.setWorkingChain);
const setIsCreatingNew = useEffectsStore((s) => s.setIsCreatingNew);
const isCreatingNew = useEffectsStore((s) => s.isCreatingNew);
const { data: presets, isLoading } = useQuery({
queryKey: ['effect-presets'],
queryFn: () => apiClient.listEffectPresets(),
staleTime: 30_000,
});
const builtIn = presets?.filter((p) => p.is_builtin) ?? [];
const userPresets = presets?.filter((p) => !p.is_builtin) ?? [];
function handleSelect(preset: EffectPresetResponse) {
setSelectedPresetId(preset.id);
setWorkingChain(preset.effects_chain);
}
function handleCreateNew() {
setIsCreatingNew(true);
setWorkingChain([]);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Effects</h2>
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={handleCreateNew}>
<Plus className="h-3.5 w-3.5" />
New Preset
</Button>
</div>
{/* Scrollable list */}
<div className="flex-1 min-h-0 overflow-y-auto space-y-4">
{/* Built-in presets */}
{builtIn.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Built-in
</div>
<div className="space-y-1.5">
{builtIn.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* User presets */}
{userPresets.length > 0 && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
Custom
</div>
<div className="space-y-1.5">
{userPresets.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id && !isCreatingNew}
onSelect={() => handleSelect(preset)}
/>
))}
</div>
</div>
)}
{/* New preset placeholder */}
{isCreatingNew && (
<div>
<div className="text-[11px] text-muted-foreground font-medium uppercase tracking-wider mb-2 px-1">
New
</div>
<div className="rounded-xl border-2 border-accent/40 bg-accent/5 p-3">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-accent" />
<span className="text-sm font-medium">Unsaved Preset</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Configure effects in the panel on the right.
</p>
</div>
</div>
)}
</div>
</div>
);
}
function PresetCard({
preset,
isSelected,
onSelect,
}: {
preset: EffectPresetResponse;
isSelected: boolean;
onSelect: () => void;
}) {
const effectCount = preset.effects_chain.length;
return (
<button
type="button"
className={cn(
'w-full text-left rounded-xl border p-3 h-[88px] transition-all duration-150',
isSelected
? 'border-accent/50 bg-accent/10'
: 'border-border bg-card hover:bg-muted/50 hover:border-border',
)}
onClick={onSelect}
>
<div className="flex items-center gap-2">
<Wand2
className={cn('h-4 w-4 shrink-0', isSelected ? 'text-accent' : 'text-muted-foreground')}
/>
<span className="text-sm font-medium truncate">{preset.name}</span>
{preset.is_builtin && (
<span className="text-[10px] bg-muted text-muted-foreground px-1.5 py-0.5 rounded-full shrink-0">
built-in
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1 pl-6">
{preset.description || 'No description'}
</p>
<div className="flex items-center gap-2 mt-1.5 pl-6">
<span className="text-[10px] text-muted-foreground">
{effectCount} effect{effectCount !== 1 ? 's' : ''}
</span>
<span className="text-[10px] text-muted-foreground/50">
{preset.effects_chain
.filter((e) => e.enabled)
.map((e) => e.type)
.join(' → ')}
</span>
</div>
</button>
);
}
================================================
FILE: app/src/components/EffectsTab/EffectsTab.tsx
================================================
import {EffectsDetail} from "./EffectsDetail";
import {EffectsList} from "./EffectsList";
export function EffectsTab() {
return (
<div className="flex flex-col h-full min-h-0 overflow-hidden">
<div className="flex-1 min-h-0 flex gap-6 overflow-hidden">
{/* Left - Presets list */}
<div className="w-full max-w-[360px] shrink-0 flex flex-col min-h-0">
<EffectsList />
</div>
{/* Right - Detail / editor */}
<div className="flex-1 min-h-0 flex flex-col">
<EffectsDetail />
</div>
</div>
</div>
);
}
================================================
FILE: app/src/components/Generation/EngineModelSelector.tsx
================================================
import { useEffect } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { FormControl } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { getLanguageOptionsForEngine } from '@/lib/constants/languages';
import type { GenerationFormValues } from '@/lib/hooks/useGenerationForm';
/**
* Engine/model options and their display metadata.
* Adding a new engine means adding one entry here.
*/
const ENGINE_OPTIONS = [
{ value: 'qwen:1.7B', label: 'Qwen3-TTS 1.7B', engine: 'qwen' },
{ value: 'qwen:0.6B', label: 'Qwen3-TTS 0.6B', engine: 'qwen' },
{ value: 'luxtts', label: 'LuxTTS', engine: 'luxtts' },
{ value: 'chatterbox', label: 'Chatterbox', engine: 'chatterbox' },
{ value: 'chatterbox_turbo', label: 'Chatterbox Turbo', engine: 'chatterbox_turbo' },
{ value: 'tada:1B', label: 'TADA 1B', engine: 'tada' },
{ value: 'tada:3B', label: 'TADA 3B Multilingual', engine: 'tada' },
{ value: 'kokoro', label: 'Kokoro 82M', engine: 'kokoro' },
] as const;
const ENGINE_DESCRIPTIONS: Record<string, string> = {
qwen: 'Multi-language, two sizes',
luxtts: 'Fast, English-focused',
chatterbox: '23 languages, incl. Hebrew',
chatterbox_turbo: 'English, [laugh] [cough] tags',
tada: 'HumeAI, 700s+ coherent audio',
kokoro: '82M params, CPU realtime, 8 langs',
};
/** Engines that only support English and should force language to 'en' on select. */
const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
/** Engines that support cloned (reference audio) profiles. */
const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatterbox_turbo', 'tada']);
function getAvailableOptions(selectedProfile?: VoiceProfileResponse | null) {
if (!selectedProfile) return ENGINE_OPTIONS;
return ENGINE_OPTIONS.filter((opt) => isProfileCompatibleWithEngine(selectedProfile, opt.engine));
}
function getSelectValue(engine: string, modelSize?: string): string {
if (engine === 'qwen') return `qwen:${modelSize || '1.7B'}`;
if (engine === 'tada') return `tada:${modelSize || '1B'}`;
return engine;
}
function handleEngineChange(form: UseFormReturn<GenerationFormValues>, value: string) {
if (value.startsWith('qwen:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'qwen');
form.setValue('modelSize', modelSize as '1.7B' | '0.6B');
// Validate language is supported by Qwen
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('qwen');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
} else if (value.startsWith('tada:')) {
const [, modelSize] = value.split(':');
form.setValue('engine', 'tada');
form.setValue('modelSize', modelSize as '1B' | '3B');
// TADA 1B is English-only; 3B is multilingual
if (modelSize === '1B') {
form.setValue('language', 'en');
} else {
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine('tada');
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
}
} else {
form.setValue('engine', value as GenerationFormValues['engine']);
form.setValue('modelSize', undefined as unknown as '1.7B' | '0.6B');
if (ENGLISH_ONLY_ENGINES.has(value)) {
form.setValue('language', 'en');
} else {
// If current language isn't supported by the new engine, reset to first available
const currentLang = form.getValues('language');
const available = getLanguageOptionsForEngine(value);
if (!available.some((l) => l.value === currentLang)) {
form.setValue('language', available[0]?.value ?? 'en');
}
}
}
}
interface EngineModelSelectorProps {
form: UseFormReturn<GenerationFormValues>;
compact?: boolean;
selectedProfile?: VoiceProfileResponse | null;
}
export function EngineModelSelector({ form, compact, selectedProfile }: EngineModelSelectorProps) {
const engine = form.watch('engine') || 'qwen';
const modelSize = form.watch('modelSize');
const selectValue = getSelectValue(engine, modelSize);
const availableOptions = getAvailableOptions(selectedProfile);
const currentEngineAvailable = availableOptions.some((opt) => opt.value === selectValue);
useEffect(() => {
if (!currentEngineAvailable && availableOptions.length > 0) {
handleEngineChange(form, availableOptions[0].value);
}
}, [availableOptions, currentEngineAvailable, form]);
const itemClass = compact ? 'text-xs text-muted-foreground' : undefined;
const triggerClass = compact
? 'h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all'
: undefined;
return (
<Select value={selectValue} onValueChange={(v) => handleEngineChange(form, v)}>
<FormControl>
<SelectTrigger className={triggerClass}>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Returns a human-readable description for the currently selected engine. */
export function getEngineDescription(engine: string): string {
return ENGINE_DESCRIPTIONS[engine] ?? '';
}
/**
* Check if a profile is compatible with the currently selected engine.
* Useful for UI hints.
*/
export function isProfileCompatibleWithEngine(
profile: VoiceProfileResponse,
engine: string,
): boolean {
const voiceType = profile.voice_type || 'cloned';
if (voiceType === 'preset') return profile.preset_engine === engine;
if (voiceType === 'cloned') return CLONING_ENGINES.has(engine);
return true; // designed — future
}
================================================
FILE: app/src/components/Generation/FloatingGenerateBox.tsx
================================================
import { useQuery } from '@tanstack/react-query';
import { useMatchRoute } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
import { Loader2, Sparkles } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { apiClient } from '@/lib/api/client';
import { getLanguageOptionsForEngine, type LanguageCode } from '@/lib/constants/languages';
import { useGenerationForm } from '@/lib/hooks/useGenerationForm';
import { useProfile, useProfiles } from '@/lib/hooks/useProfiles';
import { useStory } from '@/lib/hooks/useStories';
import { cn } from '@/lib/utils/cn';
import { useGenerationStore } from '@/stores/generationStore';
import { useStoryStore } from '@/stores/storyStore';
import { useUIStore } from '@/stores/uiStore';
import { EngineModelSelector } from './EngineModelSelector';
import { ParalinguisticInput } from './ParalinguisticInput';
interface FloatingGenerateBoxProps {
isPlayerOpen?: boolean;
showVoiceSelector?: boolean;
}
export function FloatingGenerateBox({
isPlayerOpen = false,
showVoiceSelector = false,
}: FloatingGenerateBoxProps) {
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const setSelectedEngine = useUIStore((state) => state.setSelectedEngine);
const { data: selectedProfile } = useProfile(selectedProfileId || '');
const { data: profiles } = useProfiles();
const [isExpanded, setIsExpanded] = useState(false);
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const matchRoute = useMatchRoute();
const isStoriesRoute = matchRoute({ to: '/stories' });
const selectedStoryId = useStoryStore((state) => state.selectedStoryId);
const trackEditorHeight = useStoryStore((state) => state.trackEditorHeight);
const { data: currentStory } = useStory(selectedStoryId);
const addPendingStoryAdd = useGenerationStore((s) => s.addPendingStoryAdd);
// Fetch effect presets for the dropdown
const { data: effectPresets } = useQuery({
queryKey: ['effectPresets'],
queryFn: () => apiClient.listEffectPresets(),
});
// Calculate if track editor is visible (on stories route with items)
const hasTrackEditor = isStoriesRoute && currentStory && currentStory.items.length > 0;
const { form, handleSubmit, isPending } = useGenerationForm({
onSuccess: async (generationId) => {
setIsExpanded(false);
// Defer the story add until TTS completes -- useGenerationProgress handles it
if (isStoriesRoute && selectedStoryId && generationId) {
addPendingStoryAdd(generationId, selectedStoryId);
}
},
getEffectsChain: () => {
if (!selectedPresetId) return undefined;
// Profile's own effects chain (no matching preset)
if (selectedPresetId === '_profile') {
return selectedProfile?.effects_chain ?? undefined;
}
if (!effectPresets) return undefined;
const preset = effectPresets.find((p) => p.id === selectedPresetId);
return preset?.effects_chain;
},
});
// Click away handler to collapse the box
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
// Don't collapse if clicking inside the container
if (containerRef.current?.contains(target)) {
return;
}
// Don't collapse if clicking on a Select dropdown (which renders in a portal)
if (
target.closest('[role="listbox"]') ||
target.closest('[data-radix-popper-content-wrapper]')
) {
return;
}
setIsExpanded(false);
}
if (isExpanded) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isExpanded]);
// Set first voice as default if none selected
useEffect(() => {
if (!selectedProfileId && profiles && profiles.length > 0) {
setSelectedProfileId(profiles[0].id);
}
}, [selectedProfileId, profiles, setSelectedProfileId]);
// Sync engine selection to global store so ProfileList can filter
const watchedEngine = form.watch('engine');
useEffect(() => {
if (watchedEngine) {
setSelectedEngine(watchedEngine);
}
}, [watchedEngine, setSelectedEngine]);
// Sync generation form language, engine, and effects with selected profile
useEffect(() => {
if (selectedProfile?.language) {
form.setValue('language', selectedProfile.language as LanguageCode);
}
// Auto-switch engine if profile has a default
if (selectedProfile?.default_engine) {
form.setValue(
'engine',
selectedProfile.default_engine as
| 'qwen'
| 'luxtts'
| 'chatterbox'
| 'chatterbox_turbo'
| 'tada'
| 'kokoro',
);
}
// Pre-fill effects from profile defaults
if (
selectedProfile?.effects_chain &&
selectedProfile.effects_chain.length > 0 &&
effectPresets
) {
// Try to match against a known preset
const profileChainJson = JSON.stringify(selectedProfile.effects_chain);
const matchingPreset = effectPresets.find(
(p) => JSON.stringify(p.effects_chain) === profileChainJson,
);
if (matchingPreset) {
setSelectedPresetId(matchingPreset.id);
} else {
// No matching preset — use special value to pass profile chain directly
setSelectedPresetId('_profile');
}
} else if (
selectedProfile &&
(!selectedProfile.effects_chain || selectedProfile.effects_chain.length === 0)
) {
setSelectedPresetId(null);
}
}, [selectedProfile, effectPresets, form]);
// Auto-resize textarea based on content (only when expanded)
useEffect(() => {
if (!isExpanded) {
// Reset textarea height after collapse animation completes
const timeoutId = setTimeout(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = '32px';
textarea.style.overflowY = 'hidden';
}
}, 200); // Wait for animation to complete
return () => clearTimeout(timeoutId);
}
const textarea = textareaRef.current;
if (!textarea) return;
const adjustHeight = () => {
textarea.style.height = 'auto';
const scrollHeight = textarea.scrollHeight;
const minHeight = 100; // Expanded minimum
const maxHeight = 300; // Max height in pixels
const targetHeight = Math.max(minHeight, Math.min(scrollHeight, maxHeight));
textarea.style.height = `${targetHeight}px`;
// Show scrollbar if content exceeds max height
if (scrollHeight > maxHeight) {
textarea.style.overflowY = 'auto';
} else {
textarea.style.overflowY = 'hidden';
}
};
// Small delay to let framer animation complete
const timeoutId = setTimeout(() => {
adjustHeight();
}, 200);
// Adjust on mount and when value changes
adjustHeight();
// Watch for input changes
textarea.addEventListener('input', adjustHeight);
return () => {
clearTimeout(timeoutId);
textarea.removeEventListener('input', adjustHeight);
};
}, [isExpanded]);
async function onSubmit(data: Parameters<typeof handleSubmit>[0]) {
await handleSubmit(data, selectedProfileId);
}
return (
<motion.div
ref={containerRef}
className={cn(
'fixed right-auto',
isStoriesRoute
? // Position aligned with story list: after sidebar + padding, width 360px
'left-[calc(5rem+2rem)] w-[360px]'
: 'left-[calc(5rem+2rem)] right-8 lg:right-auto lg:w-[calc((100%-5rem-4rem)/2-1rem)]',
)}
style={{
// On stories route: offset by track editor height when visible
// On other routes: offset by audio player height when visible
bottom: hasTrackEditor
? `${trackEditorHeight + 24}px`
: isPlayerOpen
? 'calc(7rem + 1.5rem)'
: '1.5rem',
}}
>
<motion.div
className="bg-background/30 backdrop-blur-2xl border border-accent/20 rounded-[2rem] shadow-2xl hover:bg-background/40 hover:border-accent/20 transition-all duration-300 p-3"
transition={{ duration: 0.6, ease: 'easeInOut' }}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex gap-2">
<motion.div className="flex-1" transition={{ duration: 0.3, ease: 'easeOut' }}>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem>
<FormControl>
<motion.div
animate={{
height: isExpanded ? 'auto' : '32px',
}}
transition={{ duration: 0.15, ease: 'easeOut' }}
style={{ overflow: 'hidden' }}
>
{form.watch('engine') === 'chatterbox_turbo' ? (
<ParalinguisticInput
value={field.value}
onChange={field.onChange}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"... (type / for effects)`
: selectedProfile
? `Type / for effects like [laugh], [sigh]...`
: 'Select a voice profile above...'
}
className="px-3 py-2 resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm w-full"
style={{
minHeight: isExpanded ? '100px' : '32px',
maxHeight: '300px',
overflowY: 'auto',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
) : (
<Textarea
{...field}
ref={(node: HTMLTextAreaElement | null) => {
textareaRef.current = node;
if (typeof field.ref === 'function') {
field.ref(node);
}
}}
placeholder={
isStoriesRoute && currentStory
? `Generate speech for "${currentStory.name}"...`
: selectedProfile
? `Generate speech using ${selectedProfile.name}...`
: 'Select a voice profile above...'
}
className="resize-none bg-transparent border-none focus-visible:ring-0 focus-visible:ring-offset-0 focus:outline-none focus:ring-0 outline-none ring-0 rounded-2xl text-sm placeholder:text-muted-foreground/60 w-full"
style={{
minHeight: isExpanded ? '100px' : '32px',
maxHeight: '300px',
}}
disabled={!selectedProfileId}
onClick={() => setIsExpanded(true)}
onFocus={() => setIsExpanded(true)}
/>
)}
</motion.div>
</FormControl>
<FormMessage className="text-xs" />
</FormItem>
)}
/>
</motion.div>
<div className="relative shrink-0">
<div className="group relative">
<Button
type="submit"
disabled={isPending || !selectedProfileId}
className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200"
size="icon"
aria-label={
isPending
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'
}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
</Button>
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 whitespace-nowrap rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground border border-border opacity-0 transition-opacity group-hover:opacity-100 z-[9999]">
{isPending
? 'Generating...'
: !selectedProfileId
? 'Select a voice profile first'
: 'Generate speech'}
</span>
</div>
</div>
</div>
<AnimatePresence>
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className=" mt-3"
>
<div className="flex items-center gap-2">
{showVoiceSelector && (
<div className="flex-1">
<Select
value={selectedProfileId || ''}
onValueChange={(value) => setSelectedProfileId(value || null)}
>
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/
gitextract_61mm52up/
├── .agents/
│ └── skills/
│ ├── add-tts-engine/
│ │ └── SKILL.md
│ ├── draft-release-notes/
│ │ └── SKILL.md
│ └── release-bump/
│ └── SKILL.md
├── .biomeignore
├── .bumpversion.cfg
├── .dockerignore
├── .github/
│ └── workflows/
│ ├── build-windows.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── CHANGELOG.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── app/
│ ├── components.json
│ ├── index.html
│ ├── package.json
│ ├── plugins/
│ │ └── changelog.ts
│ ├── src/
│ │ ├── App.tsx
│ │ ├── components/
│ │ │ ├── AppFrame/
│ │ │ │ └── AppFrame.tsx
│ │ │ ├── AudioPlayer/
│ │ │ │ └── AudioPlayer.tsx
│ │ │ ├── AudioStudio/
│ │ │ │ └── .gitkeep
│ │ │ ├── AudioTab/
│ │ │ │ └── AudioTab.tsx
│ │ │ ├── Effects/
│ │ │ │ ├── EffectsChainEditor.tsx
│ │ │ │ └── GenerationPicker.tsx
│ │ │ ├── EffectsTab/
│ │ │ │ ├── EffectsDetail.tsx
│ │ │ │ ├── EffectsList.tsx
│ │ │ │ └── EffectsTab.tsx
│ │ │ ├── Generation/
│ │ │ │ ├── EngineModelSelector.tsx
│ │ │ │ ├── FloatingGenerateBox.tsx
│ │ │ │ ├── GenerationForm.tsx
│ │ │ │ └── ParalinguisticInput.tsx
│ │ │ ├── History/
│ │ │ │ └── HistoryTable.tsx
│ │ │ ├── MainEditor/
│ │ │ │ └── MainEditor.tsx
│ │ │ ├── ModelsTab/
│ │ │ │ └── ModelsTab.tsx
│ │ │ ├── ServerSettings/
│ │ │ │ ├── ConnectionForm.tsx
│ │ │ │ ├── GenerationSettings.tsx
│ │ │ │ ├── GpuAcceleration.tsx
│ │ │ │ ├── ModelManagement.tsx
│ │ │ │ ├── ModelProgress.tsx
│ │ │ │ ├── ServerStatus.tsx
│ │ │ │ └── UpdateStatus.tsx
│ │ │ ├── ServerTab/
│ │ │ │ ├── AboutPage.tsx
│ │ │ │ ├── ChangelogPage.tsx
│ │ │ │ ├── GeneralPage.tsx
│ │ │ │ ├── GenerationPage.tsx
│ │ │ │ ├── GpuPage.tsx
│ │ │ │ ├── LogsPage.tsx
│ │ │ │ ├── ServerTab.tsx
│ │ │ │ └── SettingRow.tsx
│ │ │ ├── ShinyText.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ ├── StoriesTab/
│ │ │ │ ├── StoriesTab.tsx
│ │ │ │ ├── StoryChatItem.tsx
│ │ │ │ ├── StoryContent.tsx
│ │ │ │ ├── StoryList.tsx
│ │ │ │ └── StoryTrackEditor.tsx
│ │ │ ├── TitleBarDragRegion.tsx
│ │ │ ├── VoiceProfiles/
│ │ │ │ ├── AudioSampleRecording.tsx
│ │ │ │ ├── AudioSampleSystem.tsx
│ │ │ │ ├── AudioSampleUpload.tsx
│ │ │ │ ├── ProfileCard.tsx
│ │ │ │ ├── ProfileForm.tsx
│ │ │ │ ├── ProfileList.tsx
│ │ │ │ ├── SampleList.tsx
│ │ │ │ └── SampleUpload.tsx
│ │ │ ├── VoicesTab/
│ │ │ │ ├── VoiceInspector.tsx
│ │ │ │ └── VoicesTab.tsx
│ │ │ └── ui/
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── circle-button.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── form.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── multi-select.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── slider.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── toast.tsx
│ │ │ ├── toaster.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── use-toast.ts
│ │ ├── global.d.ts
│ │ ├── hooks/
│ │ │ ├── useAutoUpdater.ts
│ │ │ └── useAutoUpdater.tsx
│ │ ├── index.css
│ │ ├── lib/
│ │ │ ├── api/
│ │ │ │ ├── .gitkeep
│ │ │ │ ├── client.ts
│ │ │ │ ├── core/
│ │ │ │ │ ├── ApiError.ts
│ │ │ │ │ ├── ApiRequestOptions.ts
│ │ │ │ │ ├── ApiResult.ts
│ │ │ │ │ ├── CancelablePromise.ts
│ │ │ │ │ ├── OpenAPI.ts
│ │ │ │ │ └── request.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── models/
│ │ │ │ │ ├── Body_add_profile_sample_profiles__profile_id__samples_post.ts
│ │ │ │ │ ├── Body_transcribe_audio_transcribe_post.ts
│ │ │ │ │ ├── GenerationRequest.ts
│ │ │ │ │ ├── GenerationResponse.ts
│ │ │ │ │ ├── HTTPValidationError.ts
│ │ │ │ │ ├── HealthResponse.ts
│ │ │ │ │ ├── HistoryListResponse.ts
│ │ │ │ │ ├── HistoryResponse.ts
│ │ │ │ │ ├── ModelDownloadRequest.ts
│ │ │ │ │ ├── ModelStatus.ts
│ │ │ │ │ ├── ModelStatusListResponse.ts
│ │ │ │ │ ├── ProfileSampleResponse.ts
│ │ │ │ │ ├── TranscriptionResponse.ts
│ │ │ │ │ ├── ValidationError.ts
│ │ │ │ │ ├── VoiceProfileCreate.ts
│ │ │ │ │ └── VoiceProfileResponse.ts
│ │ │ │ ├── schemas/
│ │ │ │ │ ├── $Body_add_profile_sample_profiles__profile_id__samples_post.ts
│ │ │ │ │ ├── $Body_transcribe_audio_transcribe_post.ts
│ │ │ │ │ ├── $GenerationRequest.ts
│ │ │ │ │ ├── $GenerationResponse.ts
│ │ │ │ │ ├── $HTTPValidationError.ts
│ │ │ │ │ ├── $HealthResponse.ts
│ │ │ │ │ ├── $HistoryListResponse.ts
│ │ │ │ │ ├── $HistoryResponse.ts
│ │ │ │ │ ├── $ModelDownloadRequest.ts
│ │ │ │ │ ├── $ModelStatus.ts
│ │ │ │ │ ├── $ModelStatusListResponse.ts
│ │ │ │ │ ├── $ProfileSampleResponse.ts
│ │ │ │ │ ├── $TranscriptionResponse.ts
│ │ │ │ │ ├── $ValidationError.ts
│ │ │ │ │ ├── $VoiceProfileCreate.ts
│ │ │ │ │ └── $VoiceProfileResponse.ts
│ │ │ │ ├── services/
│ │ │ │ │ └── DefaultService.ts
│ │ │ │ └── types.ts
│ │ │ ├── constants/
│ │ │ │ ├── languages.ts
│ │ │ │ └── ui.ts
│ │ │ ├── hooks/
│ │ │ │ ├── useAudioPlayer.ts
│ │ │ │ ├── useAudioRecording.ts
│ │ │ │ ├── useGeneration.ts
│ │ │ │ ├── useGenerationForm.ts
│ │ │ │ ├── useGenerationProgress.ts
│ │ │ │ ├── useHistory.ts
│ │ │ │ ├── useModelDownloadToast.tsx
│ │ │ │ ├── useProfiles.ts
│ │ │ │ ├── useRestoreActiveTasks.tsx
│ │ │ │ ├── useServer.ts
│ │ │ │ ├── useStories.ts
│ │ │ │ ├── useStoryPlayback.ts
│ │ │ │ ├── useSystemAudioCapture.ts
│ │ │ │ └── useTranscription.ts
│ │ │ └── utils/
│ │ │ ├── .gitkeep
│ │ │ ├── audio.ts
│ │ │ ├── cn.ts
│ │ │ ├── debug.ts
│ │ │ ├── format.ts
│ │ │ └── parseChangelog.ts
│ │ ├── main.tsx
│ │ ├── platform/
│ │ │ ├── PlatformContext.tsx
│ │ │ └── types.ts
│ │ ├── router.tsx
│ │ ├── stores/
│ │ │ ├── audioChannelStore.ts
│ │ │ ├── effectsStore.ts
│ │ │ ├── generationStore.ts
│ │ │ ├── logStore.ts
│ │ │ ├── playerStore.ts
│ │ │ ├── serverStore.ts
│ │ │ ├── storyStore.ts
│ │ │ └── uiStore.ts
│ │ └── types/
│ │ └── index.ts
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
├── backend/
│ ├── README.md
│ ├── STYLE_GUIDE.md
│ ├── __init__.py
│ ├── app.py
│ ├── backends/
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── chatterbox_backend.py
│ │ ├── chatterbox_turbo_backend.py
│ │ ├── hume_backend.py
│ │ ├── kokoro_backend.py
│ │ ├── luxtts_backend.py
│ │ ├── mlx_backend.py
│ │ └── pytorch_backend.py
│ ├── build_binary.py
│ ├── config.py
│ ├── database/
│ │ ├── __init__.py
│ │ ├── migrations.py
│ │ ├── models.py
│ │ ├── seed.py
│ │ └── session.py
│ ├── main.py
│ ├── models.py
│ ├── pyproject.toml
│ ├── requirements-mlx.txt
│ ├── requirements.txt
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── audio.py
│ │ ├── channels.py
│ │ ├── cuda.py
│ │ ├── effects.py
│ │ ├── generations.py
│ │ ├── health.py
│ │ ├── history.py
│ │ ├── models.py
│ │ ├── profiles.py
│ │ ├── stories.py
│ │ ├── tasks.py
│ │ └── transcription.py
│ ├── server.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── channels.py
│ │ ├── cuda.py
│ │ ├── effects.py
│ │ ├── export_import.py
│ │ ├── generation.py
│ │ ├── history.py
│ │ ├── profiles.py
│ │ ├── stories.py
│ │ ├── task_queue.py
│ │ ├── transcribe.py
│ │ ├── tts.py
│ │ └── versions.py
│ ├── tests/
│ │ ├── README.md
│ │ ├── __init__.py
│ │ ├── test_cors.py
│ │ ├── test_generation_download.py
│ │ ├── test_profile_duplicate_names.py
│ │ ├── test_progress.py
│ │ ├── test_qwen_download.py
│ │ └── test_whisper_download.py
│ └── utils/
│ ├── __init__.py
│ ├── audio.py
│ ├── cache.py
│ ├── chunked_tts.py
│ ├── dac_shim.py
│ ├── effects.py
│ ├── hf_offline_patch.py
│ ├── hf_progress.py
│ ├── images.py
│ ├── platform_detect.py
│ ├── progress.py
│ └── tasks.py
├── biome.json
├── data/
│ └── .gitkeep
├── docker-compose.yml
├── docs/
│ ├── .gitignore
│ ├── README.md
│ ├── app/
│ │ ├── [[...slug]]/
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── api/
│ │ │ └── search/
│ │ │ └── route.ts
│ │ ├── global.css
│ │ ├── layout.tsx
│ │ ├── llms-full.txt/
│ │ │ └── route.ts
│ │ ├── llms.mdx/
│ │ │ └── docs/
│ │ │ └── [[...slug]]/
│ │ │ └── route.ts
│ │ └── og/
│ │ └── docs/
│ │ └── [...slug]/
│ │ └── route.tsx
│ ├── cli.json
│ ├── components/
│ │ ├── ai/
│ │ │ └── page-actions.tsx
│ │ ├── api-page.client.tsx
│ │ ├── api-page.tsx
│ │ └── ui/
│ │ ├── button.tsx
│ │ └── popover.tsx
│ ├── content/
│ │ └── docs/
│ │ ├── README.md
│ │ ├── TROUBLESHOOTING.md
│ │ ├── api-reference/
│ │ │ ├── general/
│ │ │ │ ├── health_health_get.mdx
│ │ │ │ ├── meta.json
│ │ │ │ └── root__get.mdx
│ │ │ ├── generation/
│ │ │ │ ├── generate_speech_generate_post.mdx
│ │ │ │ ├── get_audio_audio__generation_id__get.mdx
│ │ │ │ ├── meta.json
│ │ │ │ └── transcribe_audio_transcribe_post.mdx
│ │ │ ├── history/
│ │ │ │ ├── delete_generation_history__generation_id__delete.mdx
│ │ │ │ ├── get_generation_history__generation_id__get.mdx
│ │ │ │ ├── get_stats_history_stats_get.mdx
│ │ │ │ ├── list_history_history_get.mdx
│ │ │ │ └── meta.json
│ │ │ ├── meta.json
│ │ │ ├── models/
│ │ │ │ ├── get_model_progress_models_progress__model_name__get.mdx
│ │ │ │ ├── get_model_status_models_status_get.mdx
│ │ │ │ ├── load_model_models_load_post.mdx
│ │ │ │ ├── meta.json
│ │ │ │ ├── trigger_model_download_models_download_post.mdx
│ │ │ │ └── unload_model_models_unload_post.mdx
│ │ │ └── profiles/
│ │ │ ├── add_profile_sample_profiles__profile_id__samples_post.mdx
│ │ │ ├── create_profile_profiles_post.mdx
│ │ │ ├── delete_profile_profiles__profile_id__delete.mdx
│ │ │ ├── delete_profile_sample_profiles_samples__sample_id__delete.mdx
│ │ │ ├── get_profile_profiles__profile_id__get.mdx
│ │ │ ├── get_profile_samples_profiles__profile_id__samples_get.mdx
│ │ │ ├── list_profiles_profiles_get.mdx
│ │ │ ├── meta.json
│ │ │ └── update_profile_profiles__profile_id__put.mdx
│ │ ├── developer/
│ │ │ ├── architecture.mdx
│ │ │ ├── audio-channels.mdx
│ │ │ ├── autoupdater.mdx
│ │ │ ├── building.mdx
│ │ │ ├── contributing.mdx
│ │ │ ├── effects-pipeline.mdx
│ │ │ ├── history.mdx
│ │ │ ├── meta.json
│ │ │ ├── model-management.mdx
│ │ │ ├── setup.mdx
│ │ │ ├── stories.mdx
│ │ │ ├── transcription.mdx
│ │ │ ├── tts-engines.mdx
│ │ │ ├── tts-generation.mdx
│ │ │ └── voice-profiles.mdx
│ │ ├── index.mdx
│ │ ├── meta.json
│ │ └── overview/
│ │ ├── building-stories.mdx
│ │ ├── creating-voice-profiles.mdx
│ │ ├── docker.mdx
│ │ ├── generating-speech.mdx
│ │ ├── generation-history.mdx
│ │ ├── installation.mdx
│ │ ├── introduction.mdx
│ │ ├── meta.json
│ │ ├── quick-start.mdx
│ │ ├── recording-transcription.mdx
│ │ ├── remote-mode.mdx
│ │ ├── stories-editor.mdx
│ │ ├── troubleshooting.mdx
│ │ └── voice-cloning.mdx
│ ├── lib/
│ │ ├── cn.ts
│ │ ├── layout.shared.tsx
│ │ ├── openapi.ts
│ │ └── source.ts
│ ├── mdx-components.tsx
│ ├── next.config.mjs
│ ├── notes/
│ │ ├── BACKEND_CODE_REVIEW.md
│ │ ├── MIGRATION.md
│ │ ├── PROJECT_STATUS.md
│ │ ├── RELEASE_v0.2.0.md
│ │ └── issue-pain-points.md
│ ├── openapi.json
│ ├── package.json
│ ├── plans/
│ │ ├── API_REFACTOR_PLAN.md
│ │ ├── CUDA_LIBS_ADDON.md
│ │ ├── DOCKER_DEPLOYMENT.md
│ │ └── OPENAI_SUPPORT.md
│ ├── postcss.config.mjs
│ ├── scripts/
│ │ └── generate-openapi.ts
│ ├── source.config.ts
│ └── tsconfig.json
├── justfile
├── landing/
│ ├── .gitignore
│ ├── README.md
│ ├── components.json
│ ├── next.config.js
│ ├── nixpacks.toml
│ ├── package.json
│ ├── postcss.config.js
│ ├── public/
│ │ ├── audio/
│ │ │ ├── fireship.webm
│ │ │ ├── jarvis.webm
│ │ │ ├── linus.webm
│ │ │ ├── morganfreeman.webm
│ │ │ ├── samaltman.webm
│ │ │ └── samjackson.webm
│ │ └── voicebox-demo.webm
│ ├── src/
│ │ ├── app/
│ │ │ ├── api/
│ │ │ │ ├── releases/
│ │ │ │ │ └── route.ts
│ │ │ │ └── stars/
│ │ │ │ └── route.ts
│ │ │ ├── download/
│ │ │ │ └── [platform]/
│ │ │ │ └── route.ts
│ │ │ ├── globals.css
│ │ │ ├── layout.tsx
│ │ │ ├── linux-install/
│ │ │ │ └── page.tsx
│ │ │ ├── og/
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── components/
│ │ │ ├── Banner.tsx
│ │ │ ├── ControlUI.tsx
│ │ │ ├── DownloadSection.tsx
│ │ │ ├── Features.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Header.tsx
│ │ │ ├── LandingAudioPlayer.tsx
│ │ │ ├── Navbar.tsx
│ │ │ ├── PlatformIcons.tsx
│ │ │ ├── VoiceCreator.tsx
│ │ │ └── ui/
│ │ │ ├── button.tsx
│ │ │ ├── card.tsx
│ │ │ ├── feature-card.tsx
│ │ │ ├── hero.tsx
│ │ │ ├── section.tsx
│ │ │ └── separator.tsx
│ │ └── lib/
│ │ ├── constants.ts
│ │ ├── releases.ts
│ │ └── utils.ts
│ ├── tailwind.config.js
│ └── tsconfig.json
├── package.json
├── requirements.txt
├── scripts/
│ ├── build-server.sh
│ ├── convert-assets.sh
│ ├── generate-api.sh
│ ├── package_cuda.py
│ ├── prepare-release.sh
│ ├── setup-dev-sidecar.js
│ ├── test_download_progress.py
│ └── update-icons.sh
├── tauri/
│ ├── assets/
│ │ └── voicebox.icon/
│ │ └── icon.json
│ ├── index.html
│ ├── package.json
│ ├── src/
│ │ ├── main.tsx
│ │ └── platform/
│ │ ├── audio.ts
│ │ ├── filesystem.ts
│ │ ├── index.ts
│ │ ├── lifecycle.ts
│ │ ├── metadata.ts
│ │ └── updater.ts
│ ├── src-tauri/
│ │ ├── Cargo.toml
│ │ ├── Entitlements.plist
│ │ ├── Info.plist
│ │ ├── build.rs
│ │ ├── capabilities/
│ │ │ └── default.json
│ │ ├── gen/
│ │ │ └── schemas/
│ │ │ ├── acl-manifests.json
│ │ │ ├── capabilities.json
│ │ │ ├── desktop-schema.json
│ │ │ ├── macOS-schema.json
│ │ │ └── windows-schema.json
│ │ ├── icons/
│ │ │ ├── android/
│ │ │ │ ├── mipmap-anydpi-v26/
│ │ │ │ │ └── ic_launcher.xml
│ │ │ │ └── values/
│ │ │ │ └── ic_launcher_background.xml
│ │ │ └── icon.icns
│ │ ├── src/
│ │ │ ├── audio_capture/
│ │ │ │ ├── linux.rs
│ │ │ │ ├── macos.rs
│ │ │ │ ├── mod.rs
│ │ │ │ └── windows.rs
│ │ │ ├── audio_output.rs
│ │ │ ├── lib.rs
│ │ │ └── main.rs
│ │ ├── tauri.conf.json
│ │ └── tests/
│ │ └── audio_capture_test.rs
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ └── vite.config.ts
└── web/
├── index.html
├── package.json
├── src/
│ ├── main.tsx
│ └── platform/
│ ├── audio.ts
│ ├── filesystem.ts
│ ├── index.ts
│ ├── lifecycle.ts
│ ├── metadata.ts
│ └── updater.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
SYMBOL INDEX (1248 symbols across 240 files)
FILE: app/plugins/changelog.ts
function changelogPlugin (line 6) | function changelogPlugin(repoRoot: string): Plugin {
FILE: app/src/App.tsx
constant LOADING_MESSAGES (line 14) | const LOADING_MESSAGES = [
function App (line 37) | function App() {
FILE: app/src/components/AppFrame/AppFrame.tsx
type AppFrameProps (line 10) | interface AppFrameProps {
function AppFrame (line 14) | function AppFrame({ children }: AppFrameProps) {
FILE: app/src/components/AudioPlayer/AudioPlayer.tsx
function AudioPlayer (line 13) | function AudioPlayer() {
FILE: app/src/components/AudioTab/AudioTab.tsx
type AudioDevice (line 29) | interface AudioDevice {
function AudioTab (line 35) | function AudioTab() {
function ChannelVoicesList (line 396) | function ChannelVoicesList({ channelId }: { channelId: string }) {
type CreateChannelDialogProps (line 425) | interface CreateChannelDialogProps {
function CreateChannelDialog (line 432) | function CreateChannelDialog({ open, onOpenChange, devices, onCreate }: ...
type EditChannelDialogProps (line 523) | interface EditChannelDialogProps {
function EditChannelDialog (line 538) | function EditChannelDialog({
FILE: app/src/components/Effects/EffectsChainEditor.tsx
type EffectWithId (line 36) | interface EffectWithId extends EffectConfig {
function makeId (line 41) | function makeId() {
type EffectsChainEditorProps (line 45) | interface EffectsChainEditorProps {
function EffectsChainEditor (line 52) | function EffectsChainEditor({
type SortableEffectItemProps (line 251) | interface SortableEffectItemProps {
function SortableEffectItem (line 263) | function SortableEffectItem({
FILE: app/src/components/Effects/GenerationPicker.tsx
type GenerationPickerProps (line 10) | interface GenerationPickerProps {
function GenerationPicker (line 16) | function GenerationPicker({ selectedId, onSelect, className }: Generatio...
FILE: app/src/components/EffectsTab/EffectsDetail.tsx
function EffectsDetail (line 27) | function EffectsDetail() {
FILE: app/src/components/EffectsTab/EffectsList.tsx
function EffectsList (line 9) | function EffectsList() {
function PresetCard (line 116) | function PresetCard({
FILE: app/src/components/EffectsTab/EffectsTab.tsx
function EffectsTab (line 4) | function EffectsTab() {
FILE: app/src/components/Generation/EngineModelSelector.tsx
constant ENGINE_OPTIONS (line 19) | const ENGINE_OPTIONS = [
constant ENGINE_DESCRIPTIONS (line 30) | const ENGINE_DESCRIPTIONS: Record<string, string> = {
constant ENGLISH_ONLY_ENGINES (line 40) | const ENGLISH_ONLY_ENGINES = new Set(['luxtts', 'chatterbox_turbo']);
constant CLONING_ENGINES (line 43) | const CLONING_ENGINES = new Set(['qwen', 'luxtts', 'chatterbox', 'chatte...
function getAvailableOptions (line 45) | function getAvailableOptions(selectedProfile?: VoiceProfileResponse | nu...
function getSelectValue (line 50) | function getSelectValue(engine: string, modelSize?: string): string {
function handleEngineChange (line 56) | function handleEngineChange(form: UseFormReturn<GenerationFormValues>, v...
type EngineModelSelectorProps (line 97) | interface EngineModelSelectorProps {
function EngineModelSelector (line 103) | function EngineModelSelector({ form, compact, selectedProfile }: EngineM...
function getEngineDescription (line 141) | function getEngineDescription(engine: string): string {
function isProfileCompatibleWithEngine (line 149) | function isProfileCompatibleWithEngine(
FILE: app/src/components/Generation/FloatingGenerateBox.tsx
type FloatingGenerateBoxProps (line 28) | interface FloatingGenerateBoxProps {
function FloatingGenerateBox (line 33) | function FloatingGenerateBox({
FILE: app/src/components/Generation/GenerationForm.tsx
function GenerationForm (line 29) | function GenerationForm() {
FILE: app/src/components/Generation/ParalinguisticInput.tsx
constant PARALINGUISTIC_TAGS (line 16) | const PARALINGUISTIC_TAGS = [
constant TAG_REGEX (line 28) | const TAG_REGEX = /\[(laugh|chuckle|gasp|cough|sigh|groan|sniff|shush|cl...
constant BADGE_ATTR (line 31) | const BADGE_ATTR = 'data-ptag';
function makeBadgeHTML (line 36) | function makeBadgeHTML(tag: string): string {
function textToHTML (line 46) | function textToHTML(text: string): string {
function htmlToText (line 54) | function htmlToText(container: HTMLElement): string {
function getWordBeforeCaret (line 77) | function getWordBeforeCaret(_container: HTMLElement): { word: string; ra...
type ParalinguisticInputProps (line 109) | interface ParalinguisticInputProps {
type ParalinguisticInputRef (line 120) | interface ParalinguisticInputRef {
FILE: app/src/components/History/HistoryTable.tsx
function AudioBars (line 61) | function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
function HistoryTable (line 90) | function HistoryTable() {
FILE: app/src/components/MainEditor/MainEditor.tsx
function MainEditor (line 22) | function MainEditor() {
FILE: app/src/components/ModelsTab/ModelsTab.tsx
function ModelsTab (line 3) | function ModelsTab() {
FILE: app/src/components/ServerSettings/ConnectionForm.tsx
type ConnectionFormValues (line 29) | type ConnectionFormValues = z.infer<typeof connectionSchema>;
function ConnectionForm (line 31) | function ConnectionForm() {
FILE: app/src/components/ServerSettings/GenerationSettings.tsx
function GenerationSettings (line 6) | function GenerationSettings() {
FILE: app/src/components/ServerSettings/GpuAcceleration.tsx
type RestartPhase (line 13) | type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
function GpuAcceleration (line 15) | function GpuAcceleration() {
FILE: app/src/components/ServerSettings/ModelManagement.tsx
function fetchHuggingFaceModelInfo (line 48) | async function fetchHuggingFaceModelInfo(repoId: string): Promise<Huggin...
constant MODEL_DESCRIPTIONS (line 54) | const MODEL_DESCRIPTIONS: Record<string, string> = {
function formatDownloads (line 83) | function formatDownloads(n: number): string {
function formatLicense (line 89) | function formatLicense(license: string): string {
function formatPipelineTag (line 102) | function formatPipelineTag(tag: string): string {
function formatBytes (line 109) | function formatBytes(bytes: number): string {
function ModelManagement (line 117) | function ModelManagement() {
type ModelItemProps (line 1062) | interface ModelItemProps {
function ModelItem (line 1077) | function ModelItem({ model, onDownload, onDelete, isDownloading, formatS...
FILE: app/src/components/ServerSettings/ModelProgress.tsx
type ModelProgressProps (line 8) | interface ModelProgressProps {
function ModelProgress (line 15) | function ModelProgress({ modelName, displayName, isDownloading = false }...
FILE: app/src/components/ServerSettings/ServerStatus.tsx
function ServerStatus (line 7) | function ServerStatus() {
FILE: app/src/components/ServerSettings/UpdateStatus.tsx
function UpdateStatus (line 10) | function UpdateStatus() {
FILE: app/src/components/ServerTab/AboutPage.tsx
function FadeIn (line 7) | function FadeIn({ delay = 0, children }: { delay?: number; children: Rea...
function AboutPage (line 18) | function AboutPage() {
FILE: app/src/components/ServerTab/ChangelogPage.tsx
function renderMarkdown (line 6) | function renderMarkdown(md: string): React.ReactNode[] {
function renderTable (line 83) | function renderTable(tableLines: string[], keyBase: number): React.React...
function inlineMarkdown (line 125) | function inlineMarkdown(text: string): React.ReactNode {
function ChangelogEntryCard (line 178) | function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
function ChangelogPage (line 210) | function ChangelogPage() {
FILE: app/src/components/ServerTab/GeneralPage.tsx
type ConnectionFormValues (line 22) | type ConnectionFormValues = z.infer<typeof connectionSchema>;
function GeneralPage (line 24) | function GeneralPage() {
function ConnectionStatus (line 186) | function ConnectionStatus({
function UpdatesSection (line 228) | function UpdatesSection() {
constant API_ENDPOINTS (line 335) | const API_ENDPOINTS = [
function ApiReferenceCard (line 342) | function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
FILE: app/src/components/ServerTab/GenerationPage.tsx
function GenerationPage (line 10) | function GenerationPage() {
FILE: app/src/components/ServerTab/GpuPage.tsx
type RestartPhase (line 13) | type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
function AppleLogo (line 15) | function AppleLogo({ className }: { className?: string }) {
function GpuIcon (line 23) | function GpuIcon({ className }: { className?: string }) {
function GpuInfoCard (line 42) | function GpuInfoCard({ health }: { health: HealthResponse }) {
function GpuPage (line 104) | function GpuPage() {
FILE: app/src/components/ServerTab/LogsPage.tsx
function formatTime (line 6) | function formatTime(timestamp: number): string {
function LogLine (line 16) | function LogLine({ entry }: { entry: LogEntry }) {
function LogsPage (line 34) | function LogsPage() {
FILE: app/src/components/ServerTab/ServerTab.tsx
type SettingsTab (line 7) | interface SettingsTab {
function SettingsLayout (line 28) | function SettingsLayout() {
FILE: app/src/components/ServerTab/SettingRow.tsx
function SettingSection (line 6) | function SettingSection({
function SettingRow (line 30) | function SettingRow({
FILE: app/src/components/ShinyText.tsx
type ShinyTextProps (line 5) | interface ShinyTextProps {
FILE: app/src/components/Sidebar.tsx
type SidebarProps (line 11) | interface SidebarProps {
function Sidebar (line 25) | function Sidebar({ isMacOS }: SidebarProps) {
FILE: app/src/components/StoriesTab/StoriesTab.tsx
function StoriesTab (line 6) | function StoriesTab() {
FILE: app/src/components/StoriesTab/StoryChatItem.tsx
type StoryChatItemProps (line 18) | interface StoryChatItemProps {
function StoryChatItem (line 29) | function StoryChatItem({
function SortableStoryChatItem (line 142) | function SortableStoryChatItem(props: Omit<StoryChatItemProps, 'dragHand...
FILE: app/src/components/StoriesTab/StoryContent.tsx
function StoryContent (line 38) | function StoryContent() {
FILE: app/src/components/StoriesTab/StoryList.tsx
function StoryList (line 43) | function StoryList() {
FILE: app/src/components/StoriesTab/StoryTrackEditor.tsx
function ClipWaveform (line 38) | function ClipWaveform({
type StoryTrackEditorProps (line 121) | interface StoryTrackEditorProps {
constant TRACK_HEIGHT (line 126) | const TRACK_HEIGHT = 48;
constant TIME_RULER_HEIGHT (line 127) | const TIME_RULER_HEIGHT = 24;
constant MIN_PIXELS_PER_SECOND (line 128) | const MIN_PIXELS_PER_SECOND = 10;
constant MAX_PIXELS_PER_SECOND (line 129) | const MAX_PIXELS_PER_SECOND = 200;
constant DEFAULT_PIXELS_PER_SECOND (line 130) | const DEFAULT_PIXELS_PER_SECOND = 50;
constant DEFAULT_TRACKS (line 131) | const DEFAULT_TRACKS = [1, 0, -1];
constant MIN_EDITOR_HEIGHT (line 132) | const MIN_EDITOR_HEIGHT = 120;
constant MAX_EDITOR_HEIGHT (line 133) | const MAX_EDITOR_HEIGHT = 500;
function StoryTrackEditor (line 135) | function StoryTrackEditor({ storyId, items }: StoryTrackEditorProps) {
FILE: app/src/components/TitleBarDragRegion.tsx
function TitleBarDragRegion (line 3) | function TitleBarDragRegion() {
FILE: app/src/components/VoiceProfiles/AudioSampleRecording.tsx
type AudioSampleRecordingProps (line 29) | interface AudioSampleRecordingProps {
function AudioSampleRecording (line 43) | function AudioSampleRecording({
FILE: app/src/components/VoiceProfiles/AudioSampleSystem.tsx
type AudioSampleSystemProps (line 6) | interface AudioSampleSystemProps {
function AudioSampleSystem (line 19) | function AudioSampleSystem({
FILE: app/src/components/VoiceProfiles/AudioSampleUpload.tsx
type AudioSampleUploadProps (line 6) | interface AudioSampleUploadProps {
function AudioSampleUpload (line 18) | function AudioSampleUpload({
FILE: app/src/components/VoiceProfiles/ProfileCard.tsx
type ProfileCardProps (line 20) | interface ProfileCardProps {
function ProfileCard (line 24) | function ProfileCard({ profile }: ProfileCardProps) {
FILE: app/src/components/VoiceProfiles/ProfileForm.tsx
constant MAX_AUDIO_DURATION_SECONDS (line 62) | const MAX_AUDIO_DURATION_SECONDS = 30;
constant PRESET_ONLY_ENGINES (line 63) | const PRESET_ONLY_ENGINES = new Set(['kokoro']);
constant DEFAULT_ENGINE_OPTIONS (line 64) | const DEFAULT_ENGINE_OPTIONS = [
type ProfileFormValues (line 96) | type ProfileFormValues = z.infer<typeof profileSchema>;
function fileToBase64 (line 99) | async function fileToBase64(file: File): Promise<string> {
function base64ToFile (line 109) | function base64ToFile(base64: string, fileName: string, fileType: string...
function ProfileForm (line 120) | function ProfileForm() {
FILE: app/src/components/VoiceProfiles/ProfileList.tsx
constant PRESET_ENGINES (line 10) | const PRESET_ENGINES = new Set(['kokoro']);
constant ENGINE_NAMES (line 13) | const ENGINE_NAMES: Record<string, string> = {
function ProfileList (line 17) | function ProfileList() {
FILE: app/src/components/VoiceProfiles/SampleList.tsx
type MiniSamplePlayerProps (line 22) | interface MiniSamplePlayerProps {
function MiniSamplePlayer (line 26) | function MiniSamplePlayer({ audioUrl }: MiniSamplePlayerProps) {
type SampleListProps (line 143) | interface SampleListProps {
function SampleList (line 147) | function SampleList({ profileId }: SampleListProps) {
FILE: app/src/components/VoiceProfiles/SampleUpload.tsx
type SampleFormValues (line 43) | type SampleFormValues = z.infer<typeof sampleSchema>;
type SampleUploadProps (line 45) | interface SampleUploadProps {
function SampleUpload (line 51) | function SampleUpload({ profileId, open, onOpenChange }: SampleUploadPro...
FILE: app/src/components/VoicesTab/VoiceInspector.tsx
type ProfileFormValues (line 47) | type ProfileFormValues = z.infer<typeof profileSchema>;
type VoiceInspectorProps (line 49) | interface VoiceInspectorProps {
function VoiceInspector (line 53) | function VoiceInspector({ profileId }: VoiceInspectorProps) {
FILE: app/src/components/VoicesTab/VoicesTab.tsx
function VoicesTab (line 27) | function VoicesTab() {
type VoiceRowProps (line 180) | interface VoiceRowProps {
function VoiceRow (line 189) | function VoiceRow({
FILE: app/src/components/ui/badge.tsx
type BadgeProps (line 24) | interface BadgeProps
function Badge (line 28) | function Badge({ className, variant, ...props }: BadgeProps) {
FILE: app/src/components/ui/button.tsx
type ButtonProps (line 32) | interface ButtonProps
FILE: app/src/components/ui/checkbox.tsx
type CheckboxProps (line 5) | interface CheckboxProps {
FILE: app/src/components/ui/circle-button.tsx
type CircleButtonProps (line 4) | interface CircleButtonProps extends React.ButtonHTMLAttributes<HTMLButto...
FILE: app/src/components/ui/form.tsx
type FormFieldContextValue (line 17) | type FormFieldContextValue<
type FormItemContextValue (line 62) | type FormItemContextValue = {
FILE: app/src/components/ui/input.tsx
type InputProps (line 4) | interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
FILE: app/src/components/ui/multi-select.tsx
type MultiSelectOption (line 11) | interface MultiSelectOption {
type MultiSelectProps (line 16) | interface MultiSelectProps {
function MultiSelect (line 47) | function MultiSelect({
FILE: app/src/components/ui/textarea.tsx
type TextareaProps (line 4) | interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAre...
FILE: app/src/components/ui/toast.tsx
type ToastProps (line 107) | type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement (line 109) | type ToastActionElement = React.ReactElement<typeof ToastAction>;
FILE: app/src/components/ui/toaster.tsx
function Toaster (line 12) | function Toaster() {
FILE: app/src/components/ui/toggle.tsx
type ToggleProps (line 4) | interface ToggleProps {
FILE: app/src/components/ui/use-toast.ts
constant TOAST_LIMIT (line 4) | const TOAST_LIMIT = 1;
constant TOAST_REMOVE_DELAY (line 5) | const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast (line 7) | type ToasterToast = ToastProps & {
function genId (line 23) | function genId() {
type ActionType (line 28) | type ActionType = typeof actionTypes;
type Action (line 30) | type Action =
type State (line 48) | interface State {
function dispatch (line 125) | function dispatch(action: Action) {
type Toast (line 132) | type Toast = Omit<ToasterToast, 'id'>;
function toast (line 134) | function toast({ ...props }: Toast) {
function useToast (line 163) | function useToast() {
FILE: app/src/global.d.ts
type Window (line 1) | interface Window {
FILE: app/src/hooks/useAutoUpdater.ts
function useAutoUpdater (line 8) | function useAutoUpdater(checkOnMount = false) {
FILE: app/src/hooks/useAutoUpdater.tsx
type UseAutoUpdaterOptions (line 12) | interface UseAutoUpdaterOptions {
function useAutoUpdater (line 17) | function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
FILE: app/src/lib/api/client.ts
function formatErrorDetail (line 39) | function formatErrorDetail(detail: unknown, fallback: string): string {
class ApiClient (line 54) | class ApiClient {
method getBaseUrl (line 55) | private getBaseUrl(): string {
method request (line 60) | private async request<T>(endpoint: string, options?: RequestInit): Pro...
method getHealth (line 81) | async getHealth(): Promise<HealthResponse> {
method createProfile (line 86) | async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileRes...
method listProfiles (line 93) | async listProfiles(): Promise<VoiceProfileResponse[]> {
method getProfile (line 97) | async getProfile(profileId: string): Promise<VoiceProfileResponse> {
method listPresetVoices (line 101) | async listPresetVoices(engine: string): Promise<{ engine: string; voic...
method seedPresetProfiles (line 105) | async seedPresetProfiles(
method updateProfile (line 111) | async updateProfile(profileId: string, data: VoiceProfileCreate): Prom...
method deleteProfile (line 118) | async deleteProfile(profileId: string): Promise<void> {
method addProfileSample (line 124) | async addProfileSample(
method listProfileSamples (line 149) | async listProfileSamples(profileId: string): Promise<ProfileSampleResp...
method deleteProfileSample (line 153) | async deleteProfileSample(sampleId: string): Promise<void> {
method updateProfileSample (line 159) | async updateProfileSample(
method exportProfile (line 169) | async exportProfile(profileId: string): Promise<Blob> {
method importProfile (line 183) | async importProfile(file: File): Promise<VoiceProfileResponse> {
method uploadAvatar (line 203) | async uploadAvatar(profileId: string, file: File): Promise<VoiceProfil...
method deleteAvatar (line 223) | async deleteAvatar(profileId: string): Promise<void> {
method generateSpeech (line 230) | async generateSpeech(data: GenerationRequest): Promise<GenerationRespo...
method retryGeneration (line 237) | async retryGeneration(generationId: string): Promise<GenerationRespons...
method regenerateGeneration (line 243) | async regenerateGeneration(generationId: string): Promise<GenerationRe...
method toggleFavorite (line 249) | async toggleFavorite(generationId: string): Promise<{ is_favorited: bo...
method listHistory (line 256) | async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
method getGeneration (line 269) | async getGeneration(generationId: string): Promise<HistoryResponse> {
method deleteGeneration (line 273) | async deleteGeneration(generationId: string): Promise<void> {
method exportGeneration (line 279) | async exportGeneration(generationId: string): Promise<Blob> {
method exportGenerationAudio (line 293) | async exportGenerationAudio(generationId: string): Promise<Blob> {
method importGeneration (line 307) | async importGeneration(file: File): Promise<{
method getGenerationStatusUrl (line 334) | getGenerationStatusUrl(generationId: string): string {
method getAudioUrl (line 339) | getAudioUrl(audioId: string): string {
method getSampleUrl (line 343) | getSampleUrl(sampleId: string): string {
method transcribeAudio (line 348) | async transcribeAudio(
method getModelStatus (line 379) | async getModelStatus(): Promise<ModelStatusListResponse> {
method getModelsCacheDir (line 383) | async getModelsCacheDir(): Promise<{ path: string }> {
method migrateModels (line 387) | async migrateModels(destination: string): Promise<{ source: string; de...
method getMigrationProgressUrl (line 394) | getMigrationProgressUrl(): string {
method triggerModelDownload (line 398) | async triggerModelDownload(modelName: string): Promise<{ message: stri...
method deleteModel (line 413) | async deleteModel(modelName: string): Promise<{ message: string }> {
method unloadModel (line 419) | async unloadModel(modelName: string): Promise<{ message: string }> {
method cancelDownload (line 425) | async cancelDownload(modelName: string): Promise<{ message: string }> {
method getActiveTasks (line 433) | async getActiveTasks(): Promise<ActiveTasksResponse> {
method clearAllTasks (line 437) | async clearAllTasks(): Promise<{ message: string }> {
method listChannels (line 442) | async listChannels(): Promise<
method createChannel (line 454) | async createChannel(data: { name: string; device_ids: string[] }): Pro...
method updateChannel (line 467) | async updateChannel(
method deleteChannel (line 486) | async deleteChannel(channelId: string): Promise<{ message: string }> {
method getChannelVoices (line 492) | async getChannelVoices(channelId: string): Promise<{ profile_ids: stri...
method setChannelVoices (line 496) | async setChannelVoices(channelId: string, profileIds: string[]): Promi...
method getProfileChannels (line 503) | async getProfileChannels(profileId: string): Promise<{ channel_ids: st...
method setProfileChannels (line 507) | async setProfileChannels(profileId: string, channelIds: string[]): Pro...
method getCudaStatus (line 515) | async getCudaStatus(): Promise<CudaStatus> {
method downloadCudaBackend (line 519) | async downloadCudaBackend(): Promise<{ message: string; progress_key: ...
method deleteCudaBackend (line 525) | async deleteCudaBackend(): Promise<{ message: string }> {
method listStories (line 532) | async listStories(): Promise<StoryResponse[]> {
method createStory (line 536) | async createStory(data: StoryCreate): Promise<StoryResponse> {
method getStory (line 543) | async getStory(storyId: string): Promise<StoryDetailResponse> {
method updateStory (line 547) | async updateStory(storyId: string, data: StoryCreate): Promise<StoryRe...
method deleteStory (line 554) | async deleteStory(storyId: string): Promise<void> {
method addStoryItem (line 560) | async addStoryItem(storyId: string, data: StoryItemCreate): Promise<St...
method removeStoryItem (line 567) | async removeStoryItem(storyId: string, itemId: string): Promise<void> {
method updateStoryItemTimes (line 573) | async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate...
method reorderStoryItems (line 580) | async reorderStoryItems(storyId: string, data: StoryItemReorder): Prom...
method moveStoryItem (line 587) | async moveStoryItem(
method trimStoryItem (line 598) | async trimStoryItem(
method splitStoryItem (line 609) | async splitStoryItem(
method duplicateStoryItem (line 620) | async duplicateStoryItem(storyId: string, itemId: string): Promise<Sto...
method setStoryItemVersion (line 626) | async setStoryItemVersion(
method exportStoryAudio (line 637) | async exportStoryAudio(storyId: string): Promise<Blob> {
method getAvailableEffects (line 652) | async getAvailableEffects(): Promise<AvailableEffectsResponse> {
method listEffectPresets (line 656) | async listEffectPresets(): Promise<EffectPresetResponse[]> {
method createEffectPreset (line 660) | async createEffectPreset(data: EffectPresetCreate): Promise<EffectPres...
method updateEffectPreset (line 667) | async updateEffectPreset(
method deleteEffectPreset (line 677) | async deleteEffectPreset(presetId: string): Promise<void> {
method listGenerationVersions (line 683) | async listGenerationVersions(generationId: string): Promise<Generation...
method applyEffectsToGeneration (line 687) | async applyEffectsToGeneration(
method setDefaultVersion (line 700) | async setDefaultVersion(
method deleteGenerationVersion (line 710) | async deleteGenerationVersion(generationId: string, versionId: string)...
method getVersionAudioUrl (line 716) | getVersionAudioUrl(versionId: string): string {
method updateProfileEffects (line 720) | async updateProfileEffects(
method previewEffects (line 730) | async previewEffects(generationId: string, effectsChain: EffectConfig[...
FILE: app/src/lib/api/core/ApiError.ts
class ApiError (line 8) | class ApiError extends Error {
method constructor (line 15) | constructor(request: ApiRequestOptions, response: ApiResult, message: ...
FILE: app/src/lib/api/core/ApiRequestOptions.ts
type ApiRequestOptions (line 5) | type ApiRequestOptions = {
FILE: app/src/lib/api/core/ApiResult.ts
type ApiResult (line 5) | type ApiResult = {
FILE: app/src/lib/api/core/CancelablePromise.ts
class CancelError (line 5) | class CancelError extends Error {
method constructor (line 6) | constructor(message: string) {
method isCancelled (line 11) | public get isCancelled(): boolean {
type OnCancel (line 16) | interface OnCancel {
class CancelablePromise (line 24) | class CancelablePromise<T> implements Promise<T> {
method constructor (line 33) | constructor(
method then (line 91) | public then<TResult1 = T, TResult2 = never>(
method catch (line 98) | public catch<TResult = never>(
method finally (line 104) | public finally(onFinally?: (() => void) | null): Promise<T> {
method cancel (line 108) | public cancel(): void {
method isCancelled (line 127) | public get isCancelled(): boolean {
method [Symbol.toStringTag] (line 87) | get [Symbol.toStringTag]() {
FILE: app/src/lib/api/core/OpenAPI.ts
type Resolver (line 7) | type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
type Headers (line 8) | type Headers = Record<string, string>;
type OpenAPIConfig (line 10) | type OpenAPIConfig = {
FILE: app/src/lib/api/core/request.ts
type Resolver (line 132) | type Resolver<T> = (options: ApiRequestOptions) => Promise<T>;
FILE: app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts
type Body_add_profile_sample_profiles__profile_id__samples_post (line 5) | type Body_add_profile_sample_profiles__profile_id__samples_post = {
FILE: app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts
type Body_transcribe_audio_transcribe_post (line 5) | type Body_transcribe_audio_transcribe_post = {
FILE: app/src/lib/api/models/GenerationRequest.ts
type GenerationRequest (line 8) | type GenerationRequest = {
FILE: app/src/lib/api/models/GenerationResponse.ts
type GenerationResponse (line 8) | type GenerationResponse = {
FILE: app/src/lib/api/models/HTTPValidationError.ts
type HTTPValidationError (line 6) | type HTTPValidationError = {
FILE: app/src/lib/api/models/HealthResponse.ts
type HealthResponse (line 8) | type HealthResponse = {
FILE: app/src/lib/api/models/HistoryListResponse.ts
type HistoryListResponse (line 9) | type HistoryListResponse = {
FILE: app/src/lib/api/models/HistoryResponse.ts
type HistoryResponse (line 8) | type HistoryResponse = {
FILE: app/src/lib/api/models/ModelDownloadRequest.ts
type ModelDownloadRequest (line 8) | type ModelDownloadRequest = {
FILE: app/src/lib/api/models/ModelStatus.ts
type ModelStatus (line 8) | type ModelStatus = {
FILE: app/src/lib/api/models/ModelStatusListResponse.ts
type ModelStatusListResponse (line 9) | type ModelStatusListResponse = {
FILE: app/src/lib/api/models/ProfileSampleResponse.ts
type ProfileSampleResponse (line 8) | type ProfileSampleResponse = {
FILE: app/src/lib/api/models/TranscriptionResponse.ts
type TranscriptionResponse (line 8) | type TranscriptionResponse = {
FILE: app/src/lib/api/models/ValidationError.ts
type ValidationError (line 5) | type ValidationError = {
FILE: app/src/lib/api/models/VoiceProfileCreate.ts
type VoiceProfileCreate (line 8) | type VoiceProfileCreate = {
FILE: app/src/lib/api/models/VoiceProfileResponse.ts
type VoiceProfileResponse (line 8) | type VoiceProfileResponse = {
FILE: app/src/lib/api/services/DefaultService.ts
class DefaultService (line 21) | class DefaultService {
method rootGet (line 28) | public static rootGet(): CancelablePromise<any> {
method healthHealthGet (line 40) | public static healthHealthGet(): CancelablePromise<HealthResponse> {
method listProfilesProfilesGet (line 52) | public static listProfilesProfilesGet(): CancelablePromise<Array<Voice...
method createProfileProfilesPost (line 64) | public static createProfileProfilesPost({
method getProfileProfilesProfileIdGet (line 85) | public static getProfileProfilesProfileIdGet({
method updateProfileProfilesProfileIdPut (line 107) | public static updateProfileProfilesProfileIdPut({
method deleteProfileProfilesProfileIdDelete (line 133) | public static deleteProfileProfilesProfileIdDelete({
method addProfileSampleProfilesProfileIdSamplesPost (line 155) | public static addProfileSampleProfilesProfileIdSamplesPost({
method getProfileSamplesProfilesProfileIdSamplesGet (line 181) | public static getProfileSamplesProfilesProfileIdSamplesGet({
method deleteProfileSampleProfilesSamplesSampleIdDelete (line 203) | public static deleteProfileSampleProfilesSamplesSampleIdDelete({
method generateSpeechGeneratePost (line 225) | public static generateSpeechGeneratePost({
method listHistoryHistoryGet (line 246) | public static listHistoryHistoryGet({
method getGenerationHistoryGenerationIdGet (line 277) | public static getGenerationHistoryGenerationIdGet({
method deleteGenerationHistoryGenerationIdDelete (line 299) | public static deleteGenerationHistoryGenerationIdDelete({
method getStatsHistoryStatsGet (line 321) | public static getStatsHistoryStatsGet(): CancelablePromise<any> {
method transcribeAudioTranscribePost (line 333) | public static transcribeAudioTranscribePost({
method getAudioAudioGenerationIdGet (line 354) | public static getAudioAudioGenerationIdGet({
method loadModelModelsLoadPost (line 376) | public static loadModelModelsLoadPost({
method unloadModelModelsUnloadPost (line 398) | public static unloadModelModelsUnloadPost(): CancelablePromise<any> {
method getModelProgressModelsProgressModelNameGet (line 410) | public static getModelProgressModelsProgressModelNameGet({
method getModelStatusModelsStatusGet (line 432) | public static getModelStatusModelsStatusGet(): CancelablePromise<Model...
method triggerModelDownloadModelsDownloadPost (line 444) | public static triggerModelDownloadModelsDownloadPost({
FILE: app/src/lib/api/types.ts
type VoiceType (line 4) | type VoiceType = 'cloned' | 'preset' | 'designed';
type VoiceProfileCreate (line 6) | interface VoiceProfileCreate {
type VoiceProfileResponse (line 17) | interface VoiceProfileResponse {
type PresetVoice (line 35) | interface PresetVoice {
type ProfileSampleCreate (line 42) | interface ProfileSampleCreate {
type ProfileSampleResponse (line 46) | interface ProfileSampleResponse {
type EffectConfig (line 53) | interface EffectConfig {
type GenerationRequest (line 59) | interface GenerationRequest {
type GenerationVersionResponse (line 73) | interface GenerationVersionResponse {
type GenerationResponse (line 84) | interface GenerationResponse {
type HistoryQuery (line 103) | interface HistoryQuery {
type HistoryResponse (line 110) | interface HistoryResponse extends GenerationResponse {
type HistoryListResponse (line 116) | interface HistoryListResponse {
type WhisperModelSize (line 121) | type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
type TranscriptionRequest (line 123) | interface TranscriptionRequest {
type TranscriptionResponse (line 128) | interface TranscriptionResponse {
type HealthResponse (line 133) | interface HealthResponse {
type CudaDownloadProgress (line 145) | interface CudaDownloadProgress {
type CudaStatus (line 156) | interface CudaStatus {
type ModelProgress (line 164) | interface ModelProgress {
type ModelStatus (line 175) | interface ModelStatus {
type HuggingFaceModelInfo (line 185) | interface HuggingFaceModelInfo {
type ModelStatusListResponse (line 201) | interface ModelStatusListResponse {
type ModelDownloadRequest (line 205) | interface ModelDownloadRequest {
type ActiveDownloadTask (line 209) | interface ActiveDownloadTask {
type ActiveGenerationTask (line 220) | interface ActiveGenerationTask {
type ActiveTasksResponse (line 227) | interface ActiveTasksResponse {
type StoryCreate (line 232) | interface StoryCreate {
type StoryResponse (line 237) | interface StoryResponse {
type StoryItemDetail (line 246) | interface StoryItemDetail {
type StoryItemVersionUpdate (line 269) | interface StoryItemVersionUpdate {
type StoryDetailResponse (line 273) | interface StoryDetailResponse {
type StoryItemCreate (line 282) | interface StoryItemCreate {
type StoryItemUpdateTime (line 288) | interface StoryItemUpdateTime {
type StoryItemBatchUpdate (line 293) | interface StoryItemBatchUpdate {
type StoryItemReorder (line 297) | interface StoryItemReorder {
type StoryItemMove (line 301) | interface StoryItemMove {
type StoryItemTrim (line 306) | interface StoryItemTrim {
type StoryItemSplit (line 311) | interface StoryItemSplit {
type EffectPresetResponse (line 317) | interface EffectPresetResponse {
type EffectPresetCreate (line 326) | interface EffectPresetCreate {
type EffectPresetUpdate (line 332) | interface EffectPresetUpdate {
type AvailableEffectParam (line 338) | interface AvailableEffectParam {
type AvailableEffect (line 346) | interface AvailableEffect {
type AvailableEffectsResponse (line 353) | interface AvailableEffectsResponse {
type ApplyEffectsRequest (line 357) | interface ApplyEffectsRequest {
FILE: app/src/lib/constants/languages.ts
constant ALL_LANGUAGES (line 12) | const ALL_LANGUAGES = {
type LanguageCode (line 38) | type LanguageCode = keyof typeof ALL_LANGUAGES;
constant ENGINE_LANGUAGES (line 41) | const ENGINE_LANGUAGES: Record<string, readonly LanguageCode[]> = {
function getLanguageOptionsForEngine (line 75) | function getLanguageOptionsForEngine(engine: string) {
constant SUPPORTED_LANGUAGES (line 84) | const SUPPORTED_LANGUAGES = ALL_LANGUAGES;
constant LANGUAGE_CODES (line 85) | const LANGUAGE_CODES = Object.keys(ALL_LANGUAGES) as LanguageCode[];
constant LANGUAGE_OPTIONS (line 86) | const LANGUAGE_OPTIONS = LANGUAGE_CODES.map((code) => ({
FILE: app/src/lib/constants/ui.ts
constant TOP_SAFE_AREA_PADDING (line 12) | const TOP_SAFE_AREA_PADDING = isWindows ? 'pt-8' : 'pt-12';
constant BOTTOM_SAFE_AREA_PADDING (line 18) | const BOTTOM_SAFE_AREA_PADDING = 'pb-32';
FILE: app/src/lib/hooks/useAudioPlayer.ts
function useAudioPlayer (line 4) | function useAudioPlayer() {
FILE: app/src/lib/hooks/useAudioRecording.ts
type UseAudioRecordingOptions (line 5) | interface UseAudioRecordingOptions {
function useAudioRecording (line 10) | function useAudioRecording({
FILE: app/src/lib/hooks/useGeneration.ts
function useGeneration (line 5) | function useGeneration() {
FILE: app/src/lib/hooks/useGenerationForm.ts
type GenerationFormValues (line 23) | type GenerationFormValues = z.infer<typeof generationSchema>;
type UseGenerationFormOptions (line 25) | interface UseGenerationFormOptions {
function useGenerationForm (line 31) | function useGenerationForm(options: UseGenerationFormOptions = {}) {
FILE: app/src/lib/hooks/useGenerationProgress.ts
type GenerationStatusEvent (line 9) | interface GenerationStatusEvent {
function useGenerationProgress (line 21) | function useGenerationProgress() {
FILE: app/src/lib/hooks/useHistory.ts
function useHistory (line 6) | function useHistory(query?: HistoryQuery) {
function useGenerationDetail (line 13) | function useGenerationDetail(generationId: string) {
function useDeleteGeneration (line 21) | function useDeleteGeneration() {
function useExportGeneration (line 32) | function useExportGeneration() {
function useExportGenerationAudio (line 58) | function useExportGenerationAudio() {
function useImportGeneration (line 84) | function useImportGeneration() {
FILE: app/src/lib/hooks/useModelDownloadToast.tsx
type UseModelDownloadToastOptions (line 8) | interface UseModelDownloadToastOptions {
function useModelDownloadToast (line 20) | function useModelDownloadToast({
FILE: app/src/lib/hooks/useProfiles.ts
function useProfiles (line 6) | function useProfiles() {
function useProfile (line 13) | function useProfile(profileId: string) {
function useCreateProfile (line 21) | function useCreateProfile() {
function useUpdateProfile (line 32) | function useUpdateProfile() {
function useDeleteProfile (line 47) | function useDeleteProfile() {
function useProfileSamples (line 58) | function useProfileSamples(profileId: string) {
function useAddSample (line 66) | function useAddSample() {
function useDeleteSample (line 90) | function useDeleteSample() {
function useUpdateSample (line 101) | function useUpdateSample() {
function useExportProfile (line 119) | function useExportProfile() {
function useImportProfile (line 143) | function useImportProfile() {
function useUploadAvatar (line 154) | function useUploadAvatar() {
function useDeleteAvatar (line 169) | function useDeleteAvatar() {
FILE: app/src/lib/hooks/useRestoreActiveTasks.tsx
constant POLL_INTERVAL (line 7) | const POLL_INTERVAL = 30000;
function useRestoreActiveTasks (line 16) | function useRestoreActiveTasks() {
constant MODEL_DISPLAY_NAMES (line 80) | const MODEL_DISPLAY_NAMES: Record<string, string> = {
FILE: app/src/lib/hooks/useServer.ts
function useServerHealth (line 5) | function useServerHealth() {
FILE: app/src/lib/hooks/useStories.ts
function useStories (line 15) | function useStories() {
function useStory (line 22) | function useStory(storyId: string | null) {
function useCreateStory (line 30) | function useCreateStory() {
function useUpdateStory (line 41) | function useUpdateStory() {
function useDeleteStory (line 54) | function useDeleteStory() {
function useAddStoryItem (line 65) | function useAddStoryItem() {
function useRemoveStoryItem (line 78) | function useRemoveStoryItem() {
function useUpdateStoryItemTimes (line 91) | function useUpdateStoryItemTimes() {
function useReorderStoryItems (line 104) | function useReorderStoryItems() {
function useMoveStoryItem (line 117) | function useMoveStoryItem() {
function useTrimStoryItem (line 137) | function useTrimStoryItem() {
function useSplitStoryItem (line 157) | function useSplitStoryItem() {
function useDuplicateStoryItem (line 177) | function useDuplicateStoryItem() {
function useSetStoryItemVersion (line 190) | function useSetStoryItemVersion() {
function useExportStoryAudio (line 210) | function useExportStoryAudio() {
FILE: app/src/lib/hooks/useStoryPlayback.ts
type ActiveSource (line 6) | interface ActiveSource {
function useStoryPlayback (line 19) | function useStoryPlayback(items: StoryItemDetail[] | undefined) {
FILE: app/src/lib/hooks/useSystemAudioCapture.ts
type UseSystemAudioCaptureOptions (line 4) | interface UseSystemAudioCaptureOptions {
function useSystemAudioCapture (line 13) | function useSystemAudioCapture({
FILE: app/src/lib/hooks/useTranscription.ts
function useTranscription (line 6) | function useTranscription() {
FILE: app/src/lib/utils/audio.ts
function createAudioUrl (line 1) | function createAudioUrl(audioId: string, serverUrl: string): string {
function downloadAudio (line 5) | function downloadAudio(url: string, filename: string): void {
function formatAudioDuration (line 14) | function formatAudioDuration(seconds: number): string {
function getAudioDuration (line 31) | async function getAudioDuration(
function convertToWav (line 77) | async function convertToWav(audioBlob: Blob): Promise<Blob> {
function audioBufferToWav (line 99) | function audioBufferToWav(buffer: AudioBuffer): Blob {
function interleaveChannels (line 140) | function interleaveChannels(buffer: AudioBuffer): Float32Array {
function writeString (line 158) | function writeString(view: DataView, offset: number, string: string): vo...
function floatTo16BitPCM (line 167) | function floatTo16BitPCM(view: DataView, offset: number, input: Float32A...
FILE: app/src/lib/utils/cn.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: app/src/lib/utils/debug.ts
constant DEBUG (line 1) | const DEBUG = import.meta.env.DEV;
FILE: app/src/lib/utils/format.ts
function formatDuration (line 3) | function formatDuration(seconds: number): string {
function formatDate (line 9) | function formatDate(date: string | Date): string {
constant ENGINE_DISPLAY_NAMES (line 28) | const ENGINE_DISPLAY_NAMES: Record<string, string> = {
function formatEngineName (line 35) | function formatEngineName(engine?: string, modelSize?: string): string {
function formatFileSize (line 43) | function formatFileSize(bytes: number): string {
FILE: app/src/lib/utils/parseChangelog.ts
type ChangelogEntry (line 1) | interface ChangelogEntry {
function parseChangelog (line 14) | function parseChangelog(raw: string): ChangelogEntry[] {
FILE: app/src/platform/PlatformContext.tsx
type PlatformProviderProps (line 6) | interface PlatformProviderProps {
function PlatformProvider (line 11) | function PlatformProvider({ platform, children }: PlatformProviderProps) {
function usePlatform (line 19) | function usePlatform(): Platform {
FILE: app/src/platform/types.ts
type FileFilter (line 6) | interface FileFilter {
type PlatformFilesystem (line 11) | interface PlatformFilesystem {
type UpdateStatus (line 17) | interface UpdateStatus {
type PlatformUpdater (line 30) | interface PlatformUpdater {
type AudioDevice (line 38) | interface AudioDevice {
type PlatformAudio (line 44) | interface PlatformAudio {
type ServerLogEntry (line 53) | interface ServerLogEntry {
type PlatformLifecycle (line 58) | interface PlatformLifecycle {
type PlatformMetadata (line 68) | interface PlatformMetadata {
type Platform (line 73) | interface Platform {
FILE: app/src/router.tsx
function RootLayout (line 32) | function RootLayout() {
function DownloadToastRestorer (line 71) | function DownloadToastRestorer({
type Register (line 212) | interface Register {
FILE: app/src/stores/audioChannelStore.ts
type AudioChannel (line 4) | interface AudioChannel {
type AudioChannelStore (line 12) | interface AudioChannelStore {
FILE: app/src/stores/effectsStore.ts
type EffectsStore (line 4) | interface EffectsStore {
FILE: app/src/stores/generationStore.ts
type GenerationState (line 3) | interface GenerationState {
FILE: app/src/stores/logStore.ts
constant MAX_LOG_ENTRIES (line 4) | const MAX_LOG_ENTRIES = 2000;
type LogEntry (line 8) | interface LogEntry extends ServerLogEntry {
type LogStore (line 13) | interface LogStore {
FILE: app/src/stores/playerStore.ts
type PlayerState (line 3) | interface PlayerState {
FILE: app/src/stores/serverStore.ts
type ServerStore (line 4) | interface ServerStore {
FILE: app/src/stores/storyStore.ts
type StoryPlaybackState (line 4) | interface StoryPlaybackState {
constant DEFAULT_TRACK_EDITOR_HEIGHT (line 34) | const DEFAULT_TRACK_EDITOR_HEIGHT = 250;
FILE: app/src/stores/uiStore.ts
type ProfileFormDraft (line 4) | interface ProfileFormDraft {
type UIStore (line 16) | interface UIStore {
FILE: app/src/types/index.ts
type VoiceProfile (line 3) | interface VoiceProfile {
type Generation (line 12) | interface Generation {
type ServerConfig (line 23) | interface ServerConfig {
FILE: backend/app.py
class ColoredFormatter (line 10) | class ColoredFormatter(logging.Formatter):
method format (line 22) | def format(self, record):
function safe_content_disposition (line 58) | def safe_content_disposition(disposition_type: str, filename: str) -> str:
function create_app (line 69) | def create_app() -> FastAPI:
function _configure_cors (line 85) | def _configure_cors(application: FastAPI) -> None:
function _mount_frontend (line 108) | def _mount_frontend(application: FastAPI) -> None:
function _get_gpu_status (line 145) | def _get_gpu_status() -> str:
function _register_lifecycle (line 161) | def _register_lifecycle(application: FastAPI) -> None:
FILE: backend/backends/__init__.py
class ModelConfig (line 39) | class ModelConfig:
class TTSBackend (line 54) | class TTSBackend(Protocol):
method load_model (line 60) | async def load_model(self, model_size: str) -> None:
method create_voice_prompt (line 64) | async def create_voice_prompt(
method combine_voice_prompts (line 78) | async def combine_voice_prompts(
method generate (line 91) | async def generate(
method unload_model (line 107) | def unload_model(self) -> None:
method is_loaded (line 111) | def is_loaded(self) -> bool:
method _get_model_path (line 115) | def _get_model_path(self, model_size: str) -> str:
class STTBackend (line 126) | class STTBackend(Protocol):
method load_model (line 129) | async def load_model(self, model_size: str) -> None:
method transcribe (line 133) | async def transcribe(
method unload_model (line 147) | def unload_model(self) -> None:
method is_loaded (line 151) | def is_loaded(self) -> bool:
function _get_qwen_model_configs (line 174) | def _get_qwen_model_configs() -> list[ModelConfig]:
function _get_non_qwen_tts_configs (line 208) | def _get_non_qwen_tts_configs() -> list[ModelConfig]:
function _get_whisper_configs (line 293) | def _get_whisper_configs() -> list[ModelConfig]:
function get_all_model_configs (line 334) | def get_all_model_configs() -> list[ModelConfig]:
function get_tts_model_configs (line 339) | def get_tts_model_configs() -> list[ModelConfig]:
function get_model_config (line 347) | def get_model_config(model_name: str) -> Optional[ModelConfig]:
function engine_needs_trim (line 355) | def engine_needs_trim(engine: str) -> bool:
function engine_has_model_sizes (line 363) | def engine_has_model_sizes(engine: str) -> bool:
function load_engine_model (line 369) | async def load_engine_model(engine: str, model_size: str = "default") ->...
function ensure_model_cached_or_raise (line 380) | async def ensure_model_cached_or_raise(engine: str, model_size: str = "d...
function unload_model_by_config (line 406) | def unload_model_by_config(config: ModelConfig) -> bool:
function check_model_loaded (line 434) | def check_model_loaded(config: ModelConfig) -> bool:
function get_model_load_func (line 455) | def get_model_load_func(config: ModelConfig):
function get_tts_backend (line 469) | def get_tts_backend() -> TTSBackend:
function get_tts_backend_for_engine (line 479) | def get_tts_backend_for_engine(engine: str) -> TTSBackend:
function get_stt_backend (line 538) | def get_stt_backend() -> STTBackend:
function reset_backends (line 562) | def reset_backends():
FILE: backend/backends/base.py
function is_model_cached (line 24) | def is_model_cached(
function get_torch_device (line 80) | def get_torch_device(
function combine_voice_prompts (line 129) | async def combine_voice_prompts(
function model_load_progress (line 161) | def model_load_progress(
function patch_chatterbox_f32 (line 224) | def patch_chatterbox_f32(model) -> None:
FILE: backend/backends/chatterbox_backend.py
class ChatterboxTTSBackend (line 38) | class ChatterboxTTSBackend:
method __init__ (line 44) | def __init__(self):
method _get_device (line 50) | def _get_device(self) -> str:
method is_loaded (line 53) | def is_loaded(self) -> bool:
method _get_model_path (line 56) | def _get_model_path(self, model_size: str = "default") -> str:
method _is_model_cached (line 59) | def _is_model_cached(self, model_size: str = "default") -> bool:
method load_model (line 62) | async def load_model(self, model_size: str = "default") -> None:
method _load_model_sync (line 71) | def _load_model_sync(self):
method unload_model (line 113) | def unload_model(self) -> None:
method create_voice_prompt (line 126) | async def create_voice_prompt(
method combine_voice_prompts (line 145) | async def combine_voice_prompts(
method generate (line 168) | async def generate(
FILE: backend/backends/chatterbox_turbo_backend.py
class ChatterboxTurboTTSBackend (line 38) | class ChatterboxTurboTTSBackend:
method __init__ (line 44) | def __init__(self):
method _get_device (line 50) | def _get_device(self) -> str:
method is_loaded (line 53) | def is_loaded(self) -> bool:
method _get_model_path (line 56) | def _get_model_path(self, model_size: str = "default") -> str:
method _is_model_cached (line 59) | def _is_model_cached(self, model_size: str = "default") -> bool:
method load_model (line 62) | async def load_model(self, model_size: str = "default") -> None:
method _load_model_sync (line 71) | def _load_model_sync(self):
method unload_model (line 112) | def unload_model(self) -> None:
method create_voice_prompt (line 125) | async def create_voice_prompt(
method combine_voice_prompts (line 143) | async def combine_voice_prompts(
method generate (line 150) | async def generate(
FILE: backend/backends/hume_backend.py
class HumeTadaBackend (line 54) | class HumeTadaBackend:
method __init__ (line 59) | def __init__(self):
method _get_device (line 66) | def _get_device(self) -> str:
method is_loaded (line 71) | def is_loaded(self) -> bool:
method _get_model_path (line 74) | def _get_model_path(self, model_size: str = "1B") -> str:
method _is_model_cached (line 77) | def _is_model_cached(self, model_size: str = "1B") -> bool:
method load_model (line 83) | async def load_model(self, model_size: str = "1B") -> None:
method _load_model_sync (line 96) | def _load_model_sync(self, model_size: str = "1B"):
method unload_model (line 182) | def unload_model(self) -> None:
method create_voice_prompt (line 199) | async def create_voice_prompt(
method combine_voice_prompts (line 267) | async def combine_voice_prompts(
method generate (line 274) | async def generate(
FILE: backend/backends/kokoro_backend.py
class KokoroTTSBackend (line 119) | class KokoroTTSBackend:
method __init__ (line 122) | def __init__(self):
method _get_device (line 128) | def _get_device(self) -> str:
method device (line 136) | def device(self) -> str:
method is_loaded (line 141) | def is_loaded(self) -> bool:
method _get_model_path (line 144) | def _get_model_path(self, model_size: str) -> str:
method _is_model_cached (line 147) | def _is_model_cached(self, model_size: str = "default") -> bool:
method load_model (line 156) | async def load_model(self, model_size: str = "default") -> None:
method _load_model_sync (line 162) | def _load_model_sync(self):
method _get_pipeline (line 177) | def _get_pipeline(self, lang_code: str):
method unload_model (line 193) | def unload_model(self) -> None:
method create_voice_prompt (line 207) | async def create_voice_prompt(
method combine_voice_prompts (line 227) | async def combine_voice_prompts(
method generate (line 237) | async def generate(
FILE: backend/backends/luxtts_backend.py
class LuxTTSBackend (line 24) | class LuxTTSBackend:
method __init__ (line 27) | def __init__(self):
method _get_device (line 32) | def _get_device(self) -> str:
method is_loaded (line 35) | def is_loaded(self) -> bool:
method device (line 39) | def device(self) -> str:
method _get_model_path (line 44) | def _get_model_path(self, model_size: str) -> str:
method _is_model_cached (line 47) | def _is_model_cached(self, model_size: str = "default") -> bool:
method load_model (line 53) | async def load_model(self, model_size: str = "default") -> None:
method _load_model_sync (line 60) | def _load_model_sync(self):
method unload_model (line 81) | def unload_model(self) -> None:
method create_voice_prompt (line 93) | async def create_voice_prompt(
method combine_voice_prompts (line 130) | async def combine_voice_prompts(self, audio_paths, reference_texts):
method generate (line 133) | async def generate(
FILE: backend/backends/mlx_backend.py
class MLXTTSBackend (line 26) | class MLXTTSBackend:
method __init__ (line 29) | def __init__(self, model_size: str = "1.7B"):
method is_loaded (line 34) | def is_loaded(self) -> bool:
method _get_model_path (line 38) | def _get_model_path(self, model_size: str) -> str:
method _is_model_cached (line 63) | def _is_model_cached(self, model_size: str) -> bool:
method load_model_async (line 69) | async def load_model_async(self, model_size: Optional[str] = None):
method _load_model_sync (line 93) | def _load_model_sync(self, model_size: str):
method unload_model (line 130) | def unload_model(self):
method create_voice_prompt (line 138) | async def create_voice_prompt(
method combine_voice_prompts (line 189) | async def combine_voice_prompts(self, audio_paths, reference_texts):
method generate (line 192) | async def generate(
class MLXSTTBackend (line 288) | class MLXSTTBackend:
method __init__ (line 291) | def __init__(self, model_size: str = "base"):
method is_loaded (line 295) | def is_loaded(self) -> bool:
method _is_model_cached (line 299) | def _is_model_cached(self, model_size: str) -> bool:
method load_model_async (line 303) | async def load_model_async(self, model_size: Optional[str] = None):
method _load_model_sync (line 322) | def _load_model_sync(self, model_size: str):
method unload_model (line 337) | def unload_model(self):
method transcribe (line 344) | async def transcribe(
FILE: backend/backends/pytorch_backend.py
class PyTorchTTSBackend (line 24) | class PyTorchTTSBackend:
method __init__ (line 27) | def __init__(self, model_size: str = "1.7B"):
method _get_device (line 33) | def _get_device(self) -> str:
method is_loaded (line 37) | def is_loaded(self) -> bool:
method _get_model_path (line 41) | def _get_model_path(self, model_size: str) -> str:
method _is_model_cached (line 61) | def _is_model_cached(self, model_size: str) -> bool:
method load_model_async (line 64) | async def load_model_async(self, model_size: Optional[str] = None):
method _load_model_sync (line 88) | def _load_model_sync(self, model_size: str):
method unload_model (line 116) | def unload_model(self):
method create_voice_prompt (line 128) | async def create_voice_prompt(
method combine_voice_prompts (line 181) | async def combine_voice_prompts(
method generate (line 188) | async def generate(
class PyTorchSTTBackend (line 235) | class PyTorchSTTBackend:
method __init__ (line 238) | def __init__(self, model_size: str = "base"):
method _get_device (line 244) | def _get_device(self) -> str:
method is_loaded (line 248) | def is_loaded(self) -> bool:
method _is_model_cached (line 252) | def _is_model_cached(self, model_size: str) -> bool:
method load_model_async (line 256) | async def load_model_async(self, model_size: Optional[str] = None):
method _load_model_sync (line 274) | def _load_model_sync(self, model_size: str):
method unload_model (line 292) | def unload_model(self):
method transcribe (line 305) | async def transcribe(
FILE: backend/build_binary.py
function is_apple_silicon (line 20) | def is_apple_silicon():
function build_server (line 25) | def build_server(cuda=False):
FILE: backend/config.py
function set_data_dir (line 25) | def set_data_dir(path: str | Path):
function get_data_dir (line 38) | def get_data_dir() -> Path:
function get_db_path (line 48) | def get_db_path() -> Path:
function get_profiles_dir (line 53) | def get_profiles_dir() -> Path:
function get_generations_dir (line 60) | def get_generations_dir() -> Path:
function get_cache_dir (line 67) | def get_cache_dir() -> Path:
function get_models_dir (line 74) | def get_models_dir() -> Path:
FILE: backend/database/migrations.py
function run_migrations (line 27) | def run_migrations(engine) -> None:
function _get_columns (line 42) | def _get_columns(inspector, table: str) -> set[str]:
function _add_column (line 46) | def _add_column(engine, table: str, column_sql: str, label: str) -> None:
function _migrate_story_items (line 56) | def _migrate_story_items(engine, inspector, tables: set[str]) -> None:
function _migrate_profiles (line 130) | def _migrate_profiles(engine, inspector, tables: set[str]) -> None:
function _migrate_generations (line 151) | def _migrate_generations(engine, inspector, tables: set[str]) -> None:
function _migrate_effect_presets (line 169) | def _migrate_effect_presets(engine, inspector, tables: set[str]) -> None:
function _migrate_generation_versions (line 177) | def _migrate_generation_versions(engine, inspector, tables: set[str]) ->...
function _resolve_relative_paths (line 185) | def _resolve_relative_paths(engine, tables: set[str]) -> None:
FILE: backend/database/models.py
class VoiceProfile (line 12) | class VoiceProfile(Base):
class ProfileSample (line 41) | class ProfileSample(Base):
class Generation (line 52) | class Generation(Base):
class Story (line 73) | class Story(Base):
class StoryItem (line 85) | class StoryItem(Base):
class Project (line 101) | class Project(Base):
class GenerationVersion (line 113) | class GenerationVersion(Base):
class EffectPreset (line 128) | class EffectPreset(Base):
class AudioChannel (line 142) | class AudioChannel(Base):
class ChannelDeviceMapping (line 153) | class ChannelDeviceMapping(Base):
class ProfileChannelMapping (line 163) | class ProfileChannelMapping(Base):
FILE: backend/database/seed.py
function backfill_generation_versions (line 11) | def backfill_generation_versions(SessionLocal, Generation, GenerationVer...
function seed_builtin_presets (line 48) | def seed_builtin_presets(SessionLocal, EffectPreset) -> None:
FILE: backend/database/session.py
function init_db (line 30) | def init_db() -> None:
function get_db (line 72) | def get_db():
FILE: backend/models.py
class VoiceProfileCreate (line 10) | class VoiceProfileCreate(BaseModel):
class VoiceProfileResponse (line 25) | class VoiceProfileResponse(BaseModel):
class Config (line 44) | class Config:
class ProfileSampleCreate (line 48) | class ProfileSampleCreate(BaseModel):
class ProfileSampleUpdate (line 54) | class ProfileSampleUpdate(BaseModel):
class ProfileSampleResponse (line 60) | class ProfileSampleResponse(BaseModel):
class Config (line 68) | class Config:
class GenerationRequest (line 72) | class GenerationRequest(BaseModel):
class GenerationResponse (line 94) | class GenerationResponse(BaseModel):
class Config (line 114) | class Config:
class HistoryQuery (line 118) | class HistoryQuery(BaseModel):
class HistoryResponse (line 127) | class HistoryResponse(BaseModel):
class Config (line 148) | class Config:
class HistoryListResponse (line 152) | class HistoryListResponse(BaseModel):
class TranscriptionRequest (line 159) | class TranscriptionRequest(BaseModel):
class TranscriptionResponse (line 166) | class TranscriptionResponse(BaseModel):
class HealthResponse (line 173) | class HealthResponse(BaseModel):
class DirectoryCheck (line 187) | class DirectoryCheck(BaseModel):
class FilesystemHealthResponse (line 196) | class FilesystemHealthResponse(BaseModel):
class ModelStatus (line 205) | class ModelStatus(BaseModel):
class ModelStatusListResponse (line 217) | class ModelStatusListResponse(BaseModel):
class ModelDownloadRequest (line 223) | class ModelDownloadRequest(BaseModel):
class ModelMigrateRequest (line 229) | class ModelMigrateRequest(BaseModel):
class ActiveDownloadTask (line 235) | class ActiveDownloadTask(BaseModel):
class ActiveGenerationTask (line 248) | class ActiveGenerationTask(BaseModel):
class ActiveTasksResponse (line 257) | class ActiveTasksResponse(BaseModel):
class AudioChannelCreate (line 264) | class AudioChannelCreate(BaseModel):
class AudioChannelUpdate (line 271) | class AudioChannelUpdate(BaseModel):
class AudioChannelResponse (line 278) | class AudioChannelResponse(BaseModel):
class Config (line 287) | class Config:
class ChannelVoiceAssignment (line 291) | class ChannelVoiceAssignment(BaseModel):
class ProfileChannelAssignment (line 297) | class ProfileChannelAssignment(BaseModel):
class StoryCreate (line 303) | class StoryCreate(BaseModel):
class StoryResponse (line 310) | class StoryResponse(BaseModel):
class Config (line 320) | class Config:
class StoryItemDetail (line 324) | class StoryItemDetail(BaseModel):
class Config (line 350) | class Config:
class StoryDetailResponse (line 354) | class StoryDetailResponse(BaseModel):
class Config (line 364) | class Config:
class StoryItemCreate (line 368) | class StoryItemCreate(BaseModel):
class StoryItemUpdateTime (line 376) | class StoryItemUpdateTime(BaseModel):
class StoryItemBatchUpdate (line 383) | class StoryItemBatchUpdate(BaseModel):
class StoryItemReorder (line 389) | class StoryItemReorder(BaseModel):
class StoryItemMove (line 395) | class StoryItemMove(BaseModel):
class StoryItemTrim (line 402) | class StoryItemTrim(BaseModel):
class StoryItemSplit (line 409) | class StoryItemSplit(BaseModel):
class StoryItemVersionUpdate (line 415) | class StoryItemVersionUpdate(BaseModel):
class EffectConfig (line 421) | class EffectConfig(BaseModel):
class EffectsChain (line 429) | class EffectsChain(BaseModel):
class EffectPresetCreate (line 435) | class EffectPresetCreate(BaseModel):
class EffectPresetUpdate (line 443) | class EffectPresetUpdate(BaseModel):
class EffectPresetResponse (line 451) | class EffectPresetResponse(BaseModel):
class Config (line 461) | class Config:
class GenerationVersionResponse (line 465) | class GenerationVersionResponse(BaseModel):
class Config (line 477) | class Config:
class ApplyEffectsRequest (line 481) | class ApplyEffectsRequest(BaseModel):
class ProfileEffectsUpdate (line 492) | class ProfileEffectsUpdate(BaseModel):
class AvailableEffectParam (line 498) | class AvailableEffectParam(BaseModel):
class AvailableEffect (line 508) | class AvailableEffect(BaseModel):
class AvailableEffectsResponse (line 517) | class AvailableEffectsResponse(BaseModel):
FILE: backend/routes/__init__.py
function register_routers (line 6) | def register_routers(app: FastAPI) -> None:
FILE: backend/routes/audio.py
function get_version_audio (line 17) | async def get_version_audio(version_id: str, db: Session = Depends(get_d...
function get_audio (line 37) | async def get_audio(generation_id: str, db: Session = Depends(get_db)):
function get_sample_audio (line 55) | async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
FILE: backend/routes/channels.py
function list_channels (line 14) | async def list_channels(db: Session = Depends(get_db)):
function create_channel (line 20) | async def create_channel(
function get_channel (line 32) | async def get_channel(
function update_channel (line 44) | async def update_channel(
function delete_channel (line 60) | async def delete_channel(
function get_channel_voices (line 75) | async def get_channel_voices(
function set_channel_voices (line 88) | async def set_channel_voices(
FILE: backend/routes/cuda.py
function get_cuda_status (line 17) | async def get_cuda_status():
function download_cuda_backend (line 25) | async def download_cuda_backend():
function delete_cuda_backend (line 48) | async def delete_cuda_backend():
function get_cuda_download_progress (line 66) | async def get_cuda_download_progress():
FILE: backend/routes/effects.py
function preview_effects (line 20) | async def preview_effects(
function get_available_effects (line 67) | async def get_available_effects():
function list_effect_presets (line 75) | async def list_effect_presets(db: Session = Depends(get_db)):
function get_effect_preset (line 83) | async def get_effect_preset(preset_id: str, db: Session = Depends(get_db)):
function create_effect_preset (line 94) | async def create_effect_preset(
function update_effect_preset (line 108) | async def update_effect_preset(
function delete_effect_preset (line 126) | async def delete_effect_preset(preset_id: str, db: Session = Depends(get...
function list_generation_versions (line 142) | async def list_generation_versions(
function apply_effects_to_generation (line 160) | async def apply_effects_to_generation(
function set_default_version (line 225) | async def set_default_version(
function delete_generation_version (line 244) | async def delete_generation_version(
FILE: backend/routes/generations.py
function generate_speech (line 24) | async def generate_speech(
function retry_generation (line 97) | async def retry_generation(generation_id: str, db: Session = Depends(get...
function regenerate_generation (line 141) | async def regenerate_generation(generation_id: str, db: Session = Depend...
function get_generation_status (line 182) | async def get_generation_status(generation_id: str, db: Session = Depend...
function stream_speech (line 222) | async def stream_speech(
FILE: backend/routes/health.py
function root (line 25) | async def root():
function shutdown (line 36) | async def shutdown():
function watchdog_disable (line 48) | async def watchdog_disable():
function health (line 57) | async def health():
function filesystem_health (line 170) | async def filesystem_health():
FILE: backend/routes/history.py
function list_history (line 19) | async def list_history(
function get_stats (line 37) | async def get_stats(db: Session = Depends(get_db)):
function import_generation (line 43) | async def import_generation(
function get_generation (line 67) | async def get_generation(
function toggle_favorite (line 98) | async def toggle_favorite(
function delete_generation (line 112) | async def delete_generation(
function export_generation (line 124) | async def export_generation(
function export_generation_audio (line 153) | async def export_generation_audio(
FILE: backend/routes/models.py
function _get_dir_size (line 20) | def _get_dir_size(path: Path) -> int:
function _copy_with_progress (line 29) | def _copy_with_progress(src: Path, dst: Path, progress_manager, copied_s...
function load_model (line 51) | async def load_model(model_size: str = "1.7B"):
function unload_model (line 64) | async def unload_model():
function unload_model_by_name (line 76) | async def unload_model_by_name(model_name: str):
function get_model_progress (line 94) | async def get_model_progress(model_name: str):
function get_models_cache_dir (line 114) | async def get_models_cache_dir():
function migrate_models (line 122) | async def migrate_models(request: models.ModelMigrateRequest):
function get_migration_progress (line 206) | async def get_migration_progress():
function get_model_status (line 226) | async def get_model_status():
function trigger_model_download (line 387) | async def trigger_model_download(request: models.ModelDownloadRequest):
function cancel_model_download (line 425) | async def cancel_model_download(request: models.ModelDownloadRequest):
function delete_model (line 444) | async def delete_model(model_name: str):
FILE: backend/routes/profiles.py
function create_profile (line 27) | async def create_profile(
function list_profiles (line 41) | async def list_profiles(db: Session = Depends(get_db)):
function import_profile (line 47) | async def import_profile(
function list_preset_voices (line 76) | async def list_preset_voices(engine: str):
function seed_preset_profiles_route (line 97) | async def seed_preset_profiles_route(
function get_profile (line 164) | async def get_profile(
function update_profile (line 176) | async def update_profile(
function delete_profile (line 192) | async def delete_profile(
function add_profile_sample (line 208) | async def add_profile_sample(
function get_profile_samples (line 249) | async def get_profile_samples(
function delete_profile_sample (line 258) | async def delete_profile_sample(
function update_profile_sample (line 270) | async def update_profile_sample(
function upload_profile_avatar (line 283) | async def upload_profile_avatar(
function get_profile_avatar (line 304) | async def get_profile_avatar(
function delete_profile_avatar (line 324) | async def delete_profile_avatar(
function export_profile (line 336) | async def export_profile(
function get_profile_channels (line 365) | async def get_profile_channels(
function set_profile_channels (line 378) | async def set_profile_channels(
function update_profile_effects (line 392) | async def update_profile_effects(
FILE: backend/routes/stories.py
function list_stories (line 18) | async def list_stories(db: Session = Depends(get_db)):
function create_story (line 24) | async def create_story(
function get_story (line 36) | async def get_story(
function update_story (line 48) | async def update_story(
function delete_story (line 61) | async def delete_story(
function add_story_item (line 73) | async def add_story_item(
function remove_story_item (line 86) | async def remove_story_item(
function update_story_item_times (line 99) | async def update_story_item_times(
function reorder_story_items (line 112) | async def reorder_story_items(
function move_story_item (line 127) | async def move_story_item(
function trim_story_item (line 141) | async def trim_story_item(
function split_story_item (line 155) | async def split_story_item(
function duplicate_story_item (line 169) | async def duplicate_story_item(
function set_story_item_version (line 182) | async def set_story_item_version(
function export_story_audio (line 196) | async def export_story_audio(
FILE: backend/routes/tasks.py
function clear_all_tasks (line 17) | async def clear_all_tasks():
function clear_cache (line 33) | async def clear_cache():
function get_active_tasks (line 46) | async def get_active_tasks():
FILE: backend/routes/transcription.py
function transcribe_audio (line 20) | async def transcribe_audio(
FILE: backend/server.py
function _is_writable (line 14) | def _is_writable(stream):
function disable_watchdog (line 90) | def disable_watchdog():
function _start_parent_watchdog (line 102) | def _start_parent_watchdog(parent_pid, data_dir=None):
function _on_startup (line 249) | async def _on_startup():
FILE: backend/services/channels.py
function list_channels (line 25) | async def list_channels(db: Session) -> List[AudioChannelResponse]:
function get_channel (line 48) | async def get_channel(channel_id: str, db: Session) -> Optional[AudioCha...
function create_channel (line 69) | async def create_channel(
function update_channel (line 110) | async def update_channel(
function delete_channel (line 166) | async def delete_channel(channel_id: str, db: Session) -> bool:
function get_channel_voices (line 188) | async def get_channel_voices(channel_id: str, db: Session) -> List[str]:
function set_channel_voices (line 196) | async def set_channel_voices(
function get_profile_channels (line 227) | async def get_profile_channels(profile_id: str, db: Session) -> List[str]:
function set_profile_channels (line 235) | async def set_profile_channels(
FILE: backend/services/cuda.py
function get_backends_dir (line 38) | def get_backends_dir() -> Path:
function get_cuda_dir (line 45) | def get_cuda_dir() -> Path:
function get_cuda_exe_name (line 52) | def get_cuda_exe_name() -> str:
function get_cuda_binary_path (line 59) | def get_cuda_binary_path() -> Optional[Path]:
function get_cuda_libs_manifest_path (line 67) | def get_cuda_libs_manifest_path() -> Path:
function get_installed_cuda_libs_version (line 72) | def get_installed_cuda_libs_version() -> Optional[str]:
function is_cuda_active (line 85) | def is_cuda_active() -> bool:
function get_cuda_status (line 93) | def get_cuda_status() -> dict:
function _needs_server_download (line 110) | def _needs_server_download(version: Optional[str] = None) -> bool:
function _needs_cuda_libs_download (line 123) | def _needs_cuda_libs_download() -> bool:
function _download_and_extract_archive (line 131) | async def _download_and_extract_archive(
function download_cuda_binary (line 231) | async def download_cuda_binary(version: Optional[str] = None):
function get_cuda_binary_version (line 340) | def get_cuda_binary_version() -> Optional[str]:
function check_and_update_cuda_binary (line 364) | async def check_and_update_cuda_binary():
function delete_cuda_binary (line 397) | async def delete_cuda_binary() -> bool:
FILE: backend/services/effects.py
function _preset_response (line 18) | def _preset_response(p: DBEffectPreset) -> EffectPresetResponse:
function list_presets (line 31) | def list_presets(db: Session) -> List[EffectPresetResponse]:
function get_preset (line 37) | def get_preset(preset_id: str, db: Session) -> Optional[EffectPresetResp...
function get_preset_by_name (line 45) | def get_preset_by_name(name: str, db: Session) -> Optional[EffectPresetR...
function create_preset (line 53) | def create_preset(data: EffectPresetCreate, db: Session) -> EffectPreset...
function update_preset (line 84) | def update_preset(preset_id: str, data: EffectPresetUpdate, db: Session)...
function delete_preset (line 110) | def delete_preset(preset_id: str, db: Session) -> bool:
FILE: backend/services/export_import.py
function _get_unique_profile_name (line 22) | def _get_unique_profile_name(name: str, db: Session) -> str:
function export_profile_to_zip (line 45) | def export_profile_to_zip(profile_id: str, db: Session) -> bytes:
function import_profile_from_zip (line 121) | async def import_profile_from_zip(file_bytes: bytes, db: Session) -> Voi...
function export_generation_to_zip (line 243) | def export_generation_to_zip(generation_id: str, db: Session) -> bytes:
function import_generation_from_zip (line 331) | async def import_generation_from_zip(file_bytes: bytes, db: Session) -> ...
FILE: backend/services/generation.py
function run_generation (line 28) | async def run_generation(
function _save_generate (line 142) | def _save_generate(
function _save_retry (line 201) | def _save_retry(
function _save_regenerate (line 217) | def _save_regenerate(
FILE: backend/services/history.py
function _get_versions_for_generation (line 18) | def _get_versions_for_generation(generation_id: str, db: Session) -> tuple:
function create_generation (line 55) | async def create_generation(
function update_generation_status (line 111) | async def update_generation_status(
function get_generation (line 137) | async def get_generation(
function list_generations (line 158) | async def list_generations(
function delete_generation (line 232) | async def delete_generation(
function delete_generations_by_profile (line 267) | async def delete_generations_by_profile(
function get_generation_stats (line 299) | async def get_generation_stats(db: Session) -> dict:
FILE: backend/services/profiles.py
function _profile_to_response (line 30) | def _profile_to_response(
function _validate_profile_fields (line 64) | def _validate_profile_fields(
function create_profile (line 95) | async def create_profile(
function add_profile_sample (line 156) | async def add_profile_sample(
function get_profile (line 215) | async def get_profile(
function get_profile_samples (line 236) | async def get_profile_samples(
function list_profiles (line 254) | async def list_profiles(db: Session) -> list[VoiceProfileResponse]:
function update_profile (line 291) | async def update_profile(
function delete_profile (line 348) | async def delete_profile(
function delete_profile_sample (line 381) | async def delete_profile_sample(
function update_profile_sample (line 416) | async def update_profile_sample(
function create_voice_prompt_for_profile (line 450) | async def create_voice_prompt_for_profile(
function upload_avatar (line 552) | async def upload_avatar(
function delete_avatar (line 608) | async def delete_avatar(
FILE: backend/services/stories.py
function _build_item_detail (line 36) | def _build_item_detail(
function create_story (line 77) | async def create_story(
function list_stories (line 110) | async def list_stories(
function get_story (line 135) | async def get_story(
function update_story (line 171) | async def update_story(
function delete_story (line 205) | async def delete_story(
function add_item_to_story (line 233) | async def add_item_to_story(
function move_story_item (line 318) | async def move_story_item(
function remove_item_from_story (line 371) | async def remove_item_from_story(
function trim_story_item (line 410) | async def trim_story_item(
function split_story_item (line 468) | async def split_story_item(
function duplicate_story_item (line 553) | async def duplicate_story_item(
function update_story_item_times (line 621) | async def update_story_item_times(
function reorder_story_items (line 658) | async def reorder_story_items(
function set_story_item_version (line 722) | async def set_story_item_version(
function export_story_audio (line 785) | async def export_story_audio(
FILE: backend/services/task_queue.py
function create_background_task (line 16) | def create_background_task(coro) -> asyncio.Task:
function _generation_worker (line 24) | async def _generation_worker():
function enqueue_generation (line 36) | def enqueue_generation(coro):
function init_queue (line 41) | def init_queue():
FILE: backend/services/transcribe.py
function get_whisper_model (line 9) | def get_whisper_model() -> STTBackend:
function unload_whisper_model (line 19) | def unload_whisper_model():
FILE: backend/services/tts.py
function get_tts_model (line 13) | def get_tts_model() -> TTSBackend:
function unload_tts_model (line 23) | def unload_tts_model():
function audio_to_wav_bytes (line 29) | def audio_to_wav_bytes(audio: np.ndarray, sample_rate: int) -> bytes:
FILE: backend/services/versions.py
function _version_response (line 25) | def _version_response(v: DBGenerationVersion) -> GenerationVersionResponse:
function list_versions (line 43) | def list_versions(generation_id: str, db: Session) -> List[GenerationVer...
function get_version (line 54) | def get_version(version_id: str, db: Session) -> Optional[GenerationVers...
function get_default_version (line 62) | def get_default_version(generation_id: str, db: Session) -> Optional[Gen...
function create_version (line 82) | def create_version(
function set_default_version (line 122) | def set_default_version(version_id: str, db: Session) -> Optional[Genera...
function delete_version (line 142) | def delete_version(version_id: str, db: Session) -> bool:
function delete_versions_for_generation (line 187) | def delete_versions_for_generation(generation_id: str, db: Session) -> int:
function _clear_defaults (line 206) | def _clear_defaults(generation_id: str, db: Session) -> None:
FILE: backend/tests/test_cors.py
function _build_app (line 23) | def _build_app(env_origins: str = "") -> FastAPI:
function client (line 58) | def client():
function client_with_custom_origins (line 63) | def client_with_custom_origins():
function _get_with_origin (line 67) | def _get_with_origin(client: TestClient, origin: str) -> dict:
function _preflight (line 73) | def _preflight(client: TestClient, origin: str) -> dict:
class TestCORSDefaultOrigins (line 85) | class TestCORSDefaultOrigins:
method test_allowed_origins (line 96) | def test_allowed_origins(self, client, origin):
method test_blocked_origins (line 106) | def test_blocked_origins(self, client, origin):
method test_preflight_allowed (line 110) | def test_preflight_allowed(self, client):
method test_preflight_blocked (line 114) | def test_preflight_blocked(self, client):
method test_credentials_header_present (line 118) | def test_credentials_header_present(self, client):
class TestCORSCustomOrigins (line 123) | class TestCORSCustomOrigins:
method test_custom_origin_allowed (line 126) | def test_custom_origin_allowed(self, client_with_custom_origins):
method test_other_custom_origin_allowed (line 130) | def test_other_custom_origin_allowed(self, client_with_custom_origins):
method test_default_origins_still_work (line 134) | def test_default_origins_still_work(self, client_with_custom_origins):
method test_unlisted_origin_still_blocked (line 138) | def test_unlisted_origin_still_blocked(self, client_with_custom_origins):
class TestCORSEnvVarParsing (line 143) | class TestCORSEnvVarParsing:
method test_empty_env_var (line 146) | def test_empty_env_var(self):
method test_whitespace_trimmed (line 152) | def test_whitespace_trimmed(self):
method test_trailing_comma_ignored (line 158) | def test_trailing_comma_ignored(self):
FILE: backend/tests/test_generation_download.py
function monitor_sse_stream (line 15) | async def monitor_sse_stream(model_name: str, timeout: int = 120):
function trigger_generation (line 65) | async def trigger_generation(profile_id: str, text: str, model_size: str...
function get_first_profile (line 109) | async def get_first_profile():
function check_server (line 126) | async def check_server():
function _timestamp (line 137) | def _timestamp():
function test_generation_with_cached_model (line 142) | async def test_generation_with_cached_model():
function test_generation_with_fresh_download (line 196) | async def test_generation_with_fresh_download():
function main (line 257) | async def main():
FILE: backend/tests/test_profile_duplicate_names.py
function test_db (line 25) | def test_db():
function mock_profiles_dir (line 46) | def mock_profiles_dir(monkeypatch, tmp_path):
function test_create_profile_duplicate_name_raises_error (line 54) | async def test_create_profile_duplicate_name_raises_error(test_db, mock_...
function test_create_profile_different_names_succeeds (line 83) | async def test_create_profile_different_names_succeeds(test_db, mock_pro...
function test_update_profile_to_duplicate_name_raises_error (line 110) | async def test_update_profile_to_duplicate_name_raises_error(test_db, mo...
function test_update_profile_keep_same_name_succeeds (line 143) | async def test_update_profile_keep_same_name_succeeds(test_db, mock_prof...
function test_update_profile_to_new_unique_name_succeeds (line 170) | async def test_update_profile_to_new_unique_name_succeeds(test_db, mock_...
function test_case_sensitive_names_allowed (line 196) | async def test_case_sensitive_names_allowed(test_db, mock_profiles_dir):
FILE: backend/tests/test_progress.py
function test_progress_manager_basic (line 21) | def test_progress_manager_basic():
function test_progress_manager_sse (line 57) | async def test_progress_manager_sse():
function test_hf_progress_tracker (line 120) | def test_hf_progress_tracker():
function test_full_integration (line 166) | async def test_full_integration():
function main (line 252) | async def main():
FILE: backend/tests/test_qwen_download.py
function monitor_sse_stream (line 23) | async def monitor_sse_stream(model_name: str, timeout: int = 600) -> Lis...
function trigger_download (line 95) | async def trigger_download(model_name: str) -> bool:
function delete_model (line 112) | async def delete_model(model_name: str) -> bool:
function check_model_status (line 135) | async def check_model_status(model_name: str) -> Optional[Dict]:
function check_server (line 150) | async def check_server() -> bool:
function main (line 160) | async def main():
FILE: backend/tests/test_whisper_download.py
function monitor_sse_stream (line 11) | async def monitor_sse_stream(model_name: str, timeout: int = 300):
function trigger_download (line 53) | async def trigger_download(model_name: str):
function check_server (line 65) | async def check_server():
function main (line 76) | async def main():
FILE: backend/utils/audio.py
function normalize_audio (line 11) | def normalize_audio(
function load_audio (line 47) | def load_audio(
function save_audio (line 67) | def save_audio(
function trim_tts_output (line 113) | def trim_tts_output(
function validate_reference_audio (line 202) | def validate_reference_audio(
function validate_and_load_reference_audio (line 226) | def validate_and_load_reference_audio(
FILE: backend/utils/cache.py
function _get_cache_dir (line 16) | def _get_cache_dir() -> Path:
function get_cache_key (line 25) | def get_cache_key(audio_path: str, reference_text: str) -> str:
function get_cached_voice_prompt (line 47) | def get_cached_voice_prompt(
function cache_voice_prompt (line 77) | def cache_voice_prompt(
function clear_voice_prompt_cache (line 96) | def clear_voice_prompt_cache() -> int:
function clear_profile_cache (line 130) | def clear_profile_cache(profile_id: str) -> int:
FILE: backend/utils/chunked_tts.py
function split_text_into_chunks (line 61) | def split_text_into_chunks(text: str, max_chars: int = DEFAULT_MAX_CHUNK...
function _find_last_sentence_end (line 107) | def _find_last_sentence_end(text: str) -> int:
function _find_last_clause_boundary (line 142) | def _find_last_clause_boundary(text: str) -> int:
function _inside_bracket_tag (line 154) | def _inside_bracket_tag(text: str, pos: int) -> bool:
function _safe_hard_cut (line 162) | def _safe_hard_cut(segment: str, max_chars: int) -> int:
function concatenate_audio_chunks (line 172) | def concatenate_audio_chunks(
function generate_chunked (line 204) | async def generate_chunked(
FILE: backend/utils/dac_shim.py
function snake (line 30) | def snake(x: torch.Tensor, alpha: torch.Tensor) -> torch.Tensor:
class Snake1d (line 38) | class Snake1d(nn.Module):
method __init__ (line 39) | def __init__(self, channels: int):
method forward (line 43) | def forward(self, x: torch.Tensor) -> torch.Tensor:
function install_dac_shim (line 49) | def install_dac_shim() -> None:
FILE: backend/utils/effects.py
function get_available_effects (line 258) | def get_available_effects() -> List[Dict[str, Any]]:
function get_builtin_presets (line 276) | def get_builtin_presets() -> Dict[str, Dict[str, Any]]:
function validate_effects_chain (line 281) | def validate_effects_chain(effects_chain: List[Dict[str, Any]]) -> Optio...
function build_pedalboard (line 318) | def build_pedalboard(effects_chain: List[Dict[str, Any]]) -> Pedalboard:
function apply_effects (line 342) | def apply_effects(
FILE: backend/utils/hf_offline_patch.py
function patch_huggingface_hub_offline (line 15) | def patch_huggingface_hub_offline():
function ensure_original_qwen_config_cached (line 58) | def ensure_original_qwen_config_cached():
FILE: backend/utils/hf_progress.py
class HFProgressTracker (line 14) | class HFProgressTracker:
method __init__ (line 17) | def __init__(self, progress_callback: Optional[Callable] = None, filte...
method _create_tracked_tqdm_class (line 30) | def _create_tracked_tqdm_class(self):
method patch_download (line 217) | def patch_download(self):
function create_hf_progress_callback (line 365) | def create_hf_progress_callback(model_name: str, progress_manager):
FILE: backend/utils/images.py
function validate_image (line 13) | def validate_image(file_path: str) -> Tuple[bool, Optional[str]]:
function process_avatar (line 47) | def process_avatar(input_path: str, output_path: str, max_size: int = MA...
FILE: backend/utils/platform_detect.py
function is_apple_silicon (line 9) | def is_apple_silicon() -> bool:
function get_backend_type (line 19) | def get_backend_type() -> Literal["mlx", "pytorch"]:
FILE: backend/utils/progress.py
class ProgressManager (line 13) | class ProgressManager:
method __init__ (line 23) | def __init__(self):
method _set_main_loop (line 31) | def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
method _notify_listeners_threadsafe (line 35) | def _notify_listeners_threadsafe(self, model_name: str, progress_data:...
method update_progress (line 64) | def update_progress(
method get_progress (line 146) | def get_progress(self, model_name: str) -> Optional[Dict]:
method get_all_active (line 152) | def get_all_active(self) -> List[Dict]:
method create_progress_callback (line 162) | def create_progress_callback(self, model_name: str, filename: Optional...
method subscribe (line 190) | async def subscribe(self, model_name: str):
method mark_complete (line 259) | def mark_complete(self, model_name: str):
method mark_error (line 277) | def mark_error(self, model_name: str, error: str):
function get_progress_manager (line 310) | def get_progress_manager() -> ProgressManager:
FILE: backend/utils/tasks.py
class DownloadTask (line 11) | class DownloadTask:
class GenerationTask (line 20) | class GenerationTask:
class TaskManager (line 28) | class TaskManager:
method __init__ (line 31) | def __init__(self):
method start_download (line 35) | def start_download(self, model_name: str) -> None:
method complete_download (line 42) | def complete_download(self, model_name: str) -> None:
method error_download (line 47) | def error_download(self, model_name: str, error: str) -> None:
method start_generation (line 53) | def start_generation(self, task_id: str, profile_id: str, text: str) -...
method complete_generation (line 62) | def complete_generation(self, task_id: str) -> None:
method get_active_downloads (line 67) | def get_active_downloads(self) -> List[DownloadTask]:
method get_active_generations (line 71) | def get_active_generations(self) -> List[GenerationTask]:
method cancel_download (line 75) | def cancel_download(self, model_name: str) -> bool:
method clear_all (line 79) | def clear_all(self) -> None:
method is_download_active (line 84) | def is_download_active(self, model_name: str) -> bool:
method is_generation_active (line 88) | def is_generation_active(self, task_id: str) -> bool:
function get_task_manager (line 97) | def get_task_manager() -> TaskManager:
FILE: docs/app/[[...slug]]/layout.tsx
function Layout (line 5) | function Layout({ children }: LayoutProps<'/[[...slug]]'>) {
FILE: docs/app/[[...slug]]/page.tsx
function Page (line 10) | async function Page(props: PageProps<'/[[...slug]]'>) {
function generateStaticParams (line 58) | async function generateStaticParams() {
function generateMetadata (line 62) | async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promi...
FILE: docs/app/layout.tsx
function Layout (line 9) | function Layout({ children }: LayoutProps<'/'>) {
FILE: docs/app/llms-full.txt/route.ts
function GET (line 5) | async function GET() {
FILE: docs/app/llms.mdx/docs/[[...slug]]/route.ts
function GET (line 6) | async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/do...
function generateStaticParams (line 18) | function generateStaticParams() {
FILE: docs/app/og/docs/[...slug]/route.tsx
function GET (line 8) | async function GET(
function generateStaticParams (line 31) | function generateStaticParams() {
FILE: docs/components/ai/page-actions.tsx
function MarkdownCopyButton (line 11) | function MarkdownCopyButton({
function ViewOptionsPopover (line 60) | function ViewOptionsPopover({
FILE: docs/components/ui/button.tsx
type ButtonProps (line 29) | type ButtonProps = VariantProps<typeof buttonVariants>;
FILE: docs/lib/layout.shared.tsx
function baseOptions (line 3) | function baseOptions(): BaseLayoutProps {
FILE: docs/lib/source.ts
function getPageImage (line 12) | function getPageImage(page: InferPageType<typeof source>) {
function getLLMText (line 21) | async function getLLMText(page: InferPageType<typeof source>) {
FILE: docs/mdx-components.tsx
function AccordionGroup (line 12) | function AccordionGroup({ children }: { children: ReactNode }) {
function Accordion (line 16) | function Accordion({ title, children }: { title: string; children: React...
function getMDXComponents (line 28) | function getMDXComponents(components?: MDXComponents): MDXComponents {
FILE: docs/next.config.mjs
method rewrites (line 8) | async rewrites() {
FILE: landing/src/app/api/releases/route.ts
function GET (line 6) | async function GET() {
FILE: landing/src/app/api/stars/route.ts
function GET (line 7) | async function GET() {
FILE: landing/src/app/download/[platform]/route.ts
constant PLATFORM_MAP (line 6) | const PLATFORM_MAP: Record<
function GET (line 16) | async function GET(
FILE: landing/src/app/layout.tsx
function RootLayout (line 40) | function RootLayout({ children }: { children: React.ReactNode }) {
FILE: landing/src/app/linux-install/page.tsx
function LinuxInstall (line 11) | function LinuxInstall() {
FILE: landing/src/app/og/page.tsx
function OgPreview (line 3) | function OgPreview() {
FILE: landing/src/app/page.tsx
function Home (line 14) | function Home() {
FILE: landing/src/components/Banner.tsx
function Banner (line 3) | function Banner() {
FILE: landing/src/components/ControlUI.tsx
type VoiceProfile (line 25) | interface VoiceProfile {
constant PROFILES (line 33) | const PROFILES: VoiceProfile[] = [
type DemoStep (line 109) | interface DemoStep {
constant DEMO_SCRIPT (line 118) | const DEMO_SCRIPT: DemoStep[] = [
type Generation (line 166) | interface Generation {
constant INITIAL_GENERATIONS (line 178) | const INITIAL_GENERATIONS: Generation[] = [
constant SIDEBAR_ITEMS (line 236) | const SIDEBAR_ITEMS = [
type Phase (line 248) | type Phase = 'idle' | 'selecting' | 'typing' | 'generating' | 'complete'...
constant PHASE_DURATIONS (line 250) | const PHASE_DURATIONS: Record<Phase, number> = {
function TypewriterText (line 261) | function TypewriterText({ text, speed }: { text: string; speed?: number ...
function LoadingBars (line 292) | function LoadingBars({ mode }: { mode: 'idle' | 'generating' | 'playing'...
function HistoryRow (line 363) | function HistoryRow({
function FloatingGenerateBox (line 436) | function FloatingGenerateBox({
function ControlUI (line 524) | function ControlUI() {
FILE: landing/src/components/DownloadSection.tsx
function DownloadSection (line 8) | function DownloadSection() {
FILE: landing/src/components/Features.tsx
function LazyLoad (line 9) | function LazyLoad({
function VoiceCloningAnimation (line 46) | function VoiceCloningAnimation() {
constant WAVEFORM_BAR_COUNT (line 118) | const WAVEFORM_BAR_COUNT = 60;
function MiniWaveform (line 120) | function MiniWaveform({ seed, color }: { seed: number; color: string }) {
type DemoClip (line 178) | type DemoClip = { id: string; profile: string; track: number; x: number;...
constant INITIAL_CLIPS (line 180) | const INITIAL_CLIPS: DemoClip[] = [
constant TL_W (line 189) | const TL_W = 220;
type Action (line 191) | type Action = { label: string; apply: (clips: DemoClip[]) => DemoClip[] };
constant ACTIONS (line 193) | const ACTIONS: Action[] = [
function StoriesAnimation (line 238) | function StoriesAnimation() {
function EffectsAnimation (line 455) | function EffectsAnimation() {
function LocalRemoteAnimation (line 549) | function LocalRemoteAnimation() {
function TranscriptionAnimation (line 604) | function TranscriptionAnimation() {
function UnlimitedLengthAnimation (line 650) | function UnlimitedLengthAnimation() {
constant FEATURES (line 763) | const FEATURES = [
function FeatureCard (line 810) | function FeatureCard({ feature }: { feature: (typeof FEATURES)[number] }) {
function Features (line 834) | function Features() {
FILE: landing/src/components/Footer.tsx
function Footer (line 5) | function Footer() {
FILE: landing/src/components/Header.tsx
function Header (line 8) | function Header() {
FILE: landing/src/components/LandingAudioPlayer.tsx
function formatDuration (line 7) | function formatDuration(seconds: number): string {
function unlockAudioContext (line 18) | function unlockAudioContext() {
type LandingAudioPlayerProps (line 56) | interface LandingAudioPlayerProps {
function LandingAudioPlayer (line 65) | function LandingAudioPlayer({
FILE: landing/src/components/Navbar.tsx
function formatStarCount (line 8) | function formatStarCount(count: number): string {
function Navbar (line 16) | function Navbar() {
FILE: landing/src/components/PlatformIcons.tsx
function AppleIcon (line 1) | function AppleIcon({ className }: { className?: string }) {
function WindowsIcon (line 9) | function WindowsIcon({ className }: { className?: string }) {
function LinuxIcon (line 17) | function LinuxIcon({ className }: { className?: string }) {
FILE: landing/src/components/VoiceCreator.tsx
function generateWaveformBars (line 9) | function generateWaveformBars(count: number, seed: number): number[] {
function WaveformBackground (line 27) | function WaveformBackground({ active }: { active: boolean }) {
function UploadPanel (line 67) | function UploadPanel() {
function RecordPanel (line 133) | function RecordPanel() {
function SystemPanel (line 242) | function SystemPanel() {
constant TABS (line 352) | const TABS = [
type TabId (line 358) | type TabId = (typeof TABS)[number]['id'];
function VoiceCreator (line 362) | function VoiceCreator() {
FILE: landing/src/components/ui/button.tsx
type ButtonProps (line 37) | interface ButtonProps
FILE: landing/src/components/ui/feature-card.tsx
type FeatureCardProps (line 5) | interface FeatureCardProps {
function FeatureCard (line 12) | function FeatureCard({ title, description, icon, className }: FeatureCar...
FILE: landing/src/components/ui/hero.tsx
type HeroProps (line 7) | interface HeroProps {
function Hero (line 15) | function Hero({ title, description, actions, className, showLogo = true ...
FILE: landing/src/components/ui/section.tsx
type SectionProps (line 4) | interface SectionProps {
function Section (line 10) | function Section({ children, className, id }: SectionProps) {
function SectionTitle (line 18) | function SectionTitle({ children, className }: { children: ReactNode; cl...
FILE: landing/src/lib/constants.ts
constant LATEST_VERSION (line 3) | const LATEST_VERSION = 'v0.1.0';
constant GITHUB_REPO (line 5) | const GITHUB_REPO = 'https://github.com/jamiepine/voicebox';
constant GITHUB_RELEASES_PAGE (line 6) | const GITHUB_RELEASES_PAGE = `${GITHUB_REPO}/releases`;
constant DOWNLOAD_LINKS (line 8) | const DOWNLOAD_LINKS = {
FILE: landing/src/lib/releases.ts
type DownloadLinks (line 2) | interface DownloadLinks {
type ReleaseInfo (line 9) | interface ReleaseInfo {
constant GITHUB_REPO (line 15) | const GITHUB_REPO = 'jamiepine/voicebox';
constant GITHUB_API_BASE (line 16) | const GITHUB_API_BASE = 'https://api.github.com';
constant CACHE_DURATION (line 21) | const CACHE_DURATION = 1000 * 60 * 5;
function getLatestRelease (line 30) | async function getLatestRelease(): Promise<ReleaseInfo> {
function getTotalDownloads (line 114) | async function getTotalDownloads(): Promise<number> {
function getStarCount (line 161) | async function getStarCount(): Promise<number> {
FILE: landing/src/lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: scripts/package_cuda.py
function is_nvidia_file (line 53) | def is_nvidia_file(rel_path: str) -> bool:
function sha256_file (line 87) | def sha256_file(path: Path) -> str:
function package (line 99) | def package(
function main (line 193) | def main():
FILE: scripts/setup-dev-sidecar.js
constant BINARIES_DIR (line 21) | const BINARIES_DIR = join(__dirname, '..', 'tauri', 'src-tauri', 'binari...
constant MIN_REAL_BINARY_SIZE (line 24) | const MIN_REAL_BINARY_SIZE = 10000;
function getTargetTriple (line 27) | function getTargetTriple() {
function createPlaceholderBinary (line 49) | function createPlaceholderBinary(targetTriple) {
function main (line 366) | function main() {
FILE: scripts/test_download_progress.py
class ProgressSpy (line 54) | class ProgressSpy:
method __init__ (line 57) | def __init__(self):
method _elapsed (line 66) | def _elapsed(self):
method _log (line 69) | def _log(self, event_type, **kwargs):
method _create_tracked_tqdm_class (line 82) | def _create_tracked_tqdm_class(self):
method patch (line 154) | def patch(self):
method summary (line 255) | def summary(self):
function delete_cache (line 292) | def delete_cache(repo_id: str):
function download_qwen (line 306) | def download_qwen(spy: ProgressSpy):
function download_luxtts (line 318) | def download_luxtts(spy: ProgressSpy):
function download_chatterbox (line 328) | def download_chatterbox(spy: ProgressSpy):
function main (line 346) | def main():
FILE: tauri/src-tauri/build.rs
function main (line 4) | fn main() {
FILE: tauri/src-tauri/src/audio_capture/linux.rs
function start_capture (line 17) | pub async fn start_capture(
function stop_capture (line 233) | pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, S...
function is_supported (line 268) | pub fn is_supported() -> bool {
function samples_to_wav (line 284) | fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> R...
FILE: tauri/src-tauri/src/audio_capture/macos.rs
function start_capture (line 19) | pub async fn start_capture(
function stop_capture (line 114) | pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, S...
function is_supported (line 146) | pub fn is_supported() -> bool {
function extract_audio_samples (line 160) | fn extract_audio_samples(sample_buffer: CMSampleBuffer) -> Result<Vec<f3...
function samples_to_wav (line 229) | fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> R...
FILE: tauri/src-tauri/src/audio_capture/mod.rs
type AudioCaptureState (line 20) | pub struct AudioCaptureState {
method new (line 31) | pub fn new() -> Self {
method reset (line 43) | pub fn reset(&self) {
FILE: tauri/src-tauri/src/audio_capture/windows.rs
function start_capture (line 11) | pub async fn start_capture(
function stop_capture (line 219) | pub async fn stop_capture(state: &AudioCaptureState) -> Result<String, S...
function is_supported (line 251) | pub fn is_supported() -> bool {
function samples_to_wav (line 262) | fn samples_to_wav(samples: &[f32], sample_rate: u32, channels: u16) -> R...
FILE: tauri/src-tauri/src/audio_output.rs
type AudioOutputDevice (line 7) | pub struct AudioOutputDevice {
type AudioOutputState (line 13) | pub struct AudioOutputState {
method new (line 19) | pub fn new() -> Self {
method stop_all_playback (line 26) | pub fn stop_all_playback(&self) -> Result<(), String> {
method list_output_devices (line 33) | pub fn list_output_devices(&self) -> Result<Vec<AudioOutputDevice>, St...
method play_audio_to_devices (line 65) | pub async fn play_audio_to_devices(
method decode_wav (line 123) | fn decode_wav(&self, data: &[u8]) -> Result<(Vec<f32>, u32, u16), Stri...
method play_to_device (line 244) | fn play_to_device(
method resample (line 411) | fn resample(&self, samples: &[f32], from_rate: u32, to_rate: u32) -> V...
method interleave_channels (line 432) | fn interleave_channels(
method default (line 462) | fn default() -> Self {
FILE: tauri/src-tauri/src/main.rs
constant LEGACY_PORT (line 12) | const LEGACY_PORT: u16 = 8000;
constant SERVER_PORT (line 13) | const SERVER_PORT: u16 = 17493;
function find_voicebox_pid_on_port (line 23) | fn find_voicebox_pid_on_port(port: u16) -> Option<u32> {
type ServerState (line 56) | struct ServerState {
function start_server (line 64) | async fn start_server(
function stop_server (line 529) | async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
function restart_server (line 579) | async fn restart_server(
function set_keep_server_running (line 608) | fn set_keep_server_running(state: State<'_, ServerState>, keep_running: ...
function start_system_audio_capture (line 614) | async fn start_system_audio_capture(
function stop_system_audio_capture (line 622) | async fn stop_system_audio_capture(
function is_system_audio_supported (line 629) | fn is_system_audio_supported() -> bool {
function list_audio_output_devices (line 634) | fn list_audio_output_devices(
function play_audio_to_devices (line 641) | async fn play_audio_to_devices(
function stop_audio_playback (line 650) | fn stop_audio_playback(
function run (line 657) | pub fn run() {
function main (line 852) | fn main() {
FILE: tauri/src-tauri/tests/audio_capture_test.rs
function test_system_audio_capture (line 11) | async fn test_system_audio_capture() {
FILE: tauri/src/platform/audio.ts
method isSystemAudioSupported (line 5) | isSystemAudioSupported(): boolean {
method startSystemAudioCapture (line 10) | async startSystemAudioCapture(maxDurationSecs: number): Promise<void> {
method stopSystemAudioCapture (line 16) | async stopSystemAudioCapture(): Promise<Blob> {
method listOutputDevices (line 29) | async listOutputDevices(): Promise<AudioDevice[]> {
method playToDevices (line 33) | async playToDevices(audioData: Uint8Array, deviceIds: string[]): Promise...
method stopPlayback (line 40) | stopPlayback(): void {
FILE: tauri/src/platform/filesystem.ts
method saveFile (line 4) | async saveFile(filename: string, blob: Blob, filters?: FileFilter[]) {
method openPath (line 26) | async openPath(path: string) {
method pickDirectory (line 31) | async pickDirectory(title: string) {
FILE: tauri/src/platform/lifecycle.ts
class TauriLifecycle (line 5) | class TauriLifecycle implements PlatformLifecycle {
method startServer (line 8) | async startServer(remote = false, modelsDir?: string | null): Promise<...
method stopServer (line 23) | async stopServer(): Promise<void> {
method restartServer (line 33) | async restartServer(modelsDir?: string | null): Promise<string> {
method setKeepServerRunning (line 47) | async setKeepServerRunning(keepRunning: boolean): Promise<void> {
method setupWindowCloseHandler (line 55) | async setupWindowCloseHandler(): Promise<void> {
method subscribeToServerLogs (line 90) | subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () =...
FILE: tauri/src/platform/metadata.ts
method getVersion (line 5) | async getVersion(): Promise<string> {
FILE: tauri/src/platform/updater.ts
class TauriUpdater (line 10) | class TauriUpdater implements PlatformUpdater {
method notifySubscribers (line 22) | private notifySubscribers() {
method subscribe (line 26) | subscribe(callback: (status: UpdateStatus) => void): () => void {
method getStatus (line 35) | getStatus(): UpdateStatus {
method checkForUpdates (line 39) | async checkForUpdates(): Promise<void> {
method downloadAndInstall (line 83) | async downloadAndInstall(): Promise<void> {
method restartAndInstall (line 145) | async restartAndInstall(): Promise<void> {
FILE: web/src/platform/audio.ts
method isSystemAudioSupported (line 4) | isSystemAudioSupported(): boolean {
method startSystemAudioCapture (line 8) | async startSystemAudioCapture(_maxDurationSecs: number): Promise<void> {
method stopSystemAudioCapture (line 12) | async stopSystemAudioCapture(): Promise<Blob> {
method listOutputDevices (line 16) | async listOutputDevices(): Promise<AudioDevice[]> {
method playToDevices (line 20) | async playToDevices(_audioData: Uint8Array, _deviceIds: string[]): Promi...
method stopPlayback (line 24) | stopPlayback(): void {
FILE: web/src/platform/filesystem.ts
method saveFile (line 4) | async saveFile(filename: string, blob: Blob, _filters?: FileFilter[]) {
method openPath (line 16) | async openPath(_path: string) {
method pickDirectory (line 20) | async pickDirectory(_title: string) {
FILE: web/src/platform/lifecycle.ts
class WebLifecycle (line 3) | class WebLifecycle implements PlatformLifecycle {
method startServer (line 6) | async startServer(_remote = false, _modelsDir?: string | null): Promis...
method stopServer (line 14) | async stopServer(): Promise<void> {
method restartServer (line 18) | async restartServer(_modelsDir?: string | null): Promise<string> {
method setKeepServerRunning (line 23) | async setKeepServerRunning(_keep: boolean): Promise<void> {
method setupWindowCloseHandler (line 27) | async setupWindowCloseHandler(): Promise<void> {
method subscribeToServerLogs (line 31) | subscribeToServerLogs(_callback: (_entry: ServerLogEntry) => void): ()...
FILE: web/src/platform/metadata.ts
method getVersion (line 4) | async getVersion(): Promise<string> {
FILE: web/src/platform/updater.ts
class WebUpdater (line 3) | class WebUpdater implements PlatformUpdater {
method notifySubscribers (line 14) | private notifySubscribers() {
method subscribe (line 18) | subscribe(callback: (status: UpdateStatus) => void): () => void {
method getStatus (line 26) | getStatus(): UpdateStatus {
method checkForUpdates (line 30) | async checkForUpdates(): Promise<void> {
method downloadAndInstall (line 35) | async downloadAndInstall(): Promise<void> {
method restartAndInstall (line 39) | async restartAndInstall(): Promise<void> {
Condensed preview — 453 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (3,664K chars).
[
{
"path": ".agents/skills/add-tts-engine/SKILL.md",
"chars": 5357,
"preview": "---\nname: add-tts-engine\ndescription: Use this skill to add a new TTS engine to Voicebox. It walks through dependency re"
},
{
"path": ".agents/skills/draft-release-notes/SKILL.md",
"chars": 3418,
"preview": "---\nname: draft-release-notes\ndescription: Use this skill to draft or update the [Unreleased] section of CHANGELOG.md fr"
},
{
"path": ".agents/skills/release-bump/SKILL.md",
"chars": 4226,
"preview": "---\nname: release-bump\ndescription: Use this skill to finalize a release. It stamps the [Unreleased] changelog section w"
},
{
"path": ".biomeignore",
"chars": 236,
"preview": "# Dependencies\nnode_modules\nbun.lockb\n\n# Build outputs\ndist\ntarget\n.tauri\n\n# Generated files\napp/src/lib/api\n\n# Config f"
},
{
"path": ".bumpversion.cfg",
"chars": 1130,
"preview": "[bumpversion]\ncurrent_version = 0.3.1\ncommit = True\ntag = True\ntag_name = v{new_version}\ntag_message = Release v{new_ver"
},
{
"path": ".dockerignore",
"chars": 539,
"preview": "# Version control\n.git\n.github\n.gitignore\n\n# Desktop-only (not needed in web container)\ntauri/\nlanding/\ndocs/\nmlx-test/\n"
},
{
"path": ".github/workflows/build-windows.yml",
"chars": 1618,
"preview": "name: Build Windows\n\non:\n workflow_dispatch:\n\njobs:\n build-windows:\n permissions:\n contents: write\n runs-on"
},
{
"path": ".github/workflows/release.yml",
"chars": 8442,
"preview": "name: Release\n\non:\n workflow_dispatch:\n push:\n tags:\n - \"v*\"\n\njobs:\n release:\n permissions:\n contents"
},
{
"path": ".gitignore",
"chars": 607,
"preview": "# Dependencies\nnode_modules/\nbun.lockb\n__pycache__/\n*.py[cod]\n*$py.class\n*.so\n.Python\nvenv/\nenv/\nENV/\n*.prompt\n# Build o"
},
{
"path": ".npmrc",
"chars": 37,
"preview": "# Force bun usage\nengine-strict=true\n"
},
{
"path": "CHANGELOG.md",
"chars": 24738,
"preview": "<!-- This file is compiled automatically during the release workflow. -->\n<!-- Do not edit manually — your changes will "
},
{
"path": "CONTRIBUTING.md",
"chars": 10501,
"preview": "# Contributing to Voicebox\n\nThank you for your interest in contributing to Voicebox! This document provides guidelines a"
},
{
"path": "Dockerfile",
"chars": 2532,
"preview": "# ============================================================\n# Voicebox — Local TTS Server with Web UI (CPU)\n# 3-stage"
},
{
"path": "LICENSE",
"chars": 1078,
"preview": "MIT License\n\nCopyright (c) 2026 Voicebox Contributors\n\nPermission is hereby granted, free of charge, to any person obtai"
},
{
"path": "README.md",
"chars": 13493,
"preview": "<p align=\"center\">\n <img src=\".github/assets/icon-dark.webp\" alt=\"Voicebox\" width=\"120\" height=\"120\" />\n</p>\n\n<h1 align"
},
{
"path": "SECURITY.md",
"chars": 2639,
"preview": "# Security Policy\n\n## Supported Versions\n\nWe release patches for security vulnerabilities. Which versions are eligible f"
},
{
"path": "app/components.json",
"chars": 419,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"new-york\",\n \"rsc\": false,\n \"tsx\": true,\n \"tailwind\": "
},
{
"path": "app/index.html",
"chars": 370,
"preview": "<!doctype html>\n<html lang=\"en\" class=\"dark\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" type=\"image/svg"
},
{
"path": "app/package.json",
"chars": 2079,
"preview": "{\n \"name\": \"@voicebox/app\",\n \"version\": \"0.3.1\",\n \"private\": true,\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vit"
},
{
"path": "app/plugins/changelog.ts",
"chars": 672,
"preview": "import { readFileSync } from 'node:fs';\nimport path from 'node:path';\nimport type { Plugin } from 'vite';\n\n/** Vite plug"
},
{
"path": "app/src/App.tsx",
"chars": 7101,
"preview": "import { RouterProvider } from '@tanstack/react-router';\nimport { useEffect, useRef, useState } from 'react';\nimport voi"
},
{
"path": "app/src/components/AppFrame/AppFrame.tsx",
"chars": 1331,
"preview": "import { useRouterState } from '@tanstack/react-router';\nimport { TitleBarDragRegion } from '@/components/TitleBarDragRe"
},
{
"path": "app/src/components/AudioPlayer/AudioPlayer.tsx",
"chars": 21466,
"preview": "import { useQuery } from '@tanstack/react-query';\nimport { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react"
},
{
"path": "app/src/components/AudioStudio/.gitkeep",
"chars": 43,
"preview": "# Audio studio timeline editing components\n"
},
{
"path": "app/src/components/AudioTab/AudioTab.tsx",
"chars": 24618,
"preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Check, CheckCircle2, Edit, Plus,"
},
{
"path": "app/src/components/Effects/EffectsChainEditor.tsx",
"chars": 11566,
"preview": "import {\n closestCenter,\n DndContext,\n type DragEndEvent,\n KeyboardSensor,\n PointerSensor,\n useSensor,\n useSensor"
},
{
"path": "app/src/components/Effects/GenerationPicker.tsx",
"chars": 3936,
"preview": "import { ChevronDown, Search } from 'lucide-react';\nimport { useMemo, useState } from 'react';\nimport { Button } from '@"
},
{
"path": "app/src/components/EffectsTab/EffectsDetail.tsx",
"chars": 14237,
"preview": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Loader2, Play, Save, Trash2, Wand2 } from 'lu"
},
{
"path": "app/src/components/EffectsTab/EffectsList.tsx",
"chars": 5539,
"preview": "import { useQuery } from '@tanstack/react-query';\nimport { Loader2, Plus, Sparkles, Wand2 } from 'lucide-react';\nimport "
},
{
"path": "app/src/components/EffectsTab/EffectsTab.tsx",
"chars": 545,
"preview": "import {EffectsDetail} from \"./EffectsDetail\";\nimport {EffectsList} from \"./EffectsList\";\n\nexport function EffectsTab() "
},
{
"path": "app/src/components/Generation/EngineModelSelector.tsx",
"chars": 6050,
"preview": "import { useEffect } from 'react';\nimport type { UseFormReturn } from 'react-hook-form';\nimport { FormControl } from '@/"
},
{
"path": "app/src/components/Generation/FloatingGenerateBox.tsx",
"chars": 18855,
"preview": "import { useQuery } from '@tanstack/react-query';\nimport { useMatchRoute } from '@tanstack/react-router';\nimport { Anima"
},
{
"path": "app/src/components/Generation/GenerationForm.tsx",
"chars": 7310,
"preview": "import { Loader2, Mic } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent"
},
{
"path": "app/src/components/Generation/ParalinguisticInput.tsx",
"chars": 15066,
"preview": "/**\n * ParalinguisticInput — a contentEditable rich text input that renders\n * Chatterbox Turbo paralinguistic tags (e.g"
},
{
"path": "app/src/components/History/HistoryTable.tsx",
"chars": 33849,
"preview": "import { useQueryClient } from '@tanstack/react-query';\nimport { AnimatePresence, motion } from 'framer-motion';\nimport "
},
{
"path": "app/src/components/MainEditor/MainEditor.tsx",
"chars": 5681,
"preview": "import { Sparkles, Upload } from 'lucide-react';\nimport { useRef, useState } from 'react';\nimport { FloatingGenerateBox "
},
{
"path": "app/src/components/ModelsTab/ModelsTab.tsx",
"chars": 208,
"preview": "import { ModelManagement } from '@/components/ServerSettings/ModelManagement';\n\nexport function ModelsTab() {\n return ("
},
{
"path": "app/src/components/ServerSettings/ConnectionForm.tsx",
"chars": 7298,
"preview": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { Loader2, XCircle } from 'lucide-react';\nimport { useEffe"
},
{
"path": "app/src/components/ServerSettings/GenerationSettings.tsx",
"chars": 4577,
"preview": "import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Checkbox } fr"
},
{
"path": "app/src/components/ServerSettings/GpuAcceleration.tsx",
"chars": 13869,
"preview": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { AlertCircle, Download, Loader2, RotateCw, Tra"
},
{
"path": "app/src/components/ServerSettings/ModelManagement.tsx",
"chars": 45815,
"preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport {\n ChevronDown,\n ChevronRight,\n "
},
{
"path": "app/src/components/ServerSettings/ModelProgress.tsx",
"chars": 4258,
"preview": "import { Loader2, XCircle } from 'lucide-react';\nimport { useEffect, useState } from 'react';\nimport { Card, CardContent"
},
{
"path": "app/src/components/ServerSettings/ServerStatus.tsx",
"chars": 2207,
"preview": "import { Loader2, XCircle } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Card, CardConte"
},
{
"path": "app/src/components/ServerSettings/UpdateStatus.tsx",
"chars": 5586,
"preview": "import { AlertCircle, Download, RefreshCw } from 'lucide-react';\nimport { useEffect, useState } from 'react';\nimport { B"
},
{
"path": "app/src/components/ServerTab/AboutPage.tsx",
"chars": 7811,
"preview": "import { ArrowUpRight } from 'lucide-react';\nimport type { CSSProperties, ReactNode } from 'react';\nimport { useEffect, "
},
{
"path": "app/src/components/ServerTab/ChangelogPage.tsx",
"chars": 6229,
"preview": "import changelogRaw from 'virtual:changelog';\nimport { useMemo, useState } from 'react';\nimport { Badge } from '@/compon"
},
{
"path": "app/src/components/ServerTab/GeneralPage.tsx",
"chars": 15042,
"preview": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { AlertCircle, ArrowUpRight, Book, Download, Loader2, Refr"
},
{
"path": "app/src/components/ServerTab/GenerationPage.tsx",
"chars": 4810,
"preview": "import { FolderOpen } from 'lucide-react';\nimport { useCallback, useEffect, useState } from 'react';\nimport { Button } f"
},
{
"path": "app/src/components/ServerTab/GpuPage.tsx",
"chars": 14571,
"preview": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { AlertCircle, Cpu, Download, Loader2, RotateCw"
},
{
"path": "app/src/components/ServerTab/LogsPage.tsx",
"chars": 3219,
"preview": "import { useEffect, useRef, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { cn } from"
},
{
"path": "app/src/components/ServerTab/ServerTab.tsx",
"chars": 2163,
"preview": "import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';\nimport { BOTTOM_SAFE_AREA_PADDING } from '@/lib/co"
},
{
"path": "app/src/components/ServerTab/SettingRow.tsx",
"chars": 1723,
"preview": "import type { ReactNode } from 'react';\n\n/**\n * A section header with title and optional description, separated by a bor"
},
{
"path": "app/src/components/ShinyText.tsx",
"chars": 4016,
"preview": "import { motion, useAnimationFrame, useMotionValue, useTransform } from 'motion/react';\nimport type React from 'react';\n"
},
{
"path": "app/src/components/Sidebar.tsx",
"chars": 4202,
"preview": "import { Link, useMatchRoute } from '@tanstack/react-router';\nimport { AudioLines, Box, Mic, Settings, Speaker, Volume2,"
},
{
"path": "app/src/components/StoriesTab/StoriesTab.tsx",
"chars": 1040,
"preview": "import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox';\nimport { usePlayerStore } from '@/sto"
},
{
"path": "app/src/components/StoriesTab/StoryChatItem.tsx",
"chars": 5324,
"preview": "import { useSortable } from '@dnd-kit/sortable';\nimport { CSS } from '@dnd-kit/utilities';\nimport { GripVertical, Mic, M"
},
{
"path": "app/src/components/StoriesTab/StoryContent.tsx",
"chars": 13638,
"preview": "import {\n closestCenter,\n DndContext,\n type DragEndEvent,\n KeyboardSensor,\n PointerSensor,\n useSensor,\n useSensor"
},
{
"path": "app/src/components/StoriesTab/StoryList.tsx",
"chars": 14176,
"preview": "import { BookOpen, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';\nimport { useEffect, useState } from 'reac"
},
{
"path": "app/src/components/StoriesTab/StoryTrackEditor.tsx",
"chars": 38424,
"preview": "import {\n Check,\n Copy,\n GalleryVerticalEnd,\n GripHorizontal,\n Minus,\n Pause,\n Play,\n Plus,\n Scissors,\n Square"
},
{
"path": "app/src/components/TitleBarDragRegion.tsx",
"chars": 226,
"preview": "const isWindows = navigator.userAgent.includes('Windows');\n\nexport function TitleBarDragRegion() {\n if (isWindows) retu"
},
{
"path": "app/src/components/VoiceProfiles/AudioSampleRecording.tsx",
"chars": 5971,
"preview": "import { Mic, Pause, Play, Square } from 'lucide-react';\nimport { memo, useEffect, useState } from 'react';\nimport { Vis"
},
{
"path": "app/src/components/VoiceProfiles/AudioSampleSystem.tsx",
"chars": 4114,
"preview": "import { Mic, Monitor, Pause, Play, Square } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimpor"
},
{
"path": "app/src/components/VoiceProfiles/AudioSampleUpload.tsx",
"chars": 4956,
"preview": "import { Mic, Pause, Play, Upload } from 'lucide-react';\nimport { useRef, useState } from 'react';\nimport { Button } fro"
},
{
"path": "app/src/components/VoiceProfiles/ProfileCard.tsx",
"chars": 5536,
"preview": "import { Download, Edit, Sparkles, Trash2 } from 'lucide-react';\nimport { useState } from 'react';\nimport { Badge } from"
},
{
"path": "app/src/components/VoiceProfiles/ProfileForm.tsx",
"chars": 49633,
"preview": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useQuery } from '@tanstack/react-query';\nimport { Edit2,"
},
{
"path": "app/src/components/VoiceProfiles/ProfileList.tsx",
"chars": 3384,
"preview": "import { Mic, Music, Sparkles } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, Car"
},
{
"path": "app/src/components/VoiceProfiles/SampleList.tsx",
"chars": 12394,
"preview": "import { Check, Edit, Pause, Play, Plus, Trash2, Volume2, X } from 'lucide-react';\nimport { useEffect, useRef, useState "
},
{
"path": "app/src/components/VoiceProfiles/SampleUpload.tsx",
"chars": 11497,
"preview": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { Mic, Monitor, Upload } from 'lucide-react';\nimport { use"
},
{
"path": "app/src/components/VoicesTab/VoiceInspector.tsx",
"chars": 11264,
"preview": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { Edit2, Mic, X } from 'lucide-react';\nimport { useEffect,"
},
{
"path": "app/src/components/VoicesTab/VoicesTab.tsx",
"chars": 9458,
"preview": "import { useQuery, useQueryClient } from '@tanstack/react-query';\nimport { Mic, Plus, Search, Sparkles } from 'lucide-re"
},
{
"path": "app/src/components/ui/alert-dialog.tsx",
"chars": 4338,
"preview": "import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';\nimport * as React from 'react';\nimport { cn } from"
},
{
"path": "app/src/components/ui/badge.tsx",
"chars": 1122,
"preview": "import { cva, type VariantProps } from 'class-variance-authority';\nimport type * as React from 'react';\nimport { cn } fr"
},
{
"path": "app/src/components/ui/button.tsx",
"chars": 1871,
"preview": "import { Slot } from '@radix-ui/react-slot';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport *"
},
{
"path": "app/src/components/ui/card.tsx",
"chars": 1847,
"preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nconst Card = React.forwardRef<HTMLDivElement, Reac"
},
{
"path": "app/src/components/ui/checkbox.tsx",
"chars": 1250,
"preview": "import { Check } from 'lucide-react';\nimport * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nexport inter"
},
{
"path": "app/src/components/ui/circle-button.tsx",
"chars": 976,
"preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nexport interface CircleButtonProps extends React.B"
},
{
"path": "app/src/components/ui/dialog.tsx",
"chars": 3787,
"preview": "import * as DialogPrimitive from '@radix-ui/react-dialog';\nimport { X } from 'lucide-react';\nimport * as React from 'rea"
},
{
"path": "app/src/components/ui/dropdown-menu.tsx",
"chars": 7269,
"preview": "import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';\nimport { MoreHorizontal } from 'lucide-react';\ni"
},
{
"path": "app/src/components/ui/form.tsx",
"chars": 4110,
"preview": "import type * as LabelPrimitive from '@radix-ui/react-label';\nimport { Slot } from '@radix-ui/react-slot';\nimport * as R"
},
{
"path": "app/src/components/ui/input.tsx",
"chars": 853,
"preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nexport interface InputProps extends React.InputHTM"
},
{
"path": "app/src/components/ui/label.tsx",
"chars": 703,
"preview": "import * as LabelPrimitive from '@radix-ui/react-label';\nimport { cva, type VariantProps } from 'class-variance-authorit"
},
{
"path": "app/src/components/ui/multi-select.tsx",
"chars": 3392,
"preview": "import * as React from 'react';\nimport { ChevronDown, Check } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n"
},
{
"path": "app/src/components/ui/popover.tsx",
"chars": 1241,
"preview": "import * as PopoverPrimitive from '@radix-ui/react-popover';\nimport * as React from 'react';\nimport { cn } from '@/lib/u"
},
{
"path": "app/src/components/ui/progress.tsx",
"chars": 766,
"preview": "import * as ProgressPrimitive from '@radix-ui/react-progress';\nimport * as React from 'react';\nimport { cn } from '@/lib"
},
{
"path": "app/src/components/ui/select.tsx",
"chars": 5609,
"preview": "import * as SelectPrimitive from '@radix-ui/react-select';\nimport { Check, ChevronDown, ChevronUp } from 'lucide-react';"
},
{
"path": "app/src/components/ui/separator.tsx",
"chars": 725,
"preview": "import * as SeparatorPrimitive from '@radix-ui/react-separator';\nimport * as React from 'react';\nimport { cn } from '@/l"
},
{
"path": "app/src/components/ui/slider.tsx",
"chars": 1229,
"preview": "import * as SliderPrimitive from '@radix-ui/react-slider';\nimport * as React from 'react';\nimport { cn } from '@/lib/uti"
},
{
"path": "app/src/components/ui/table.tsx",
"chars": 2726,
"preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nconst Table = React.forwardRef<HTMLTableElement, R"
},
{
"path": "app/src/components/ui/tabs.tsx",
"chars": 1899,
"preview": "import * as TabsPrimitive from '@radix-ui/react-tabs';\nimport * as React from 'react';\nimport { cn } from '@/lib/utils/c"
},
{
"path": "app/src/components/ui/textarea.tsx",
"chars": 780,
"preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nexport interface TextareaProps extends React.Texta"
},
{
"path": "app/src/components/ui/toast.tsx",
"chars": 4856,
"preview": "import * as ToastPrimitives from '@radix-ui/react-toast';\nimport { cva, type VariantProps } from 'class-variance-authori"
},
{
"path": "app/src/components/ui/toaster.tsx",
"chars": 850,
"preview": "import { usePlayerStore } from '@/stores/playerStore';\nimport {\n Toast,\n ToastClose,\n ToastDescription,\n ToastProvid"
},
{
"path": "app/src/components/ui/toggle.tsx",
"chars": 1424,
"preview": "import * as React from 'react';\nimport { cn } from '@/lib/utils/cn';\n\nexport interface ToggleProps {\n checked?: boolean"
},
{
"path": "app/src/components/ui/use-toast.ts",
"chars": 3787,
"preview": "import * as React from 'react';\nimport type { ToastActionElement, ToastProps } from './toast';\n\nconst TOAST_LIMIT = 1;\nc"
},
{
"path": "app/src/global.d.ts",
"chars": 146,
"preview": "interface Window {\n __voiceboxServerStartedByApp?: boolean;\n}\n\ndeclare module 'virtual:changelog' {\n const raw: string"
},
{
"path": "app/src/hooks/useAutoUpdater.ts",
"chars": 1832,
"preview": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { usePlatform } from '@/platform/PlatformContex"
},
{
"path": "app/src/hooks/useAutoUpdater.tsx",
"chars": 6462,
"preview": "import { Download, RefreshCw } from 'lucide-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nim"
},
{
"path": "app/src/index.css",
"chars": 4019,
"preview": "@import \"tailwindcss\" source(\".\");\n@import \"loaders.css/loaders.min.css\";\n\n@theme {\n --radius-sm: calc(var(--radius) - "
},
{
"path": "app/src/lib/api/.gitkeep",
"chars": 47,
"preview": "# Generated OpenAPI client will be placed here\n"
},
{
"path": "app/src/lib/api/client.ts",
"chars": 21905,
"preview": "import type { LanguageCode } from '@/lib/constants/languages';\nimport { useServerStore } from '@/stores/serverStore';\nim"
},
{
"path": "app/src/lib/api/core/ApiError.ts",
"chars": 762,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/core/ApiRequestOptions.ts",
"chars": 619,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/core/ApiResult.ts",
"chars": 290,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/core/CancelablePromise.ts",
"chars": 3589,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/core/OpenAPI.ts",
"chars": 952,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/core/request.ts",
"chars": 9229,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/index.ts",
"chars": 2882,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/Body_add_profile_sample_profiles__profile_id__samples_post.ts",
"chars": 251,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/Body_transcribe_audio_transcribe_post.ts",
"chars": 232,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/GenerationRequest.ts",
"chars": 358,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/GenerationResponse.ts",
"chars": 405,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/HTTPValidationError.ts",
"chars": 265,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/HealthResponse.ts",
"chars": 379,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/HistoryListResponse.ts",
"chars": 324,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/HistoryResponse.ts",
"chars": 447,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/ModelDownloadRequest.ts",
"chars": 251,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/ModelStatus.ts",
"chars": 385,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/ModelStatusListResponse.ts",
"chars": 305,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/ProfileSampleResponse.ts",
"chars": 304,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/TranscriptionResponse.ts",
"chars": 255,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/ValidationError.ts",
"chars": 230,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/VoiceProfileCreate.ts",
"chars": 294,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/models/VoiceProfileResponse.ts",
"chars": 342,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$Body_add_profile_sample_profiles__profile_id__samples_post.ts",
"chars": 407,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$Body_transcribe_audio_transcribe_post.ts",
"chars": 475,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$GenerationRequest.ts",
"chars": 874,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$GenerationResponse.ts",
"chars": 948,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$HTTPValidationError.ts",
"chars": 306,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$HealthResponse.ts",
"chars": 973,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$HistoryListResponse.ts",
"chars": 446,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$HistoryResponse.ts",
"chars": 1039,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$ModelDownloadRequest.ts",
"chars": 339,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$ModelStatus.ts",
"chars": 670,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$ModelStatusListResponse.ts",
"chars": 386,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$ProfileSampleResponse.ts",
"chars": 539,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$TranscriptionResponse.ts",
"chars": 392,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$ValidationError.ts",
"chars": 580,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$VoiceProfileCreate.ts",
"chars": 638,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/schemas/$VoiceProfileResponse.ts",
"chars": 841,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/services/DefaultService.ts",
"chars": 11622,
"preview": "/* generated using openapi-typescript-codegen -- do not edit */\n/* istanbul ignore file */\n/* tslint:disable */\n/* eslin"
},
{
"path": "app/src/lib/api/types.ts",
"chars": 7607,
"preview": "// API Types matching backend Pydantic models\nimport type { LanguageCode } from '@/lib/constants/languages';\n\nexport typ"
},
{
"path": "app/src/lib/constants/languages.ts",
"chars": 1998,
"preview": "/**\n * Supported languages for voice generation, per engine.\n *\n * Qwen3-TTS supports 10 languages.\n * LuxTTS is English"
},
{
"path": "app/src/lib/constants/ui.ts",
"chars": 590,
"preview": "/**\n * UI layout constants for safe area padding\n */\n\nconst isWindows = typeof navigator !== 'undefined' && navigator.us"
},
{
"path": "app/src/lib/hooks/useAudioPlayer.ts",
"chars": 1622,
"preview": "import { useRef, useState } from 'react';\nimport { useToast } from '@/components/ui/use-toast';\n\nexport function useAudi"
},
{
"path": "app/src/lib/hooks/useAudioRecording.ts",
"chars": 7278,
"preview": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { usePlatform } from '@/platform/PlatformContex"
},
{
"path": "app/src/lib/hooks/useGeneration.ts",
"chars": 502,
"preview": "import { useMutation, useQueryClient } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api/client';\nimpor"
},
{
"path": "app/src/lib/hooks/useGenerationForm.ts",
"chars": 5749,
"preview": "import { zodResolver } from '@hookform/resolvers/zod';\nimport { useState } from 'react';\nimport { useForm } from 'react-"
},
{
"path": "app/src/lib/hooks/useGenerationProgress.ts",
"chars": 5611,
"preview": "import { useQueryClient } from '@tanstack/react-query';\nimport { useEffect, useRef } from 'react';\nimport { useToast } f"
},
{
"path": "app/src/lib/hooks/useHistory.ts",
"chars": 2505,
"preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api/clie"
},
{
"path": "app/src/lib/hooks/useModelDownloadToast.tsx",
"chars": 7837,
"preview": "import { CheckCircle2, Loader2, XCircle } from 'lucide-react';\nimport { useCallback, useEffect, useRef } from 'react';\ni"
},
{
"path": "app/src/lib/hooks/useProfiles.ts",
"chars": 4966,
"preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api/clie"
},
{
"path": "app/src/lib/hooks/useRestoreActiveTasks.tsx",
"chars": 2999,
"preview": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { apiClient } from '@/lib/api/client';\nimport t"
},
{
"path": "app/src/lib/hooks/useServer.ts",
"chars": 438,
"preview": "import { useQuery } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api/client';\nimport { useServerStore "
},
{
"path": "app/src/lib/hooks/useStories.ts",
"chars": 6494,
"preview": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api/clie"
},
{
"path": "app/src/lib/hooks/useStoryPlayback.ts",
"chars": 14268,
"preview": "import { useCallback, useEffect, useRef } from 'react';\nimport { apiClient } from '@/lib/api/client';\nimport type { Stor"
},
{
"path": "app/src/lib/hooks/useSystemAudioCapture.ts",
"chars": 4663,
"preview": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { usePlatform } from '@/platform/PlatformContex"
},
{
"path": "app/src/lib/hooks/useTranscription.ts",
"chars": 498,
"preview": "import { useMutation } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api/client';\nimport type { Whisper"
},
{
"path": "app/src/lib/utils/.gitkeep",
"chars": 40,
"preview": "# Utility functions will be placed here\n"
},
{
"path": "app/src/lib/utils/audio.ts",
"chars": 5412,
"preview": "export function createAudioUrl(audioId: string, serverUrl: string): string {\n return `${serverUrl}/audio/${audioId}`;\n}"
},
{
"path": "app/src/lib/utils/cn.ts",
"chars": 169,
"preview": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: C"
},
{
"path": "app/src/lib/utils/debug.ts",
"chars": 335,
"preview": "const DEBUG = import.meta.env.DEV;\n\nexport const debug = {\n log: (...args: unknown[]) => {\n if (DEBUG) {\n conso"
},
{
"path": "app/src/lib/utils/format.ts",
"chars": 1582,
"preview": "import { formatDistance } from 'date-fns';\n\nexport function formatDuration(seconds: number): string {\n const mins = Mat"
},
{
"path": "app/src/lib/utils/parseChangelog.ts",
"chars": 1208,
"preview": "export interface ChangelogEntry {\n version: string;\n date: string | null;\n body: string;\n}\n\n/**\n * Parses a Keep-a-Ch"
},
{
"path": "app/src/main.tsx",
"chars": 769,
"preview": "import { QueryClient, QueryClientProvider } from '@tanstack/react-query';\n// import { ReactQueryDevtools } from '@tansta"
},
{
"path": "app/src/platform/PlatformContext.tsx",
"chars": 666,
"preview": "import { createContext, useContext, type ReactNode } from 'react';\nimport type { Platform } from './types';\n\nconst Platf"
},
{
"path": "app/src/platform/types.ts",
"chars": 2139,
"preview": "/**\n * Platform abstraction types\n * These interfaces define the contract that platform implementations must fulfill\n */"
},
{
"path": "app/src/router.tsx",
"chars": 5605,
"preview": "import {\n createRootRoute,\n createRoute,\n createRouter,\n Outlet,\n redirect,\n} from '@tanstack/react-router';\nimport"
},
{
"path": "app/src/stores/audioChannelStore.ts",
"chars": 1139,
"preview": "import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\nexport interface AudioChannel {\n id: s"
},
{
"path": "app/src/stores/effectsStore.ts",
"chars": 872,
"preview": "import { create } from 'zustand';\nimport type { EffectConfig } from '@/lib/api/types';\n\ninterface EffectsStore {\n selec"
},
{
"path": "app/src/stores/generationStore.ts",
"chars": 1917,
"preview": "import { create } from 'zustand';\n\ninterface GenerationState {\n /** IDs of generations currently in progress */\n pendi"
},
{
"path": "app/src/stores/logStore.ts",
"chars": 815,
"preview": "import { create } from 'zustand';\nimport type { ServerLogEntry } from '@/platform/types';\n\nconst MAX_LOG_ENTRIES = 2000;"
},
{
"path": "app/src/stores/playerStore.ts",
"chars": 2551,
"preview": "import { create } from 'zustand';\n\ninterface PlayerState {\n audioUrl: string | null;\n audioId: string | null;\n profil"
},
{
"path": "app/src/stores/serverStore.ts",
"chars": 1818,
"preview": "import { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\ninterface ServerStore {\n serverUrl: st"
},
{
"path": "app/src/stores/storyStore.ts",
"chars": 4382,
"preview": "import { create } from 'zustand';\nimport type { StoryItemDetail } from '@/lib/api/types';\n\ninterface StoryPlaybackState "
},
{
"path": "app/src/stores/uiStore.ts",
"chars": 2375,
"preview": "import { create } from 'zustand';\n\n// Draft state for the create voice profile form\nexport interface ProfileFormDraft {\n"
},
{
"path": "app/src/types/index.ts",
"chars": 483,
"preview": "// Shared TypeScript types for the voicebox application\n\nexport interface VoiceProfile {\n id: string;\n name: string;\n "
},
{
"path": "app/tsconfig.json",
"chars": 726,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"useDefineForClassFields\": true,\n \"lib\": [\"ES2020\", \"DOM\", \"DOM."
},
{
"path": "app/tsconfig.node.json",
"chars": 232,
"preview": "{\n \"compilerOptions\": {\n \"composite\": true,\n \"skipLibCheck\": true,\n \"module\": \"ESNext\",\n \"moduleResolution\""
},
{
"path": "app/vite.config.ts",
"chars": 412,
"preview": "import path from 'node:path';\nimport tailwindcss from '@tailwindcss/vite';\nimport react from '@vitejs/plugin-react';\nimp"
},
{
"path": "backend/README.md",
"chars": 5593,
"preview": "# Voicebox Backend\n\nFastAPI server powering voice cloning, speech generation, and audio processing. Runs locally as a Ta"
},
{
"path": "backend/STYLE_GUIDE.md",
"chars": 14091,
"preview": "# Python Style Guide\n\nTarget: **Python 3.12+** | Formatter/Linter: **Ruff** | Config: `backend/pyproject.toml`\n\nThis gui"
},
{
"path": "backend/__init__.py",
"chars": 41,
"preview": "# Backend package\n\n__version__ = \"0.3.1\"\n"
},
{
"path": "backend/app.py",
"chars": 8829,
"preview": "\"\"\"FastAPI application factory, middleware, and lifecycle events.\"\"\"\n\nimport asyncio\nimport logging\nimport os\nimport sys"
},
{
"path": "backend/backends/__init__.py",
"chars": 16941,
"preview": "\"\"\"\nBackend abstraction layer for TTS and STT.\n\nProvides a unified interface for MLX and PyTorch backends,\nand a model c"
},
{
"path": "backend/backends/base.py",
"chars": 8000,
"preview": "\"\"\"\nShared utilities for TTS/STT backend implementations.\n\nEliminates duplication of cache checking, device detection,\nv"
},
{
"path": "backend/backends/chatterbox_backend.py",
"chars": 7364,
"preview": "\"\"\"\nChatterbox TTS backend implementation.\n\nWraps ChatterboxMultilingualTTS from chatterbox-tts for zero-shot\nvoice clon"
},
{
"path": "backend/backends/chatterbox_turbo_backend.py",
"chars": 6564,
"preview": "\"\"\"\nChatterbox Turbo TTS backend implementation.\n\nWraps ChatterboxTurboTTS from chatterbox-tts for fast, English-only\nvo"
},
{
"path": "backend/backends/hume_backend.py",
"chars": 13046,
"preview": "\"\"\"\nHumeAI TADA TTS backend implementation.\n\nWraps HumeAI's TADA (Text-Acoustic Dual Alignment) model for\nhigh-quality v"
},
{
"path": "backend/backends/kokoro_backend.py",
"chars": 9394,
"preview": "\"\"\"\nKokoro TTS backend implementation.\n\nWraps the Kokoro-82M model for fast, lightweight text-to-speech.\n82M parameters,"
},
{
"path": "backend/backends/luxtts_backend.py",
"chars": 5458,
"preview": "\"\"\"\nLuxTTS backend implementation.\n\nWraps the LuxTTS (ZipVoice) model for zero-shot voice cloning.\n~1GB VRAM, 48kHz outp"
},
{
"path": "backend/backends/mlx_backend.py",
"chars": 14383,
"preview": "\"\"\"\nMLX backend implementation for TTS and STT using mlx-audio.\n\"\"\"\n\nfrom typing import Optional, List, Tuple\nimport asy"
},
{
"path": "backend/backends/pytorch_backend.py",
"chars": 12006,
"preview": "\"\"\"\nPyTorch backend implementation for TTS and STT.\n\"\"\"\n\nfrom typing import Optional, List, Tuple\nimport asyncio\nimport "
},
{
"path": "backend/build_binary.py",
"chars": 14877,
"preview": "\"\"\"\nPyInstaller build script for creating standalone Python server binary.\n\nUsage:\n python build_binary.py "
},
{
"path": "backend/config.py",
"chars": 1921,
"preview": "\"\"\"\nConfiguration module for voicebox backend.\n\nHandles data directory configuration for production bundling.\n\"\"\"\n\nimpor"
},
{
"path": "backend/database/__init__.py",
"chars": 919,
"preview": "\"\"\"Database package — ORM models, session management, and migrations.\n\nRe-exports all public symbols so that ``from .dat"
},
{
"path": "backend/database/migrations.py",
"chars": 10632,
"preview": "\"\"\"Column-level migrations for the voicebox SQLite database.\n\nWhy not Alembic? voicebox is a single-user desktop app sh"
},
{
"path": "backend/database/models.py",
"chars": 6391,
"preview": "\"\"\"ORM model definitions for the voicebox SQLite database.\"\"\"\n\nfrom datetime import datetime\nimport uuid\n\nfrom sqlalchem"
},
{
"path": "backend/database/seed.py",
"chars": 2387,
"preview": "\"\"\"Post-migration data seeding and backfills.\"\"\"\n\nimport json\nimport logging\nimport uuid\nfrom pathlib import Path\n\nlogge"
},
{
"path": "backend/database/session.py",
"chars": 2062,
"preview": "\"\"\"Engine creation, initialization, and session management.\"\"\"\n\nimport logging\nimport uuid\n\nfrom sqlalchemy import creat"
},
{
"path": "backend/main.py",
"chars": 1091,
"preview": "\"\"\"Entry point for the voicebox backend.\n\nImports the configured FastAPI app and provides a ``python -m backend.main``\ne"
}
]
// ... and 253 more files (download for full content)
About this extraction
This page contains the full source code of the jamiepine/voicebox GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 453 files (3.3 MB), approximately 887.2k tokens, and a symbol index with 1248 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.