mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e17bd469 | ||
|
|
aada13a5c9 | ||
|
|
de8558d197 | ||
|
|
9d79ea367a | ||
|
|
04316f7adc | ||
|
|
4e4361d350 | ||
|
|
e9a249587c | ||
|
|
d8a9ed7d15 | ||
|
|
3dbf1c200e | ||
|
|
40fcb8d917 | ||
|
|
ad64d1c3d9 | ||
|
|
f826e45250 | ||
|
|
3d53c06c5b | ||
|
|
9835b9f6d4 | ||
|
|
a15dd30b1e | ||
|
|
1d343ac071 | ||
|
|
ca602de0ae | ||
|
|
cdc0293ca8 | ||
|
|
e7f749f082 | ||
|
|
d42e926e5c | ||
|
|
32768ea874 | ||
|
|
b585e18ccf | ||
|
|
655910457f | ||
|
|
d6984f1057 | ||
|
|
a637aebe69 | ||
|
|
a5269d23db | ||
|
|
fc450e5024 | ||
|
|
a99c2b572d | ||
|
|
96289e95f1 | ||
|
|
e316b0b4bb | ||
|
|
732270b571 | ||
|
|
410413dc57 | ||
|
|
e239be5bbb | ||
|
|
f1ba73a386 | ||
|
|
f1963740b4 | ||
|
|
4d6c976ad9 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 0.2.0
|
||||
current_version = 0.2.3
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = v{new_version}
|
||||
|
||||
@@ -61,6 +61,7 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
|
||||
- name: Install MLX dependencies (Apple Silicon only)
|
||||
if: matrix.backend == 'mlx'
|
||||
@@ -122,7 +123,7 @@ jobs:
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- uses: tauri-apps/tauri-action@v0.6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -173,6 +174,7 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install pyinstaller
|
||||
pip install -r backend/requirements.txt
|
||||
pip install --no-deps chatterbox-tts
|
||||
|
||||
- name: Install PyTorch with CUDA 12.1
|
||||
run: |
|
||||
|
||||
+36
-98
@@ -33,101 +33,41 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
||||
|
||||
### Development Setup
|
||||
|
||||
**Using `just` (recommended):**
|
||||
|
||||
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
|
||||
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
cd voicebox
|
||||
|
||||
just setup # creates venv, installs Python + JS deps
|
||||
just dev # starts backend + desktop app in one terminal
|
||||
just dev # starts backend + desktop app
|
||||
```
|
||||
|
||||
`just setup` handles everything automatically, including:
|
||||
- Creating a Python virtual environment
|
||||
- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected)
|
||||
- Installing MLX dependencies on Apple Silicon
|
||||
- Installing JavaScript dependencies
|
||||
|
||||
`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend.
|
||||
|
||||
Other useful commands:
|
||||
|
||||
```bash
|
||||
just dev-web # backend + web app (no Tauri/Rust build)
|
||||
just dev-backend # backend only
|
||||
just dev-frontend # Tauri app only (backend must be running)
|
||||
just kill # stop all dev processes
|
||||
just clean-all # nuke everything and start fresh
|
||||
just --list # see all available commands
|
||||
```
|
||||
|
||||
**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands.
|
||||
> **Note:** In dev mode, the app connects to a manually-started Python server.
|
||||
> The bundled server binary is only used in production builds.
|
||||
|
||||
**Manual setup (required for Windows):**
|
||||
#### Windows Notes
|
||||
|
||||
1. **Fork and clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||
cd voicebox
|
||||
```
|
||||
|
||||
2. **Install JavaScript dependencies**
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
This installs dependencies for:
|
||||
- `app/` - Shared React frontend
|
||||
- `tauri/` - Tauri desktop wrapper
|
||||
- `web/` - Web deployment wrapper
|
||||
|
||||
3. **Set up Python backend**
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate # On macOS/Linux
|
||||
# or
|
||||
venv\Scripts\activate # On Windows
|
||||
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install MLX dependencies (Apple Silicon only - for faster inference)
|
||||
# On Apple Silicon, this enables native Metal acceleration
|
||||
if [[ $(uname -m) == "arm64" ]]; then
|
||||
pip install -r requirements-mlx.txt
|
||||
fi
|
||||
|
||||
# Install Qwen3-TTS (required for voice synthesis)
|
||||
pip install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
```
|
||||
|
||||
4. **Start development servers**
|
||||
|
||||
Development requires two terminals: one for the Python backend, one for the Tauri app.
|
||||
|
||||
**Terminal 1: Backend server** (start this first)
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate # Activate venv if not already active
|
||||
bun run dev:server
|
||||
# Or manually: uvicorn main:app --reload --port 17493
|
||||
```
|
||||
Backend will be available at `http://localhost:17493`
|
||||
|
||||
**Terminal 2: Desktop app**
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
This will:
|
||||
- Create a placeholder sidecar binary (for Tauri compilation)
|
||||
- Start Vite dev server on port 5173
|
||||
- Launch Tauri window pointing to localhost:5173
|
||||
- Connect to the Python server you started in Terminal 1
|
||||
- Enable hot reload
|
||||
|
||||
> **Note:** In dev mode, the app connects to your manually-started Python server.
|
||||
> The bundled server binary is only used in production builds.
|
||||
|
||||
**Optional: Web app**
|
||||
```bash
|
||||
bun run dev:web
|
||||
```
|
||||
Web app will be available at `http://localhost:5174`
|
||||
The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration.
|
||||
|
||||
### Model Downloads
|
||||
|
||||
@@ -139,25 +79,30 @@ First-time usage will be slower due to model downloads, but subsequent runs will
|
||||
|
||||
### Building
|
||||
|
||||
**Build everything (recommended):**
|
||||
**Build production app:**
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
just build # Build CPU server binary + Tauri installer
|
||||
```
|
||||
This automatically:
|
||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
||||
|
||||
On Windows, to build with CUDA support for local testing:
|
||||
|
||||
```bash
|
||||
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
||||
```
|
||||
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
|
||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||
|
||||
**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others).
|
||||
**Individual build targets:**
|
||||
|
||||
**Build server binary only:**
|
||||
```bash
|
||||
bun run build:server
|
||||
# or
|
||||
./scripts/build-server.sh
|
||||
just build-server # CPU server binary only
|
||||
just build-server-cuda # CUDA server binary only (Windows)
|
||||
just build-tauri # Tauri desktop app only
|
||||
just build-web # Web app only
|
||||
```
|
||||
Creates platform-specific binary in `tauri/src-tauri/binaries/`
|
||||
|
||||
**Building with local Qwen3-TTS development version:**
|
||||
|
||||
@@ -165,17 +110,10 @@ If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_
|
||||
|
||||
```bash
|
||||
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
||||
bun run build:server
|
||||
just build-server
|
||||
```
|
||||
|
||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`).
|
||||
|
||||
**Build web app:**
|
||||
```bash
|
||||
cd web
|
||||
bun run build
|
||||
```
|
||||
Output in `web/dist/`
|
||||
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
|
||||
|
||||
### Generate OpenAPI Client
|
||||
|
||||
|
||||
@@ -240,13 +240,24 @@ just dev # starts backend + desktop app
|
||||
|
||||
Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands.
|
||||
|
||||
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), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [Xcode](https://developer.apple.com/xcode/) on macOS.
|
||||
|
||||
**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/).
|
||||
### Platform Notes
|
||||
|
||||
**Performance:**
|
||||
- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference
|
||||
- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower)
|
||||
| Platform | GPU Backend | Notes |
|
||||
|----------|-------------|-------|
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster inference via Neural Engine |
|
||||
| Windows (NVIDIA) | PyTorch (CUDA) | `just setup` auto-installs CUDA PyTorch |
|
||||
| Windows/Linux (no NVIDIA) | PyTorch (CPU) | Works but slower |
|
||||
|
||||
### Building Locally
|
||||
|
||||
```bash
|
||||
just build # Build CPU server binary + Tauri app
|
||||
just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app
|
||||
```
|
||||
|
||||
`just build-local` produces a production-ready installer with the CUDA binary pre-placed for GPU switching.
|
||||
|
||||
### Project Structure
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -246,13 +246,18 @@ export function GpuAcceleration() {
|
||||
{/* CUDA download section - only show when no GPU is active (native or CUDA) */}
|
||||
{!hasNativeGpu && !isCurrentlyCuda && (
|
||||
<>
|
||||
{/* Download progress */}
|
||||
{/* Download progress (manual download or auto-update) */}
|
||||
{cudaDownloading && downloadProgress && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{downloadProgress.filename || 'Downloading CUDA backend...'}</span>
|
||||
<span>
|
||||
{downloadProgress.filename ||
|
||||
(cudaAvailable
|
||||
? 'Updating CUDA backend...'
|
||||
: 'Downloading CUDA backend...')}
|
||||
</span>
|
||||
</div>
|
||||
{downloadProgress.total > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Backend package
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.3"
|
||||
|
||||
@@ -224,7 +224,8 @@ class ChatterboxTTSBackend:
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load Chatterbox: {e}")
|
||||
import traceback
|
||||
logger.error(f"Failed to load Chatterbox: {e}\n{traceback.format_exc()}")
|
||||
if not is_cached:
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
|
||||
@@ -228,7 +228,8 @@ class ChatterboxTurboTTSBackend:
|
||||
task_manager.error_download(model_name, str(e))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load Chatterbox Turbo: {e}")
|
||||
import traceback
|
||||
logger.error(f"Failed to load Chatterbox Turbo: {e}\n{traceback.format_exc()}")
|
||||
if not is_cached:
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
|
||||
@@ -149,7 +149,8 @@ class LuxTTSBackend:
|
||||
logger.info("LuxTTS loaded successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load LuxTTS: {e}")
|
||||
import traceback
|
||||
logger.error(f"Failed to load LuxTTS: {e}\n{traceback.format_exc()}")
|
||||
if not is_cached:
|
||||
progress_manager.mark_error(model_name, str(e))
|
||||
task_manager.error_download(model_name, str(e))
|
||||
|
||||
+113
-4
@@ -10,6 +10,7 @@ import PyInstaller.__main__
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -36,6 +37,11 @@ def build_server(cuda=False):
|
||||
'--name', binary_name,
|
||||
]
|
||||
|
||||
# Hide console window on Windows only. On macOS/Linux the sidecar needs
|
||||
# stdout/stderr for Tauri to capture logs.
|
||||
if platform.system() == "Windows":
|
||||
args.append('--noconsole')
|
||||
|
||||
# Add local qwen_tts path if specified (for editable installs)
|
||||
qwen_tts_path = os.getenv('QWEN_TTS_PATH')
|
||||
if qwen_tts_path and Path(qwen_tts_path).exists():
|
||||
@@ -62,6 +68,20 @@ def build_server(cuda=False):
|
||||
'--hidden-import', 'backend.utils.hf_progress',
|
||||
'--hidden-import', 'backend.utils.validation',
|
||||
'--hidden-import', 'backend.cuda_download',
|
||||
'--hidden-import', 'backend.effects',
|
||||
'--hidden-import', 'backend.utils.effects',
|
||||
'--hidden-import', 'backend.versions',
|
||||
'--hidden-import', 'pedalboard',
|
||||
'--hidden-import', 'chatterbox',
|
||||
'--hidden-import', 'chatterbox.tts_turbo',
|
||||
'--hidden-import', 'chatterbox.mtl_tts',
|
||||
'--hidden-import', 'backend.backends.chatterbox_backend',
|
||||
'--hidden-import', 'backend.backends.chatterbox_turbo_backend',
|
||||
'--hidden-import', 'backend.backends.luxtts_backend',
|
||||
'--hidden-import', 'zipvoice',
|
||||
'--hidden-import', 'zipvoice.luxvoice',
|
||||
'--collect-all', 'zipvoice',
|
||||
'--collect-all', 'linacodec',
|
||||
'--hidden-import', 'torch',
|
||||
'--hidden-import', 'transformers',
|
||||
'--hidden-import', 'fastapi',
|
||||
@@ -76,11 +96,27 @@ def build_server(cuda=False):
|
||||
'--hidden-import', 'qwen_tts.core',
|
||||
'--hidden-import', 'qwen_tts.cli',
|
||||
'--copy-metadata', 'qwen-tts',
|
||||
'--copy-metadata', 'requests',
|
||||
'--copy-metadata', 'transformers',
|
||||
'--copy-metadata', 'huggingface-hub',
|
||||
'--copy-metadata', 'tokenizers',
|
||||
'--copy-metadata', 'safetensors',
|
||||
'--copy-metadata', 'tqdm',
|
||||
'--hidden-import', 'requests',
|
||||
'--collect-submodules', 'qwen_tts',
|
||||
'--collect-data', 'qwen_tts',
|
||||
# Fix for pkg_resources and jaraco namespace packages
|
||||
'--hidden-import', 'pkg_resources.extern',
|
||||
'--collect-submodules', 'jaraco',
|
||||
# inflect uses typeguard @typechecked which calls inspect.getsource()
|
||||
# at import time — needs .py source files, not just .pyc bytecode
|
||||
'--collect-all', 'inflect',
|
||||
# perth ships pretrained watermark model files (hparams.yaml, .pth.tar)
|
||||
# in perth/perth_net/pretrained/ — needed by chatterbox at runtime
|
||||
'--collect-all', 'perth',
|
||||
# piper_phonemize ships espeak-ng-data/ (phoneme tables, language dicts)
|
||||
# needed by LuxTTS for text-to-phoneme conversion
|
||||
'--collect-all', 'piper_phonemize',
|
||||
])
|
||||
|
||||
# Add CUDA-specific hidden imports
|
||||
@@ -91,9 +127,10 @@ def build_server(cuda=False):
|
||||
'--hidden-import', 'torch.backends.cudnn',
|
||||
])
|
||||
else:
|
||||
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary under 4GB.
|
||||
# On Linux, pip may pull CUDA-enabled PyTorch by default which includes ~3GB
|
||||
# of NVIDIA shared libraries that PyInstaller would bundle.
|
||||
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
|
||||
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||
# modules and the binary DLLs.
|
||||
nvidia_packages = [
|
||||
'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc',
|
||||
'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand',
|
||||
@@ -127,7 +164,12 @@ def build_server(cuda=False):
|
||||
elif not cuda:
|
||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
||||
|
||||
dist_dir = str(backend_dir / 'dist')
|
||||
build_dir = str(backend_dir / 'build')
|
||||
|
||||
args.extend([
|
||||
'--distpath', dist_dir,
|
||||
'--workpath', build_dir,
|
||||
'--noconfirm',
|
||||
'--clean',
|
||||
])
|
||||
@@ -135,12 +177,79 @@ def build_server(cuda=False):
|
||||
# Change to backend directory
|
||||
os.chdir(backend_dir)
|
||||
|
||||
# For CPU builds on Windows, ensure we're using CPU-only torch.
|
||||
# If CUDA torch is installed (local dev), swap to CPU torch before building,
|
||||
# then restore CUDA torch after. This prevents PyInstaller from bundling
|
||||
# ~3GB of CUDA DLLs into the CPU binary.
|
||||
restore_cuda = False
|
||||
if not cuda and platform.system() == "Windows":
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
has_cuda_torch = bool(result.stdout.strip())
|
||||
if has_cuda_torch:
|
||||
print("CUDA torch detected — installing CPU torch for CPU build...")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
|
||||
"--index-url", "https://download.pytorch.org/whl/cpu", "--force-reinstall", "-q"],
|
||||
check=True
|
||||
)
|
||||
restore_cuda = True
|
||||
|
||||
# Run PyInstaller
|
||||
PyInstaller.__main__.run(args)
|
||||
try:
|
||||
PyInstaller.__main__.run(args)
|
||||
finally:
|
||||
# Restore CUDA torch if we swapped it out (even on build failure)
|
||||
if restore_cuda:
|
||||
print("Restoring CUDA torch...")
|
||||
import subprocess
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
|
||||
"--index-url", "https://download.pytorch.org/whl/cu126", "--force-reinstall", "-q"],
|
||||
check=True
|
||||
)
|
||||
|
||||
print(f"Binary built in {backend_dir / 'dist' / binary_name}")
|
||||
|
||||
|
||||
def _get_cuda_dll_excludes():
|
||||
"""Get list of CUDA DLL filenames to exclude from CPU builds.
|
||||
|
||||
When building locally with CUDA torch installed, PyInstaller bundles ~3GB of
|
||||
CUDA DLLs from torch/lib/. Returns a list of DLL filenames to exclude.
|
||||
"""
|
||||
try:
|
||||
import torch
|
||||
torch_lib = Path(torch.__file__).parent / 'lib'
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
cuda_prefixes = (
|
||||
'torch_cuda', 'cublas', 'cublasLt', 'cudnn', 'cusparse', 'cufft',
|
||||
'cusolver', 'cusolverMg', 'curand', 'nvrtc', 'nvJitLink', 'nccl',
|
||||
'nvperf', 'nvrtc-builtins',
|
||||
)
|
||||
|
||||
exclude_dlls = []
|
||||
if torch_lib.exists():
|
||||
for f in torch_lib.iterdir():
|
||||
if f.suffix == '.dll' and any(f.name.startswith(p) for p in cuda_prefixes):
|
||||
exclude_dlls.append(f.name)
|
||||
|
||||
if exclude_dlls:
|
||||
total_mb = sum(
|
||||
(torch_lib / dll).stat().st_size
|
||||
for dll in exclude_dlls
|
||||
if (torch_lib / dll).exists()
|
||||
) / 1024 / 1024
|
||||
print(f"CPU build: will exclude {len(exclude_dlls)} CUDA DLLs ({total_mb:.0f} MB)")
|
||||
|
||||
return exclude_dlls
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
|
||||
parser.add_argument(
|
||||
|
||||
@@ -129,6 +129,17 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch checksum file — skipping verification: {e}")
|
||||
|
||||
# Get total size across all parts by issuing HEAD requests
|
||||
total_size = 0
|
||||
for part_name in parts:
|
||||
try:
|
||||
head_resp = await client.head(f"{base_url}/{part_name}")
|
||||
content_length = int(head_resp.headers.get("content-length", 0))
|
||||
total_size += content_length
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"Total download size: {total_size / 1024 / 1024:.1f} MB")
|
||||
|
||||
# Download and concatenate parts
|
||||
total_downloaded = 0
|
||||
with open(temp_path, "wb") as f:
|
||||
@@ -142,8 +153,8 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
f.write(chunk)
|
||||
total_downloaded += len(chunk)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY, current=total_downloaded, total=0,
|
||||
filename=f"Part {i + 1}/{len(parts)}",
|
||||
PROGRESS_KEY, current=total_downloaded, total=total_size,
|
||||
filename=f"Downloading CUDA backend ({i + 1}/{len(parts)})",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
@@ -188,6 +199,56 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
raise
|
||||
|
||||
|
||||
def get_cuda_binary_version() -> Optional[str]:
|
||||
"""Get the version of the installed CUDA binary, or None if not installed."""
|
||||
import subprocess
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(cuda_path), "--version"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
# Output format: "voicebox-server 0.2.0"
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if "voicebox-server" in line:
|
||||
return line.split()[-1]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get CUDA binary version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_and_update_cuda_binary():
|
||||
"""Check if the CUDA binary is outdated and auto-download if so.
|
||||
|
||||
Called on server startup. If a CUDA binary exists but its version
|
||||
doesn't match the current app version, triggers a background download
|
||||
of the updated CUDA binary. The download progress is visible to the
|
||||
frontend via the existing SSE progress endpoint.
|
||||
"""
|
||||
cuda_path = get_cuda_binary_path()
|
||||
if not cuda_path:
|
||||
return # No CUDA binary installed, nothing to update
|
||||
|
||||
cuda_version = get_cuda_binary_version()
|
||||
current_version = __version__
|
||||
|
||||
if cuda_version == current_version:
|
||||
logger.info(f"CUDA binary is up to date (v{current_version})")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"CUDA binary version mismatch: binary=v{cuda_version}, app=v{current_version}. "
|
||||
f"Auto-downloading updated CUDA backend..."
|
||||
)
|
||||
|
||||
try:
|
||||
await download_cuda_binary()
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-update of CUDA binary failed: {e}")
|
||||
|
||||
|
||||
async def delete_cuda_binary() -> bool:
|
||||
"""Delete the downloaded CUDA binary. Returns True if deleted."""
|
||||
path = get_cuda_binary_path()
|
||||
|
||||
@@ -108,6 +108,7 @@ _default_origins = [
|
||||
"http://127.0.0.1:17493",
|
||||
"tauri://localhost", # Tauri webview (macOS)
|
||||
"https://tauri.localhost", # Tauri webview (Windows/Linux)
|
||||
"http://tauri.localhost", # Tauri webview (Windows, some builds)
|
||||
]
|
||||
_env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
|
||||
_cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()]
|
||||
@@ -142,6 +143,14 @@ async def shutdown():
|
||||
return {"message": "Shutting down..."}
|
||||
|
||||
|
||||
@app.post("/watchdog/disable")
|
||||
async def watchdog_disable():
|
||||
"""Disable the parent process watchdog so the server keeps running."""
|
||||
from backend.server import disable_watchdog
|
||||
disable_watchdog()
|
||||
return {"message": "Watchdog disabled"}
|
||||
|
||||
|
||||
@app.get("/health", response_model=models.HealthResponse)
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
@@ -3095,6 +3104,10 @@ async def startup_event():
|
||||
print(f"Backend: {backend_type.upper()}")
|
||||
print(f"GPU available: {_get_gpu_status()}")
|
||||
|
||||
# Auto-update CUDA binary if installed but outdated
|
||||
from .cuda_download import check_and_update_cuda_binary
|
||||
_create_background_task(check_and_update_cuda_binary())
|
||||
|
||||
# Initialize progress manager with main event loop for thread-safe operations
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
+167
-5
@@ -6,6 +6,47 @@ absolute imports instead of relative imports.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# On Windows with --noconsole (PyInstaller), sys.stdout/stderr are None.
|
||||
# They can also be broken file objects in some edge cases.
|
||||
# Redirect to devnull to prevent crashes from print()/tqdm/logging.
|
||||
def _is_writable(stream):
|
||||
"""Check if a stream is usable for writing."""
|
||||
if stream is None:
|
||||
return False
|
||||
try:
|
||||
stream.write("")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not _is_writable(sys.stdout):
|
||||
sys.stdout = open(os.devnull, 'w')
|
||||
if not _is_writable(sys.stderr):
|
||||
sys.stderr = open(os.devnull, 'w')
|
||||
|
||||
# PyInstaller + multiprocessing: child processes re-execute the frozen binary
|
||||
# with internal arguments. freeze_support() handles this and exits early.
|
||||
import multiprocessing
|
||||
multiprocessing.freeze_support()
|
||||
|
||||
# In frozen builds, piper_phonemize's espeak-ng C library falls back to
|
||||
# /usr/share/espeak-ng-data/ which doesn't exist. Point it at the bundled
|
||||
# data directory instead.
|
||||
if getattr(sys, 'frozen', False):
|
||||
_meipass = getattr(sys, '_MEIPASS', os.path.dirname(sys.executable))
|
||||
_espeak_data = os.path.join(_meipass, 'piper_phonemize', 'espeak-ng-data')
|
||||
if os.path.isdir(_espeak_data):
|
||||
os.environ.setdefault('ESPEAK_DATA_PATH', _espeak_data)
|
||||
|
||||
# Fast path: handle --version before any heavy imports so the Rust
|
||||
# version check doesn't block for 30+ seconds loading torch etc.
|
||||
if "--version" in sys.argv:
|
||||
from backend import __version__
|
||||
print(f"voicebox-server {__version__}")
|
||||
sys.exit(0)
|
||||
|
||||
import logging
|
||||
|
||||
# Set up logging FIRST, before any imports that might fail
|
||||
@@ -43,6 +84,115 @@ except Exception as e:
|
||||
logger.error(f"Failed to import required modules: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
_watchdog_disabled = False
|
||||
|
||||
|
||||
def disable_watchdog():
|
||||
"""Disable the parent watchdog so the server keeps running after parent exits."""
|
||||
global _watchdog_disabled
|
||||
_watchdog_disabled = True
|
||||
# Ignore SIGHUP so the server survives when the parent Tauri process exits.
|
||||
# On Unix, child processes receive SIGHUP when the parent's session leader
|
||||
# exits, which would kill the server even though we want it to persist.
|
||||
if sys.platform != "win32":
|
||||
import signal
|
||||
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
||||
|
||||
|
||||
def _start_parent_watchdog(parent_pid, data_dir=None):
|
||||
"""Monitor parent process and exit if it dies.
|
||||
|
||||
This is the clean shutdown mechanism: instead of the Tauri app trying to
|
||||
forcefully kill the server (which spawns console windows on Windows),
|
||||
the server monitors its parent and shuts itself down gracefully.
|
||||
"""
|
||||
import os
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Set up a file logger so we can debug in production
|
||||
watchdog_logger = logging.getLogger("watchdog")
|
||||
if data_dir:
|
||||
try:
|
||||
log_dir = os.path.join(data_dir, "logs")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
fh = logging.FileHandler(os.path.join(log_dir, "watchdog.log"))
|
||||
fh.setFormatter(logging.Formatter('%(asctime)s - %(message)s'))
|
||||
watchdog_logger.addHandler(fh)
|
||||
except Exception:
|
||||
pass
|
||||
watchdog_logger.setLevel(logging.INFO)
|
||||
|
||||
def _is_pid_alive(pid):
|
||||
"""Check if a process with the given PID exists (cross-platform)."""
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||
if handle:
|
||||
# Check if process has actually exited
|
||||
STILL_ACTIVE = 259
|
||||
exit_code = ctypes.c_ulong()
|
||||
result = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
|
||||
kernel32.CloseHandle(handle)
|
||||
if result and exit_code.value == STILL_ACTIVE:
|
||||
return True
|
||||
watchdog_logger.info(f"PID {pid}: exited with code {exit_code.value}")
|
||||
return False
|
||||
# OpenProcess failed — check if it's an access error (process exists
|
||||
# but we can't open it) vs process not found
|
||||
error = ctypes.GetLastError()
|
||||
ACCESS_DENIED = 5
|
||||
if error == ACCESS_DENIED:
|
||||
return True # process exists, we just can't open it
|
||||
watchdog_logger.info(f"PID {pid}: OpenProcess failed, error={error}")
|
||||
return False
|
||||
else:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
def _watch():
|
||||
watchdog_logger.info(f"Parent watchdog started, monitoring PID {parent_pid}, server PID {os.getpid()}")
|
||||
# Verify parent is alive before starting the loop
|
||||
alive = _is_pid_alive(parent_pid)
|
||||
watchdog_logger.info(f"Parent PID {parent_pid} initial check: alive={alive}")
|
||||
if not alive:
|
||||
watchdog_logger.warning(f"Parent PID {parent_pid} not found on first check — disabling watchdog")
|
||||
return
|
||||
while True:
|
||||
if _watchdog_disabled:
|
||||
watchdog_logger.info("Watchdog disabled (keep server running), stopping monitor")
|
||||
return
|
||||
if not _is_pid_alive(parent_pid):
|
||||
# Parent is gone. Before shutting down, give the app a moment
|
||||
# to send /watchdog/disable — there is a race where the Tauri
|
||||
# RunEvent::Exit handler sends the disable request while we are
|
||||
# mid-iteration (already past the _watchdog_disabled check above).
|
||||
watchdog_logger.info(f"Parent process {parent_pid} gone, waiting for possible disable request...")
|
||||
time.sleep(1)
|
||||
if _watchdog_disabled:
|
||||
watchdog_logger.info("Watchdog was disabled during grace period, keeping server alive")
|
||||
return
|
||||
watchdog_logger.info("Watchdog still enabled after grace period, shutting down server...")
|
||||
if sys.platform == "win32":
|
||||
# sys.exit triggers SystemExit, allowing uvicorn to run
|
||||
# shutdown handlers. os.kill(SIGTERM) on Windows calls
|
||||
# TerminateProcess which hard-kills without cleanup.
|
||||
os._exit(0)
|
||||
else:
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return
|
||||
time.sleep(2)
|
||||
|
||||
t = threading.Thread(target=_watch, daemon=True)
|
||||
t.start()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
parser = argparse.ArgumentParser(description="voicebox backend server")
|
||||
@@ -64,17 +214,21 @@ if __name__ == "__main__":
|
||||
default=None,
|
||||
help="Data directory for database, profiles, and generated audio",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parent-pid",
|
||||
type=int,
|
||||
default=None,
|
||||
help="PID of parent process to monitor; server exits when parent dies",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="store_true",
|
||||
help="Print version and exit",
|
||||
help="Print version and exit (handled above, kept for argparse help)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
from backend import __version__
|
||||
print(f"voicebox-server {__version__}")
|
||||
sys.exit(0)
|
||||
if args.parent_pid is not None and args.parent_pid <= 0:
|
||||
parser.error("--parent-pid must be a positive integer")
|
||||
|
||||
# Detect backend variant from binary name
|
||||
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
|
||||
@@ -87,6 +241,14 @@ if __name__ == "__main__":
|
||||
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
|
||||
logger.info("Backend variant: CPU")
|
||||
|
||||
# Register parent watchdog to start after server is fully ready
|
||||
if args.parent_pid is not None:
|
||||
_parent_pid = args.parent_pid
|
||||
_data_dir = args.data_dir
|
||||
@app.on_event("startup")
|
||||
async def _on_startup():
|
||||
_start_parent_watchdog(_parent_pid, _data_dir)
|
||||
|
||||
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
|
||||
|
||||
# Set data directory if provided
|
||||
|
||||
@@ -64,11 +64,17 @@ class HFProgressTracker:
|
||||
if key in tqdm_kwargs:
|
||||
filtered_kwargs[key] = value
|
||||
|
||||
# Force-enable the progress bar — we're tracking progress ourselves,
|
||||
# we don't need tqdm to render to a terminal, but we DO need
|
||||
# self.n to be updated when update() is called.
|
||||
filtered_kwargs['disable'] = False
|
||||
|
||||
# Try to initialize with filtered kwargs, fall back to all kwargs if that fails
|
||||
try:
|
||||
super().__init__(*args, **filtered_kwargs)
|
||||
except TypeError:
|
||||
# If filtering failed, try with all kwargs (maybe tqdm version accepts them)
|
||||
kwargs['disable'] = False
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._tracker_filename = filename or "unknown"
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
from PyInstaller.utils.hooks import collect_data_files
|
||||
from PyInstaller.utils.hooks import collect_submodules
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
from PyInstaller.utils.hooks import copy_metadata
|
||||
|
||||
datas = []
|
||||
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', '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', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
||||
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']
|
||||
datas += collect_data_files('qwen_tts')
|
||||
# Use collect_all (not collect_data_files) so native .dylib and .metallib
|
||||
# files are bundled as binaries, not data. Without this, MLX raises OSError
|
||||
# when loading Metal shaders inside the PyInstaller bundle.
|
||||
from PyInstaller.utils.hooks import collect_all as _collect_all
|
||||
_mlx_datas, _mlx_bins, _mlx_hidden = _collect_all('mlx')
|
||||
_mlxa_datas, _mlxa_bins, _mlxa_hidden = _collect_all('mlx_audio')
|
||||
datas += _mlx_datas + _mlxa_datas
|
||||
datas += copy_metadata('qwen-tts')
|
||||
datas += copy_metadata('requests')
|
||||
datas += copy_metadata('transformers')
|
||||
datas += copy_metadata('huggingface-hub')
|
||||
datas += copy_metadata('tokenizers')
|
||||
datas += copy_metadata('safetensors')
|
||||
datas += copy_metadata('tqdm')
|
||||
hiddenimports += collect_submodules('qwen_tts')
|
||||
hiddenimports += collect_submodules('jaraco')
|
||||
hiddenimports += collect_submodules('mlx')
|
||||
hiddenimports += collect_submodules('mlx_audio')
|
||||
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('mlx')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
tmp_ret = collect_all('mlx_audio')
|
||||
datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2]
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['server.py'],
|
||||
pathex=[],
|
||||
binaries=_mlx_bins + _mlxa_bins,
|
||||
binaries=binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
excludes=['nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc', 'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand', 'nvidia.cusolver', 'nvidia.cusparse', 'nvidia.nccl', 'nvidia.nvjitlink', 'nvidia.nvtx'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
@@ -52,6 +52,7 @@ setup-python:
|
||||
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||
fi
|
||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
{{ pip }} install pyinstaller -q
|
||||
echo "Python environment ready."
|
||||
|
||||
[windows]
|
||||
@@ -74,6 +75,7 @@ setup-python:
|
||||
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
|
||||
& "{{ pip }}" install --no-deps chatterbox-tts
|
||||
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
& "{{ pip }}" install pyinstaller -q
|
||||
Write-Host "Python environment ready."
|
||||
|
||||
# Install JavaScript dependencies
|
||||
@@ -105,14 +107,14 @@ dev: _ensure-venv _ensure-sidecar
|
||||
|
||||
[windows]
|
||||
dev: _ensure-venv _ensure-sidecar
|
||||
$backendJob = $null
|
||||
$backendJob = $null; \
|
||||
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
|
||||
Write-Host "Starting backend on http://localhost:17493 ..."; \
|
||||
$backendJob = Start-Job -ScriptBlock { & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 } -WorkingDirectory (Get-Location); \
|
||||
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
|
||||
Start-Sleep -Seconds 2; \
|
||||
}
|
||||
Write-Host "Starting Tauri desktop app..."
|
||||
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { Stop-Job $backendJob -ErrorAction SilentlyContinue; Remove-Job $backendJob -Force -ErrorAction SilentlyContinue } }
|
||||
}; \
|
||||
Write-Host "Starting Tauri desktop app..."; \
|
||||
try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
||||
|
||||
# Start backend only
|
||||
[unix]
|
||||
@@ -124,9 +126,14 @@ dev-backend: _ensure-venv
|
||||
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
|
||||
|
||||
# Start Tauri desktop app only (backend must be running separately)
|
||||
[unix]
|
||||
dev-frontend: _ensure-sidecar
|
||||
cd {{ tauri_dir }} && bun run tauri dev
|
||||
|
||||
[windows]
|
||||
dev-frontend: _ensure-sidecar
|
||||
Set-Location "{{ tauri_dir }}"; bun run tauri dev
|
||||
|
||||
# Start backend (if not already running) + web app (no Tauri)
|
||||
[unix]
|
||||
dev-web: _ensure-venv
|
||||
@@ -149,14 +156,14 @@ dev-web: _ensure-venv
|
||||
|
||||
[windows]
|
||||
dev-web: _ensure-venv
|
||||
$backendJob = $null
|
||||
$backendJob = $null; \
|
||||
try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \
|
||||
Write-Host "Starting backend on http://localhost:17493 ..."; \
|
||||
$backendJob = Start-Job -ScriptBlock { & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 } -WorkingDirectory (Get-Location); \
|
||||
$backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \
|
||||
Start-Sleep -Seconds 2; \
|
||||
}
|
||||
Write-Host "Starting web app..."
|
||||
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { Stop-Job $backendJob -ErrorAction SilentlyContinue; Remove-Job $backendJob -Force -ErrorAction SilentlyContinue } }
|
||||
}; \
|
||||
Write-Host "Starting web app..."; \
|
||||
try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
|
||||
|
||||
# Kill all dev processes
|
||||
[unix]
|
||||
@@ -175,23 +182,56 @@ kill:
|
||||
# Build everything (server binary + desktop app)
|
||||
build: build-server build-tauri
|
||||
|
||||
# Build Python server binary
|
||||
# Build Python server binary (CPU)
|
||||
[unix]
|
||||
build-server: _ensure-venv
|
||||
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
|
||||
|
||||
[windows]
|
||||
build-server: _ensure-venv
|
||||
$env:PATH = "{{ venv_bin }};$env:PATH"; & "{{ python }}" -m PyInstaller backend/voicebox-server.spec
|
||||
$ErrorActionPreference = "Stop"; \
|
||||
$env:PATH = "{{ venv_bin }};$env:PATH"; \
|
||||
& "{{ python }}" backend/build_binary.py; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
|
||||
$triple = (rustc --print host-tuple); \
|
||||
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
|
||||
Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
|
||||
Write-Host "Copied sidecar: voicebox-server-$triple.exe"
|
||||
|
||||
# Build CUDA server binary and place in app data dir for local testing
|
||||
[windows]
|
||||
build-server-cuda: _ensure-venv
|
||||
$ErrorActionPreference = "Stop"; \
|
||||
$env:PATH = "{{ venv_bin }};$env:PATH"; \
|
||||
& "{{ python }}" backend/build_binary.py --cuda; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --cuda failed with exit code $LASTEXITCODE" }; \
|
||||
$dest = "$env:APPDATA/com.voicebox.app/backends"; \
|
||||
New-Item -ItemType Directory -Path $dest -Force | Out-Null; \
|
||||
Copy-Item "backend/dist/voicebox-server-cuda.exe" "$dest/voicebox-server-cuda.exe" -Force; \
|
||||
Write-Host "Copied CUDA binary to $dest"
|
||||
|
||||
# Build everything locally: CPU server + CUDA server + installable Tauri app
|
||||
[windows]
|
||||
build-local: build-server build-server-cuda build-tauri
|
||||
|
||||
# Build Tauri desktop app
|
||||
[unix]
|
||||
build-tauri:
|
||||
cd {{ tauri_dir }} && bun run tauri build
|
||||
|
||||
[windows]
|
||||
build-tauri:
|
||||
Set-Location "{{ tauri_dir }}"; bun run tauri build
|
||||
|
||||
# Build web app
|
||||
[unix]
|
||||
build-web:
|
||||
cd {{ web_dir }} && bun run build
|
||||
|
||||
[windows]
|
||||
build-web:
|
||||
Set-Location "{{ web_dir }}"; bun run build
|
||||
|
||||
# ─── Code Quality ────────────────────────────────────────────────────
|
||||
|
||||
# Run all checks (lint + format + typecheck)
|
||||
@@ -213,8 +253,13 @@ fix:
|
||||
# ─── Database ─────────────────────────────────────────────────────────
|
||||
|
||||
# Initialize SQLite database
|
||||
[unix]
|
||||
db-init: _ensure-venv
|
||||
cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()"
|
||||
{{ python }} -c "from backend.database import init_db; init_db()"
|
||||
|
||||
[windows]
|
||||
db-init: _ensure-venv
|
||||
& "{{ python }}" -c "from backend.database import init_db; init_db()"
|
||||
|
||||
# Reset database (delete + reinit)
|
||||
[unix]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"description": "Landing page for voicebox.sh",
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev --turbo",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NextResponse } from 'next/server';
|
||||
import { getLatestRelease } from '@/lib/releases';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 600; // Revalidate every 10 minutes
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -115,6 +115,20 @@
|
||||
animation: fadeUp 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.hero-glow-fade {
|
||||
opacity: 0;
|
||||
animation: fadeIn 2s ease-out 0.3s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Noise texture overlay for hero glow */
|
||||
/* .hero-glow::after {
|
||||
content: "";
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Footer } from '@/components/Footer';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { GITHUB_REPO } from '@/lib/constants';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Linux Install - Voicebox',
|
||||
description: 'Build Voicebox from source on Linux. Clone, setup, and build in three commands.',
|
||||
};
|
||||
|
||||
export default function LinuxInstall() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<section className="relative pt-32 pb-24">
|
||||
<div className="mx-auto max-w-2xl px-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Install on Linux</h1>
|
||||
|
||||
<p className="mt-4 text-muted-foreground">
|
||||
We're currently working through CI issues that prevent us from shipping a reliable
|
||||
pre-built binary for Linux. In the meantime, building from source is straightforward and
|
||||
takes just a few minutes.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-6">
|
||||
{/* Prerequisites */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||
Prerequisites
|
||||
</h2>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://git-scm.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Git
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rust-lang.org/tools/install"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Rust
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/casey/just#installation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
just
|
||||
</a>{' '}
|
||||
— install via{' '}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">cargo install just</code>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://bun.sh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Bun
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
Tauri system deps —{' '}
|
||||
<a
|
||||
href="https://v2.tauri.app/start/prerequisites/#linux"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
see Tauri docs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||
Build from source
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none"># Clone the repo</div>
|
||||
<div>git clone https://github.com/jamiepine/voicebox.git</div>
|
||||
<div>cd voicebox</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none">
|
||||
# Install all dependencies (Python venv, JS deps, etc.)
|
||||
</div>
|
||||
<div>just setup</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none"># Build the app</div>
|
||||
<div>just build</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
The built app will be in{' '}
|
||||
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
tauri/src-tauri/target/release/bundle/
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dev mode */}
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider mb-3">
|
||||
Or run in dev mode
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border bg-card/60 p-4 font-mono text-sm">
|
||||
<div className="text-muted-foreground select-none">
|
||||
# Start the dev server with hot reload
|
||||
</div>
|
||||
<div>just dev</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="mt-12 pt-8 border-t border-border flex flex-wrap gap-4 text-sm">
|
||||
<a
|
||||
href={GITHUB_REPO}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub Repo
|
||||
</a>
|
||||
<a
|
||||
href={`${GITHUB_REPO}/issues`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Report an issue
|
||||
</a>
|
||||
<a
|
||||
href={`${GITHUB_REPO}/blob/main/CONTRIBUTING.md`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Contributing guide
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
+18
-18
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Github, Globe, Languages, MessageSquare, Zap } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ControlUI } from '@/components/ControlUI';
|
||||
import { Features } from '@/components/Features';
|
||||
@@ -14,6 +13,8 @@ import type { DownloadLinks } from '@/lib/releases';
|
||||
|
||||
export default function Home() {
|
||||
const [downloadLinks, setDownloadLinks] = useState<DownloadLinks>(DOWNLOAD_LINKS);
|
||||
const [version, setVersion] = useState<string | null>(null);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/releases')
|
||||
@@ -23,6 +24,8 @@ export default function Home() {
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.downloadLinks) setDownloadLinks(data.downloadLinks);
|
||||
if (data.version) setVersion(data.version);
|
||||
if (data.totalDownloads != null) setTotalDownloads(data.totalDownloads);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to fetch release info:', error);
|
||||
@@ -36,7 +39,7 @@ export default function Home() {
|
||||
{/* ── Hero Section ─────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
{/* Background glow */}
|
||||
<div className="hero-glow pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[800px] h-[600px] rounded-full bg-accent/15 blur-[150px]" />
|
||||
<div className="absolute left-1/2 top-12 -translate-x-1/2 w-[500px] h-[400px] rounded-full bg-accent/10 blur-[80px]" />
|
||||
</div>
|
||||
@@ -45,25 +48,19 @@ export default function Home() {
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="fade-in mx-auto mb-8 h-[120px] w-[120px] md:h-[160px] md:w-[160px]"
|
||||
style={{
|
||||
animationDelay: '0ms',
|
||||
filter:
|
||||
'drop-shadow(0 0 20px hsl(43 60% 50% / 0.4)) drop-shadow(0 0 60px hsl(43 60% 50% / 0.2))',
|
||||
}}
|
||||
style={{ animationDelay: '0ms' }}
|
||||
>
|
||||
<Image
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/voicebox-logo-app.webp"
|
||||
alt="Voicebox"
|
||||
width={160}
|
||||
height={160}
|
||||
className="h-full w-full object-contain mix-blend-lighten"
|
||||
priority
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headline */}
|
||||
<div className="fade-in relative" style={{ animationDelay: '100ms' }}>
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground drop-shadow-[0_16px_50px_rgba(0,0,0,0.95)] md:text-7xl lg:text-8xl">
|
||||
<h1 className="text-5xl font-bold tracking-tighter leading-[0.9] text-foreground md:text-7xl lg:text-8xl">
|
||||
Your voice, your machine.
|
||||
</h1>
|
||||
</div>
|
||||
@@ -99,12 +96,16 @@ export default function Home() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
{/* Version + downloads */}
|
||||
<p
|
||||
className="fade-in mt-4 text-xs text-muted-foreground/50"
|
||||
style={{ animationDelay: '400ms' }}
|
||||
>
|
||||
Free and open source · macOS, Windows, Linux
|
||||
{version ?? ''}
|
||||
{version && totalDownloads != null ? ' \u00b7 ' : ''}
|
||||
{totalDownloads != null ? `${totalDownloads.toLocaleString()} downloads` : ''}
|
||||
{version || totalDownloads != null ? ' \u00b7 ' : ''}
|
||||
macOS, Windows, Linux
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -293,14 +294,13 @@ export default function Home() {
|
||||
|
||||
{/* Linux */}
|
||||
<a
|
||||
href={downloadLinks.linux}
|
||||
download
|
||||
href="/linux-install"
|
||||
className="flex items-center rounded-xl border border-border bg-card/60 backdrop-blur-sm px-5 py-4 transition-all hover:border-accent/30 hover:bg-card group"
|
||||
>
|
||||
<LinuxIcon className="h-6 w-6 shrink-0 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium">Linux</div>
|
||||
<div className="text-xs text-muted-foreground">AppImage (x64)</div>
|
||||
<div className="text-xs text-muted-foreground">Build from source</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,9 @@ export function unlockAudioContext() {
|
||||
audioUnlocked = true;
|
||||
|
||||
// Unlock WaveSurfer's internal audio element
|
||||
if (sharedWaveSurfer) {
|
||||
// Skip if already playing — the context is already unlocked and the
|
||||
// play/pause/reset dance would destroy the active playback.
|
||||
if (sharedWaveSurfer && !sharedWaveSurfer.isPlaying()) {
|
||||
const media = sharedWaveSurfer.getMediaElement();
|
||||
if (media) {
|
||||
media.muted = true;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface DownloadLinks {
|
||||
export interface ReleaseInfo {
|
||||
version: string;
|
||||
downloadLinks: DownloadLinks;
|
||||
totalDownloads: number;
|
||||
}
|
||||
|
||||
const GITHUB_REPO = 'jamiepine/voicebox';
|
||||
@@ -17,7 +18,7 @@ const GITHUB_API_BASE = 'https://api.github.com';
|
||||
// Cache for release info (in-memory cache, resets on server restart)
|
||||
let cachedReleaseInfo: ReleaseInfo | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_DURATION = 1000 * 60 * 10; // 10 minutes
|
||||
const CACHE_DURATION = 1000 * 60 * 5; // 5 minutes
|
||||
|
||||
// Cache for star count
|
||||
let cachedStarCount: number | null = null;
|
||||
@@ -35,7 +36,7 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
|
||||
try {
|
||||
const response = await fetch(`${GITHUB_API_BASE}/repos/${GITHUB_REPO}/releases/latest`, {
|
||||
next: { revalidate: 600 }, // Revalidate every 10 minutes
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
},
|
||||
@@ -72,11 +73,15 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch total downloads across ALL releases
|
||||
const totalDownloads = await getTotalDownloads();
|
||||
|
||||
// Fallback: construct URLs if not found in assets
|
||||
const baseUrl = `https://github.com/${GITHUB_REPO}/releases/download/${version}`;
|
||||
|
||||
const releaseInfo: ReleaseInfo = {
|
||||
version,
|
||||
totalDownloads,
|
||||
downloadLinks: {
|
||||
macArm: downloadLinks.macArm || `${baseUrl}/voicebox_aarch64.app.tar.gz`,
|
||||
macIntel: downloadLinks.macIntel || `${baseUrl}/voicebox_x64.app.tar.gz`,
|
||||
@@ -97,6 +102,57 @@ export async function getLatestRelease(): Promise<ReleaseInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for total download count
|
||||
let cachedTotalDownloads: number | null = null;
|
||||
let downloadsCacheTimestamp: number = 0;
|
||||
|
||||
/**
|
||||
* Fetches download counts across ALL releases (paginated)
|
||||
*/
|
||||
async function getTotalDownloads(): Promise<number> {
|
||||
const now = Date.now();
|
||||
if (cachedTotalDownloads !== null && now - downloadsCacheTimestamp < CACHE_DURATION) {
|
||||
return cachedTotalDownloads;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
let page = 1;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${GITHUB_API_BASE}/repos/${GITHUB_REPO}/releases?per_page=100&page=${page}`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) break;
|
||||
|
||||
const releases = await response.json();
|
||||
if (!Array.isArray(releases) || releases.length === 0) break;
|
||||
|
||||
for (const release of releases) {
|
||||
for (const asset of release.assets || []) {
|
||||
total += asset.download_count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (releases.length < 100) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
cachedTotalDownloads = total;
|
||||
downloadsCacheTimestamp = now;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch total downloads:', error);
|
||||
if (cachedTotalDownloads !== null) return cachedTotalDownloads;
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the star count for the repo from GitHub
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "voicebox",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"app",
|
||||
|
||||
@@ -9,12 +9,14 @@ PLATFORM=$(rustc --print host-tuple 2>/dev/null || echo "unknown")
|
||||
echo "Building voicebox-server for platform: $PLATFORM"
|
||||
|
||||
# Build Python binary
|
||||
# Resolve PATH to absolute paths before changing directory
|
||||
export PATH="$(cd "$(dirname "$0")/.." && pwd)/backend/venv/bin:$PATH"
|
||||
cd backend
|
||||
|
||||
# Check if PyInstaller is installed
|
||||
if ! python -c "import PyInstaller" 2>/dev/null; then
|
||||
echo "Installing PyInstaller..."
|
||||
pip install pyinstaller
|
||||
python -m pip install pyinstaller
|
||||
fi
|
||||
|
||||
# Build binary
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/tauri",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voicebox"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"core-foundation-sys",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "voicebox"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
Binary file not shown.
+54
-214
@@ -282,6 +282,7 @@ async fn start_server(
|
||||
.ok_or_else(|| "Invalid data dir path".to_string())?
|
||||
.to_string();
|
||||
let port_str = SERVER_PORT.to_string();
|
||||
let parent_pid_str = std::process::id().to_string();
|
||||
let is_remote = remote.unwrap_or(false);
|
||||
|
||||
// Resolve the custom models directory from the parameter or stored state
|
||||
@@ -294,7 +295,7 @@ async fn start_server(
|
||||
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
||||
println!("Launching CUDA backend: {:?}", cuda_path);
|
||||
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str]);
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote {
|
||||
cmd = cmd.args(["--host", "0.0.0.0"]);
|
||||
}
|
||||
@@ -304,7 +305,7 @@ async fn start_server(
|
||||
cmd.spawn()
|
||||
} else {
|
||||
// Use the bundled CPU sidecar
|
||||
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str]);
|
||||
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote {
|
||||
sidecar = sidecar.args(["--host", "0.0.0.0"]);
|
||||
}
|
||||
@@ -490,67 +491,13 @@ async fn start_server(
|
||||
Ok(format!("http://127.0.0.1:{}", SERVER_PORT))
|
||||
}
|
||||
|
||||
/// Check if a Windows process is still running
|
||||
#[cfg(windows)]
|
||||
fn is_process_running(pid: u32) -> bool {
|
||||
use std::process::Command;
|
||||
if let Ok(output) = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||
.output()
|
||||
{
|
||||
// If process exists, tasklist returns it in output
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
return !output_str.trim().is_empty() && output_str.contains(&pid.to_string());
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Kill entire Windows process tree by enumerating children
|
||||
#[cfg(windows)]
|
||||
fn kill_windows_process_tree(parent_pid: u32) -> Result<(), String> {
|
||||
use std::process::Command;
|
||||
|
||||
// Find all child processes using WMIC
|
||||
let output = Command::new("wmic")
|
||||
.args([
|
||||
"process",
|
||||
"where",
|
||||
&format!("ParentProcessId={}", parent_pid),
|
||||
"get",
|
||||
"ProcessId"
|
||||
])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines().skip(1) { // Skip header
|
||||
if let Ok(child_pid) = line.trim().parse::<u32>() {
|
||||
println!("Found child process: {}", child_pid);
|
||||
// Recursively kill child's children
|
||||
let _ = kill_windows_process_tree(child_pid);
|
||||
// Kill the child
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &child_pid.to_string(), "/F"])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill the parent process
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &parent_pid.to_string(), "/F"])
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||
let pid = state.server_pid.lock().unwrap().take();
|
||||
let _child = state.child.lock().unwrap().take();
|
||||
|
||||
if let Some(pid) = pid {
|
||||
println!("stop_server: Killing server process group with PID: {}", pid);
|
||||
println!("stop_server: Stopping server with PID: {}", pid);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -569,62 +516,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
println!("stop_server: Process group kill completed");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Layer 1: Try graceful HTTP shutdown first
|
||||
println!("Attempting graceful shutdown via HTTP...");
|
||||
// Send graceful shutdown via HTTP — the server's parent-pid watchdog
|
||||
// will also handle cleanup if this app process exits.
|
||||
println!("Sending graceful shutdown via HTTP...");
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let shutdown_result = client
|
||||
let _ = client
|
||||
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||
.send();
|
||||
|
||||
if shutdown_result.is_ok() {
|
||||
println!("HTTP shutdown sent, waiting for graceful exit...");
|
||||
// Wait up to 3 seconds for graceful shutdown
|
||||
for i in 0..30 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if !is_process_running(pid) {
|
||||
println!("Process exited gracefully after {}ms", i * 100);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
println!("Graceful shutdown timed out, forcing kill...");
|
||||
} else {
|
||||
println!("HTTP shutdown failed, forcing kill...");
|
||||
}
|
||||
|
||||
// Layer 2: Kill process tree with enumeration
|
||||
println!("Killing process tree for wrapper PID {}...", pid);
|
||||
kill_windows_process_tree(pid)?;
|
||||
|
||||
// Layer 3: Verify and kill by name if still running
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
if is_process_running(pid) {
|
||||
println!("Process tree kill failed, killing by name...");
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
|
||||
.output();
|
||||
}
|
||||
|
||||
// Layer 4: Final verification
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
if is_process_running(pid) {
|
||||
eprintln!("WARNING: Failed to kill server after all attempts");
|
||||
} else {
|
||||
println!("Server killed successfully");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
println!("stop_server: Process group kill completed");
|
||||
println!("Shutdown request sent (server watchdog will handle cleanup)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,6 +572,7 @@ async fn restart_server(
|
||||
|
||||
#[command]
|
||||
fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
|
||||
println!("set_keep_server_running called with: {}", keep_running);
|
||||
*state.keep_running_on_close.lock().unwrap() = keep_running;
|
||||
}
|
||||
|
||||
@@ -798,9 +709,17 @@ pub fn run() {
|
||||
play_audio_to_devices,
|
||||
stop_audio_playback
|
||||
])
|
||||
.on_window_event(|window, event| {
|
||||
.on_window_event({
|
||||
let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
move |window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Prevent automatic close
|
||||
// If we're already in the close flow, let it proceed
|
||||
if closing.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
closing.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
// Prevent automatic close so frontend can clean up
|
||||
api.prevent_close();
|
||||
|
||||
// Emit event to frontend to check setting and stop server if needed
|
||||
@@ -808,162 +727,83 @@ pub fn run() {
|
||||
|
||||
if let Err(e) = app_handle.emit("window-close-requested", ()) {
|
||||
eprintln!("Failed to emit window-close-requested event: {}", e);
|
||||
// If event emission fails, allow close anyway
|
||||
window.close().ok();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up listener for frontend response
|
||||
let window_for_close = window.clone();
|
||||
let closing_for_timeout = closing.clone();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<()>();
|
||||
|
||||
// Listen for response from frontend using window's listen method
|
||||
let listener_id = window.listen("window-close-allowed", move |_| {
|
||||
// Frontend has checked setting and stopped server if needed
|
||||
// Signal that we can close
|
||||
let _ = tx.send(());
|
||||
});
|
||||
|
||||
// Wait for frontend response or timeout
|
||||
// Use tauri::async_runtime::spawn instead of tokio::spawn to avoid
|
||||
// panics when the Tokio runtime is being dropped during app shutdown
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::select! {
|
||||
_ = rx.recv() => {
|
||||
// Frontend responded, close window
|
||||
window_for_close.close().ok();
|
||||
}
|
||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
|
||||
// Timeout - close anyway
|
||||
eprintln!("Window close timeout, closing anyway");
|
||||
window_for_close.close().ok();
|
||||
}
|
||||
}
|
||||
// Clean up listener
|
||||
window_for_close.unlisten(listener_id);
|
||||
closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
})
|
||||
}})
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app, event| {
|
||||
let _ = &app; // used on unix
|
||||
match &event {
|
||||
RunEvent::Exit => {
|
||||
println!("=================================================================");
|
||||
println!("RunEvent::Exit received - checking server cleanup");
|
||||
let state = app.state::<ServerState>();
|
||||
let keep_running = *state.keep_running_on_close.lock().unwrap();
|
||||
println!("keep_running_on_close = {}", keep_running);
|
||||
|
||||
if !keep_running {
|
||||
// Get the stored PID for process group killing
|
||||
let pid = state.server_pid.lock().unwrap().take();
|
||||
// Also take the child to clean up
|
||||
let _child = state.child.lock().unwrap().take();
|
||||
|
||||
if let Some(pid) = pid {
|
||||
println!("Killing server process group with PID: {}", pid);
|
||||
|
||||
// Kill the entire process group on Unix systems
|
||||
// Using negative PID sends signal to all processes in the group
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let has_pid = state.server_pid.lock().unwrap().is_some();
|
||||
println!("RunEvent::Exit — keep_running={}, has_pid={}", keep_running, has_pid);
|
||||
|
||||
if keep_running {
|
||||
// Tell the server to disable its watchdog so it survives
|
||||
// after this process exits.
|
||||
println!("Keep server running: disabling watchdog...");
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
match client
|
||||
.post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => println!("Watchdog disable response: {}", resp.status()),
|
||||
Err(e) => eprintln!("Failed to disable watchdog: {}", e),
|
||||
}
|
||||
} else {
|
||||
// Server will self-terminate via parent-pid watchdog when
|
||||
// this process exits. On Unix, also send SIGTERM for
|
||||
// immediate cleanup.
|
||||
println!("RunEvent::Exit - server will self-terminate via watchdog");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Some(pid) = state.server_pid.lock().unwrap().take() {
|
||||
use std::process::Command;
|
||||
// First try SIGTERM to the process group
|
||||
let pgid_kill = Command::new("kill")
|
||||
let _ = Command::new("kill")
|
||||
.args(["-TERM", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
|
||||
match pgid_kill {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
println!("SIGTERM sent to process group -{}", pid);
|
||||
} else {
|
||||
// Process group kill failed, try direct kill
|
||||
println!("Process group kill failed, trying direct kill");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to execute kill command: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Give it a moment, then force kill if needed
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
|
||||
// Force kill with SIGKILL
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", "--", &format!("-{}", pid)])
|
||||
.output();
|
||||
let _ = Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
println!("Server process group kill completed");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Layer 1: Try graceful HTTP shutdown first
|
||||
println!("Attempting graceful shutdown via HTTP...");
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let shutdown_result = client
|
||||
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||
.send();
|
||||
|
||||
if shutdown_result.is_ok() {
|
||||
println!("HTTP shutdown sent, waiting for graceful exit...");
|
||||
// Wait up to 3 seconds for graceful shutdown
|
||||
for i in 0..30 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
if !is_process_running(pid) {
|
||||
println!("Process exited gracefully after {}ms", i * 100);
|
||||
println!("Server process tree kill completed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
println!("Graceful shutdown timed out, forcing kill...");
|
||||
} else {
|
||||
println!("HTTP shutdown failed, forcing kill...");
|
||||
}
|
||||
|
||||
// Layer 2: Kill process tree with enumeration
|
||||
println!("Killing process tree for wrapper PID {}...", pid);
|
||||
let _ = kill_windows_process_tree(pid);
|
||||
|
||||
// Layer 3: Verify and kill by name if still running
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
if is_process_running(pid) {
|
||||
println!("Process tree kill failed, killing by name...");
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/IM", "voicebox-server.exe", "/T", "/F"])
|
||||
.output();
|
||||
}
|
||||
|
||||
// Layer 4: Final verification
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
if is_process_running(pid) {
|
||||
eprintln!("WARNING: Failed to kill server after all attempts");
|
||||
} else {
|
||||
println!("Server killed successfully");
|
||||
}
|
||||
println!("Server process tree kill completed");
|
||||
}
|
||||
} else {
|
||||
println!("No server PID found (already stopped or never started)");
|
||||
}
|
||||
} else {
|
||||
println!("Keeping server running per user setting");
|
||||
}
|
||||
println!("=================================================================");
|
||||
}
|
||||
RunEvent::ExitRequested { api, .. } => {
|
||||
println!("RunEvent::ExitRequested received");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Voicebox",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"identifier": "sh.voicebox.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -12,7 +12,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": false,
|
||||
"createUpdaterArtifacts": "v1Compatible",
|
||||
"externalBin": ["binaries/voicebox-server"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
|
||||
@@ -64,6 +64,12 @@ class TauriLifecycle implements PlatformLifecycle {
|
||||
// @ts-expect-error - accessing module-level variable from another module
|
||||
const serverStartedByApp = window.__voiceboxServerStartedByApp ?? false;
|
||||
|
||||
console.log(
|
||||
'[lifecycle] window-close-requested: keepRunning=%s, serverStartedByApp=%s',
|
||||
keepRunning,
|
||||
serverStartedByApp,
|
||||
);
|
||||
|
||||
if (!keepRunning && serverStartedByApp) {
|
||||
// Stop server before closing (only if we started it)
|
||||
try {
|
||||
|
||||
@@ -64,13 +64,17 @@ class TauriUpdater implements PlatformUpdater {
|
||||
}
|
||||
this.notifySubscribers();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// Tauri updater throws on 404 / no published release / network errors.
|
||||
// Treat "no update available" style errors as up-to-date, not failures.
|
||||
const isNoUpdate = /404|not found|no update|up.to.date/i.test(message);
|
||||
this.status = {
|
||||
checking: false,
|
||||
available: false,
|
||||
downloading: false,
|
||||
installing: false,
|
||||
readyToInstall: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates',
|
||||
error: isNoUpdate ? undefined : message,
|
||||
};
|
||||
this.notifySubscribers();
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@voicebox/web",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Reference in New Issue
Block a user