2026-03-20 21:33:43 -07:00
|
|
|
#!/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}"
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 22:58:30 -07:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 21:33:43 -07:00
|
|
|
def create_venv_and_install(cpu_only: bool) -> Path:
|
2026-03-20 22:58:30 -07:00
|
|
|
"""Create a fresh venv and install dependencies.
|
|
|
|
|
|
|
|
|
|
Uses uv if available (much faster), falls back to standard venv + pip.
|
|
|
|
|
"""
|
2026-03-20 21:33:43 -07:00
|
|
|
venv_dir = BUILD_DIR / "sidecar-venv"
|
|
|
|
|
if venv_dir.exists():
|
|
|
|
|
shutil.rmtree(venv_dir)
|
|
|
|
|
|
2026-03-20 22:58:30 -07:00
|
|
|
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)
|
2026-03-20 21:33:43 -07:00
|
|
|
|
2026-03-20 22:58:30 -07:00
|
|
|
# Determine python path inside venv
|
2026-03-20 21:33:43 -07:00
|
|
|
if sys.platform == "win32":
|
2026-03-20 23:05:23 -07:00
|
|
|
python = str(venv_dir / "Scripts" / "python.exe")
|
2026-03-20 21:33:43 -07:00
|
|
|
else:
|
|
|
|
|
python = str(venv_dir / "bin" / "python")
|
|
|
|
|
|
2026-03-20 23:03:23 -07:00
|
|
|
def pip_install(*args: str) -> None:
|
|
|
|
|
"""Install packages. Pass package names and flags only, not 'install'."""
|
2026-03-20 22:58:30 -07:00
|
|
|
if use_uv:
|
2026-03-20 23:05:23 -07:00
|
|
|
# Use --python with the venv directory (not the python binary) for uv
|
|
|
|
|
subprocess.run(
|
|
|
|
|
["uv", "pip", "install", "--python", str(venv_dir), *args],
|
|
|
|
|
check=True,
|
|
|
|
|
)
|
2026-03-20 22:58:30 -07:00
|
|
|
else:
|
2026-03-20 23:03:23 -07:00
|
|
|
subprocess.run([python, "-m", "pip", "install", *args], check=True)
|
2026-03-20 21:56:25 -07:00
|
|
|
|
2026-03-20 22:58:30 -07:00
|
|
|
if not use_uv:
|
|
|
|
|
# Upgrade pip (uv doesn't need this)
|
2026-03-20 23:03:23 -07:00
|
|
|
pip_install("--upgrade", "pip", "setuptools", "wheel")
|
2026-03-20 21:33:43 -07:00
|
|
|
|
|
|
|
|
# Install torch (CPU-only to avoid bundling ~2GB of CUDA libs)
|
|
|
|
|
if cpu_only:
|
|
|
|
|
print("[build] Installing PyTorch (CPU-only)")
|
2026-03-20 23:03:23 -07:00
|
|
|
pip_install(
|
|
|
|
|
"torch", "torchaudio",
|
2026-03-20 21:56:25 -07:00
|
|
|
"--index-url", "https://download.pytorch.org/whl/cpu",
|
2026-03-20 21:33:43 -07:00
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
print("[build] Installing PyTorch (default, may include CUDA)")
|
2026-03-20 23:03:23 -07:00
|
|
|
pip_install("torch", "torchaudio")
|
2026-03-20 21:33:43 -07:00
|
|
|
|
|
|
|
|
# Install project and dev deps (includes pyinstaller)
|
|
|
|
|
print("[build] Installing project dependencies")
|
2026-03-20 23:03:23 -07:00
|
|
|
pip_install("-e", f"{SCRIPT_DIR}[dev]")
|
2026-03-20 21:33:43 -07:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
rename_binary(output_dir, target_triple)
|
|
|
|
|
|
|
|
|
|
print(f"\n[build] Done! Sidecar built at: {output_dir}")
|
|
|
|
|
print(f"[build] Copy contents to src-tauri/binaries/ for Tauri bundling")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|