#!/usr/bin/env python3 """Build the Voice to Notes sidecar as a standalone binary using PyInstaller. Usage: python build_sidecar.py [--cpu-only] Produces a directory `dist/voice-to-notes-sidecar/` containing the frozen sidecar binary and all dependencies. The main binary is renamed to include the Tauri target triple for externalBin resolution. """ from __future__ import annotations import argparse import os import platform import shutil import stat import subprocess import sys import urllib.request import zipfile from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent DIST_DIR = SCRIPT_DIR / "dist" BUILD_DIR = SCRIPT_DIR / "build" SPEC_FILE = SCRIPT_DIR / "voice_to_notes.spec" # Static ffmpeg download URLs (GPL-licensed builds) FFMPEG_URLS: dict[str, str] = { "linux-x86_64": "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz", "darwin-x86_64": "https://evermeet.cx/ffmpeg/getrelease/zip", "darwin-arm64": "https://evermeet.cx/ffmpeg/getrelease/zip", "win32-x86_64": "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", } def get_target_triple() -> str: """Determine the Tauri-compatible target triple for the current platform.""" machine = platform.machine().lower() system = platform.system().lower() arch_map = { "x86_64": "x86_64", "amd64": "x86_64", "aarch64": "aarch64", "arm64": "aarch64", } arch = arch_map.get(machine, machine) if system == "linux": return f"{arch}-unknown-linux-gnu" elif system == "darwin": return f"{arch}-apple-darwin" elif system == "windows": return f"{arch}-pc-windows-msvc" else: return f"{arch}-unknown-{system}" def _has_uv() -> bool: """Check if uv is available.""" try: subprocess.run(["uv", "--version"], capture_output=True, check=True) return True except (FileNotFoundError, subprocess.CalledProcessError): return False def create_venv_and_install(cpu_only: bool) -> Path: """Create a fresh venv and install dependencies. Uses uv if available (much faster), falls back to standard venv + pip. """ venv_dir = BUILD_DIR / "sidecar-venv" if venv_dir.exists(): shutil.rmtree(venv_dir) use_uv = _has_uv() if use_uv: print(f"[build] Creating venv with uv at {venv_dir}") subprocess.run( ["uv", "venv", "--python", f"{sys.version_info.major}.{sys.version_info.minor}", str(venv_dir)], check=True, ) else: print(f"[build] Creating venv at {venv_dir}") subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True) # Determine python path inside venv if sys.platform == "win32": python = str(venv_dir / "Scripts" / "python.exe") else: python = str(venv_dir / "bin" / "python") def pip_install(*args: str) -> None: """Install packages. Pass package names and flags only, not 'install'.""" if use_uv: # Use --python with the venv directory (not the python binary) for uv subprocess.run( ["uv", "pip", "install", "--python", str(venv_dir), *args], check=True, ) else: subprocess.run([python, "-m", "pip", "install", *args], check=True) if not use_uv: # Upgrade pip (uv doesn't need this) pip_install("--upgrade", "pip", "setuptools", "wheel") # Install torch (CPU-only to avoid bundling ~2GB of CUDA libs) if cpu_only: print("[build] Installing PyTorch (CPU-only)") pip_install( "torch", "torchaudio", "--index-url", "https://download.pytorch.org/whl/cpu", ) else: print("[build] Installing PyTorch (CUDA 12.6)") pip_install( "torch", "torchaudio", "--index-url", "https://download.pytorch.org/whl/cu126", ) # Install project and dev deps (includes pyinstaller) print("[build] Installing project dependencies") pip_install("-e", f"{SCRIPT_DIR}[dev]") return Path(python) def run_pyinstaller(python: Path) -> Path: """Run PyInstaller using the spec file.""" print("[build] Running PyInstaller") subprocess.run( [str(python), "-m", "PyInstaller", "--clean", "--noconfirm", str(SPEC_FILE)], cwd=str(SCRIPT_DIR), check=True, ) output_dir = DIST_DIR / "voice-to-notes-sidecar" if not output_dir.exists(): raise RuntimeError(f"PyInstaller output not found at {output_dir}") return output_dir def download_ffmpeg(output_dir: Path) -> None: """Download a static ffmpeg/ffprobe binary for the current platform.""" system = sys.platform machine = platform.machine().lower() if machine in ("amd64", "x86_64"): machine = "x86_64" elif machine in ("aarch64", "arm64"): machine = "arm64" key = f"{system}-{machine}" if system == "win32": key = f"win32-{machine}" elif system == "linux": key = f"linux-{machine}" url = FFMPEG_URLS.get(key) if not url: print(f"[build] Warning: No ffmpeg download URL for platform {key}, skipping") return print(f"[build] Downloading ffmpeg for {key}") tmp_path = output_dir / "ffmpeg_download" try: urllib.request.urlretrieve(url, str(tmp_path)) if url.endswith(".tar.xz"): # Linux static build import tarfile with tarfile.open(str(tmp_path), "r:xz") as tar: for member in tar.getmembers(): basename = os.path.basename(member.name) if basename in ("ffmpeg", "ffprobe"): member.name = basename tar.extract(member, path=str(output_dir)) dest = output_dir / basename dest.chmod(dest.stat().st_mode | stat.S_IEXEC) elif url.endswith(".zip"): with zipfile.ZipFile(str(tmp_path), "r") as zf: for name in zf.namelist(): basename = os.path.basename(name) if basename in ("ffmpeg", "ffprobe", "ffmpeg.exe", "ffprobe.exe"): data = zf.read(name) dest = output_dir / basename dest.write_bytes(data) if sys.platform != "win32": dest.chmod(dest.stat().st_mode | stat.S_IEXEC) print("[build] ffmpeg downloaded successfully") except Exception as e: print(f"[build] Warning: Failed to download ffmpeg: {e}") finally: if tmp_path.exists(): tmp_path.unlink() def rename_binary(output_dir: Path, target_triple: str) -> None: """Rename the main binary to include the target triple for Tauri.""" if sys.platform == "win32": src = output_dir / "voice-to-notes-sidecar.exe" dst = output_dir / f"voice-to-notes-sidecar-{target_triple}.exe" else: src = output_dir / "voice-to-notes-sidecar" dst = output_dir / f"voice-to-notes-sidecar-{target_triple}" if src.exists(): print(f"[build] Renaming {src.name} -> {dst.name}") src.rename(dst) else: print(f"[build] Warning: Expected binary not found at {src}") def main() -> None: parser = argparse.ArgumentParser(description="Build the Voice to Notes sidecar binary") parser.add_argument( "--cpu-only", action="store_true", default=True, help="Install CPU-only PyTorch (default: True, avoids bundling CUDA)", ) parser.add_argument( "--with-cuda", action="store_true", help="Install PyTorch with CUDA support", ) args = parser.parse_args() cpu_only = not args.with_cuda target_triple = get_target_triple() print(f"[build] Target triple: {target_triple}") print(f"[build] CPU-only: {cpu_only}") python = create_venv_and_install(cpu_only) output_dir = run_pyinstaller(python) download_ffmpeg(output_dir) print(f"\n[build] Done! Sidecar built at: {output_dir}") print(f"[build] Copy directory to src-tauri/sidecar/ for Tauri resource bundling") if __name__ == "__main__": main()