diff --git a/.github/workflows/build-cuda.yml b/.github/workflows/build-cuda.yml new file mode 100644 index 00000000..7076b3f4 --- /dev/null +++ b/.github/workflows/build-cuda.yml @@ -0,0 +1,73 @@ +name: Build CUDA Backend + +on: + workflow_dispatch: + push: + tags: + - "v*" + +jobs: + build-cuda-windows: + runs-on: windows-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install pyinstaller + pip install -r backend/requirements.txt + + - name: Install PyTorch with CUDA 12.1 + 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 + + - name: Verify CUDA support in torch + run: | + python -c "import torch; print(f'CUDA available in build: {torch.cuda.is_available()}'); print(f'CUDA version: {torch.version.cuda}')" + + - name: Build CUDA server binary + shell: bash + working-directory: backend + run: python build_binary.py --cuda + + - name: Split binary for GitHub Releases + shell: bash + run: | + python scripts/split_binary.py \ + backend/dist/voicebox-server-cuda.exe \ + --output release-assets/ + + - name: Upload split parts to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v1 + with: + files: | + release-assets/voicebox-server-cuda.part*.exe + release-assets/voicebox-server-cuda.sha256 + release-assets/voicebox-server-cuda.manifest + draft: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload binary as workflow artifact (for testing) + uses: actions/upload-artifact@v4 + with: + name: voicebox-server-cuda-windows + path: backend/dist/voicebox-server-cuda.exe + retention-days: 7 + + # Linux CUDA build can be added later with: + # build-cuda-linux: + # runs-on: ubuntu-22.04 + # ... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e65f520..dcb78521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,10 +22,10 @@ jobs: args: "--target x86_64-apple-darwin" python-version: "3.12" backend: "pytorch" - # - platform: 'ubuntu-22.04' - # args: '' - # python-version: '3.12' - # backend: 'pytorch' + - platform: "ubuntu-22.04" + args: "" + python-version: "3.12" + backend: "pytorch" - platform: "windows-latest" args: "" python-version: "3.12" diff --git a/CHANGELOG.md b/CHANGELOG.md index b7116d39..d662bd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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 + ## [0.1.0] - 2026-01-25 ### Added @@ -55,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 ### Added - **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4bf1e2bd..8b96e514 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,7 +33,26 @@ Thank you for your interest in contributing to Voicebox! This document provides ### Development Setup -**Using the Makefile (recommended for macOS/Linux):** Run `make setup` to install all dependencies, then `make dev` to start development servers. See `make help` for all available commands. +**Using `just` (recommended):** + +Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then: + +```bash +just setup # creates venv, installs Python + JS deps +just dev # starts backend + desktop app in one terminal +``` + +Other useful commands: + +```bash +just dev-web # backend + web app (no Tauri/Rust build) +just dev-backend # backend only +just kill # stop all dev processes +just clean-all # nuke everything and start fresh +just --list # see all available commands +``` + +**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands. **Manual setup (required for Windows):** @@ -408,7 +427,7 @@ See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for common issues and sol - **Backend won't start:** Check Python version (3.11+), ensure venv is activated, install dependencies - **Tauri build fails:** Ensure Rust is installed, clean build with `cd tauri/src-tauri && cargo clean` -- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:8000/openapi.json` +- **OpenAPI client generation fails:** Ensure backend is running, check `curl http://localhost:17493/openapi.json` ## Questions? diff --git a/Makefile b/Makefile index 620f6c8c..38918613 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ setup-python: $(VENV)/bin/activate ## Set up Python virtual environment and depe @echo -e "$(BLUE)Installing Python dependencies...$(NC)" $(PIP) install --upgrade pip $(PIP) install -r $(BACKEND_DIR)/requirements.txt + $(PIP) install --no-deps chatterbox-tts @if [ "$$(uname -m)" = "arm64" ] && [ "$$(uname)" = "Darwin" ]; then \ echo -e "$(BLUE)Detected Apple Silicon - installing MLX dependencies...$(NC)"; \ $(PIP) install -r $(BACKEND_DIR)/requirements-mlx.txt; \ @@ -79,7 +80,11 @@ dev: ## Start backend + desktop app (parallel) @echo -e "$(YELLOW)Note: If Tauri fails, run 'make build-server' first or use separate terminals$(NC)" @trap 'kill 0' EXIT; \ $(MAKE) dev-backend & \ - sleep 2 && $(MAKE) dev-frontend & \ + sleep 2 && if [ "$$(uname)" = "Linux" ] && lspci 2>/dev/null | grep -qi nvidia; then \ + WEBKIT_DISABLE_DMABUF_RENDERER=1 $(MAKE) dev-frontend; \ + else \ + $(MAKE) dev-frontend; \ + fi & \ wait dev-backend: ## Start FastAPI backend server diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md new file mode 100644 index 00000000..e5c08175 --- /dev/null +++ b/PATCH_NOTES.md @@ -0,0 +1,58 @@ +# 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: `make build` +2. Disconnect from internet +3. Try generating speech +4. Should work without network requests + +## Build Instructions + +```bash +# Install dependencies +pip install -r requirements.txt + +# Build the app +make build + +# Or build just the server +make 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* diff --git a/README.md b/README.md index 89c9544c..5dfba8c3 100644 --- a/README.md +++ b/README.md @@ -147,17 +147,20 @@ Create multi-voice narratives, podcasts, and conversations with a timeline-based Voicebox exposes a full REST API, so you can integrate voice synthesis into your own apps. +For the current local app and development workflow, the backend is typically available at `http://localhost:17493`. +If you launch the backend manually with a different host or port, use that address instead. + ```bash # Generate speech -curl -X POST http://localhost:8000/generate \ +curl -X POST http://localhost:17493/generate \ -H "Content-Type: application/json" \ -d '{"text": "Hello world", "profile_id": "abc123", "language": "en"}' # List voice profiles -curl http://localhost:8000/profiles +curl http://localhost:17493/profiles # Create a profile -curl -X POST http://localhost:8000/profiles \ +curl -X POST http://localhost:17493/profiles \ -H "Content-Type: application/json" \ -d '{"name": "My Voice", "language": "en"}' ``` @@ -170,7 +173,7 @@ curl -X POST http://localhost:8000/profiles \ - Voice assistants - Content creation automation -Full API documentation available at `http://localhost:8000/docs` when running. +Full API documentation is available at `http://localhost:17493/docs` in the default local workflow, or at `/docs` on whatever server address you configured. --- @@ -225,40 +228,19 @@ Voicebox aims to be the **one-stop shop for everything voice** — cloning, synt See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed setup and contribution guidelines. -**Using the Makefile (recommended):** Run `make help` to see all available commands for setup, development, building, and testing. - ### Quick Start -**With Makefile (Unix/macOS/Linux):** - ```bash -# Clone the repo git clone https://github.com/jamiepine/voicebox.git cd voicebox -# Setup everything -make setup - -# Start development -make dev +just setup # creates Python venv, installs all deps +just dev # starts backend + desktop app ``` -**Manual setup (all platforms):** +Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands. -```bash -# Clone the repo -git clone https://github.com/jamiepine/voicebox.git -cd voicebox - -# Install dependencies -bun install - -# Install Python dependencies -cd backend && pip install -r requirements.txt && cd .. - -# Start development -bun run dev -``` +Also available via Makefile: `make setup && make dev` (run `make help` for all commands). **Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/). diff --git a/app/src/App.tsx b/app/src/App.tsx index fbe29118..8a218742 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -93,10 +93,11 @@ function App() { } serverStartingRef.current = true; - console.log('Production mode: Starting bundled server...'); + const isRemote = useServerStore.getState().mode === 'remote'; + console.log(`Production mode: Starting bundled server... (remote: ${isRemote})`); platform.lifecycle - .startServer(false) + .startServer(isRemote) .then((serverUrl) => { console.log('Server is ready at:', serverUrl); // Update the server URL in the store with the dynamically assigned port diff --git a/app/src/components/AudioPlayer/AudioPlayer.tsx b/app/src/components/AudioPlayer/AudioPlayer.tsx index 48dd9e78..1c398ed8 100644 --- a/app/src/components/AudioPlayer/AudioPlayer.tsx +++ b/app/src/components/AudioPlayer/AudioPlayer.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { Pause, Play, Repeat, Volume2, VolumeX, X } from 'lucide-react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import WaveSurfer from 'wavesurfer.js'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; @@ -12,6 +12,7 @@ import { usePlatform } from '@/platform/PlatformContext'; export function AudioPlayer() { const platform = usePlatform(); + const volumeLabelId = useId(); const { audioUrl, audioId, @@ -831,6 +832,13 @@ export function AudioPlayer() { disabled={isLoading || duration === 0} className="shrink-0" title={duration === 0 && !isLoading ? 'Audio not loaded' : ''} + aria-label={ + duration === 0 && !isLoading + ? 'Audio not loaded' + : isPlaying + ? 'Pause' + : 'Play' + } > {isPlaying ? : } @@ -845,6 +853,8 @@ export function AudioPlayer() { max={100} step={0.1} className="w-full" + aria-label="Playback position" + aria-valuetext={`${formatAudioDuration(currentTime)} of ${formatAudioDuration(duration)}`} /> )} {isLoading && ( @@ -872,26 +882,33 @@ export function AudioPlayer() { onClick={toggleLoop} className={isLooping ? 'text-primary' : ''} title="Toggle loop" + aria-label={isLooping ? 'Stop looping' : 'Loop'} > {/* Volume Control */} -
+
+ + Volume level, {Math.round(volume * 100)}% +
@@ -902,6 +919,7 @@ export function AudioPlayer() { onClick={handleClose} className="shrink-0" title="Close player" + aria-label="Close player" > diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index a8d556a6..f0dc2b7e 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; -import { LANGUAGE_OPTIONS } from '@/lib/constants/languages'; +import { getLanguageOptionsForEngine } from '@/lib/constants/languages'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useProfile, useProfiles } from '@/lib/hooks/useProfiles'; import { useAddStoryItem, useStory } from '@/lib/hooks/useStories'; @@ -300,6 +300,13 @@ export function FloatingGenerateBox({ disabled={isPending || !selectedProfileId} className="h-10 w-10 rounded-full bg-accent hover:bg-accent/90 hover:scale-105 text-accent-foreground shadow-lg hover:shadow-accent/50 transition-all duration-200" size="icon" + aria-label={ + isPending + ? 'Generating...' + : !selectedProfileId + ? 'Select a voice profile first' + : 'Generate speech' + } > {isPending ? ( @@ -316,7 +323,7 @@ export function FloatingGenerateBox({
- {isExpanded && ( + {isExpanded && form.watch('engine') === 'qwen' && ( @@ -381,51 +393,86 @@ export function FloatingGenerateBox({ ( - - - - - )} + render={({ field }) => { + const engineLangs = getLanguageOptionsForEngine( + form.watch('engine') || 'qwen', + ); + return ( + + + + + ); + }} /> - ( - - - - - )} - /> + + + diff --git a/app/src/components/Generation/GenerationForm.tsx b/app/src/components/Generation/GenerationForm.tsx index 31b100f8..a3c96cbc 100644 --- a/app/src/components/Generation/GenerationForm.tsx +++ b/app/src/components/Generation/GenerationForm.tsx @@ -19,7 +19,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; -import { LANGUAGE_OPTIONS } from '@/lib/constants/languages'; +import { getLanguageOptionsForEngine } from '@/lib/constants/languages'; import { useGenerationForm } from '@/lib/hooks/useGenerationForm'; import { useProfile } from '@/lib/hooks/useProfiles'; import { useUIStore } from '@/stores/uiStore'; @@ -76,75 +76,109 @@ export function GenerationForm() { )} /> - ( - - Delivery Instructions (optional) - -