mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Fix server binary build, watchdog logging, pedalboard import, window close loop
This commit is contained in:
+80
-3
@@ -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,78 @@ 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)
|
PyInstaller.__main__.run(args)
|
||||||
|
|
||||||
|
# Restore CUDA torch if we swapped it out
|
||||||
|
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(
|
||||||
|
|||||||
+46
-9
@@ -51,7 +51,7 @@ 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)
|
||||||
|
|
||||||
def _start_parent_watchdog(parent_pid):
|
def _start_parent_watchdog(parent_pid, data_dir=None):
|
||||||
"""Monitor parent process and exit if it dies.
|
"""Monitor parent process and exit if it dies.
|
||||||
|
|
||||||
This is the clean shutdown mechanism: instead of the Tauri app trying to
|
This is the clean shutdown mechanism: instead of the Tauri app trying to
|
||||||
@@ -63,17 +63,44 @@ def _start_parent_watchdog(parent_pid):
|
|||||||
import threading
|
import threading
|
||||||
import time
|
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):
|
def _is_pid_alive(pid):
|
||||||
"""Check if a process with the given PID exists (cross-platform)."""
|
"""Check if a process with the given PID exists (cross-platform)."""
|
||||||
try:
|
try:
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
import ctypes
|
import ctypes
|
||||||
kernel32 = ctypes.windll.kernel32
|
kernel32 = ctypes.windll.kernel32
|
||||||
SYNCHRONIZE = 0x00100000
|
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||||
handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
|
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
||||||
if handle:
|
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)
|
kernel32.CloseHandle(handle)
|
||||||
return True
|
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
|
return False
|
||||||
else:
|
else:
|
||||||
os.kill(pid, 0)
|
os.kill(pid, 0)
|
||||||
@@ -82,13 +109,19 @@ def _start_parent_watchdog(parent_pid):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _watch():
|
def _watch():
|
||||||
logger.info(f"Parent watchdog started, monitoring PID {parent_pid}")
|
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:
|
while True:
|
||||||
if not _is_pid_alive(parent_pid):
|
if not _is_pid_alive(parent_pid):
|
||||||
logger.info(f"Parent process {parent_pid} no longer exists, shutting down...")
|
watchdog_logger.info(f"Parent process {parent_pid} gone, shutting down server...")
|
||||||
os.kill(os.getpid(), signal.SIGTERM)
|
os.kill(os.getpid(), signal.SIGTERM)
|
||||||
return
|
return
|
||||||
time.sleep(1)
|
time.sleep(2)
|
||||||
|
|
||||||
t = threading.Thread(target=_watch, daemon=True)
|
t = threading.Thread(target=_watch, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
@@ -139,9 +172,13 @@ 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")
|
||||||
|
|
||||||
# Start parent process watchdog if requested
|
# Register parent watchdog to start after server is fully ready
|
||||||
if args.parent_pid is not None:
|
if args.parent_pid is not None:
|
||||||
_start_parent_watchdog(args.parent_pid)
|
_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}")
|
||||||
|
|
||||||
|
|||||||
@@ -189,8 +189,10 @@ build-server: _ensure-venv
|
|||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
build-server: _ensure-venv
|
build-server: _ensure-venv
|
||||||
|
$ErrorActionPreference = "Stop"; \
|
||||||
$env:PATH = "{{ venv_bin }};$env:PATH"; \
|
$env:PATH = "{{ venv_bin }};$env:PATH"; \
|
||||||
& "{{ python }}" backend/build_binary.py; \
|
& "{{ python }}" backend/build_binary.py; \
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
|
||||||
$triple = (rustc --print host-tuple); \
|
$triple = (rustc --print host-tuple); \
|
||||||
Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \
|
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"
|
Write-Host "Copied sidecar: voicebox-server-$triple.exe"
|
||||||
|
|||||||
+13
-13
@@ -708,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
|
||||||
@@ -718,42 +726,34 @@ 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| {
|
||||||
|
|||||||
Reference in New Issue
Block a user