Merge pull request #272 from jamiepine/windows-support

Windows support: CUDA detection, cross-platform justfile, clean server shutdown
This commit is contained in:
Jamie Pine
2026-03-15 06:20:46 -07:00
committed by GitHub
7 changed files with 367 additions and 339 deletions
+36 -98
View File
@@ -33,101 +33,41 @@ Thank you for your interest in contributing to Voicebox! This document provides
### Development Setup ### Development Setup
**Using `just` (recommended):** Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
```bash ```bash
git clone https://github.com/YOUR_USERNAME/voicebox.git
cd voicebox
just setup # creates venv, installs Python + JS deps 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: Other useful commands:
```bash ```bash
just dev-web # backend + web app (no Tauri/Rust build) just dev-web # backend + web app (no Tauri/Rust build)
just dev-backend # backend only just dev-backend # backend only
just dev-frontend # Tauri app only (backend must be running)
just kill # stop all dev processes just kill # stop all dev processes
just clean-all # nuke everything and start fresh just clean-all # nuke everything and start fresh
just --list # see all available commands 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** 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.
```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`
### Model Downloads ### Model Downloads
@@ -139,25 +79,30 @@ First-time usage will be slower due to model downloads, but subsequent runs will
### Building ### Building
**Build everything (recommended):** **Build production app:**
```bash ```bash
bun run build just build # Build CPU server binary + Tauri installer
``` ```
This automatically:
1. Builds the Python server binary (`./scripts/build-server.sh`) On Windows, to build with CUDA support for local testing:
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
```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/`. 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 ```bash
bun run build:server just build-server # CPU server binary only
# or just build-server-cuda # CUDA server binary only (Windows)
./scripts/build-server.sh 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:** **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 ```bash
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS 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`). This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
**Build web app:**
```bash
cd web
bun run build
```
Output in `web/dist/`
### Generate OpenAPI Client ### Generate OpenAPI Client
+16 -5
View File
@@ -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. 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:** | Platform | GPU Backend | Notes |
- **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) | 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 ### Project Structure
+82 -4
View File
@@ -10,6 +10,7 @@ import PyInstaller.__main__
import argparse import argparse
import os import os
import platform import platform
import sys
from pathlib import Path from pathlib import Path
@@ -62,6 +63,10 @@ def build_server(cuda=False):
'--hidden-import', 'backend.utils.hf_progress', '--hidden-import', 'backend.utils.hf_progress',
'--hidden-import', 'backend.utils.validation', '--hidden-import', 'backend.utils.validation',
'--hidden-import', 'backend.cuda_download', '--hidden-import', 'backend.cuda_download',
'--hidden-import', 'backend.effects',
'--hidden-import', 'backend.utils.effects',
'--hidden-import', 'backend.versions',
'--hidden-import', 'pedalboard',
'--hidden-import', 'torch', '--hidden-import', 'torch',
'--hidden-import', 'transformers', '--hidden-import', 'transformers',
'--hidden-import', 'fastapi', '--hidden-import', 'fastapi',
@@ -91,9 +96,10 @@ def build_server(cuda=False):
'--hidden-import', 'torch.backends.cudnn', '--hidden-import', 'torch.backends.cudnn',
]) ])
else: else:
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary under 4GB. # Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
# On Linux, pip may pull CUDA-enabled PyTorch by default which includes ~3GB # When building from a venv with CUDA torch installed, PyInstaller would
# of NVIDIA shared libraries that PyInstaller would bundle. # bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
# modules and the binary DLLs.
nvidia_packages = [ nvidia_packages = [
'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc', 'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc',
'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand', 'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand',
@@ -127,7 +133,12 @@ def build_server(cuda=False):
elif not cuda: elif not cuda:
print("Building for non-Apple Silicon platform - PyTorch only") print("Building for non-Apple Silicon platform - PyTorch only")
dist_dir = str(backend_dir / 'dist')
build_dir = str(backend_dir / 'build')
args.extend([ args.extend([
'--distpath', dist_dir,
'--workpath', build_dir,
'--noconfirm', '--noconfirm',
'--clean', '--clean',
]) ])
@@ -135,12 +146,79 @@ def build_server(cuda=False):
# Change to backend directory # Change to backend directory
os.chdir(backend_dir) 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 # 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}") 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__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Build voicebox-server binary") parser = argparse.ArgumentParser(description="Build voicebox-server binary")
parser.add_argument( parser.add_argument(
+9
View File
@@ -108,6 +108,7 @@ _default_origins = [
"http://127.0.0.1:17493", "http://127.0.0.1:17493",
"tauri://localhost", # Tauri webview (macOS) "tauri://localhost", # Tauri webview (macOS)
"https://tauri.localhost", # Tauri webview (Windows/Linux) "https://tauri.localhost", # Tauri webview (Windows/Linux)
"http://tauri.localhost", # Tauri webview (Windows, some builds)
] ]
_env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "") _env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
_cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()] _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..."} 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) @app.get("/health", response_model=models.HealthResponse)
async def health(): async def health():
"""Health check endpoint.""" """Health check endpoint."""
+119 -5
View File
@@ -6,6 +6,14 @@ absolute imports instead of relative imports.
""" """
import sys import sys
# 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 import logging
# Set up logging FIRST, before any imports that might fail # Set up logging FIRST, before any imports that might fail
@@ -43,6 +51,100 @@ except Exception as e:
logger.error(f"Failed to import required modules: {e}", exc_info=True) logger.error(f"Failed to import required modules: {e}", exc_info=True)
sys.exit(1) 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
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):
watchdog_logger.info(f"Parent process {parent_pid} gone, 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__": if __name__ == "__main__":
try: try:
parser = argparse.ArgumentParser(description="voicebox backend server") parser = argparse.ArgumentParser(description="voicebox backend server")
@@ -64,17 +166,21 @@ if __name__ == "__main__":
default=None, default=None,
help="Data directory for database, profiles, and generated audio", 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( parser.add_argument(
"--version", "--version",
action="store_true", action="store_true",
help="Print version and exit", help="Print version and exit (handled above, kept for argparse help)",
) )
args = parser.parse_args() args = parser.parse_args()
if args.version: if args.parent_pid is not None and args.parent_pid <= 0:
from backend import __version__ parser.error("--parent-pid must be a positive integer")
print(f"voicebox-server {__version__}")
sys.exit(0)
# Detect backend variant from binary name # Detect backend variant from binary name
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda # voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
@@ -87,6 +193,14 @@ if __name__ == "__main__":
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu" os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
logger.info("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}") logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
# Set data directory if provided # Set data directory if provided
+58 -13
View File
@@ -52,6 +52,7 @@ setup-python:
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt {{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
fi fi
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git {{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
{{ pip }} install pyinstaller -q
echo "Python environment ready." echo "Python environment ready."
[windows] [windows]
@@ -74,6 +75,7 @@ setup-python:
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt & "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
& "{{ pip }}" install --no-deps chatterbox-tts & "{{ pip }}" install --no-deps chatterbox-tts
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git & "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
& "{{ pip }}" install pyinstaller -q
Write-Host "Python environment ready." Write-Host "Python environment ready."
# Install JavaScript dependencies # Install JavaScript dependencies
@@ -105,14 +107,14 @@ dev: _ensure-venv _ensure-sidecar
[windows] [windows]
dev: _ensure-venv _ensure-sidecar 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 { \ 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 ..."; \ 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; \ Start-Sleep -Seconds 2; \
} }; \
Write-Host "Starting Tauri desktop app..." 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 } } 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 # Start backend only
[unix] [unix]
@@ -124,9 +126,14 @@ dev-backend: _ensure-venv
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
# Start Tauri desktop app only (backend must be running separately) # Start Tauri desktop app only (backend must be running separately)
[unix]
dev-frontend: _ensure-sidecar dev-frontend: _ensure-sidecar
cd {{ tauri_dir }} && bun run tauri dev 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) # Start backend (if not already running) + web app (no Tauri)
[unix] [unix]
dev-web: _ensure-venv dev-web: _ensure-venv
@@ -149,14 +156,14 @@ dev-web: _ensure-venv
[windows] [windows]
dev-web: _ensure-venv 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 { \ 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 ..."; \ 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; \ Start-Sleep -Seconds 2; \
} }; \
Write-Host "Starting web app..." 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 } } 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 # Kill all dev processes
[unix] [unix]
@@ -175,23 +182,56 @@ kill:
# Build everything (server binary + desktop app) # Build everything (server binary + desktop app)
build: build-server build-tauri build: build-server build-tauri
# Build Python server binary # Build Python server binary (CPU)
[unix] [unix]
build-server: _ensure-venv build-server: _ensure-venv
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
[windows] [windows]
build-server: _ensure-venv 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 # Build Tauri desktop app
[unix]
build-tauri: build-tauri:
cd {{ tauri_dir }} && bun run tauri build cd {{ tauri_dir }} && bun run tauri build
[windows]
build-tauri:
Set-Location "{{ tauri_dir }}"; bun run tauri build
# Build web app # Build web app
[unix]
build-web: build-web:
cd {{ web_dir }} && bun run build cd {{ web_dir }} && bun run build
[windows]
build-web:
Set-Location "{{ web_dir }}"; bun run build
# ─── Code Quality ──────────────────────────────────────────────────── # ─── Code Quality ────────────────────────────────────────────────────
# Run all checks (lint + format + typecheck) # Run all checks (lint + format + typecheck)
@@ -213,8 +253,13 @@ fix:
# ─── Database ───────────────────────────────────────────────────────── # ─── Database ─────────────────────────────────────────────────────────
# Initialize SQLite database # Initialize SQLite database
[unix]
db-init: _ensure-venv 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) # Reset database (delete + reinit)
[unix] [unix]
+47 -214
View File
@@ -282,6 +282,7 @@ async fn start_server(
.ok_or_else(|| "Invalid data dir path".to_string())? .ok_or_else(|| "Invalid data dir path".to_string())?
.to_string(); .to_string();
let port_str = SERVER_PORT.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); let is_remote = remote.unwrap_or(false);
// Resolve the custom models directory from the parameter or stored state // 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 { let spawn_result = if let Some(ref cuda_path) = cuda_binary {
println!("Launching CUDA backend: {:?}", cuda_path); println!("Launching CUDA backend: {:?}", cuda_path);
let mut cmd = app.shell().command(cuda_path.to_str().unwrap()); 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 { if is_remote {
cmd = cmd.args(["--host", "0.0.0.0"]); cmd = cmd.args(["--host", "0.0.0.0"]);
} }
@@ -304,7 +305,7 @@ async fn start_server(
cmd.spawn() cmd.spawn()
} else { } else {
// Use the bundled CPU sidecar // 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 { if is_remote {
sidecar = sidecar.args(["--host", "0.0.0.0"]); 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)) 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] #[command]
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> { async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
let pid = state.server_pid.lock().unwrap().take(); let pid = state.server_pid.lock().unwrap().take();
let _child = state.child.lock().unwrap().take(); let _child = state.child.lock().unwrap().take();
if let Some(pid) = pid { 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)] #[cfg(unix)]
{ {
@@ -569,62 +516,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
let _ = Command::new("kill") let _ = Command::new("kill")
.args(["-9", &pid.to_string()]) .args(["-9", &pid.to_string()])
.output(); .output();
println!("stop_server: Process group kill completed");
} }
#[cfg(windows)] #[cfg(windows)]
{ {
// Layer 1: Try graceful HTTP shutdown first // Send graceful shutdown via HTTP — the server's parent-pid watchdog
println!("Attempting graceful shutdown via HTTP..."); // will also handle cleanup if this app process exits.
println!("Sending graceful shutdown via HTTP...");
let client = reqwest::blocking::Client::builder() let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2)) .timeout(std::time::Duration::from_secs(2))
.build() .build()
.unwrap(); .unwrap();
let shutdown_result = client let _ = client
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT)) .post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
.send(); .send();
if shutdown_result.is_ok() { println!("Shutdown request sent (server watchdog will handle cleanup)");
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");
} }
} }
@@ -798,9 +708,17 @@ pub fn run() {
play_audio_to_devices, play_audio_to_devices,
stop_audio_playback 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 { 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(); api.prevent_close();
// Emit event to frontend to check setting and stop server if needed // Emit event to frontend to check setting and stop server if needed
@@ -808,162 +726,77 @@ pub fn run() {
if let Err(e) = app_handle.emit("window-close-requested", ()) { if let Err(e) = app_handle.emit("window-close-requested", ()) {
eprintln!("Failed to emit window-close-requested event: {}", e); eprintln!("Failed to emit window-close-requested event: {}", e);
// If event emission fails, allow close anyway
window.close().ok(); window.close().ok();
return; return;
} }
// Set up listener for frontend response // Set up listener for frontend response
let window_for_close = window.clone(); let window_for_close = window.clone();
let closing_for_timeout = closing.clone();
let (tx, mut rx) = mpsc::unbounded_channel::<()>(); 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 |_| { 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(()); 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 { tauri::async_runtime::spawn(async move {
tokio::select! { tokio::select! {
_ = rx.recv() => { _ = rx.recv() => {
// Frontend responded, close window
window_for_close.close().ok(); window_for_close.close().ok();
} }
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => { _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
// Timeout - close anyway
eprintln!("Window close timeout, closing anyway"); eprintln!("Window close timeout, closing anyway");
window_for_close.close().ok(); window_for_close.close().ok();
} }
} }
// Clean up listener
window_for_close.unlisten(listener_id); window_for_close.unlisten(listener_id);
closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst);
}); });
} }
}) }})
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application")
.run(|app, event| { .run(|app, event| {
let _ = &app; // used on unix
match &event { match &event {
RunEvent::Exit => { RunEvent::Exit => {
println!("=================================================================");
println!("RunEvent::Exit received - checking server cleanup");
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
let keep_running = *state.keep_running_on_close.lock().unwrap(); let keep_running = *state.keep_running_on_close.lock().unwrap();
println!("keep_running_on_close = {}", keep_running);
if keep_running {
if !keep_running { // Tell the server to disable its watchdog so it survives
// Get the stored PID for process group killing // after this process exits.
let pid = state.server_pid.lock().unwrap().take(); println!("Keep server running: disabling watchdog...");
// Also take the child to clean up let client = reqwest::blocking::Client::builder()
let _child = state.child.lock().unwrap().take(); .timeout(std::time::Duration::from_secs(2))
.build()
if let Some(pid) = pid { .unwrap();
println!("Killing server process group with PID: {}", pid); let _ = client
.post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT))
// Kill the entire process group on Unix systems .send();
// Using negative PID sends signal to all processes in the group } else {
#[cfg(unix)] // 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; use std::process::Command;
// First try SIGTERM to the process group let _ = Command::new("kill")
let pgid_kill = Command::new("kill")
.args(["-TERM", "--", &format!("-{}", pid)]) .args(["-TERM", "--", &format!("-{}", pid)])
.output(); .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)); std::thread::sleep(std::time::Duration::from_millis(100));
// Force kill with SIGKILL
let _ = Command::new("kill") let _ = Command::new("kill")
.args(["-9", "--", &format!("-{}", pid)]) .args(["-9", "--", &format!("-{}", pid)])
.output(); .output();
let _ = Command::new("kill") let _ = Command::new("kill")
.args(["-9", &pid.to_string()]) .args(["-9", &pid.to_string()])
.output(); .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, .. } => { RunEvent::ExitRequested { api, .. } => {
println!("RunEvent::ExitRequested received"); println!("RunEvent::ExitRequested received");