mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-27 06:05:14 -07:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df50b8a925 | ||
|
|
a672ac5279 | ||
|
|
d35e6f0cc5 | ||
|
|
b1069b4521 | ||
|
|
f9e1aa153d | ||
|
|
01800f196f | ||
|
|
606da1c894 | ||
|
|
664178f0cf | ||
|
|
f1541701fb | ||
|
|
a2adc3b506 | ||
|
|
15ba824472 | ||
|
|
2ad4776a76 | ||
|
|
a8469b39f1 | ||
|
|
7dd70a52e4 | ||
|
|
1526f2de26 | ||
|
|
2c63dfff25 | ||
|
|
e0a798dc0d | ||
|
|
5933cba8e9 | ||
|
|
1b2d492398 | ||
|
|
faa825290f | ||
|
|
4a8a9eac14 | ||
|
|
3e4d9ff641 |
@@ -0,0 +1,94 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,124 @@
|
||||
---
|
||||
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)`
|
||||
@@ -123,6 +123,29 @@ jobs:
|
||||
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/[email protected]
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -139,17 +162,7 @@ jobs:
|
||||
projectPath: tauri
|
||||
tagName: v__VERSION__
|
||||
releaseName: "voicebox v__VERSION__"
|
||||
releaseBody: |
|
||||
## What's Changed
|
||||
See the assets below to download and install this version.
|
||||
|
||||
### Installation
|
||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||
- **Windows**: Download the `.msi` installer
|
||||
- **Linux**: Compile from source (see README)
|
||||
|
||||
The app includes automatic updates - future updates will be installed automatically.
|
||||
releaseBody: ${{ steps.changelog.outputs.notes }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
@@ -176,10 +189,10 @@ jobs:
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
|
||||
- name: Install PyTorch with CUDA 12.1
|
||||
- name: Install PyTorch with CUDA 12.6
|
||||
run: |
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
||||
pip install torchaudio --index-url https://download.pytorch.org/whl/cu126 --force-reinstall --no-deps
|
||||
|
||||
- name: Verify CUDA support in torch
|
||||
run: |
|
||||
|
||||
@@ -50,6 +50,7 @@ logs/
|
||||
app/openapi.json
|
||||
tauri/src-tauri/binaries/*
|
||||
tauri/src-tauri/gen/Assets.car
|
||||
tauri/src-tauri/gen/voicebox.icns
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
|
||||
+410
-69
@@ -1,96 +1,437 @@
|
||||
<!-- 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
|
||||
|
||||
All notable changes to Voicebox will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Profile Name Validation** - Added proper validation to prevent duplicate profile names ([#134](https://github.com/jamiepine/voicebox/issues/134))
|
||||
- Users now receive clear error messages when attempting to create or update profiles with duplicate names
|
||||
- Improved error handling in create and update profile API endpoints
|
||||
- Added comprehensive test suite for duplicate name validation
|
||||
This release rewrites the backend into a modular architecture, migrates the documentation site to Fumadocs, and ships a batch of bug fixes and UI polish across the stack.
|
||||
|
||||
## [0.1.0] - 2026-01-25
|
||||
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, model loading status is now visible in the UI, effects presets get a dropdown, and several race conditions and accessibility gaps are closed.
|
||||
|
||||
### Added
|
||||
### 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
|
||||
|
||||
#### Core Features
|
||||
- **Voice Cloning** - Clone voices from audio samples using Qwen3-TTS (1.7B and 0.6B models)
|
||||
- **Voice Profile Management** - Create, edit, and organize voice profiles with multiple samples
|
||||
- **Speech Generation** - Generate high-quality speech from text using cloned voices
|
||||
- **Generation History** - Track all generations with search and filtering capabilities
|
||||
- **Audio Transcription** - Automatic transcription powered by Whisper
|
||||
- **In-App Recording** - Record audio samples directly in the app with waveform visualization
|
||||
### 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
|
||||
|
||||
#### Desktop App
|
||||
- **Tauri Desktop App** - Native desktop application for macOS, Windows, and Linux
|
||||
- **Local Server Mode** - Embedded Python server runs automatically
|
||||
- **Remote Server Mode** - Connect to a remote Voicebox server on your network
|
||||
- **Auto-Updates** - Automatic update notifications and installation
|
||||
### 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
|
||||
|
||||
#### API
|
||||
- **REST API** - Full REST API for voice synthesis and profile management
|
||||
- **OpenAPI Documentation** - Interactive API docs at `/docs` endpoint
|
||||
- **Type-Safe Client** - Auto-generated TypeScript client from OpenAPI schema
|
||||
### 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
|
||||
|
||||
#### Technical
|
||||
- **Voice Prompt Caching** - Fast regeneration with cached voice prompts
|
||||
- **Multi-Sample Support** - Combine multiple audio samples for better voice quality
|
||||
- **GPU/CPU/MPS Support** - Automatic device detection and optimization
|
||||
- **Model Management** - Lazy loading and VRAM management
|
||||
- **SQLite Database** - Local data persistence
|
||||
### 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
|
||||
|
||||
### Technical Details
|
||||
## [0.2.3] - 2026-03-15
|
||||
|
||||
- Built with Tauri v2 (Rust + React)
|
||||
- FastAPI backend with async Python
|
||||
- TypeScript frontend with React Query and Zustand
|
||||
- Qwen3-TTS for voice cloning
|
||||
- Whisper for transcription
|
||||
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
|
||||
|
||||
- macOS (Apple Silicon and Intel)
|
||||
- Windows
|
||||
- Linux (AppImage)
|
||||
- **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))
|
||||
|
||||
## [Unreleased]
|
||||
Per-model unload, custom models directory, model folder migration, download cancel/clear UI ([#238](https://github.com/jamiepine/voicebox/pull/238)), restructured settings UI.
|
||||
|
||||
### Fixed
|
||||
- Audio export failing when Tauri save dialog returns object instead of string path
|
||||
- OpenAPI client generator script now documents the local backend port and avoids an unused loop variable warning
|
||||
### Security & Reliability
|
||||
|
||||
### Added
|
||||
- **justfile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
|
||||
- Cross-platform support (macOS, Linux, Windows)
|
||||
- Python version detection and compatibility warnings
|
||||
- Self-documenting help system with `just --list`
|
||||
- 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))
|
||||
|
||||
### Changed
|
||||
- **README** - Updated Quick Start with justfile-based setup instructions
|
||||
### Accessibility ([#243](https://github.com/jamiepine/voicebox/pull/243))
|
||||
|
||||
### Removed
|
||||
- **Makefile** - Replaced by justfile (cross-platform, simpler syntax)
|
||||
Screen reader support, keyboard navigation, state-aware `aria-label` attributes on all interactive controls.
|
||||
|
||||
---
|
||||
### UI Polish
|
||||
|
||||
## [Unreleased - Planned]
|
||||
- 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))
|
||||
|
||||
### Planned
|
||||
- Real-time streaming synthesis
|
||||
- Conversation mode with multiple speakers
|
||||
- Voice effects (pitch shift, reverb, M3GAN-style)
|
||||
- Timeline-based audio editor
|
||||
- Additional voice models (XTTS, Bark)
|
||||
- Voice design from text descriptions
|
||||
- Project system for saving sessions
|
||||
- Plugin architecture
|
||||
### 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
|
||||
|
||||
@@ -31,6 +31,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
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 \
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# Voicebox Offline Mode Fix
|
||||
|
||||
## Problem
|
||||
Voicebox crashes when generating speech if HuggingFace is unreachable, even when models are fully cached locally.
|
||||
|
||||
**Root Cause:**
|
||||
- Voicebox downloads `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` (MLX optimized version)
|
||||
- But `mlx_audio.tts.load()` tries to fetch `config.json` from original repo `Qwen/Qwen3-TTS-12Hz-1.7B-Base`
|
||||
- This network request fails → server crashes with `RemoteDisconnected`
|
||||
|
||||
**Related Issues:**
|
||||
- Issue #150: "Internet connection required, even though models are downloaded?"
|
||||
- Issue #151: "API Stability Issues: Model Loading Hangs and Server Crashes"
|
||||
|
||||
## Solution
|
||||
Two-part fix:
|
||||
|
||||
### 1. Monkey-patch huggingface_hub (`backend/utils/hf_offline_patch.py`)
|
||||
- Intercepts cache lookup functions
|
||||
- Forces offline mode early (before mlx_audio imports)
|
||||
- Adds debug logging for cache hits/misses
|
||||
|
||||
### 2. Symlink original repo to MLX version (`ensure_original_qwen_config_cached()`)
|
||||
- When original `Qwen/Qwen3-TTS-12Hz-1.7B-Base` cache doesn't exist
|
||||
- But MLX `mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16` does exist
|
||||
- Creates a symlink so cache lookups succeed
|
||||
|
||||
## Files Changed
|
||||
- `backend/backends/mlx_backend.py` - Added patch imports at top
|
||||
- `backend/utils/hf_offline_patch.py` - New patch module
|
||||
|
||||
## Testing
|
||||
To test this fix:
|
||||
1. Build Voicebox from source: `just build`
|
||||
2. Disconnect from internet
|
||||
3. Try generating speech
|
||||
4. Should work without network requests
|
||||
|
||||
## Build Instructions
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
just setup
|
||||
|
||||
# Build the app
|
||||
just build
|
||||
|
||||
# Or build just the server
|
||||
just build-server
|
||||
```
|
||||
|
||||
## Notes
|
||||
- The patch is applied automatically when `mlx_backend.py` is imported
|
||||
- Set `VOICEBOX_OFFLINE_PATCH=0` to disable the patch
|
||||
- The symlink approach works because the config.json is compatible between versions
|
||||
|
||||
---
|
||||
*Patch contributed by community*
|
||||
@@ -27,10 +27,10 @@
|
||||
|
||||
<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> •
|
||||
<a href="#roadmap">Roadmap</a>
|
||||
<a href="#api">API</a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
@@ -76,12 +76,12 @@ Voicebox is a **local-first voice cloning studio** — a free and open-source al
|
||||
|
||||
## 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` |
|
||||
| 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)**
|
||||
|
||||
@@ -95,12 +95,12 @@ Voicebox is a **local-first voice cloning studio** — a free and open-source al
|
||||
|
||||
Four 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 |
|
||||
| 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 |
|
||||
|
||||
### Emotions & Paralinguistic Tags
|
||||
|
||||
@@ -112,16 +112,16 @@ Type `/` in the text input to insert expressive tags that the model synthesizes
|
||||
|
||||
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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
@@ -186,14 +186,14 @@ Multi-voice timeline editor for conversations, podcasts, and narratives.
|
||||
|
||||
### GPU Support
|
||||
|
||||
| Platform | Backend | Notes |
|
||||
|----------|---------|-------|
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -224,30 +224,30 @@ 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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)};`;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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 = [
|
||||
@@ -63,6 +64,14 @@ function App() {
|
||||
// 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) {
|
||||
|
||||
@@ -17,7 +17,6 @@ export function AudioPlayer() {
|
||||
audioUrl,
|
||||
audioId,
|
||||
profileId,
|
||||
title,
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
@@ -63,7 +62,7 @@ export function AudioPlayer() {
|
||||
);
|
||||
|
||||
return shouldUseNative;
|
||||
}, [profileChannels, channels, profileId]);
|
||||
}, [profileChannels, channels, platform.metadata.isTauri]);
|
||||
|
||||
const waveformRef = useRef<HTMLDivElement>(null);
|
||||
const wavesurferRef = useRef<WaveSurfer | null>(null);
|
||||
@@ -73,31 +72,21 @@ export function AudioPlayer() {
|
||||
const isUsingNativePlaybackRef = useRef(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [wsReady, setWsReady] = useState(false);
|
||||
|
||||
// Initialize WaveSurfer (only when audioUrl exists and container is ready)
|
||||
// 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(() => {
|
||||
// Don't initialize if no audioUrl or already initialized
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
}
|
||||
if (!audioUrl) return;
|
||||
if (wavesurferRef.current) return; // already created
|
||||
|
||||
if (wavesurferRef.current) {
|
||||
debug.log('WaveSurfer already initialized, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Creating NEW WaveSurfer instance');
|
||||
|
||||
// Wait for container to be properly rendered
|
||||
const initWaveSurfer = () => {
|
||||
const container = waveformRef.current;
|
||||
if (!container) {
|
||||
// Container not ready yet, retry
|
||||
setTimeout(initWaveSurfer, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if container has dimensions and is visible
|
||||
const rect = container.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(container);
|
||||
const isVisible =
|
||||
@@ -107,412 +96,221 @@ export function AudioPlayer() {
|
||||
style.visibility !== 'hidden';
|
||||
|
||||
if (!isVisible) {
|
||||
// Retry after a short delay
|
||||
setTimeout(initWaveSurfer, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
debug.log('Initializing WaveSurfer...', {
|
||||
container,
|
||||
debug.log('Creating WaveSurfer instance', {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get computed CSS variable values
|
||||
const root = document.documentElement;
|
||||
const getCSSVar = (varName: string) => {
|
||||
const value = getComputedStyle(root).getPropertyValue(varName).trim();
|
||||
return value ? `hsl(${value})` : '';
|
||||
};
|
||||
|
||||
const waveColor = getCSSVar('--muted');
|
||||
const progressColor = getCSSVar('--accent');
|
||||
const cursorColor = getCSSVar('--accent');
|
||||
|
||||
const wavesurfer = WaveSurfer.create({
|
||||
container: container,
|
||||
waveColor: waveColor,
|
||||
progressColor: progressColor,
|
||||
cursorColor: cursorColor,
|
||||
container,
|
||||
waveColor: getCSSVar('--muted'),
|
||||
progressColor: getCSSVar('--accent'),
|
||||
cursorColor: getCSSVar('--accent'),
|
||||
cursorWidth: 3,
|
||||
barWidth: 2,
|
||||
barRadius: 2,
|
||||
height: 80,
|
||||
normalize: true,
|
||||
// Use MediaElement backend (default). Unlike the WebAudio backend,
|
||||
// MediaElement uses a standard <audio> element for playback which
|
||||
// benefits from the browser/webview's built-in audio session recovery.
|
||||
// This prevents audio loss when another app steals audio output or
|
||||
// the system audio session is interrupted.
|
||||
interact: true, // Enable interaction (click to seek)
|
||||
mediaControls: false, // Don't show native controls
|
||||
interact: true,
|
||||
dragToSeek: { debounceTime: 0 },
|
||||
mediaControls: false,
|
||||
backend: 'WebAudio',
|
||||
});
|
||||
|
||||
wavesurferRef.current = wavesurfer;
|
||||
debug.log('WaveSurfer created successfully');
|
||||
} catch (error) {
|
||||
debug.error('Failed to create WaveSurfer:', error);
|
||||
setError(
|
||||
`Failed to initialize waveform: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 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);
|
||||
});
|
||||
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer) return;
|
||||
wavesurfer.on('ready', () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
setDuration(dur);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
debug.log('Audio ready, duration:', dur);
|
||||
|
||||
// Update store when time changes, stop if past duration
|
||||
wavesurfer.on('timeupdate', (time) => {
|
||||
const dur = usePlayerStore.getState().duration;
|
||||
if (dur > 0 && time >= dur) {
|
||||
setCurrentTime(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();
|
||||
wavesurfer.play().catch((err) => debug.error('Loop play failed:', err));
|
||||
} else {
|
||||
wavesurfer.pause();
|
||||
setIsPlaying(false);
|
||||
const onFinish = usePlayerStore.getState().onFinish;
|
||||
if (onFinish) onFinish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCurrentTime(time);
|
||||
});
|
||||
|
||||
// Update store when duration is loaded
|
||||
wavesurfer.on('ready', async () => {
|
||||
const dur = wavesurfer.getDuration();
|
||||
setDuration(dur);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
debug.log('Audio ready, duration:', dur);
|
||||
debug.log('Waveform should be visible now');
|
||||
|
||||
// Ensure volume is set
|
||||
const currentVolume = usePlayerStore.getState().volume;
|
||||
wavesurfer.setVolume(currentVolume);
|
||||
|
||||
// Auto-play when ready - check if we should use native playback
|
||||
// Get current values from the store and queries at runtime (not captured closure values)
|
||||
const currentAudioUrl = usePlayerStore.getState().audioUrl;
|
||||
const currentProfileId = usePlayerStore.getState().profileId;
|
||||
|
||||
debug.log('Auto-play check - capturing runtime values...');
|
||||
|
||||
// Fetch profile channels at runtime (not using captured value)
|
||||
let runtimeProfileChannels = null;
|
||||
let runtimeChannels = null;
|
||||
|
||||
if (platform.metadata.isTauri && currentProfileId) {
|
||||
try {
|
||||
runtimeProfileChannels = await apiClient.getProfileChannels(currentProfileId);
|
||||
debug.log('Runtime profileChannels:', runtimeProfileChannels);
|
||||
|
||||
if (runtimeProfileChannels && runtimeProfileChannels.channel_ids.length > 0) {
|
||||
runtimeChannels = await apiClient.listChannels();
|
||||
debug.log('Runtime channels:', runtimeChannels);
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch runtime channel data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
debug.log('Auto-play check:', {
|
||||
isTauri: platform.metadata.isTauri,
|
||||
currentAudioUrl,
|
||||
currentProfileId,
|
||||
hasProfileChannels: !!runtimeProfileChannels,
|
||||
hasChannels: !!runtimeChannels,
|
||||
});
|
||||
|
||||
if (
|
||||
platform.metadata.isTauri &&
|
||||
currentAudioUrl &&
|
||||
currentProfileId &&
|
||||
runtimeProfileChannels &&
|
||||
runtimeChannels
|
||||
) {
|
||||
debug.log('Attempting native audio playback...');
|
||||
|
||||
// Stop any existing native playback first
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped existing native playback before starting new one');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop existing playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Collect all device IDs from assigned channels
|
||||
const assignedChannels = runtimeChannels.filter((ch: any) =>
|
||||
runtimeProfileChannels.channel_ids.includes(ch.id),
|
||||
);
|
||||
debug.log('Assigned channels for playback:', assignedChannels);
|
||||
|
||||
// Check if any assigned channel has non-default devices
|
||||
const shouldUseNative = assignedChannels.some(
|
||||
(ch: any) => ch.device_ids.length > 0 && !ch.is_default,
|
||||
);
|
||||
debug.log('Should use native playback:', shouldUseNative);
|
||||
|
||||
if (!shouldUseNative) {
|
||||
debug.log('No custom devices assigned, using standard playback');
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
} else {
|
||||
const deviceIds = assignedChannels.flatMap((ch: any) => ch.device_ids);
|
||||
debug.log('Device IDs to play to:', deviceIds);
|
||||
|
||||
if (deviceIds.length > 0) {
|
||||
debug.log('Fetching audio data from:', currentAudioUrl);
|
||||
// Fetch audio data
|
||||
const response = await fetch(currentAudioUrl);
|
||||
const audioData = new Uint8Array(await response.arrayBuffer());
|
||||
debug.log('Audio data size:', audioData.length);
|
||||
|
||||
// Play via native audio
|
||||
debug.log('Invoking play_audio_to_devices...');
|
||||
try {
|
||||
await platform.audio.playToDevices(audioData, deviceIds);
|
||||
debug.log('play_audio_to_devices completed successfully');
|
||||
|
||||
// Mark that we're using native playback
|
||||
isUsingNativePlaybackRef.current = true;
|
||||
|
||||
// Mute WaveSurfer's audio output — native handles the actual sound
|
||||
// Keep WaveSurfer running for waveform visualization
|
||||
wavesurfer.setVolume(0);
|
||||
wavesurfer.setMuted(true);
|
||||
|
||||
// Start WaveSurfer playback for visualization (muted)
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to start WaveSurfer visualization:', error);
|
||||
});
|
||||
|
||||
setIsPlaying(true);
|
||||
debug.log('Auto-playing via native audio routing - SUCCESS');
|
||||
return;
|
||||
} catch (invokeError) {
|
||||
debug.error('play_audio_to_devices invoke failed:', invokeError);
|
||||
throw invokeError;
|
||||
}
|
||||
} else {
|
||||
debug.log('No device IDs found, falling back to WaveSurfer');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
debug.error(
|
||||
'Native playback failed during auto-play, falling back to WaveSurfer:',
|
||||
error,
|
||||
);
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
// Fall through to WaveSurfer playback
|
||||
}
|
||||
}
|
||||
|
||||
// Standard playback path — ensure WaveSurfer is unmuted
|
||||
if (!isUsingNativePlaybackRef.current) {
|
||||
wavesurfer.setMuted(false);
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
}
|
||||
|
||||
// Only auto-play if shouldAutoPlay flag is set (user explicitly clicked to play)
|
||||
const shouldAutoPlayNow = usePlayerStore.getState().shouldAutoPlay;
|
||||
if (shouldAutoPlayNow) {
|
||||
// Clear the flag first
|
||||
usePlayerStore.getState().clearAutoPlayFlag();
|
||||
|
||||
// Use a small delay to ensure audio element is fully ready
|
||||
setTimeout(() => {
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to autoplay:', error);
|
||||
// Don't show error for autoplay failures (browser restrictions)
|
||||
});
|
||||
}, 100);
|
||||
} else {
|
||||
debug.log('Skipping auto-play - shouldAutoPlay is false');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle play/pause
|
||||
wavesurfer.on('play', () => {
|
||||
setIsPlaying(true);
|
||||
});
|
||||
wavesurfer.on('pause', () => setIsPlaying(false));
|
||||
wavesurfer.on('finish', () => {
|
||||
// Check loop state from store
|
||||
const loop = usePlayerStore.getState().isLooping;
|
||||
if (loop) {
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play();
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
// Trigger finish callback if set
|
||||
const onFinish = usePlayerStore.getState().onFinish;
|
||||
if (onFinish) {
|
||||
onFinish();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
wavesurfer.on('error', (error) => {
|
||||
debug.error('WaveSurfer error:', error);
|
||||
setIsLoading(false);
|
||||
setError(`Audio error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
// Handle loading
|
||||
wavesurfer.on('loading', (percent) => {
|
||||
setIsLoading(true);
|
||||
if (percent === 100) {
|
||||
wavesurfer.on('error', (err) => {
|
||||
debug.error('WaveSurfer error:', err);
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
setError(`Audio error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
|
||||
// Load audio immediately if audioUrl is already set
|
||||
if (audioUrl) {
|
||||
debug.log('WaveSurfer ready, loading audio:', audioUrl);
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
// Stop any current playback before loading new audio
|
||||
if (wavesurfer.isPlaying()) {
|
||||
wavesurfer.pause();
|
||||
}
|
||||
wavesurfer
|
||||
.load(audioUrl)
|
||||
.then(() => {
|
||||
debug.log('Audio loaded into WaveSurfer');
|
||||
loadingRef.current = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
debug.error('Failed to load audio into WaveSurfer:', error);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
});
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Use double requestAnimationFrame to ensure DOM is fully rendered
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
let timeoutId: number | null = null;
|
||||
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
// Add a small delay to ensure container is fully laid out
|
||||
timeoutId = setTimeout(() => {
|
||||
initWaveSurfer();
|
||||
}, 10);
|
||||
});
|
||||
let rafId: number;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
initWaveSurfer();
|
||||
});
|
||||
|
||||
return () => {
|
||||
debug.log('Cleaning up WaveSurfer initialization effect');
|
||||
if (rafId1) cancelAnimationFrame(rafId1);
|
||||
if (rafId2) cancelAnimationFrame(rafId2);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
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');
|
||||
debug.log('Destroying WaveSurfer instance (unmount)');
|
||||
try {
|
||||
wavesurferRef.current.destroy();
|
||||
} catch (error) {
|
||||
debug.error('Error destroying WaveSurfer:', error);
|
||||
} catch (err) {
|
||||
debug.error('Error destroying WaveSurfer:', err);
|
||||
}
|
||||
wavesurferRef.current = null;
|
||||
setWsReady(false);
|
||||
}
|
||||
};
|
||||
}, [audioUrl, setIsPlaying, setCurrentTime, setDuration]);
|
||||
}, []);
|
||||
|
||||
// Load audio when URL changes (only if WaveSurfer is already initialized)
|
||||
// Load audio when URL changes (reuses the existing WaveSurfer instance)
|
||||
useEffect(() => {
|
||||
const wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer || !wsReady) return;
|
||||
|
||||
if (!audioUrl || !wavesurfer) {
|
||||
// Reset state when no audio or WaveSurfer not ready
|
||||
if (!audioUrl && wavesurfer) {
|
||||
wavesurfer.pause();
|
||||
wavesurfer.seekTo(0);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setDuration(0);
|
||||
setCurrentTime(0);
|
||||
setError(null);
|
||||
// Reset native playback flag
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Stop native playback if it was active
|
||||
if (isUsingNativePlaybackRef.current && platform.metadata.isTauri) {
|
||||
try {
|
||||
platform.audio.stopPlayback();
|
||||
debug.log('Stopped native audio playback');
|
||||
} catch (error) {
|
||||
debug.error('Failed to stop native playback:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset native playback flag when loading new audio
|
||||
// Unmute WaveSurfer if it was muted for native playback
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
wavesurfer.setMuted(false);
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
}
|
||||
// Reset native playback state
|
||||
isUsingNativePlaybackRef.current = false;
|
||||
wavesurfer.setMuted(false);
|
||||
wavesurfer.setVolume(usePlayerStore.getState().volume);
|
||||
|
||||
// CRITICAL: Force stop any current playback and cancel any pending loads
|
||||
// This must happen BEFORE any early returns
|
||||
debug.log('Audio URL changed to:', audioUrl);
|
||||
|
||||
// COMPLETELY stop and destroy the current audio
|
||||
// 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 {
|
||||
// First pause if playing
|
||||
if (wavesurfer.isPlaying()) {
|
||||
debug.log('Pausing current playback');
|
||||
wavesurfer.pause();
|
||||
}
|
||||
|
||||
// Use empty() to completely destroy the waveform and reset media
|
||||
debug.log('Calling wavesurfer.empty() to destroy audio');
|
||||
wavesurfer.empty();
|
||||
} catch (error) {
|
||||
debug.error('Error stopping previous audio:', error);
|
||||
// Continue anyway to load new audio
|
||||
wavesurfer.seekTo(0);
|
||||
} catch (err) {
|
||||
debug.error('Error resetting before load:', err);
|
||||
}
|
||||
|
||||
// Reset loading state to allow new load (cancel any pending loads)
|
||||
loadingRef.current = false;
|
||||
|
||||
// Now start the new load
|
||||
loadingRef.current = true;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
// Load new audio
|
||||
debug.log('Starting new audio load for:', audioUrl);
|
||||
wavesurfer
|
||||
.load(audioUrl)
|
||||
.then(() => {
|
||||
debug.log('Audio load promise resolved');
|
||||
// Don't set loading to false here - wait for 'ready' event
|
||||
debug.log('Audio loaded into WaveSurfer');
|
||||
loadingRef.current = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
debug.error('Failed to load audio:', error);
|
||||
debug.error('Audio URL:', audioUrl);
|
||||
.catch((err) => {
|
||||
debug.error('Failed to load audio:', err);
|
||||
loadingRef.current = false;
|
||||
setIsLoading(false);
|
||||
setError(`Failed to load audio: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setError(`Failed to load audio: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}, [audioUrl, setCurrentTime, setDuration]);
|
||||
}, [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
|
||||
@@ -520,7 +318,6 @@ export function AudioPlayer() {
|
||||
if (!wavesurferRef.current || duration === 0) return;
|
||||
|
||||
if (isPlaying && wavesurferRef.current.isPlaying() === false) {
|
||||
// Only auto-play if audio is ready
|
||||
wavesurferRef.current.play().catch((error) => {
|
||||
debug.error('Failed to play:', error);
|
||||
setIsPlaying(false);
|
||||
@@ -534,14 +331,7 @@ export function AudioPlayer() {
|
||||
// Sync volume
|
||||
useEffect(() => {
|
||||
if (wavesurferRef.current) {
|
||||
// If using native playback, keep WaveSurfer muted regardless of volume setting
|
||||
if (isUsingNativePlaybackRef.current) {
|
||||
wavesurferRef.current.setVolume(0);
|
||||
debug.log('Volume sync: Using native playback, keeping WaveSurfer muted');
|
||||
} else {
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
debug.log('Volume synced:', volume);
|
||||
}
|
||||
wavesurferRef.current.setVolume(volume);
|
||||
}
|
||||
}, [volume]);
|
||||
|
||||
@@ -566,7 +356,6 @@ export function AudioPlayer() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset to beginning and play
|
||||
debug.log('Restarting current audio from beginning');
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((error) => {
|
||||
@@ -575,34 +364,35 @@ export function AudioPlayer() {
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
// Clear the restart flag
|
||||
clearRestartFlag();
|
||||
}, [shouldRestart, duration, setIsPlaying, clearRestartFlag]);
|
||||
|
||||
// Handle shouldAutoPlay flag - for story mode auto-advance
|
||||
const shouldAutoPlay = usePlayerStore((state) => state.shouldAutoPlay);
|
||||
const clearAutoPlayFlag = usePlayerStore((state) => state.clearAutoPlayFlag);
|
||||
// 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 wavesurfer = wavesurferRef.current;
|
||||
if (!wavesurfer || !shouldAutoPlay || duration === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-play the newly loaded audio
|
||||
debug.log('Auto-playing next track in story mode');
|
||||
wavesurfer.seekTo(0);
|
||||
wavesurfer.play().catch((error) => {
|
||||
debug.error('Failed to auto-play:', error);
|
||||
setIsPlaying(false);
|
||||
setError(`Playback error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
});
|
||||
|
||||
// Clear the auto-play flag
|
||||
clearAutoPlayFlag();
|
||||
}, [shouldAutoPlay, duration, setIsPlaying, clearAutoPlayFlag]);
|
||||
|
||||
// Handle loop - WaveSurfer handles this via the 'finish' event
|
||||
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)
|
||||
@@ -741,32 +531,32 @@ export function AudioPlayer() {
|
||||
size="icon"
|
||||
onClick={handlePlayPause}
|
||||
disabled={isLoading || duration === 0}
|
||||
className="shrink-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" /> : <Play className="h-5 w-5" />}
|
||||
{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]" />
|
||||
{duration > 0 && (
|
||||
<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)}`}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">Loading audio...</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -777,19 +567,12 @@ export function AudioPlayer() {
|
||||
<span className="font-mono">{formatAudioDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="text-sm font-medium truncate max-w-[200px] shrink-0 hidden lg:block">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loop Button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleLoop}
|
||||
className={isLooping ? 'text-primary' : ''}
|
||||
className={isLooping ? 'bg-accent text-accent-foreground' : ''}
|
||||
title="Toggle loop"
|
||||
aria-label={isLooping ? 'Stop looping' : 'Loop'}
|
||||
>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Loader from 'react-loaders';
|
||||
|
||||
import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -56,8 +56,35 @@ import { formatDate, formatDuration, formatEngineName } from '@/lib/utils/format
|
||||
import { useGenerationStore } from '@/stores/generationStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
|
||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||
// This is the new alternate history view with fixed height rows
|
||||
// ─── Audio Bars ─────────────────────────────────────────────────────────────
|
||||
|
||||
function AudioBars({ mode }: { mode: 'idle' | 'generating' | 'playing' }) {
|
||||
const barColor = mode !== 'idle' ? 'bg-accent' : 'bg-muted-foreground/40';
|
||||
return (
|
||||
<div className="flex items-center gap-[2px] h-5">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<motion.div
|
||||
key={`${mode}-${i}`}
|
||||
className={`w-[3px] rounded-full ${barColor}`}
|
||||
animate={
|
||||
mode === 'generating'
|
||||
? { height: ['6px', '16px', '6px'] }
|
||||
: mode === 'playing'
|
||||
? { height: ['8px', '14px', '4px', '12px', '8px'] }
|
||||
: { height: '8px' }
|
||||
}
|
||||
transition={
|
||||
mode === 'generating'
|
||||
? { duration: 0.6, repeat: Infinity, delay: i * 0.08, ease: 'easeInOut' }
|
||||
: mode === 'playing'
|
||||
? { duration: 1.2, repeat: Infinity, delay: i * 0.15, ease: 'easeInOut' }
|
||||
: { duration: 0.4, ease: 'easeOut' }
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL
|
||||
export function HistoryTable() {
|
||||
@@ -126,7 +153,9 @@ export function HistoryTable() {
|
||||
}
|
||||
}, [historyData, page]);
|
||||
|
||||
// Reset to page 0 when deletions or imports occur
|
||||
// Reset to page 0 when deletions, imports, or generation completions occur
|
||||
const pendingCount = useGenerationStore((state) => state.pendingGenerationIds.size);
|
||||
const prevPendingCountRef = useRef(pendingCount);
|
||||
useEffect(() => {
|
||||
if (deleteGeneration.isSuccess || importGeneration.isSuccess) {
|
||||
setPage(0);
|
||||
@@ -134,6 +163,19 @@ export function HistoryTable() {
|
||||
}
|
||||
}, [deleteGeneration.isSuccess, importGeneration.isSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
// A generation finished (pending count decreased) — scroll back to show it
|
||||
if (
|
||||
prevPendingCountRef.current > 0 &&
|
||||
pendingCount < prevPendingCountRef.current &&
|
||||
page !== 0
|
||||
) {
|
||||
setPage(0);
|
||||
setAllHistory([]);
|
||||
}
|
||||
prevPendingCountRef.current = pendingCount;
|
||||
}, [pendingCount, page]);
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const loadMoreEl = loadMoreRef.current;
|
||||
@@ -413,7 +455,7 @@ export function HistoryTable() {
|
||||
role={isPlayable ? 'button' : undefined}
|
||||
tabIndex={isPlayable ? 0 : undefined}
|
||||
className={cn(
|
||||
'flex items-stretch gap-4 h-26 p-3',
|
||||
'flex items-stretch gap-4 h-26 p-3 outline-none',
|
||||
isPlayable && 'hover:bg-muted/70 cursor-pointer rounded-md',
|
||||
isVersionsExpanded && 'rounded-b-none',
|
||||
)}
|
||||
@@ -446,12 +488,9 @@ export function HistoryTable() {
|
||||
>
|
||||
{/* Status icon */}
|
||||
<div className="flex items-center shrink-0 w-10 justify-center overflow-hidden">
|
||||
<div className="scale-50">
|
||||
<Loader
|
||||
type={isGenerating ? 'line-scale' : 'line-scale-pulse-out-rapid'}
|
||||
active={isGenerating || isCurrentlyPlaying}
|
||||
/>
|
||||
</div>
|
||||
<AudioBars
|
||||
mode={isGenerating ? 'generating' : isCurrentlyPlaying ? 'playing' : 'idle'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Left side - Meta information */}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="animate-[fadeInUp_0.5s_ease_both]"
|
||||
style={{ animationDelay: `${delay}ms` } as CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AboutPage() {
|
||||
const platform = usePlatform();
|
||||
const [version, setVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setVersion)
|
||||
.catch(() => setVersion(''));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div className="max-w-md mx-auto h-full flex items-center">
|
||||
<div className="flex flex-col items-center text-center space-y-5">
|
||||
<FadeIn delay={0}>
|
||||
<img src={voiceboxLogo} alt="Voicebox" className="w-20 h-20 object-contain" />
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={80}>
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-lg font-semibold">Voicebox</h1>
|
||||
<p className="text-xs text-muted-foreground/60 h-4">
|
||||
{version ? `v${version}` : '\u00A0'}
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={160}>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
||||
The open-source voice synthesis studio. Clone voices, generate speech, apply effects,
|
||||
and build voice-powered apps — all running locally on your machine.
|
||||
</p>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={240}>
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<span>Created by</span>
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Jamie Pine
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={320}>
|
||||
<div className="flex flex-wrap justify-center gap-3 pt-2">
|
||||
<a
|
||||
href="https://buymeacoffee.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-[#FFDD00]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m20.216 6.415-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 0 0-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 0 0-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 0 1-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 0 1 3.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 0 1-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 0 1-4.743.295 37.059 37.059 0 0 1-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0 0 11.343.376.483.483 0 0 1 .535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 0 1 .39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 0 1-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 0 1-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 0 0-1.322-.238c-.826 0-1.491.284-2.26.613z" />
|
||||
</svg>
|
||||
Buy me a coffee
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group inline-flex items-center gap-2 rounded-lg border border-border/60 px-4 py-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" />
|
||||
</svg>
|
||||
GitHub
|
||||
<ArrowUpRight className="h-3.5 w-3.5 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<FadeIn delay={400}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
Licensed under{' '}
|
||||
<a
|
||||
href="https://github.com/jamiepine/voicebox/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-muted-foreground/60 transition-colors"
|
||||
>
|
||||
MIT
|
||||
</a>
|
||||
</p>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import changelogRaw from 'virtual:changelog';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { type ChangelogEntry, parseChangelog } from '@/lib/utils/parseChangelog';
|
||||
|
||||
function renderMarkdown(md: string): React.ReactNode[] {
|
||||
const lines = md.split('\n');
|
||||
const elements: React.ReactNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip empty lines
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Tables — collect all lines starting with |
|
||||
if (line.trim().startsWith('|')) {
|
||||
const tableLines: string[] = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||
tableLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
elements.push(renderTable(tableLines, elements.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings
|
||||
if (line.startsWith('#### ')) {
|
||||
elements.push(
|
||||
<h5 key={elements.length} className="text-sm font-medium mt-5 mb-1">
|
||||
{inlineMarkdown(line.slice(5))}
|
||||
</h5>,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('### ')) {
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-sm font-medium mt-6 mb-2">
|
||||
{inlineMarkdown(line.slice(4))}
|
||||
</h4>,
|
||||
);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// List items — collect consecutive
|
||||
if (line.startsWith('- ')) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('- ')) {
|
||||
items.push(lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
elements.push(
|
||||
<ul key={elements.length} className="space-y-1 my-2">
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} className="text-sm text-muted-foreground flex gap-2">
|
||||
<span className="text-muted-foreground/50 select-none shrink-0">•</span>
|
||||
<span>{inlineMarkdown(item)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-sm text-muted-foreground my-2">
|
||||
{inlineMarkdown(line)}
|
||||
</p>,
|
||||
);
|
||||
i++;
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
function renderTable(tableLines: string[], keyBase: number): React.ReactNode {
|
||||
const parseRow = (line: string) =>
|
||||
line
|
||||
.split('|')
|
||||
.slice(1, -1)
|
||||
.map((c) => c.trim());
|
||||
|
||||
const headers = parseRow(tableLines[0]);
|
||||
// Skip separator line (index 1)
|
||||
const rows = tableLines.slice(2).map(parseRow);
|
||||
|
||||
return (
|
||||
<div key={keyBase} className="overflow-x-auto my-3">
|
||||
<table className="text-sm w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{headers.map((h, hIdx) => (
|
||||
<th
|
||||
key={hIdx}
|
||||
className="text-left py-1.5 pr-4 text-muted-foreground font-medium text-xs"
|
||||
>
|
||||
{inlineMarkdown(h)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, rowIdx) => (
|
||||
<tr key={rowIdx} className="border-b border-border/50">
|
||||
{row.map((cell, cellIdx) => (
|
||||
<td key={cellIdx} className="py-1.5 pr-4 text-muted-foreground">
|
||||
{inlineMarkdown(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function inlineMarkdown(text: string): React.ReactNode {
|
||||
// Process inline markdown: bold, code, links
|
||||
const parts: React.ReactNode[] = [];
|
||||
// Regex matches: **bold**, `code`, [text](url)
|
||||
const inlineRe = /\*\*(.+?)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = inlineRe.exec(text);
|
||||
|
||||
while (match !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
if (match[1] !== undefined) {
|
||||
// Bold
|
||||
parts.push(
|
||||
<strong key={parts.length} className="font-medium text-foreground">
|
||||
{match[1]}
|
||||
</strong>,
|
||||
);
|
||||
} else if (match[2] !== undefined) {
|
||||
// Code
|
||||
parts.push(
|
||||
<code key={parts.length} className="px-1 py-0.5 rounded bg-muted text-xs font-mono">
|
||||
{match[2]}
|
||||
</code>,
|
||||
);
|
||||
} else if (match[3] !== undefined && match[4] !== undefined) {
|
||||
// Link
|
||||
parts.push(
|
||||
<a
|
||||
key={parts.length}
|
||||
href={match[4]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{match[3]}
|
||||
</a>,
|
||||
);
|
||||
}
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
match = inlineRe.exec(text);
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length === 1 ? parts[0] : parts;
|
||||
}
|
||||
|
||||
function ChangelogEntryCard({ entry }: { entry: ChangelogEntry }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const content = useMemo(() => renderMarkdown(entry.body), [entry.body]);
|
||||
const isLong = entry.body.split('\n').length > 12;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50 pb-6">
|
||||
<div className="flex items-baseline gap-3 mb-1">
|
||||
<h3 className="text-sm font-medium">{entry.version}</h3>
|
||||
{entry.date && <span className="text-xs text-muted-foreground">{entry.date}</span>}
|
||||
{entry.version === 'Unreleased' && <Badge variant="outline">dev</Badge>}
|
||||
</div>
|
||||
|
||||
<div className={isLong && !expanded ? 'max-h-48 overflow-hidden relative' : ''}>
|
||||
{content}
|
||||
{isLong && !expanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-accent hover:underline mt-2"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogPage() {
|
||||
const entries = useMemo(() => parseChangelog(changelogRaw), []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{entries.map((entry) => (
|
||||
<ChangelogEntryCard key={entry.version} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { AlertCircle, ArrowUpRight, Book, Download, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Form, FormControl, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
const connectionSchema = z.object({
|
||||
serverUrl: z.string().url('Please enter a valid URL'),
|
||||
});
|
||||
|
||||
type ConnectionFormValues = z.infer<typeof connectionSchema>;
|
||||
|
||||
export function GeneralPage() {
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const setServerUrl = useServerStore((state) => state.setServerUrl);
|
||||
const keepServerRunningOnClose = useServerStore((state) => state.keepServerRunningOnClose);
|
||||
const setKeepServerRunningOnClose = useServerStore((state) => state.setKeepServerRunningOnClose);
|
||||
const mode = useServerStore((state) => state.mode);
|
||||
const setMode = useServerStore((state) => state.setMode);
|
||||
const { toast } = useToast();
|
||||
const { data: health, isLoading, error: healthError } = useServerHealth();
|
||||
|
||||
const form = useForm<ConnectionFormValues>({
|
||||
resolver: zodResolver(connectionSchema),
|
||||
defaultValues: { serverUrl },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ serverUrl });
|
||||
}, [serverUrl, form]);
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
|
||||
function onSubmit(data: ConnectionFormValues) {
|
||||
setServerUrl(data.serverUrl);
|
||||
form.reset(data);
|
||||
toast({
|
||||
title: 'Server URL updated',
|
||||
description: `Connected to ${data.serverUrl}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Book className="h-5 w-5 shrink-0 text-accent" strokeWidth={2.5} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">Read the Docs</div>
|
||||
<div className="text-xs text-muted-foreground">docs.voicebox.sh</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.gg/StkzQasqPS"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-accent"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">Join the Discord</div>
|
||||
<div className="text-xs text-muted-foreground">Get help & share voices</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SettingSection>
|
||||
<SettingRow
|
||||
title="Server URL"
|
||||
description="The address of your voicebox backend server."
|
||||
action={
|
||||
<ConnectionStatus health={health} isLoading={isLoading} healthError={healthError} />
|
||||
}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="http://127.0.0.1:17493" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{isDirty && (
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Keep server running when app closes"
|
||||
description="The server will continue running in the background after closing the app."
|
||||
htmlFor="keepServerRunning"
|
||||
action={
|
||||
<Toggle
|
||||
id="keepServerRunning"
|
||||
checked={keepServerRunningOnClose}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setKeepServerRunningOnClose(checked);
|
||||
platform.lifecycle.setKeepServerRunning(checked).catch((error) => {
|
||||
console.error('Failed to sync setting to Rust:', error);
|
||||
setKeepServerRunningOnClose(!checked);
|
||||
toast({
|
||||
title: 'Failed to update setting',
|
||||
description: 'Could not sync setting to backend.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
});
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: checked
|
||||
? 'Server will continue running when app closes'
|
||||
: 'Server will stop when app closes',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Allow network access"
|
||||
description="Makes the server accessible from other devices on your network. Restart the app after changing."
|
||||
htmlFor="allowNetworkAccess"
|
||||
action={
|
||||
<Toggle
|
||||
id="allowNetworkAccess"
|
||||
checked={mode === 'remote'}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
setMode(checked ? 'remote' : 'local');
|
||||
toast({
|
||||
title: 'Setting updated',
|
||||
description: checked
|
||||
? 'Network access enabled. Restart the app to apply.'
|
||||
: 'Network access disabled. Restart the app to apply.',
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingSection>
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionStatus({
|
||||
health,
|
||||
isLoading,
|
||||
healthError,
|
||||
}: {
|
||||
health: ReturnType<typeof useServerHealth>['data'];
|
||||
isLoading: boolean;
|
||||
healthError: ReturnType<typeof useServerHealth>['error'];
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-border/60 px-3 py-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Connecting</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (healthError) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-destructive/30 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-destructive/40" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-destructive" />
|
||||
</span>
|
||||
<span className="text-xs text-destructive">Offline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (health) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-3 py-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-accent shadow-[0_0_6px_1px_hsl(var(--accent)/0.5)]" />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">Online</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UpdatesSection() {
|
||||
const platform = usePlatform();
|
||||
const { status, checkForUpdates, downloadAndInstall, restartAndInstall } = useAutoUpdater(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
const isDev = !import.meta.env?.PROD;
|
||||
|
||||
useEffect(() => {
|
||||
platform.metadata
|
||||
.getVersion()
|
||||
.then(setCurrentVersion)
|
||||
.catch(() => setCurrentVersion('Unknown'));
|
||||
}, [platform]);
|
||||
|
||||
return (
|
||||
<SettingSection title="App Updates" description={`v${currentVersion}${isDev ? ' (dev)' : ''}`}>
|
||||
{isDev ? (
|
||||
<SettingRow
|
||||
title="Development mode"
|
||||
description="Auto-updates are disabled in development mode."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SettingRow
|
||||
title="Check for updates"
|
||||
description={
|
||||
status.available
|
||||
? `Version ${status.version} available`
|
||||
: status.checking
|
||||
? 'Checking...'
|
||||
: "You're up to date"
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
onClick={checkForUpdates}
|
||||
disabled={status.checking || status.downloading || status.readyToInstall}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 mr-1.5 ${status.checking ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
Check
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{status.error && (
|
||||
<SettingRow title="Update error">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{status.error}
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{status.available && !status.downloading && !status.readyToInstall && (
|
||||
<SettingRow
|
||||
title={`Update to ${status.version}`}
|
||||
description="Download and install the latest version."
|
||||
action={
|
||||
<Button onClick={downloadAndInstall} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status.downloading && (
|
||||
<SettingRow title="Downloading update...">
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={status.downloadProgress} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
{status.downloadedBytes !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0 ? (
|
||||
<span>
|
||||
{(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(status.totalBytes / 1024 / 1024).toFixed(1)} MB
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{status.downloadProgress !== undefined && <span>{status.downloadProgress}%</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{status.readyToInstall && (
|
||||
<SettingRow
|
||||
title="Update ready to install"
|
||||
description={`Version ${status.version} has been downloaded. Restart to complete.`}
|
||||
action={
|
||||
<Button onClick={restartAndInstall} size="sm">
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restart Now
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
|
||||
const API_ENDPOINTS = [
|
||||
{ method: 'POST', path: '/generate', label: 'Generate speech' },
|
||||
{ method: 'GET', path: '/health', label: 'Server status' },
|
||||
{ method: 'GET', path: '/profiles', label: 'List voices' },
|
||||
{ method: 'GET', path: '/history', label: 'Past generations' },
|
||||
];
|
||||
|
||||
function ApiReferenceCard({ serverUrl }: { serverUrl: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">API Access</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Integrate Voicebox into your workflow via the REST API at{' '}
|
||||
<code className="text-xs bg-muted px-1 py-0.5 rounded font-mono">{serverUrl}</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{API_ENDPOINTS.map((ep) => (
|
||||
<div key={ep.path} className="flex items-center gap-2.5 py-1">
|
||||
<span
|
||||
className={`text-[10px] font-mono font-semibold w-9 text-center rounded px-1 py-px ${
|
||||
ep.method === 'POST' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-xs font-mono text-muted-foreground">{ep.path}</code>
|
||||
<span className="text-xs text-muted-foreground/50 ml-auto">{ep.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a
|
||||
href={`${serverUrl}/docs`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
View the full API reference
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
export function GenerationPage() {
|
||||
const platform = usePlatform();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const maxChunkChars = useServerStore((state) => state.maxChunkChars);
|
||||
const setMaxChunkChars = useServerStore((state) => state.setMaxChunkChars);
|
||||
const crossfadeMs = useServerStore((state) => state.crossfadeMs);
|
||||
const setCrossfadeMs = useServerStore((state) => state.setCrossfadeMs);
|
||||
const normalizeAudio = useServerStore((state) => state.normalizeAudio);
|
||||
const setNormalizeAudio = useServerStore((state) => state.setNormalizeAudio);
|
||||
const autoplayOnGenerate = useServerStore((state) => state.autoplayOnGenerate);
|
||||
const setAutoplayOnGenerate = useServerStore((state) => state.setAutoplayOnGenerate);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [generationsPath, setGenerationsPath] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${serverUrl}/health/filesystem`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const genDir = data.directories?.find((d: { path: string }) =>
|
||||
d.path.includes('generations'),
|
||||
);
|
||||
if (genDir?.path) setGenerationsPath(genDir.path);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [serverUrl]);
|
||||
|
||||
const openGenerationsFolder = useCallback(async () => {
|
||||
if (!generationsPath) return;
|
||||
setOpening(true);
|
||||
try {
|
||||
await platform.filesystem.openPath(generationsPath);
|
||||
} catch (e) {
|
||||
console.error('Failed to open generations folder:', e);
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
}, [platform, generationsPath]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<SettingSection
|
||||
title="Generation"
|
||||
description="Controls for long text generation. These settings apply to all engines."
|
||||
>
|
||||
<SettingRow
|
||||
title="Auto-chunking limit"
|
||||
description="Long text is split into chunks at sentence boundaries. Lower values can improve quality for long outputs."
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{maxChunkChars} chars
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
id="maxChunkChars"
|
||||
value={[maxChunkChars]}
|
||||
onValueChange={([value]) => setMaxChunkChars(value)}
|
||||
min={100}
|
||||
max={5000}
|
||||
step={50}
|
||||
aria-label="Auto-chunking character limit"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Chunk crossfade"
|
||||
description="Blends audio between chunks to smooth transitions. Set to 0 for a hard cut."
|
||||
action={
|
||||
<span className="text-sm tabular-nums text-muted-foreground">
|
||||
{crossfadeMs === 0 ? 'Cut' : `${crossfadeMs}ms`}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Slider
|
||||
id="crossfadeMs"
|
||||
value={[crossfadeMs]}
|
||||
onValueChange={([value]) => setCrossfadeMs(value)}
|
||||
min={0}
|
||||
max={200}
|
||||
step={10}
|
||||
aria-label="Chunk crossfade duration"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Normalize audio"
|
||||
description="Adjusts output volume to a consistent level across generations."
|
||||
htmlFor="normalizeAudio"
|
||||
action={
|
||||
<Toggle
|
||||
id="normalizeAudio"
|
||||
checked={normalizeAudio}
|
||||
onCheckedChange={setNormalizeAudio}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Autoplay on generate"
|
||||
description="Automatically play audio when a generation completes."
|
||||
htmlFor="autoplayOnGenerate"
|
||||
action={
|
||||
<Toggle
|
||||
id="autoplayOnGenerate"
|
||||
checked={autoplayOnGenerate}
|
||||
onCheckedChange={setAutoplayOnGenerate}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
title="Generations folder"
|
||||
description={generationsPath ?? 'Where generated audio files are stored on disk.'}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openGenerationsFolder}
|
||||
disabled={opening || !generationsPath}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
|
||||
Open
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, Cpu, Download, Loader2, RotateCw, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
type RestartPhase = 'idle' | 'stopping' | 'waiting' | 'ready';
|
||||
|
||||
function AppleLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="6" width="16" height="12" rx="2" />
|
||||
<path d="M2 10h2M2 14h2M20 10h2M20 14h2" />
|
||||
<path d="M9 10h6M9 14h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GpuInfoCard({ health }: { health: HealthResponse }) {
|
||||
const hasGpu = health.gpu_available && health.gpu_type;
|
||||
|
||||
// Parse GPU name from type string like "CUDA (NVIDIA RTX 4090)" or "MPS (Apple M2 Pro)"
|
||||
const gpuName = hasGpu
|
||||
? health.gpu_type!.replace(/^(CUDA|ROCm|MPS|Metal|XPU|DirectML)\s*\((.+)\)$/, '$2') ||
|
||||
health.gpu_type!
|
||||
: null;
|
||||
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
|
||||
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
|
||||
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasGpu ? (
|
||||
isApple ? (
|
||||
<AppleLogo className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<GpuIcon className="h-5 w-5 shrink-0 text-accent" />
|
||||
)
|
||||
) : (
|
||||
<Cpu className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
<div className="text-sm font-medium">{hasGpu ? gpuName : 'CPU Only'}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{hasGpu ? (
|
||||
<>
|
||||
<span>{gpuBackend}</span>
|
||||
{showBackendVariant && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span className="uppercase">{health.backend_variant}</span>
|
||||
</>
|
||||
)}
|
||||
{health.vram_used_mb != null && health.vram_used_mb > 0 && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span>{health.vram_used_mb.toFixed(0)} MB VRAM</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span>No GPU acceleration detected</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasGpu && (
|
||||
<div className="flex items-center gap-2 rounded-full border border-accent/30 px-2.5 py-0.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent/60" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-accent shadow-[0_0_4px_1px_hsl(var(--accent)/0.4)]" />
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground">Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GpuPage() {
|
||||
const platform = usePlatform();
|
||||
const queryClient = useQueryClient();
|
||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||
const { data: health } = useServerHealth();
|
||||
|
||||
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
|
||||
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const {
|
||||
data: cudaStatus,
|
||||
isLoading: _cudaStatusLoading,
|
||||
refetch: refetchCudaStatus,
|
||||
} = useQuery({
|
||||
queryKey: ['cuda-status', serverUrl],
|
||||
queryFn: () => apiClient.getCudaStatus(),
|
||||
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
|
||||
retry: 1,
|
||||
enabled: !!health,
|
||||
});
|
||||
|
||||
const isCurrentlyCuda = health?.backend_variant === 'cuda';
|
||||
const cudaAvailable = cudaStatus?.available ?? false;
|
||||
const cudaDownloading = cudaStatus?.downloading ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cudaDownloading || !serverUrl) return;
|
||||
|
||||
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as CudaDownloadProgress;
|
||||
setDownloadProgress(data);
|
||||
|
||||
if (data.status === 'complete') {
|
||||
eventSource.close();
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
} else if (data.status === 'error') {
|
||||
eventSource.close();
|
||||
setError(data.error || 'Download failed');
|
||||
setDownloadProgress(null);
|
||||
refetchCudaStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing CUDA progress event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
|
||||
|
||||
const clearHealthPolling = useCallback(() => {
|
||||
if (healthPollRef.current) {
|
||||
clearInterval(healthPollRef.current);
|
||||
healthPollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startHealthPolling = useCallback(() => {
|
||||
clearHealthPolling();
|
||||
|
||||
healthPollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const result = await apiClient.getHealth();
|
||||
if (result.status === 'healthy') {
|
||||
clearHealthPolling();
|
||||
setRestartPhase('ready');
|
||||
queryClient.invalidateQueries();
|
||||
setTimeout(() => setRestartPhase('idle'), 2000);
|
||||
}
|
||||
} catch {
|
||||
// Server still down, keep polling
|
||||
}
|
||||
}, 1000);
|
||||
}, [queryClient, clearHealthPolling]);
|
||||
|
||||
const restartServerWithPolling = useCallback(
|
||||
async (errorMessage: string) => {
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await platform.lifecycle.restartServer();
|
||||
setRestartPhase('waiting');
|
||||
startHealthPolling();
|
||||
} catch (e: unknown) {
|
||||
clearHealthPolling();
|
||||
setRestartPhase('idle');
|
||||
throw new Error(e instanceof Error ? e.message : errorMessage);
|
||||
}
|
||||
},
|
||||
[platform, startHealthPolling, clearHealthPolling],
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.downloadCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Failed to start download';
|
||||
if (msg.includes('already downloaded')) {
|
||||
refetchCudaStatus();
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await restartServerWithPolling('Restart failed');
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Restart failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToCpu = async () => {
|
||||
setError(null);
|
||||
setRestartPhase('stopping');
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
await restartServerWithPolling('Failed to switch to CPU');
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
|
||||
refetchCudaStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await apiClient.deleteCudaBackend();
|
||||
refetchCudaStatus();
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to delete CUDA backend');
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
if (!health) return null;
|
||||
|
||||
const hasNativeGpu =
|
||||
health.gpu_available &&
|
||||
!isCurrentlyCuda &&
|
||||
health.gpu_type &&
|
||||
!health.gpu_type.includes('CUDA');
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<GpuInfoCard health={health} />
|
||||
|
||||
{/* CUDA section — only when no native GPU and not already on CUDA */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
<SettingSection
|
||||
title="CUDA Backend"
|
||||
description="NVIDIA GPU acceleration via a downloadable CUDA backend."
|
||||
>
|
||||
{/* Download progress */}
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<SettingRow title="Downloading CUDA backend...">
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={downloadProgress.progress} className="h-2" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{downloadProgress.filename ||
|
||||
(cudaAvailable ? 'Updating...' : 'Downloading...')}
|
||||
</span>
|
||||
<span>
|
||||
{downloadProgress.total > 0
|
||||
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
|
||||
: `${downloadProgress.progress.toFixed(1)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Restart in progress */}
|
||||
{restartPhase !== 'idle' && (
|
||||
<SettingRow
|
||||
title={
|
||||
restartPhase === 'ready'
|
||||
? 'Server restarted successfully'
|
||||
: restartPhase === 'waiting'
|
||||
? 'Restarting server...'
|
||||
: 'Stopping server...'
|
||||
}
|
||||
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<SettingRow title="Error">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{restartPhase === 'idle' && !cudaDownloading && (
|
||||
<>
|
||||
{!cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title="Download CUDA backend"
|
||||
description="~2.4 GB download. Requires an NVIDIA GPU with CUDA support."
|
||||
action={
|
||||
<Button onClick={handleDownload} size="sm">
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Switch to CUDA backend"
|
||||
description="CUDA backend is downloaded and ready. Restart to enable."
|
||||
action={
|
||||
<Button onClick={handleRestart} size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isCurrentlyCuda && platform.metadata.isTauri && (
|
||||
<SettingRow
|
||||
title="Switch to CPU backend"
|
||||
description="Disable GPU acceleration. You can re-download CUDA later."
|
||||
action={
|
||||
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Switch
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{cudaAvailable && !isCurrentlyCuda && (
|
||||
<SettingRow
|
||||
title="Remove CUDA backend"
|
||||
description="Delete the downloaded CUDA binary to free disk space."
|
||||
action={
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Remove
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 leading-relaxed">
|
||||
Voicebox automatically detects and uses the best available GPU on your system. On Apple
|
||||
Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal
|
||||
Performance Shaders (MPS), with no additional setup required. On Windows and Linux with
|
||||
NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference.
|
||||
AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When
|
||||
no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { type LogEntry, useLogStore } from '@/stores/logStore';
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
return d.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function LogLine({ entry }: { entry: LogEntry }) {
|
||||
return (
|
||||
<div className="flex gap-3 font-mono text-xs leading-5 hover:bg-muted/30">
|
||||
<span className="text-muted-foreground/50 select-none shrink-0">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-all',
|
||||
entry.stream === 'stderr' ? 'text-orange-400/80' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const entries = useLogStore((s) => s.entries);
|
||||
const clear = useLogStore((s) => s.clear);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
// Auto-scroll to bottom when new entries arrive
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [entries.length, autoScroll]);
|
||||
|
||||
// Detect manual scroll to disable auto-scroll
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Server Logs</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{entries.length} {entries.length === 1 ? 'line' : 'lines'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAutoScroll(true);
|
||||
containerRef.current?.scrollTo({ top: containerRef.current.scrollHeight });
|
||||
}}
|
||||
>
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={clear}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-md border bg-black/20 p-3"
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground/50 font-mono space-y-1">
|
||||
<p>No log output yet.</p>
|
||||
{!import.meta.env?.PROD && (
|
||||
<p>
|
||||
Server logs are only captured when the app manages the server process (production
|
||||
builds).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
entries.map((entry) => <LogLine key={entry.id} entry={entry} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,70 @@
|
||||
import { ConnectionForm } from '@/components/ServerSettings/ConnectionForm';
|
||||
import { GenerationSettings } from '@/components/ServerSettings/GenerationSettings';
|
||||
import { GpuAcceleration } from '@/components/ServerSettings/GpuAcceleration';
|
||||
import { UpdateStatus } from '@/components/ServerSettings/UpdateStatus';
|
||||
import { Link, Outlet, useMatchRoute } from '@tanstack/react-router';
|
||||
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';
|
||||
|
||||
export function ServerTab() {
|
||||
interface SettingsTab {
|
||||
label: string;
|
||||
path:
|
||||
| '/settings'
|
||||
| '/settings/generation'
|
||||
| '/settings/gpu'
|
||||
| '/settings/logs'
|
||||
| '/settings/changelog'
|
||||
| '/settings/about';
|
||||
tauriOnly?: boolean;
|
||||
}
|
||||
|
||||
const tabs: SettingsTab[] = [
|
||||
{ label: 'General', path: '/settings' },
|
||||
{ label: 'Generation', path: '/settings/generation' },
|
||||
{ label: 'GPU', path: '/settings/gpu', tauriOnly: true },
|
||||
{ label: 'Logs', path: '/settings/logs', tauriOnly: true },
|
||||
{ label: 'Changelog', path: '/settings/changelog' },
|
||||
{ label: 'About', path: '/settings/about' },
|
||||
];
|
||||
|
||||
export function SettingsLayout() {
|
||||
const platform = usePlatform();
|
||||
const isPlayerVisible = !!usePlayerStore((state) => state.audioUrl);
|
||||
const matchRoute = useMatchRoute();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('overflow-y-auto flex flex-col', isPlayerVisible && BOTTOM_SAFE_AREA_PADDING)}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<ConnectionForm />
|
||||
<GenerationSettings />
|
||||
{platform.metadata.isTauri && <GpuAcceleration />}
|
||||
{platform.metadata.isTauri && <UpdateStatus />}
|
||||
</div>
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Created by{' '}
|
||||
<a
|
||||
href="https://github.com/jamiepine"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Jamie Pine
|
||||
</a>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<nav className="flex gap-1 border-b shrink-0">
|
||||
{tabs.map((tab) => {
|
||||
if (tab.tauriOnly && !platform.metadata.isTauri) return null;
|
||||
|
||||
const isActive =
|
||||
tab.path === '/settings'
|
||||
? matchRoute({ to: tab.path, fuzzy: false })
|
||||
: matchRoute({ to: tab.path });
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.path}
|
||||
to={tab.path}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'border-accent text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground/30',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 overflow-y-auto pt-6 pb-6 px-2 -mx-2',
|
||||
isPlayerVisible && BOTTOM_SAFE_AREA_PADDING,
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* A section header with title and optional description, separated by a border.
|
||||
*/
|
||||
export function SettingSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{title && <h3 className="text-sm font-medium">{title}</h3>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
<div className={`${title || description ? 'pt-3' : ''} space-y-0 divide-y divide-border/60`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single settings row: label+description on the left, action on the right.
|
||||
* Use for toggles, inputs, buttons, badges — any control type.
|
||||
*/
|
||||
export function SettingRow({
|
||||
title,
|
||||
description,
|
||||
htmlFor,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
htmlFor?: string;
|
||||
/** Right-aligned control (checkbox, button, badge, etc.) */
|
||||
action?: ReactNode;
|
||||
/** Full-width content rendered below the label row (for sliders, inputs, etc.) */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-3">
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={`text-sm font-medium leading-none select-none ${htmlFor ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
{title}
|
||||
</label>
|
||||
{description && <p className="text-sm text-muted-foreground mt-0.5">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
{children && <div className="mt-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useMatchRoute } from '@tanstack/react-router';
|
||||
import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||
import { AudioLines, Box, Mic, Settings, Speaker, Volume2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
@@ -19,7 +19,7 @@ const tabs = [
|
||||
{ id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' },
|
||||
{ id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' },
|
||||
{ id: 'models', path: '/models', icon: Box, label: 'Models' },
|
||||
{ id: 'server', path: '/server', icon: Server, label: 'Server' },
|
||||
{ id: 'settings', path: '/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
@@ -54,9 +54,10 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
<div className="flex flex-col gap-3">
|
||||
{tabs.map((tab, index) => {
|
||||
const Icon = tab.icon;
|
||||
// For index route, use exact match; for others, use default matching
|
||||
const isActive =
|
||||
tab.path === '/' ? matchRoute({ to: '/', exact: true }) : matchRoute({ to: tab.path });
|
||||
tab.path === '/'
|
||||
? matchRoute({ to: '/', fuzzy: false })
|
||||
: matchRoute({ to: tab.path, fuzzy: true });
|
||||
|
||||
// Accent fades as buttons get further from the logo
|
||||
const accentOpacity = Math.max(0.08, 0.5 - index * 0.07);
|
||||
@@ -98,7 +99,7 @@ export function Sidebar({ isMacOS }: SidebarProps) {
|
||||
<span className="text-[10px] text-muted-foreground/50">v{version}</span>
|
||||
{updateStatus.available && (
|
||||
<Link
|
||||
to="/server"
|
||||
to="/settings"
|
||||
className="text-[9px] font-semibold tracking-wide uppercase px-2 py-0.5 rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors"
|
||||
>
|
||||
Update
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
@@ -14,7 +14,7 @@ const Slider = React.forwardRef<
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 translate-x-0.5" />
|
||||
<SliderPrimitive.Thumb className="block h-0 w-0 outline-none disabled:pointer-events-none disabled:opacity-50 after:block after:h-5 after:w-5 after:rounded-full after:border-2 after:border-primary after:bg-background after:ring-offset-background after:transition-colors after:absolute after:top-1/2 after:left-1/2 after:-translate-x-1/2 after:-translate-y-1/2 focus-visible:after:ring-2 focus-visible:after:ring-ring focus-visible:after:ring-offset-2" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
@@ -10,6 +11,7 @@ import { useToast } from './use-toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
const isPlayerOpen = !!usePlayerStore((s) => s.audioUrl);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
@@ -23,7 +25,7 @@ export function Toaster() {
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
<ToastViewport className={isPlayerOpen ? 'sm:bottom-44' : ''} />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface ToggleProps {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const Toggle = React.forwardRef<HTMLButtonElement, ToggleProps>(
|
||||
({ checked = false, onCheckedChange, disabled = false, className, id, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
id={id}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (!disabled && onCheckedChange) {
|
||||
onCheckedChange(!checked);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
checked ? 'bg-accent' : 'bg-muted-foreground/25',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[2px]',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
Toggle.displayName = 'Toggle';
|
||||
|
||||
export { Toggle };
|
||||
Vendored
+5
@@ -1,3 +1,8 @@
|
||||
interface Window {
|
||||
__voiceboxServerStartedByApp?: boolean;
|
||||
}
|
||||
|
||||
declare module 'virtual:changelog' {
|
||||
const raw: string;
|
||||
export default raw;
|
||||
}
|
||||
|
||||
+35
-12
@@ -32,8 +32,24 @@ import type {
|
||||
TranscriptionResponse,
|
||||
VoiceProfileCreate,
|
||||
VoiceProfileResponse,
|
||||
WhisperModelSize,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
return detail
|
||||
.map((e: Record<string, unknown>) => e.msg || e.message || JSON.stringify(e))
|
||||
.join('; ');
|
||||
}
|
||||
if (detail && typeof detail === 'object') {
|
||||
const obj = detail as Record<string, unknown>;
|
||||
if (typeof obj.message === 'string') return obj.message;
|
||||
return JSON.stringify(detail);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private getBaseUrl(): string {
|
||||
const serverUrl = useServerStore.getState().serverUrl;
|
||||
@@ -54,7 +70,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -113,7 +129,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -147,7 +163,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -167,7 +183,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -187,7 +203,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -257,7 +273,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -271,7 +287,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -297,7 +313,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -318,12 +334,19 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// Transcription
|
||||
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
|
||||
async transcribeAudio(
|
||||
file: File,
|
||||
language?: LanguageCode,
|
||||
model?: WhisperModelSize,
|
||||
): Promise<TranscriptionResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (language) {
|
||||
formData.append('language', language);
|
||||
}
|
||||
if (model) {
|
||||
formData.append('model', model);
|
||||
}
|
||||
|
||||
const url = `${this.getBaseUrl()}/transcribe`;
|
||||
const response = await fetch(url, {
|
||||
@@ -335,7 +358,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json();
|
||||
@@ -608,7 +631,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
@@ -705,7 +728,7 @@ class ApiClient {
|
||||
const error = await response.json().catch(() => ({
|
||||
detail: response.statusText,
|
||||
}));
|
||||
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
|
||||
throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
|
||||
@@ -99,8 +99,11 @@ export interface HistoryListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type WhisperModelSize = 'base' | 'small' | 'medium' | 'large' | 'turbo';
|
||||
|
||||
export interface TranscriptionRequest {
|
||||
language?: LanguageCode;
|
||||
model?: WhisperModelSize;
|
||||
}
|
||||
|
||||
export interface TranscriptionResponse {
|
||||
|
||||
@@ -75,8 +75,8 @@ export function useGenerationProgress() {
|
||||
currentSources.delete(id);
|
||||
removePendingGeneration(id);
|
||||
|
||||
// Refresh history to pick up the completed generation
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
// Refetch history to pick up the completed generation
|
||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||
|
||||
// If this generation was queued for a story, add it now
|
||||
const storyId = removePendingStoryAdd(id);
|
||||
@@ -120,7 +120,7 @@ export function useGenerationProgress() {
|
||||
removePendingGeneration(id);
|
||||
removePendingStoryAdd(id);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['history'] });
|
||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||
|
||||
toast({
|
||||
title: data.status === 'not_found' ? 'Generation not found' : 'Generation failed',
|
||||
@@ -134,11 +134,12 @@ export function useGenerationProgress() {
|
||||
};
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource auto-reconnects, but if we get repeated errors
|
||||
// just clean up
|
||||
// SSE connection dropped — clean up and refresh history so any
|
||||
// completed/failed generation still appears in the list
|
||||
source.close();
|
||||
currentSources.delete(id);
|
||||
removePendingGeneration(id);
|
||||
queryClient.refetchQueries({ queryKey: ['history'] });
|
||||
};
|
||||
|
||||
currentSources.set(id, source);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
interface UseSystemAudioCaptureOptions {
|
||||
@@ -94,15 +94,13 @@ export function useSystemAudioCapture({
|
||||
const blob = await platform.audio.stopSystemAudioCapture();
|
||||
|
||||
// Pass the actual recorded duration
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
const recordedDuration = startTimeRef.current
|
||||
? (Date.now() - startTimeRef.current) / 1000
|
||||
: undefined;
|
||||
onRecordingComplete?.(blob, recordedDuration);
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to stop system audio capture.';
|
||||
err instanceof Error ? err.message : 'Failed to stop system audio capture.';
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [isRecording, onRecordingComplete, platform]);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import type { WhisperModelSize } from '@/lib/api/types';
|
||||
import type { LanguageCode } from '@/lib/constants/languages';
|
||||
|
||||
export function useTranscription() {
|
||||
return useMutation({
|
||||
mutationFn: ({ file, language }: { file: File; language?: LanguageCode }) =>
|
||||
apiClient.transcribeAudio(file, language),
|
||||
mutationFn: ({
|
||||
file,
|
||||
language,
|
||||
model,
|
||||
}: {
|
||||
file: File;
|
||||
language?: LanguageCode;
|
||||
model?: WhisperModelSize;
|
||||
}) => apiClient.transcribeAudio(file, language, model),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string | null;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Keep-a-Changelog style markdown string into structured entries.
|
||||
*
|
||||
* Splits on `## [version]` headings and extracts the version + date from each.
|
||||
* The body is the raw markdown between headings (trimmed), with the leading
|
||||
* `# Changelog` title and trailing link references stripped.
|
||||
*/
|
||||
export function parseChangelog(raw: string): ChangelogEntry[] {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
|
||||
// Strip trailing link reference definitions (e.g. [0.1.0]: https://...)
|
||||
const cleaned = raw.replace(/^\[[\w.]+\]:.*$/gm, '').trimEnd();
|
||||
|
||||
// Match `## [version]` or `## [version] - date`
|
||||
const headingRe = /^## \[(.+?)\](?:\s*-\s*(.+))?$/gm;
|
||||
const matches = [...cleaned.matchAll(headingRe)];
|
||||
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const match = matches[i];
|
||||
const version = match[1];
|
||||
const date = match[2]?.trim() || null;
|
||||
|
||||
const start = match.index! + match[0].length;
|
||||
const end = i + 1 < matches.length ? matches[i + 1].index! : cleaned.length;
|
||||
const body = cleaned.slice(start, end).trim();
|
||||
|
||||
entries.push({ version, date, body });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -50,12 +50,18 @@ export interface PlatformAudio {
|
||||
stopPlayback(): void;
|
||||
}
|
||||
|
||||
export interface ServerLogEntry {
|
||||
stream: 'stdout' | 'stderr';
|
||||
line: string;
|
||||
}
|
||||
|
||||
export interface PlatformLifecycle {
|
||||
startServer(remote?: boolean, modelsDir?: string | null): Promise<string>;
|
||||
stopServer(): Promise<void>;
|
||||
restartServer(modelsDir?: string | null): Promise<string>;
|
||||
setKeepServerRunning(keep: boolean): Promise<void>;
|
||||
setupWindowCloseHandler(): Promise<void>;
|
||||
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void;
|
||||
onServerReady?: () => void;
|
||||
}
|
||||
|
||||
|
||||
+72
-6
@@ -1,10 +1,22 @@
|
||||
import { createRootRoute, createRoute, createRouter, Outlet } from '@tanstack/react-router';
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from '@tanstack/react-router';
|
||||
import { AppFrame } from '@/components/AppFrame/AppFrame';
|
||||
import { AudioTab } from '@/components/AudioTab/AudioTab';
|
||||
import { EffectsTab } from '@/components/EffectsTab/EffectsTab';
|
||||
import { MainEditor } from '@/components/MainEditor/MainEditor';
|
||||
import { ModelsTab } from '@/components/ModelsTab/ModelsTab';
|
||||
import { ServerTab } from '@/components/ServerTab/ServerTab';
|
||||
import { AboutPage } from '@/components/ServerTab/AboutPage';
|
||||
import { ChangelogPage } from '@/components/ServerTab/ChangelogPage';
|
||||
import { GeneralPage } from '@/components/ServerTab/GeneralPage';
|
||||
import { GenerationPage } from '@/components/ServerTab/GenerationPage';
|
||||
import { GpuPage } from '@/components/ServerTab/GpuPage';
|
||||
import { LogsPage } from '@/components/ServerTab/LogsPage';
|
||||
import { SettingsLayout } from '@/components/ServerTab/ServerTab';
|
||||
import { Sidebar } from '@/components/Sidebar';
|
||||
import { StoriesTab } from '@/components/StoriesTab/StoriesTab';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
@@ -120,11 +132,57 @@ const modelsRoute = createRoute({
|
||||
component: ModelsTab,
|
||||
});
|
||||
|
||||
// Server route
|
||||
const serverRoute = createRoute({
|
||||
// Settings layout route (parent for sub-tabs)
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/settings',
|
||||
component: SettingsLayout,
|
||||
});
|
||||
|
||||
// Settings sub-routes
|
||||
const settingsGeneralRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/',
|
||||
component: GeneralPage,
|
||||
});
|
||||
|
||||
const settingsGenerationRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/generation',
|
||||
component: GenerationPage,
|
||||
});
|
||||
|
||||
const settingsGpuRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/gpu',
|
||||
component: GpuPage,
|
||||
});
|
||||
|
||||
const settingsChangelogRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/changelog',
|
||||
component: ChangelogPage,
|
||||
});
|
||||
|
||||
const settingsLogsRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/logs',
|
||||
component: LogsPage,
|
||||
});
|
||||
|
||||
const settingsAboutRoute = createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path: '/about',
|
||||
component: AboutPage,
|
||||
});
|
||||
|
||||
// Redirect old /server path to /settings
|
||||
const serverRedirectRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/server',
|
||||
component: ServerTab,
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings' });
|
||||
},
|
||||
});
|
||||
|
||||
// Route tree
|
||||
@@ -135,7 +193,15 @@ const routeTree = rootRoute.addChildren([
|
||||
audioRoute,
|
||||
effectsRoute,
|
||||
modelsRoute,
|
||||
serverRoute,
|
||||
settingsRoute.addChildren([
|
||||
settingsGeneralRoute,
|
||||
settingsGenerationRoute,
|
||||
settingsGpuRoute,
|
||||
settingsLogsRoute,
|
||||
settingsChangelogRoute,
|
||||
settingsAboutRoute,
|
||||
]),
|
||||
serverRedirectRoute,
|
||||
]);
|
||||
|
||||
// Create router
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ServerLogEntry } from '@/platform/types';
|
||||
|
||||
const MAX_LOG_ENTRIES = 2000;
|
||||
|
||||
let nextLogEntryId = 0;
|
||||
|
||||
export interface LogEntry extends ServerLogEntry {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface LogStore {
|
||||
entries: LogEntry[];
|
||||
addEntry: (entry: ServerLogEntry) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useLogStore = create<LogStore>((set) => ({
|
||||
entries: [],
|
||||
addEntry: (entry) =>
|
||||
set((state) => {
|
||||
const newEntry: LogEntry = { ...entry, id: nextLogEntryId++, timestamp: Date.now() };
|
||||
const entries = [...state.entries, newEntry];
|
||||
if (entries.length > MAX_LOG_ENTRIES) {
|
||||
return { entries: entries.slice(entries.length - MAX_LOG_ENTRIES) };
|
||||
}
|
||||
return { entries };
|
||||
}),
|
||||
clear: () => set({ entries: [] }),
|
||||
}));
|
||||
@@ -6,5 +6,5 @@
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
"include": ["vite.config.ts", "plugins/**/*.ts"]
|
||||
}
|
||||
|
||||
+2
-1
@@ -2,9 +2,10 @@ import path from 'node:path';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import { changelogPlugin } from './plugins/changelog';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react()],
|
||||
plugins: [tailwindcss(), react(), changelogPlugin(path.resolve(__dirname, '..'))],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
|
||||
@@ -77,6 +77,7 @@ def create_app() -> FastAPI:
|
||||
_configure_cors(application)
|
||||
register_routers(application)
|
||||
_register_lifecycle(application)
|
||||
_mount_frontend(application)
|
||||
|
||||
return application
|
||||
|
||||
@@ -104,6 +105,43 @@ def _configure_cors(application: FastAPI) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _mount_frontend(application: FastAPI) -> None:
|
||||
"""Serve the built web frontend when present (Docker / web deployment).
|
||||
|
||||
The Dockerfile copies the Vite build output to ``/app/frontend/``. When
|
||||
that directory exists we mount static assets and add a catch-all route so
|
||||
the React SPA handles client-side routing. In dev or API-only mode the
|
||||
directory is absent and this function is a no-op.
|
||||
"""
|
||||
frontend_dir = Path(__file__).resolve().parent.parent / "frontend"
|
||||
if not frontend_dir.is_dir():
|
||||
return
|
||||
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Mount hashed assets (JS, CSS, images) that Vite places under /assets
|
||||
assets_dir = frontend_dir / "assets"
|
||||
if assets_dir.is_dir():
|
||||
application.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=str(assets_dir)),
|
||||
name="frontend-assets",
|
||||
)
|
||||
|
||||
# SPA catch-all: serve files if they exist, otherwise index.html for
|
||||
# client-side routes like /voices, /stories, /models, etc.
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = (frontend_dir / full_path).resolve()
|
||||
# Guard against path traversal — only serve files inside frontend_dir
|
||||
if full_path and file_path.is_file() and str(file_path).startswith(str(frontend_dir)):
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(frontend_dir / "index.html", media_type="text/html")
|
||||
|
||||
logger.info("Frontend: serving SPA from %s", frontend_dir)
|
||||
|
||||
|
||||
def _get_gpu_status() -> str:
|
||||
"""Return a human-readable string describing GPU availability."""
|
||||
backend_type = get_backend_type()
|
||||
|
||||
@@ -134,6 +134,7 @@ class STTBackend(Protocol):
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
@@ -345,18 +345,20 @@ class MLXSTTBackend:
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
language: Optional language hint
|
||||
model_size: Optional model size override
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
|
||||
@@ -306,18 +306,20 @@ class PyTorchSTTBackend:
|
||||
self,
|
||||
audio_path: str,
|
||||
language: Optional[str] = None,
|
||||
model_size: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Transcribe audio to text.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Optional language hint (en or zh)
|
||||
language: Optional language hint
|
||||
model_size: Optional model size override
|
||||
|
||||
Returns:
|
||||
Transcribed text
|
||||
"""
|
||||
await self.load_model_async(None)
|
||||
await self.load_model_async(model_size)
|
||||
|
||||
def _transcribe_sync():
|
||||
"""Run synchronous transcription in thread pool."""
|
||||
|
||||
@@ -127,7 +127,13 @@ def build_server(cuda=False):
|
||||
"uvicorn",
|
||||
"--hidden-import",
|
||||
"sqlalchemy",
|
||||
"--hidden-import",
|
||||
# librosa uses lazy_loader which generates .pyi stub files at
|
||||
# install time and reads them at runtime to discover submodules.
|
||||
# --hidden-import alone doesn't bundle the stubs, causing
|
||||
# "Cannot load imports from non-existent stub" at runtime.
|
||||
"--collect-all",
|
||||
"lazy_loader",
|
||||
"--collect-all",
|
||||
"librosa",
|
||||
"--hidden-import",
|
||||
"soundfile",
|
||||
|
||||
+2
-1
@@ -149,7 +149,8 @@ class HistoryListResponse(BaseModel):
|
||||
class TranscriptionRequest(BaseModel):
|
||||
"""Request model for audio transcription."""
|
||||
|
||||
language: Optional[str] = Field(None, pattern="^(en|zh)$")
|
||||
language: Optional[str] = Field(None, pattern="^(en|zh|ja|ko|de|fr|ru|pt|es|it)$")
|
||||
model: Optional[str] = Field(None, pattern="^(base|small|medium|large|turbo)$")
|
||||
|
||||
|
||||
class TranscriptionResponse(BaseModel):
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"""TTS generation endpoints."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .. import models
|
||||
from ..services import history, profiles, tts
|
||||
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
|
||||
@@ -181,25 +184,28 @@ async def get_generation_status(generation_id: str, db: Session = Depends(get_db
|
||||
import json
|
||||
|
||||
async def event_stream():
|
||||
while True:
|
||||
db.expire_all()
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
db.expire_all()
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
if not gen:
|
||||
yield f"data: {json.dumps({'status': 'not_found', 'id': generation_id})}\n\n"
|
||||
return
|
||||
|
||||
payload = {
|
||||
"id": gen.id,
|
||||
"status": gen.status or "completed",
|
||||
"duration": gen.duration,
|
||||
"error": gen.error,
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
payload = {
|
||||
"id": gen.id,
|
||||
"status": gen.status or "completed",
|
||||
"duration": gen.duration,
|
||||
"error": gen.error,
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
if (gen.status or "completed") in ("completed", "failed"):
|
||||
return
|
||||
if (gen.status or "completed") in ("completed", "failed"):
|
||||
return
|
||||
|
||||
await asyncio.sleep(1)
|
||||
await asyncio.sleep(1)
|
||||
except (BrokenPipeError, ConnectionResetError, asyncio.CancelledError):
|
||||
logger.debug("SSE client disconnected for generation %s", generation_id)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
@@ -265,9 +271,12 @@ async def stream_speech(
|
||||
wav_bytes = tts.audio_to_wav_bytes(audio, sample_rate)
|
||||
|
||||
async def _wav_stream():
|
||||
chunk_size = 64 * 1024
|
||||
for i in range(0, len(wav_bytes), chunk_size):
|
||||
yield wav_bytes[i : i + chunk_size]
|
||||
try:
|
||||
chunk_size = 64 * 1024
|
||||
for i in range(0, len(wav_bytes), chunk_size):
|
||||
yield wav_bytes[i : i + chunk_size]
|
||||
except (BrokenPipeError, ConnectionResetError, asyncio.CancelledError):
|
||||
logger.debug("Client disconnected during audio stream")
|
||||
|
||||
return StreamingResponse(
|
||||
_wav_stream(),
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config, models
|
||||
@@ -15,12 +17,18 @@ from ..utils.platform_detect import get_backend_type
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Frontend build directory — present in Docker, absent in dev/API-only mode
|
||||
_frontend_dir = Path(__file__).resolve().parent.parent.parent / "frontend"
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def root():
|
||||
"""Root endpoint."""
|
||||
"""Root endpoint — serves SPA index.html in Docker, JSON otherwise."""
|
||||
from .. import __version__
|
||||
|
||||
index = _frontend_dir / "index.html"
|
||||
if index.is_file():
|
||||
return FileResponse(index, media_type="text/html")
|
||||
return {"message": "voicebox API", "version": __version__}
|
||||
|
||||
|
||||
@@ -199,7 +207,7 @@ async def filesystem_health():
|
||||
|
||||
checks.append(
|
||||
models.DirectoryCheck(
|
||||
path=str(dir_path),
|
||||
path=str(dir_path.resolve()),
|
||||
exists=exists,
|
||||
writable=writable,
|
||||
error=error,
|
||||
|
||||
@@ -102,6 +102,10 @@ async def delete_profile(
|
||||
return {"message": "Profile deleted successfully"}
|
||||
|
||||
|
||||
SAMPLE_MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
SAMPLE_UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1 MB
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/samples", response_model=models.ProfileSampleResponse)
|
||||
async def add_profile_sample(
|
||||
profile_id: str,
|
||||
@@ -115,8 +119,16 @@ async def add_profile_sample(
|
||||
file_suffix = _uploaded_ext if _uploaded_ext in _allowed_audio_exts else ".wav"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
total_size = 0
|
||||
while chunk := await file.read(SAMPLE_UPLOAD_CHUNK_SIZE):
|
||||
total_size += len(chunk)
|
||||
if total_size > SAMPLE_MAX_FILE_SIZE:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large (max {SAMPLE_MAX_FILE_SIZE // (1024 * 1024)} MB)",
|
||||
)
|
||||
tmp.write(chunk)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
|
||||
@@ -20,6 +20,7 @@ UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
async def transcribe_audio(
|
||||
file: UploadFile = File(...),
|
||||
language: str | None = Form(None),
|
||||
model: str | None = Form(None),
|
||||
):
|
||||
"""Transcribe audio file to text."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
@@ -29,14 +30,23 @@ async def transcribe_audio(
|
||||
|
||||
try:
|
||||
from ..utils.audio import load_audio
|
||||
from ..backends import WHISPER_HF_REPOS
|
||||
|
||||
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
model_size = whisper_model.model_size
|
||||
model_size = model if model else whisper_model.model_size
|
||||
|
||||
if not whisper_model.is_loaded() and not whisper_model._is_model_cached(model_size):
|
||||
valid_sizes = list(WHISPER_HF_REPOS.keys())
|
||||
if model_size not in valid_sizes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid model size '{model_size}'. Must be one of: {', '.join(valid_sizes)}",
|
||||
)
|
||||
|
||||
already_loaded = whisper_model.is_loaded() and whisper_model.model_size == model_size
|
||||
if not already_loaded and not whisper_model._is_model_cached(model_size):
|
||||
progress_model_name = f"whisper-{model_size}"
|
||||
task_manager = get_task_manager()
|
||||
|
||||
@@ -59,7 +69,7 @@ async def transcribe_audio(
|
||||
},
|
||||
)
|
||||
|
||||
text = await whisper_model.transcribe(tmp_path, language)
|
||||
text = await whisper_model.transcribe(tmp_path, language, model_size)
|
||||
|
||||
return models.TranscriptionResponse(
|
||||
text=text,
|
||||
|
||||
@@ -22,7 +22,7 @@ from ..database import (
|
||||
Generation as DBGeneration,
|
||||
)
|
||||
from ..models import EffectConfig
|
||||
from ..utils.audio import validate_reference_audio, load_audio, save_audio
|
||||
from ..utils.audio import validate_reference_audio, validate_and_load_reference_audio, load_audio, save_audio
|
||||
from ..utils.images import validate_image, process_avatar
|
||||
from ..utils.cache import _get_cache_dir, clear_profile_cache
|
||||
from .tts import get_tts_model
|
||||
@@ -117,11 +117,16 @@ async def add_profile_sample(
|
||||
Returns:
|
||||
Created sample
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
profile = db.query(DBVoiceProfile).filter_by(id=profile_id).first()
|
||||
if not profile:
|
||||
raise ValueError(f"Profile {profile_id} not found")
|
||||
|
||||
is_valid, error_msg = validate_reference_audio(audio_path)
|
||||
# Validate and load audio in a single pass, off the event loop
|
||||
is_valid, error_msg, audio, sr = await asyncio.to_thread(
|
||||
validate_and_load_reference_audio, audio_path
|
||||
)
|
||||
if not is_valid:
|
||||
raise ValueError(f"Invalid reference audio: {error_msg}")
|
||||
|
||||
@@ -130,8 +135,7 @@ async def add_profile_sample(
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dest_path = profile_dir / f"{sample_id}.wav"
|
||||
audio, sr = load_audio(audio_path)
|
||||
save_audio(audio, str(dest_path), sr)
|
||||
await asyncio.to_thread(save_audio, audio, str(dest_path), sr)
|
||||
|
||||
db_sample = DBProfileSample(
|
||||
id=sample_id,
|
||||
|
||||
+24
-6
@@ -217,22 +217,40 @@ def validate_reference_audio(
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
result = validate_and_load_reference_audio(
|
||||
audio_path, min_duration, max_duration, min_rms
|
||||
)
|
||||
return (result[0], result[1])
|
||||
|
||||
|
||||
def validate_and_load_reference_audio(
|
||||
audio_path: str,
|
||||
min_duration: float = 2.0,
|
||||
max_duration: float = 30.0,
|
||||
min_rms: float = 0.01,
|
||||
) -> Tuple[bool, Optional[str], Optional[np.ndarray], Optional[int]]:
|
||||
"""
|
||||
Validate and load reference audio in a single pass.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message, audio_array, sample_rate)
|
||||
"""
|
||||
try:
|
||||
audio, sr = load_audio(audio_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
if duration < min_duration:
|
||||
return False, f"Audio too short (minimum {min_duration} seconds)"
|
||||
return False, f"Audio too short (minimum {min_duration} seconds)", None, None
|
||||
if duration > max_duration:
|
||||
return False, f"Audio too long (maximum {max_duration} seconds)"
|
||||
return False, f"Audio too long (maximum {max_duration} seconds)", None, None
|
||||
|
||||
rms = np.sqrt(np.mean(audio**2))
|
||||
if rms < min_rms:
|
||||
return False, "Audio is too quiet or silent"
|
||||
return False, "Audio is too quiet or silent", None, None
|
||||
|
||||
if np.abs(audio).max() > 0.99:
|
||||
return False, "Audio is clipping (reduce input gain)"
|
||||
return False, "Audio is clipping (reduce input gain)", None, None
|
||||
|
||||
return True, None
|
||||
return True, None, audio, sr
|
||||
except Exception as e:
|
||||
return False, f"Error validating audio: {str(e)}"
|
||||
return False, f"Error validating audio: {str(e)}", None, None
|
||||
|
||||
@@ -246,6 +246,8 @@ class ProgressManager:
|
||||
# Send heartbeat
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
except (BrokenPipeError, ConnectionResetError, asyncio.CancelledError):
|
||||
logger.debug(f"SSE client disconnected from {model_name}")
|
||||
finally:
|
||||
# Remove from listeners
|
||||
if model_name in self._listeners:
|
||||
|
||||
@@ -6,7 +6,7 @@ from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
binaries = []
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'backend.cuda_download', 'backend.effects', 'backend.utils.effects', 'backend.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.services.profiles', 'backend.services.history', 'backend.services.tts', 'backend.services.transcribe', 'backend.utils.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.services.cuda', 'backend.services.effects', 'backend.utils.effects', 'backend.services.versions', 'pedalboard', 'chatterbox', 'chatterbox.tts_turbo', 'chatterbox.mtl_tts', 'backend.backends.chatterbox_backend', 'backend.backends.chatterbox_turbo_backend', 'backend.backends.luxtts_backend', 'zipvoice', 'zipvoice.luxvoice', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'requests', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
datas += collect_data_files('qwen_tts')
|
||||
datas += copy_metadata('qwen-tts')
|
||||
datas += copy_metadata('requests')
|
||||
@@ -23,6 +23,16 @@ tmp_ret = collect_all('zipvoice')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('linacodec')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('lazy_loader')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('librosa')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('inflect')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('perth')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('piper_phonemize')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx_audio')
|
||||
|
||||
+1
-5
@@ -6,11 +6,7 @@ This is a Next.js application generated with
|
||||
Run development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function HomePage() {
|
||||
redirect('/docs');
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { source } from '@/lib/source';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
export default function Layout({ children }: LayoutProps<'/[[...slug]]'>) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions()}>
|
||||
{children}
|
||||
@@ -7,7 +7,7 @@ import { APIPage } from '@/components/api-page';
|
||||
import { getPageImage, source } from '@/lib/source';
|
||||
import { getMDXComponents } from '@/mdx-components';
|
||||
|
||||
export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
|
||||
export default async function Page(props: PageProps<'/[[...slug]]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
@@ -59,7 +59,7 @@ export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps<'/docs/[[...slug]]'>): Promise<Metadata> {
|
||||
export async function generateMetadata(props: PageProps<'/[[...slug]]'>): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "General",
|
||||
"pages": ["root__get", "health_health_get"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "Generation",
|
||||
"pages": [
|
||||
"generate_speech_generate_post",
|
||||
"transcribe_audio_transcribe_post",
|
||||
"get_audio_audio__generation_id__get"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "History",
|
||||
"pages": [
|
||||
"list_history_history_get",
|
||||
"get_generation_history__generation_id__get",
|
||||
"delete_generation_history__generation_id__delete",
|
||||
"get_stats_history_stats_get"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "API Reference",
|
||||
"defaultOpen": true,
|
||||
"pages": ["unknown"]
|
||||
"pages": ["general", "profiles", "generation", "history", "models"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Models",
|
||||
"pages": [
|
||||
"get_model_status_models_status_get",
|
||||
"load_model_models_load_post",
|
||||
"unload_model_models_unload_post",
|
||||
"trigger_model_download_models_download_post",
|
||||
"get_model_progress_models_progress__model_name__get"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"title": "Profiles",
|
||||
"pages": [
|
||||
"list_profiles_profiles_get",
|
||||
"create_profile_profiles_post",
|
||||
"get_profile_profiles__profile_id__get",
|
||||
"update_profile_profiles__profile_id__put",
|
||||
"delete_profile_profiles__profile_id__delete",
|
||||
"get_profile_samples_profiles__profile_id__samples_get",
|
||||
"add_profile_sample_profiles__profile_id__samples_post",
|
||||
"delete_profile_sample_profiles_samples__sample_id__delete"
|
||||
]
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"title": "Endpoints",
|
||||
"pages": [
|
||||
"root__get",
|
||||
"health_health_get",
|
||||
"list_profiles_profiles_get",
|
||||
"create_profile_profiles_post",
|
||||
"get_profile_profiles__profile_id__get",
|
||||
"update_profile_profiles__profile_id__put",
|
||||
"delete_profile_profiles__profile_id__delete",
|
||||
"add_profile_sample_profiles__profile_id__samples_post",
|
||||
"get_profile_samples_profiles__profile_id__samples_get",
|
||||
"delete_profile_sample_profiles_samples__sample_id__delete",
|
||||
"generate_speech_generate_post",
|
||||
"list_history_history_get",
|
||||
"get_generation_history__generation_id__get",
|
||||
"delete_generation_history__generation_id__delete",
|
||||
"get_stats_history_stats_get",
|
||||
"transcribe_audio_transcribe_post",
|
||||
"get_audio_audio__generation_id__get",
|
||||
"load_model_models_load_post",
|
||||
"unload_model_models_unload_post",
|
||||
"get_model_progress_models_progress__model_name__get",
|
||||
"get_model_status_models_status_get",
|
||||
"trigger_model_download_models_download_post"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
title: "Docker Deployment"
|
||||
description: "Run Voicebox as a headless server with a web UI using Docker"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Voicebox can run as a Docker container with a full web UI -- no desktop app required. This is ideal for headless servers, shared GPU machines, or self-hosted deployments.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jamiepine/voicebox.git
|
||||
cd voicebox
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Open [http://localhost:17493](http://localhost:17493) in your browser. The full Voicebox UI is served directly from the backend.
|
||||
|
||||
<Callout type="info">
|
||||
The first build takes a few minutes (compiling the frontend, installing Python dependencies). Subsequent starts are fast thanks to Docker layer caching.
|
||||
</Callout>
|
||||
|
||||
## How It Works
|
||||
|
||||
The Docker image uses a 3-stage build:
|
||||
|
||||
1. **Frontend** -- builds the React SPA with Bun and Vite
|
||||
2. **Backend** -- installs Python dependencies and TTS model packages
|
||||
3. **Runtime** -- combines both into a minimal image running the FastAPI server
|
||||
|
||||
The backend serves the web UI automatically when the built frontend is present. All API routes work exactly as they do in the desktop app.
|
||||
|
||||
## Configuration
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
The default `docker-compose.yml` binds to localhost only, mounts persistent volumes for data and model cache, and sets sensible resource limits:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
build: .
|
||||
container_name: voicebox
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:17493:17493"
|
||||
volumes:
|
||||
- ./output:/app/data/generations
|
||||
- voicebox-data:/app/data
|
||||
- huggingface-cache:/home/voicebox/.cache/huggingface
|
||||
environment:
|
||||
- LOG_LEVEL=info
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '4'
|
||||
memory: 8G
|
||||
```
|
||||
|
||||
### Exposing to Your Network
|
||||
|
||||
By default the container only listens on `127.0.0.1`. To allow other machines on your network to connect, change the port binding:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "0.0.0.0:17493:17493"
|
||||
```
|
||||
|
||||
<Callout type="warn">
|
||||
The API has no built-in authentication. Only expose to trusted networks, or put a reverse proxy with auth in front of it.
|
||||
</Callout>
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_LEVEL` | `info` | Logging verbosity (`debug`, `info`, `warning`, `error`) |
|
||||
| `VOICEBOX_MODELS_DIR` | (HuggingFace cache) | Custom path for model storage |
|
||||
| `VOICEBOX_CORS_ORIGINS` | (local origins) | Additional CORS origins, comma-separated |
|
||||
|
||||
### Resource Limits
|
||||
|
||||
The default compose file limits the container to 4 CPUs and 8GB RAM. Adjust these based on your hardware:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '8'
|
||||
memory: 16G
|
||||
```
|
||||
|
||||
<Callout type="info">
|
||||
TTS model inference is memory-intensive. 8GB is the minimum for running a single engine. 16GB+ is recommended if you want multiple engines loaded simultaneously.
|
||||
</Callout>
|
||||
|
||||
## Volumes
|
||||
|
||||
| Volume | Container Path | Purpose |
|
||||
|--------|---------------|---------|
|
||||
| `./output` | `/app/data/generations` | Generated audio files (bind-mount, easy access from host) |
|
||||
| `voicebox-data` | `/app/data` | Profiles, database, cache |
|
||||
| `huggingface-cache` | `/home/voicebox/.cache/huggingface` | Downloaded models (persists across rebuilds) |
|
||||
|
||||
The `huggingface-cache` volume is important -- without it, models would be re-downloaded every time the container is rebuilt.
|
||||
|
||||
## GPU Acceleration
|
||||
|
||||
### NVIDIA GPU (CUDA)
|
||||
|
||||
To use your NVIDIA GPU inside the container, install the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) and add GPU access to your compose file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
build: .
|
||||
# ... existing config ...
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [gpu]
|
||||
```
|
||||
|
||||
### AMD GPU (ROCm)
|
||||
|
||||
For AMD GPUs, use the ROCm runtime:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
voicebox:
|
||||
build: .
|
||||
# ... existing config ...
|
||||
devices:
|
||||
- /dev/kfd
|
||||
- /dev/dri
|
||||
group_add:
|
||||
- video
|
||||
```
|
||||
|
||||
### CPU Only
|
||||
|
||||
The default configuration runs on CPU. This works fine but generation will be slower. LuxTTS is the fastest engine on CPU (150x realtime).
|
||||
|
||||
## Security
|
||||
|
||||
The Docker image follows security best practices:
|
||||
|
||||
- **Non-root user** -- the server runs as `voicebox`, not `root`
|
||||
- **Localhost binding** -- only accessible from the host machine by default
|
||||
- **Health checks** -- automatic restart if the server hangs (`/health` endpoint polled every 30s)
|
||||
- **CORS restricted** -- only local origins allowed by default
|
||||
|
||||
### Running Behind a Reverse Proxy
|
||||
|
||||
For production deployments, put Voicebox behind nginx or Caddy with TLS and authentication:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name voicebox.example.com;
|
||||
|
||||
ssl_certificate /etc/ssl/certs/voicebox.pem;
|
||||
ssl_certificate_key /etc/ssl/private/voicebox.key;
|
||||
|
||||
auth_basic "Voicebox";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:17493;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container starts but UI shows JSON
|
||||
|
||||
If you see `{"message": "voicebox API", ...}` instead of the web UI, the frontend build may have failed during the Docker build. Check the build logs:
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
```
|
||||
|
||||
Look for errors in the "Build frontend" stage.
|
||||
|
||||
### Models downloading on every restart
|
||||
|
||||
Make sure the `huggingface-cache` volume is configured. Without it, the model cache is lost when the container stops:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- huggingface-cache:/home/voicebox/.cache/huggingface
|
||||
```
|
||||
|
||||
### Out of memory
|
||||
|
||||
TTS models are large. If the container is killed by the OOM killer, increase the memory limit:
|
||||
|
||||
```yaml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 16G
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
|
||||
```bash
|
||||
# Check what's using port 17493
|
||||
lsof -i :17493
|
||||
|
||||
# Or use a different port
|
||||
ports:
|
||||
- "127.0.0.1:8080:17493"
|
||||
```
|
||||
|
||||
## Prebuilt Images (Coming Soon)
|
||||
|
||||
We plan to publish prebuilt Docker images to GitHub Container Registry so you won't need to build locally:
|
||||
|
||||
```bash
|
||||
# Not available yet — coming in a future release
|
||||
docker run -p 17493:17493 ghcr.io/jamiepine/voicebox:latest
|
||||
```
|
||||
|
||||
The CPU image will be ~3-4 GB (Python + PyTorch + TTS packages). A separate CUDA tag (~6-8 GB) will be available for NVIDIA GPU users. This is normal for ML containers.
|
||||
|
||||
For now, use `docker compose up` to build from source as described above.
|
||||
|
||||
## Connecting the Desktop App
|
||||
|
||||
You can also use the desktop app as a frontend for a Docker-hosted backend. In the desktop app, go to **Settings -> Server**, enable **Remote Mode**, and enter `http://<server-ip>:17493`.
|
||||
|
||||
See the [Remote Mode guide](/overview/remote-mode) for details.
|
||||
@@ -4,6 +4,7 @@
|
||||
"pages": [
|
||||
"introduction",
|
||||
"installation",
|
||||
"docker",
|
||||
"quick-start",
|
||||
"voice-cloning",
|
||||
"stories-editor",
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
import { docs } from '@/.source';
|
||||
import { type InferPageType, loader } from 'fumadocs-core/source';
|
||||
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
|
||||
import { docs } from '@/.source';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: '/docs',
|
||||
baseUrl: '/',
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [lucideIconsPlugin()],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# Code Review: `backend/` Post-Refactor
|
||||
|
||||
**Date:** 2026-03-16
|
||||
**Scope:** Full review of `backend/` after major refactor
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
The refactor is well-executed. The codebase follows a clean layered architecture (routes -> services -> backends) with good separation of concerns. The code is readable, the module boundaries are sensible, and the migration strategy is pragmatic for a desktop app. Below are findings organized by severity.
|
||||
|
||||
---
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### 1. Double `init_db()` in bundled entry point
|
||||
|
||||
**File:** `server.py:261`
|
||||
|
||||
`server.py:261` calls `database.init_db()` explicitly, but `app.py:140` also calls `database.init_db()` inside the `startup` event handler. When running via `server.py`, the database gets initialized twice -- once before uvicorn starts and once during the startup event. This is likely benign (idempotent migrations), but the second call recreates the engine and `SessionLocal`, which could cause subtle issues if any sessions were opened between the two calls.
|
||||
|
||||
**Recommendation:** Remove the explicit `init_db()` call in `server.py:260-262` and rely solely on the startup event in `app.py`. The same issue exists in `main.py:38`.
|
||||
|
||||
### 2. SSE endpoint holds DB session open indefinitely
|
||||
|
||||
**File:** `routes/generations.py:179-212`
|
||||
|
||||
The `get_generation_status` SSE endpoint receives a `db` session via `Depends(get_db)` but keeps it open for the lifetime of the SSE stream (polling every 1 second). This ties up a SQLite connection for potentially minutes. With SQLite's single-writer model, this is a contention risk.
|
||||
|
||||
**Recommendation:** Open and close a short-lived session on each poll iteration instead of holding one via dependency injection:
|
||||
|
||||
```python
|
||||
async def event_stream():
|
||||
while True:
|
||||
db = next(get_db())
|
||||
try:
|
||||
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
|
||||
...
|
||||
finally:
|
||||
db.close()
|
||||
await asyncio.sleep(1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Severity
|
||||
|
||||
### 3. `_save_retry` creates no version record
|
||||
|
||||
**File:** `services/generation.py:201-214`
|
||||
|
||||
`_save_retry` writes the audio file but creates no `GenerationVersion` entry. If the generation previously had versions (from an initial generate that failed mid-effects, for example), the retry result won't appear in the versions list. This creates an inconsistency: some generations have versions, retried ones don't.
|
||||
|
||||
**Recommendation:** Create a "clean" version in `_save_retry` the same way `_save_generate` does.
|
||||
|
||||
### 4. `datetime.utcnow()` is deprecated
|
||||
|
||||
**File:** `services/stories.py` and others
|
||||
|
||||
`datetime.utcnow()` is deprecated as of Python 3.12 and returns a naive datetime. Used throughout `services/stories.py` (lines 95, 96, 193, 307, 360, 404, 457, 529, 537, 598, 610, 652, 716, 775) and possibly other service files.
|
||||
|
||||
**Recommendation:** Replace with `datetime.now(datetime.UTC)` or `datetime.now(timezone.utc)`.
|
||||
|
||||
### 5. `list_stories` N+1 query
|
||||
|
||||
**File:** `services/stories.py:122-132`
|
||||
|
||||
`list_stories` issues one `COUNT(*)` query per story inside a loop. For N stories, that's N+1 queries.
|
||||
|
||||
**Recommendation:** Use a subquery or a single aggregated query:
|
||||
|
||||
```python
|
||||
from sqlalchemy import func
|
||||
counts = dict(
|
||||
db.query(DBStoryItem.story_id, func.count(DBStoryItem.id))
|
||||
.group_by(DBStoryItem.story_id)
|
||||
.all()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity
|
||||
|
||||
### 6. `create_story` queries item count immediately after creation
|
||||
|
||||
**File:** `services/stories.py:103`
|
||||
|
||||
Line 103 queries the item count for a story that was just created -- it will always be 0. This is wasted I/O.
|
||||
|
||||
### 7. Bare `except Exception` with silent `pass`
|
||||
|
||||
**File:** `routes/generations.py:69-70`
|
||||
|
||||
When parsing a profile's stored `effects_chain` JSON, exceptions are silently swallowed. A corrupt JSON blob would result in no effects being applied with no logging.
|
||||
|
||||
**Recommendation:** Log the exception at warning level.
|
||||
|
||||
### 8. `update_story_item_times` uses `generation_id` as key
|
||||
|
||||
**File:** `services/stories.py:643-649`
|
||||
|
||||
`item_map` is keyed by `generation_id`, but the same generation can appear in a story multiple times (via split/duplicate). This would cause key collisions, and only the last item per generation_id would be updatable.
|
||||
|
||||
**Recommendation:** Key by `item_id` instead, and change the `StoryItemUpdateTime` model to use `item_id`.
|
||||
|
||||
### 9. Thread safety gap in `get_stt_backend`
|
||||
|
||||
**File:** `backends/__init__.py:499-520`
|
||||
|
||||
`get_stt_backend()` uses no locking (unlike `get_tts_backend_for_engine` which uses `_tts_backends_lock`). A race condition could create duplicate STT backend instances.
|
||||
|
||||
**Recommendation:** Add a lock or use the same double-checked locking pattern.
|
||||
|
||||
### 10. Unused `_tts_backend` global
|
||||
|
||||
**File:** `backends/__init__.py:156`
|
||||
|
||||
`_tts_backend` is declared but never read or written outside of `reset_backends()`. All TTS access goes through `_tts_backends` dict. Dead code.
|
||||
|
||||
### 11. `trim_story_item` returns `None` for validation errors
|
||||
|
||||
**File:** `services/stories.py:448`
|
||||
|
||||
Returning `None` for "item not found" and "invalid trim values" is ambiguous. The route handler can't distinguish between a 404 and a 400 response.
|
||||
|
||||
**Recommendation:** Raise specific exceptions (e.g., `ValueError` for invalid trim) so the route can return the appropriate HTTP status.
|
||||
|
||||
### 12. `load_engine_model` calls different method names
|
||||
|
||||
**File:** `backends/__init__.py:340-346`
|
||||
|
||||
For Qwen, it calls `load_model_async(model_size)`. For others, it calls `load_model()` with no arguments. But the `TTSBackend` protocol defines `load_model(self, model_size: str)`. This means the protocol signature doesn't match actual usage for either path.
|
||||
|
||||
**Recommendation:** Align the protocol definition with actual backend implementations, or add `load_model_async` to the protocol.
|
||||
|
||||
---
|
||||
|
||||
## Low Severity / Style
|
||||
|
||||
### 13. Inconsistent `async` usage in services
|
||||
|
||||
Functions like `create_story`, `list_stories`, etc. in `services/stories.py` are `async def` but contain no `await` expressions. They do synchronous SQLAlchemy I/O. While this works (the functions are awaitable), it's misleading -- these will block the event loop during DB access.
|
||||
|
||||
This is a known tradeoff with synchronous SQLAlchemy + FastAPI, and acceptable for a single-user desktop app with SQLite, but worth noting for documentation.
|
||||
|
||||
### 14. `getattr(item, "version_id", None)` pattern
|
||||
|
||||
**File:** `services/stories.py:57, 504, 524, etc.`
|
||||
|
||||
Multiple places use `getattr(item, "version_id", None)` on a DB model that has `version_id` as a declared column (from migrations). After the migration runs, this is always a real attribute. The defensive `getattr` is cargo-culted.
|
||||
|
||||
**Recommendation:** Access `item.version_id` directly. If the column is missing, the ORM will raise a clear error.
|
||||
|
||||
### 15. `reorder_story_items` ignores trim values
|
||||
|
||||
**File:** `services/stories.py:707`
|
||||
|
||||
When recalculating timecodes, it uses the full `generation.duration` rather than the effective (trimmed) duration. Trimmed items will have larger gaps than intended.
|
||||
|
||||
### 16. Module-level `import torch` in `app.py:44`
|
||||
|
||||
`import torch` at module level in `app.py` means torch loads on every import of the app module. This is intentional (AMD env vars must be set first), but the comment on line 38 should mention that this is why the import is here and not at the top.
|
||||
|
||||
### 17. f-strings in logging in `server.py`
|
||||
|
||||
`server.py` uses f-strings in logging calls (e.g., lines 63-66, 252, 256, 264). This evaluates the string even when the log level is filtered out. The rest of the codebase correctly uses `%s` style (e.g., `app.py:131`).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Observations (Not Issues)
|
||||
|
||||
- **Clean layered design**: routes -> services -> backends with Pydantic models as the API contract.
|
||||
- **Backend abstraction** with `Protocol` classes and a config registry is a solid pattern.
|
||||
- **Serial generation queue** (`task_queue.py`) is simple and effective for single-GPU serialization.
|
||||
- **Migration approach** is pragmatic for the use case. The idempotent, check-then-act pattern is reliable.
|
||||
- **The `generation.py` refactor** (collapsing three closures into `run_generation` with a mode parameter) is a clear improvement.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| Critical | 2 |
|
||||
| High | 3 |
|
||||
| Medium | 7 |
|
||||
| Low/Style | 5 |
|
||||
|
||||
The refactor achieved its goals: clear module boundaries, reduced duplication (especially in `generation.py`), and a well-organized backend abstraction. The critical items (double init_db and SSE session leak) should be addressed first, followed by the version consistency issue in retry and the N+1 query.
|
||||
+1441
-1
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://voicebox.sh'),
|
||||
title: 'Voicebox - Open Source Voice Cloning Desktop App',
|
||||
description:
|
||||
'Near-perfect voice cloning with multiple TTS engines. Desktop app for Mac, Windows, and Linux. Multi-sample support, smart caching, local or remote inference.',
|
||||
@@ -26,6 +27,13 @@ export const metadata: Metadata = {
|
||||
description: 'Open source voice cloning. Local-first. Free forever.',
|
||||
type: 'website',
|
||||
url: 'https://voicebox.sh',
|
||||
images: [{ url: '/og.webp', width: 1200, height: 630 }],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Voicebox',
|
||||
description: 'Open source voice cloning. Local-first. Free forever.',
|
||||
images: ['/og.webp'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
export default function OgPreview() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a09] p-10">
|
||||
{/* The card — standard OG image dimensions */}
|
||||
<div
|
||||
id="og"
|
||||
className="relative flex items-center overflow-hidden"
|
||||
style={{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
background:
|
||||
'radial-gradient(ellipse 80% 70% at 50% 45%, hsla(43,60%,50%,0.12) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)',
|
||||
}}
|
||||
>
|
||||
{/* Logo + text — left-justified, horizontal */}
|
||||
<div className="relative z-10 flex items-center" style={{ paddingLeft: 20 }}>
|
||||
{/* Glow behind logo */}
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-full blur-[100px]"
|
||||
style={{
|
||||
width: 300,
|
||||
height: 300,
|
||||
top: '50%',
|
||||
left: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'hsla(43, 60%, 50%, 0.15)',
|
||||
}}
|
||||
/>
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt=""
|
||||
className="relative shrink-0 object-contain"
|
||||
style={{ width: 260, height: 260 }}
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="flex flex-col" style={{ marginLeft: -8 }}>
|
||||
<h1
|
||||
className="font-bold tracking-tight"
|
||||
style={{
|
||||
fontSize: 72,
|
||||
lineHeight: 1,
|
||||
color: 'hsl(30, 10%, 94%)',
|
||||
}}
|
||||
>
|
||||
Voicebox
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 24,
|
||||
lineHeight: 1.4,
|
||||
marginTop: 16,
|
||||
color: 'hsl(30, 5%, 55%)',
|
||||
}}
|
||||
>
|
||||
Open source voice cloning.
|
||||
<br />
|
||||
Local-first. Free forever.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App screenshot — right, overflowing */}
|
||||
<img
|
||||
src="/assets/app-screenshot-1.webp"
|
||||
alt=""
|
||||
className="pointer-events-none absolute top-1/2 -translate-y-1/2 z-10"
|
||||
style={{
|
||||
right: -300,
|
||||
width: 900,
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Border overlay */}
|
||||
<div className="pointer-events-none absolute inset-0 ring-1 ring-inset ring-white/[0.06]" />
|
||||
</div>
|
||||
|
||||
{/* Helper text */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 text-xs text-white/30">
|
||||
1200 × 630 — Right-click the card or screenshot at 1:1 zoom
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,16 @@ export function Footer() {
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-3">Resources</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
<Link
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={GITHUB_REPO}
|
||||
|
||||
@@ -32,9 +32,9 @@ export function Navbar() {
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 top-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-3">
|
||||
<div className="mx-auto grid max-w-7xl grid-cols-3 items-center px-6 py-3">
|
||||
{/* Logo + wordmark */}
|
||||
<a href="/" className="flex items-center gap-2.5">
|
||||
<a href="/" className="flex items-center gap-2.5 justify-self-start">
|
||||
<Image
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
@@ -45,8 +45,8 @@ export function Navbar() {
|
||||
<span className="text-[15px] font-semibold text-foreground">Voicebox</span>
|
||||
</a>
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="hidden sm:flex items-center gap-1">
|
||||
{/* Nav links - centered */}
|
||||
<div className="hidden sm:flex items-center gap-1 justify-self-center">
|
||||
<a
|
||||
href="#features"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
@@ -65,6 +65,14 @@ export function Navbar() {
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.voicebox.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* GitHub star button */}
|
||||
@@ -72,7 +80,7 @@ export function Navbar() {
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
className="flex items-center gap-2 justify-self-end rounded-lg border border-border/60 bg-card/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-[13px] font-medium">Star</span>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Virtual environment
|
||||
venv/
|
||||
|
||||
# Generated test files
|
||||
*.wav
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick demo script to test MLX audio generation speed.
|
||||
|
||||
Usage:
|
||||
python demo.py # Use default text
|
||||
python demo.py "Your custom text" # Use custom text
|
||||
"""
|
||||
import sys
|
||||
import time
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from mlx_audio.tts import load
|
||||
|
||||
# Default demo text
|
||||
DEFAULT_TEXT = "Hello! This is MLX audio running natively on Apple Silicon. It's incredibly fast!"
|
||||
|
||||
def main():
|
||||
text = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_TEXT
|
||||
|
||||
print(f"\n🎙️ MLX Audio Demo")
|
||||
print(f"{'=' * 50}")
|
||||
print(f"Text: \"{text}\"\n")
|
||||
|
||||
# Load model
|
||||
print("Loading model...", end=" ", flush=True)
|
||||
start = time.time()
|
||||
model = load("mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16")
|
||||
print(f"done ({time.time() - start:.1f}s)\n")
|
||||
|
||||
# Generate
|
||||
print("Generating audio...")
|
||||
start = time.time()
|
||||
|
||||
for result in model.generate(text):
|
||||
# Calculate duration from audio samples
|
||||
audio = np.array(result.audio)
|
||||
sample_rate = result.sample_rate
|
||||
duration = len(audio) / sample_rate
|
||||
gen_time = float(result.processing_time_seconds)
|
||||
rtf = gen_time / duration if duration > 0 else 0
|
||||
|
||||
print(f" Audio duration: {duration:.2f}s")
|
||||
print(f" Generation time: {gen_time:.2f}s")
|
||||
print(f" Real-time factor: {rtf:.2f}x", end="")
|
||||
|
||||
if rtf < 1.0:
|
||||
print(f" ⚡ ({1/rtf:.1f}x faster than real-time)")
|
||||
else:
|
||||
print()
|
||||
|
||||
# Save audio
|
||||
sf.write("test_output.wav", audio, sample_rate)
|
||||
|
||||
print(f"\n✅ Saved to test_output.wav")
|
||||
print(f"{'=' * 50}")
|
||||
|
||||
# Play audio
|
||||
print("\n🔊 Playing audio...\n")
|
||||
import subprocess
|
||||
subprocess.run(["afplay", "test_output.wav"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user