# Voicebox development commands # Install: brew install just (or cargo install just) # Usage: just --list # Directories backend_dir := "backend" tauri_dir := "tauri" app_dir := "app" web_dir := "web" venv := backend_dir / "venv" # Platform-aware paths venv_bin := if os() == "windows" { venv / "Scripts" } else { venv / "bin" } python := if os() == "windows" { venv_bin / "python.exe" } else { venv_bin / "python" } # Shell selection: use powershell on Windows, bash elsewhere set windows-shell := ["powershell", "-NoProfile", "-Command"] # Pin the venv to one version instead of following whatever `python3` happens to be; # a newer interpreter fails `pip install` with "no matching distribution". See # requires-python in backend/pyproject.toml for which dependencies set the ceiling. # Keep in sync with that and with actions/setup-python in .github/workflows. python_version := "3.12" # ─── Setup ──────────────────────────────────────────────────────────── # Full project setup (python venv + JS deps + dev sidecar) setup: setup-python setup-js @echo "" @echo "Setup complete! Run: just dev" # Create venv and install Python dependencies [unix] setup-python: #!/usr/bin/env bash set -euo pipefail is_pinned() { "$1" -c 'import sys; sys.exit(sys.version_info[:2] != tuple(map(int, "{{ python_version }}".split("."))))' 2>/dev/null } # Invoke pip via the venv's python so validation and installs share the same # interpreter. A copied venv can keep a working `pip` console script whose # shebang still points at the original tree — that would make `pip --version` # succeed while later installs land in the wrong environment. pip_works() { "{{ python }}" -m pip --version >/dev/null 2>&1 } # Every interpreter on PATH that could be the pinned version, plus uv's, which # keeps its Pythons off PATH. More than one can match, and the first is not # necessarily the good one. candidates() { type -aP "python{{ python_version }}" python3 python 2>/dev/null || true if command -v uv >/dev/null 2>&1; then uv python find "{{ python_version }}" 2>/dev/null || true fi } # Reuse the venv only if it is on the pinned version and has a working pip: a # wrong-version venv resolves dependencies against the wrong Python, and a # broken pip means an earlier `-m venv` was interrupted or the venv was moved. if [ -d "{{ venv }}" ] && ! { is_pinned "{{ python }}" && pip_works; }; then echo "Existing venv is not a working Python {{ python_version }} environment — recreating..." rm -rf "{{ venv }}" fi if [ ! -d "{{ venv }}" ]; then # Building the venv is itself the test, so there is no separate probe to keep # in sync. An interpreter can report the right version and still fail inside # `-m venv` — Debian packages ensurepip separately, and Homebrew has shipped a # pyexpat that won't dlopen — so fall through to the next candidate rather # than giving up on the first. Their output is held back until every candidate # has failed, so a run that recovers doesn't look like a broken one. log="$(mktemp)" while IFS= read -r py; do is_pinned "$py" || continue echo "Creating Python virtual environment with $py ..." "$py" -m venv "{{ venv }}" >>"$log" 2>&1 && pip_works && break echo " ...it reports {{ python_version }} but cannot build a working venv; trying the next." rm -rf "{{ venv }}" done < <(candidates | sed 's|//*|/|g' | awk '!seen[$0]++') pip_works || cat "$log" rm -f "$log" fi if ! pip_works; then echo "" echo "ERROR: Voicebox needs a Python {{ python_version }} that can create virtual" echo " environments (see requires-python in backend/pyproject.toml)." echo " Any: uv python install {{ python_version }}" echo " macOS: brew install python@{{ python_version }}" echo " Debian: sudo apt install python{{ python_version }} python{{ python_version }}-venv" echo "" echo "Then re-run: just setup" exit 1 fi echo "Installing Python dependencies..." "{{ python }}" -m pip install --upgrade pip -q if [ "$(uname)" = "Linux" ]; then torch_index="" if [ -e /proc/driver/nvidia/version ] || [ -d /sys/module/nvidia ]; then echo "Detected NVIDIA GPU — installing CUDA PyTorch..." torch_index="https://download.pytorch.org/whl/cu128" elif [ -e /dev/kfd ]; then if [ -n "${VOICEBOX_ROCM_VERSION:-}" ]; then rocm_ver="$VOICEBOX_ROCM_VERSION" elif lspci 2>/dev/null | grep -qi "Navi 4"; then rocm_ver=7.2 else rocm_ver=6.3 fi echo "Detected AMD GPU — installing ROCm PyTorch (rocm${rocm_ver})..." torch_index="https://download.pytorch.org/whl/rocm${rocm_ver}" fi if [ -n "$torch_index" ]; then "{{ python }}" -m pip install torch torchaudio --index-url "$torch_index" fi fi "{{ python }}" -m pip install -r {{ backend_dir }}/requirements.txt # Chatterbox pins numpy<1.26 / torch==2.6 which break on Python 3.12+ "{{ python }}" -m pip install --no-deps chatterbox-tts # HumeAI TADA pins torch>=2.7,<2.8 which conflicts with our torch>=2.1 "{{ python }}" -m pip install --no-deps hume-tada # Apple Silicon: install MLX backend if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then echo "Detected Apple Silicon — installing MLX dependencies..." "{{ python }}" -m pip install -r {{ backend_dir }}/requirements-mlx.txt # mlx-lm and mlx-audio declare transformers>=5.x, which conflicts with # our transformers<=4.57.x cap, so install them --no-deps (their other # runtime deps are covered by requirements.txt / requirements-mlx.txt — # see the note in requirements-mlx.txt and .github/workflows/release.yml) "{{ python }}" -m pip install --no-deps mlx-lm==0.31.1 "{{ python }}" -m pip install --no-deps mlx-audio==0.4.1 fi "{{ python }}" -m pip install git+https://github.com/QwenLM/Qwen3-TTS.git "{{ python }}" -m pip install pyinstaller ruff pytest pytest-asyncio -q echo "Python environment ready." [windows] setup-python: $target = "{{ python_version }}"; \ function Test-Py($exe) { \ if (-not $exe -or -not (Test-Path $exe)) { return $false }; \ $v = & $exe -c "import sys; print('%s.%s' % sys.version_info[:2])" 2>$null; \ return ($LASTEXITCODE -eq 0 -and $v -eq $target); \ }; \ function Test-Pip { \ if (-not (Test-Path "{{ python }}")) { return $false }; \ & "{{ python }}" -m pip --version *> $null; \ return ($LASTEXITCODE -eq 0); \ }; \ if ((Test-Path "{{ venv }}") -and (-not (Test-Py "{{ python }}") -or -not (Test-Pip))) { \ Write-Host "Existing venv is not a working Python $target env - recreating..."; \ Remove-Item -Recurse -Force "{{ venv }}"; \ }; \ if (-not (Test-Path "{{ venv }}")) { \ $py = $null; \ if (Get-Command py -ErrorAction SilentlyContinue) { \ $p = & py "-$target" -c "import sys; print(sys.executable)" 2>$null; \ if ($LASTEXITCODE -eq 0) { $py = $p }; \ }; \ if (-not $py) { foreach ($n in @("python$target", "python")) { \ $c = Get-Command $n -ErrorAction SilentlyContinue; \ if ($c -and (Test-Py $c.Source)) { $py = $c.Source; break }; \ } }; \ if (-not $py -and (Get-Command uv -ErrorAction SilentlyContinue)) { \ $p = & uv python find $target 2>$null; \ if ($LASTEXITCODE -eq 0 -and (Test-Py $p)) { $py = $p }; \ }; \ if (-not $py) { \ Write-Host "ERROR: Voicebox requires Python $target (see requires-python in backend/pyproject.toml)."; \ Write-Host "Install it from https://python.org, or: winget install -e --id Python.Python.$target"; \ Write-Host " or: uv python install $target"; \ Write-Host "Then re-run: just setup"; \ exit 1; \ }; \ Write-Host "Creating Python virtual environment with $py ..."; \ & $py -m venv "{{ venv }}"; \ if ($LASTEXITCODE -ne 0 -or -not (Test-Pip)) { Write-Host "ERROR: could not create a working venv with $py"; exit 1 }; \ } # PowerShell does not fail the recipe when a native command returns nonzero, so # wrap every pip invocation and exit on $LASTEXITCODE. Write-Host "Installing Python dependencies..."; \ function Invoke-Pip { \ & "{{ python }}" -m pip @args; \ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ }; \ Invoke-Pip install --upgrade pip -q; \ $gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name; \ Write-Host "Detected GPUs: $($gpus -join ', ')"; \ $hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0; \ $hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0; \ if ($hasNvidia) { \ Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \ Invoke-Pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \ } elseif ($hasIntelArc) { \ Write-Host "Intel Arc GPU detected — installing PyTorch with XPU support..."; \ Invoke-Pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu; \ Invoke-Pip install intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu; \ } else { \ Write-Host "No NVIDIA or Intel Arc GPU detected — using CPU-only PyTorch."; \ Write-Host "If you have an Intel Arc GPU, install XPU support manually:"; \ Write-Host " python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu"; \ Write-Host " python -m pip install intel-extension-for-pytorch --index-url https://download.pytorch.org/whl/xpu"; \ }; \ Invoke-Pip install -r {{ backend_dir }}/requirements.txt; \ Invoke-Pip install --no-deps chatterbox-tts; \ Invoke-Pip install --no-deps hume-tada; \ Invoke-Pip install git+https://github.com/QwenLM/Qwen3-TTS.git; \ Invoke-Pip install pyinstaller ruff pytest pytest-asyncio -q; \ Write-Host "Python environment ready." # Install JavaScript dependencies setup-js: bun install # ─── Development ────────────────────────────────────────────────────── # Start backend (if not already running) + frontend for development [unix] dev: _ensure-venv _ensure-sidecar #!/usr/bin/env bash set -euo pipefail backend_pid="" if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then echo "Backend already running on http://localhost:17493" else echo "Starting backend on http://localhost:17493 ..." {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 & backend_pid=$! sleep 2 fi trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT echo "Starting Tauri desktop app..." cd {{ tauri_dir }} && bun run tauri dev [windows] dev: _ensure-venv _ensure-sidecar $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-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) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } } # Start backend only [unix] dev-backend: _ensure-venv {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 [windows] 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 #!/usr/bin/env bash set -euo pipefail backend_pid="" if curl -sf http://127.0.0.1:17493/health > /dev/null 2>&1; then echo "Backend already running on http://localhost:17493" else echo "Starting backend on http://localhost:17493 ..." {{ venv_bin }}/uvicorn backend.main:app --reload --port 17493 & backend_pid=$! sleep 2 fi trap '[ -n "$backend_pid" ] && kill "$backend_pid" 2>/dev/null; wait' EXIT cd {{ web_dir }} && bun run dev [windows] dev-web: _ensure-venv $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-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) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } } # Kill all dev processes [unix] kill: -pkill -f "uvicorn backend.main:app" 2>/dev/null || true -pkill -f "vite" 2>/dev/null || true @echo "Dev processes killed." [windows] kill: Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -like '*uvicorn*backend.main*' -or $_.CommandLine -like '*vite*' } | Stop-Process -Force -ErrorAction SilentlyContinue Write-Host "Dev processes killed." # ─── Build ──────────────────────────────────────────────────────────── # Build everything (server binary + desktop app) build: build-server build-tauri # Build Python server binary (CPU) [unix] build-server: _ensure-venv PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh [windows] build-server: _ensure-venv $ErrorActionPreference = "Stop"; \ $env:PATH = "{{ venv_bin }};$env:PATH"; \ $triple = (rustc --print host-tuple); \ New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \ & "{{ python }}" backend/build_binary.py; \ if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \ 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"; \ & "{{ python }}" backend/build_binary.py --shim; \ if ($LASTEXITCODE -ne 0) { throw "build_binary.py --shim failed with exit code $LASTEXITCODE" }; \ Copy-Item "backend/dist/voicebox-mcp.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-mcp-$triple.exe" -Force; \ Write-Host "Copied sidecar: voicebox-mcp-$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/sh.voicebox.app/backends/cuda"; \ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }; \ New-Item -ItemType Directory -Path $dest -Force | Out-Null; \ Copy-Item "backend/dist/voicebox-server-cuda/*" $dest -Recurse -Force; \ Write-Host "Copied CUDA backend 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 (JS + Python lint + format) check: check-js check-python # JS/TS: lint + format + typecheck (Biome) check-js: bun run check # Python: lint + format check (ruff) check-python: _ensure-venv {{ venv_bin }}/ruff check {{ backend_dir }} {{ venv_bin }}/ruff format --check {{ backend_dir }} # Lint with Biome (JS) + ruff (Python) lint: _ensure-venv bun run lint {{ venv_bin }}/ruff check {{ backend_dir }} # Format with Biome (JS) + ruff (Python) format: _ensure-venv bun run format {{ venv_bin }}/ruff format {{ backend_dir }} # Fix lint + format issues (JS + Python) fix: _ensure-venv bun run check:fix {{ venv_bin }}/ruff check {{ backend_dir }} --fix {{ venv_bin }}/ruff format {{ backend_dir }} # Python lint only lint-python: _ensure-venv {{ venv_bin }}/ruff check {{ backend_dir }} # Python format only format-python: _ensure-venv {{ venv_bin }}/ruff format {{ backend_dir }} # Python auto-fix lint issues fix-python: _ensure-venv {{ venv_bin }}/ruff check {{ backend_dir }} --fix {{ venv_bin }}/ruff format {{ backend_dir }} # Run Python tests test: _ensure-venv {{ venv_bin }}/python -m pytest {{ backend_dir }}/tests -v # E2E: generate with every TTS model against the frozen binary (pass extra flags like --only kokoro) [unix] test-models *ARGS: _ensure-venv {{ venv_bin }}/python {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }} [windows] test-models *ARGS: _ensure-venv & "{{ python }}" {{ backend_dir }}/tests/test_all_models_e2e.py {{ ARGS }} # ─── Database ───────────────────────────────────────────────────────── # Initialize SQLite database [unix] db-init: _ensure-venv {{ 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] db-reset: rm -f {{ backend_dir }}/data/voicebox.db just db-init [windows] db-reset: if (Test-Path "{{ backend_dir }}/data/voicebox.db") { Remove-Item -Force "{{ backend_dir }}/data/voicebox.db" } just db-init # ─── Utilities ──────────────────────────────────────────────────────── # Generate TypeScript API client (backend must be running) [unix] generate-api: ./scripts/generate-api.sh [windows] generate-api: bash scripts/generate-api.sh # Open API docs in browser [unix] docs: open http://localhost:17493/docs 2>/dev/null || xdg-open http://localhost:17493/docs [windows] docs: Start-Process "http://localhost:17493/docs" # Tail backend logs [unix] logs: tail -f {{ backend_dir }}/logs/*.log 2>/dev/null || echo "No log files found" [windows] logs: Get-ChildItem {{ backend_dir }}/logs/*.log -ErrorAction SilentlyContinue | ForEach-Object { Get-Content $_.FullName -Tail 50 -Wait } ; if (-not $?) { Write-Host "No log files found" } # ─── Clean ──────────────────────────────────────────────────────────── # Clean build artifacts [unix] clean: rm -rf {{ tauri_dir }}/src-tauri/target/release rm -rf {{ web_dir }}/dist rm -rf {{ app_dir }}/dist [windows] clean: if (Test-Path "{{ tauri_dir }}/src-tauri/target/release") { Remove-Item -Recurse -Force "{{ tauri_dir }}/src-tauri/target/release" } if (Test-Path "{{ web_dir }}/dist") { Remove-Item -Recurse -Force "{{ web_dir }}/dist" } if (Test-Path "{{ app_dir }}/dist") { Remove-Item -Recurse -Force "{{ app_dir }}/dist" } # Clean Python venv and cache [unix] clean-python: rm -rf {{ venv }} find {{ backend_dir }} -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true [windows] clean-python: if (Test-Path "{{ venv }}") { Remove-Item -Recurse -Force "{{ venv }}" } Get-ChildItem -Path "{{ backend_dir }}" -Directory -Recurse -Filter "__pycache__" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force # Nuclear clean (everything including node_modules) [unix] clean-all: clean clean-python rm -rf node_modules rm -rf {{ app_dir }}/node_modules rm -rf {{ tauri_dir }}/node_modules rm -rf {{ web_dir }}/node_modules cd {{ tauri_dir }}/src-tauri && cargo clean [windows] clean-all: clean clean-python if (Test-Path "node_modules") { Remove-Item -Recurse -Force "node_modules" } if (Test-Path "{{ app_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ app_dir }}/node_modules" } if (Test-Path "{{ tauri_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ tauri_dir }}/node_modules" } if (Test-Path "{{ web_dir }}/node_modules") { Remove-Item -Recurse -Force "{{ web_dir }}/node_modules" } Push-Location "{{ tauri_dir }}/src-tauri"; cargo clean; Pop-Location # ─── Internal ───────────────────────────────────────────────────────── # Ensure venv exists (prompt to run setup if not) [private, unix] _ensure-venv: #!/usr/bin/env bash if [ ! -d "{{ venv }}" ]; then echo "Python venv not found. Run: just setup" exit 1 fi [private, windows] _ensure-venv: if (-not (Test-Path "{{ venv }}")) { Write-Host "Python venv not found. Run: just setup"; exit 1 } # Ensure Tauri dev sidecar placeholder exists [private] _ensure-sidecar: bun run setup:dev