mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 06:40:38 -07:00
* feat(windows): add native ROCm support for AMD GPUs Implements native ROCm architecture for Windows. - Adds backend build pipeline for voicebox-server-rocm.exe - Detects AMD GPUs dynamically and routes PyTorch allocations - Adds automatic download and update logic for ROCm dependencies - Refactors UI in GpuPage.tsx and GpuAcceleration.tsx to add AMD flows - Fixes 'Switch to CPU' lock on Windows via Tauri backend_override state - Resolves PyInstaller/rocm_sdk UnboundLocalError silent crashes - Resolves Numba/NumPy 2.x incompatibilities during Qwen3-TTS load - Resolves HF_HUB_OFFLINE Catch-22 for CustomVoice processor caching * fix(rocm): host libs archive under the app release tag, drop offline-load regression Align the ROCm libs download with the CUDA pattern: both the server core and the libs archive are published under the app-version release tag, with the libs content version encoded in the filename only. The previous code fetched libs from a separate rocm7.2-v1 tag, which disagreed with the download test. Also revert the unrelated Qwen CustomVoice changes that wrapped model loading in force_offline_if_cached (not imported — a NameError on load for every platform) and re-added a Base-model cache gate. The inference-path offline guard was deliberately removed previously. * feat(rocm): gate download on AMD detection and persist the backend variant The ROCm download section now only shows when the backend reports an AMD GPU on Windows (new supports_rocm health field, backed by the memoized is_amd_gpu_windows detection that was previously unused), or when ROCm is already downloaded/active. Make the backend override honor a pinned variant: set_backend_override persists the choice to disk so it survives an app restart, start_server reads it back, and a cuda/rocm pin now actually selects that variant instead of always preferring ROCm. A stale pin to a deleted backend self-heals to the default order rather than forcing CPU. Add the web no-op stub for the new method. * chore(rocm): drop incomplete vitest harness for the unused GpuAcceleration component GpuAcceleration.tsx is not routed anywhere (GpuPage is the live settings view), and the added vitest setup referenced testing-library/vitest deps that were not in the lockfile, breaking the web typecheck. Remove the dead component's test and its scaffolding to keep this PR scoped to the ROCm feature. * ci(rocm): add ROCm release-artifact pipeline Mirror the CUDA packaging path for ROCm so the runtime download has artifacts to fetch. scripts/package_rocm.py splits the PyInstaller --rocm onedir into voicebox-server-rocm.tar.gz (core) + rocm-libs-rocm7.2-v1.tar.gz (AMD runtime: HIP DLLs, rocBLAS Tensile data, MIOpen kernel DBs) + rocm-libs.json, matching the names services/rocm.py expects, both under the app-version release tag. The new build-rocm-windows job in release.yml builds on windows-latest/cp312 and lets build_binary.py --rocm pull the official AMD Radeon wheels. The file classifier can't be validated against a real AMD build on CI, so it has unit coverage (test_package_rocm.py) against a synthetic onedir layout. The prefixes/dir markers may need a tweak after the first real build on AMD hardware — the packager hard-fails loudly if it classifies zero ROCm files. --------- Co-authored-by: Jamie Pine <[email protected]>
This commit is contained in:
co-authored by
Jamie Pine
parent
c2282b256a
commit
e766c7cbfb
+188
-39
@@ -200,6 +200,63 @@ struct ServerState {
|
||||
server_pid: Mutex<Option<u32>>,
|
||||
keep_running_on_close: Mutex<bool>,
|
||||
models_dir: Mutex<Option<String>>,
|
||||
/// Override the backend selection: Some("cpu") forces the CPU sidecar even
|
||||
/// when GPU binaries exist (solving the Windows catch-22 where an active
|
||||
/// .exe cannot be deleted), while Some("cuda")/Some("rocm") pin a specific
|
||||
/// GPU variant when more than one is installed. None uses the on-disk
|
||||
/// default (ROCm preferred, then CUDA). Persisted to disk so the choice
|
||||
/// survives an app restart.
|
||||
backend_override: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
fn backend_override_file(data_dir: &std::path::Path) -> std::path::PathBuf {
|
||||
data_dir.join("backend_override")
|
||||
}
|
||||
|
||||
fn read_persisted_backend_override(data_dir: &std::path::Path) -> Option<String> {
|
||||
std::fs::read_to_string(backend_override_file(data_dir))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn write_persisted_backend_override(data_dir: &std::path::Path, value: Option<&str>) {
|
||||
let path = backend_override_file(data_dir);
|
||||
match value {
|
||||
Some(v) => {
|
||||
let _ = std::fs::create_dir_all(data_dir);
|
||||
if let Err(e) = std::fs::write(&path, v) {
|
||||
println!("Failed to persist backend override: {}", e);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `<exe> --version` with a 10-second timeout to avoid hanging Tauri startup.
|
||||
/// Returns the last whitespace-delimited token from stdout (e.g. "0.4.4"), or None on any failure.
|
||||
async fn probe_binary_version(exe: &std::path::Path, cwd: &std::path::Path) -> Option<String> {
|
||||
let mut cmd = tokio::process::Command::new(exe);
|
||||
cmd.arg("--version")
|
||||
.current_dir(cwd)
|
||||
.kill_on_drop(true);
|
||||
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
|
||||
Ok(Ok(output)) => {
|
||||
let s = String::from_utf8_lossy(&output.stdout);
|
||||
s.trim().split_whitespace().last().map(String::from)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
println!("Version probe failed: {}", e);
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
println!("Version probe timed out after 10s");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
@@ -360,6 +417,45 @@ async fn start_server(
|
||||
println!("Data directory: {:?}", data_dir);
|
||||
println!("Remote mode: {}", remote.unwrap_or(false));
|
||||
|
||||
// Check for ROCm backend in data directory (onedir layout: backends/rocm/)
|
||||
let rocm_binary = {
|
||||
let rocm_dir = data_dir.join("backends").join("rocm");
|
||||
let rocm_name = if cfg!(windows) {
|
||||
"voicebox-server-rocm.exe"
|
||||
} else {
|
||||
"voicebox-server-rocm"
|
||||
};
|
||||
let exe_path = rocm_dir.join(rocm_name);
|
||||
if exe_path.exists() {
|
||||
println!("Found ROCm backend at {:?}", rocm_dir);
|
||||
|
||||
let app_version = app.config().version.clone().unwrap_or_default();
|
||||
let binary_version = probe_binary_version(&exe_path, &rocm_dir).await;
|
||||
let version_ok = if !app_version.is_empty()
|
||||
&& binary_version.as_deref() == Some(app_version.as_str())
|
||||
{
|
||||
println!("ROCm binary version {} matches app version", app_version);
|
||||
true
|
||||
} else {
|
||||
println!(
|
||||
"ROCm binary version mismatch: binary={}, app={}. Falling back to CPU.",
|
||||
binary_version.as_deref().unwrap_or("<unknown>"),
|
||||
app_version
|
||||
);
|
||||
false
|
||||
};
|
||||
|
||||
if version_ok {
|
||||
Some(exe_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
println!("No ROCm backend found");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Check for CUDA backend in data directory (onedir layout: backends/cuda/)
|
||||
let cuda_binary = {
|
||||
let cuda_dir = data_dir.join("backends").join("cuda");
|
||||
@@ -375,30 +471,19 @@ async fn start_server(
|
||||
// Version check: run --version from the onedir directory so
|
||||
// PyInstaller can find its support files for the fast --version path
|
||||
let app_version = app.config().version.clone().unwrap_or_default();
|
||||
let version_ok = match std::process::Command::new(&exe_path)
|
||||
.arg("--version")
|
||||
.current_dir(&cuda_dir)
|
||||
.output()
|
||||
let binary_version = probe_binary_version(&exe_path, &cuda_dir).await;
|
||||
let version_ok = if !app_version.is_empty()
|
||||
&& binary_version.as_deref() == Some(app_version.as_str())
|
||||
{
|
||||
Ok(output) => {
|
||||
// Output format: "voicebox-server X.Y.Z\n"
|
||||
let version_str = String::from_utf8_lossy(&output.stdout);
|
||||
let binary_version = version_str.trim().split_whitespace().last().unwrap_or("");
|
||||
if binary_version == app_version {
|
||||
println!("CUDA binary version {} matches app version", binary_version);
|
||||
true
|
||||
} else {
|
||||
println!(
|
||||
"CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.",
|
||||
binary_version, app_version
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Failed to check CUDA binary version: {}. Falling back to CPU.", e);
|
||||
false
|
||||
}
|
||||
println!("CUDA binary version {} matches app version", app_version);
|
||||
true
|
||||
} else {
|
||||
println!(
|
||||
"CUDA binary version mismatch: binary={}, app={}. Falling back to CPU.",
|
||||
binary_version.as_deref().unwrap_or("<unknown>"),
|
||||
app_version
|
||||
);
|
||||
false
|
||||
};
|
||||
|
||||
if version_ok {
|
||||
@@ -465,24 +550,74 @@ async fn start_server(
|
||||
println!("Custom models directory: {}", dir);
|
||||
}
|
||||
|
||||
// Respect backend override (e.g., user wants CPU even though a GPU binary
|
||||
// exists, or pinned a specific GPU variant). The in-memory value resets to
|
||||
// None on app launch, so fall back to the persisted choice on disk.
|
||||
let backend_override = {
|
||||
let in_memory = state.backend_override.lock().unwrap().clone();
|
||||
in_memory.or_else(|| read_persisted_backend_override(&data_dir))
|
||||
};
|
||||
|
||||
// Honor a pinned GPU variant by ignoring the other one — but only when the
|
||||
// pinned variant is actually installed, so a stale pin to a deleted backend
|
||||
// self-heals to the default order instead of forcing CPU. With no pin, both
|
||||
// stay eligible and the launch order below prefers ROCm, then CUDA.
|
||||
let pin = backend_override.as_deref();
|
||||
let pin_cuda = pin == Some("cuda") && cuda_binary.is_some();
|
||||
let pin_rocm = pin == Some("rocm") && rocm_binary.is_some();
|
||||
let rocm_binary = if pin_cuda { None } else { rocm_binary };
|
||||
let cuda_binary = if pin_rocm { None } else { cuda_binary };
|
||||
|
||||
// If ROCm binary exists, launch it from the onedir directory.
|
||||
// If CUDA binary exists, launch it from the onedir directory.
|
||||
// .current_dir() is critical: PyInstaller onedir expects all DLLs and
|
||||
// support files (nvidia/, _internal/, etc.) relative to the exe.
|
||||
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
||||
let cuda_dir = cuda_path.parent().unwrap();
|
||||
println!("Launching CUDA backend: {:?} (cwd: {:?})", cuda_path, cuda_dir);
|
||||
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
|
||||
cmd = cmd.current_dir(cuda_dir);
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote {
|
||||
cmd = cmd.args(["--host", "0.0.0.0"]);
|
||||
// support files relative to the exe.
|
||||
let spawn_result = if backend_override.as_deref() != Some("cpu") {
|
||||
let mut gpu_spawn = None;
|
||||
|
||||
if let Some(ref rocm_path) = rocm_binary {
|
||||
let rocm_dir = rocm_path.parent().unwrap();
|
||||
println!("Launching ROCm backend: {:?} (cwd: {:?})", rocm_path, rocm_dir);
|
||||
let mut cmd = app.shell().command(rocm_path.to_str().unwrap());
|
||||
cmd = cmd.current_dir(rocm_dir);
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote { cmd = cmd.args(["--host", "0.0.0.0"]); }
|
||||
if let Some(ref dir) = effective_models_dir { cmd = cmd.env("VOICEBOX_MODELS_DIR", dir); }
|
||||
match cmd.spawn() {
|
||||
Ok(r) => { gpu_spawn = Some(Ok(r)); }
|
||||
Err(e) => { println!("ROCm spawn failed ({}), trying CUDA/CPU fallback", e); }
|
||||
}
|
||||
}
|
||||
if let Some(ref dir) = effective_models_dir {
|
||||
cmd = cmd.env("VOICEBOX_MODELS_DIR", dir);
|
||||
|
||||
if gpu_spawn.is_none() {
|
||||
if let Some(ref cuda_path) = cuda_binary {
|
||||
let cuda_dir = cuda_path.parent().unwrap();
|
||||
println!("Launching CUDA backend: {:?} (cwd: {:?})", cuda_path, cuda_dir);
|
||||
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
|
||||
cmd = cmd.current_dir(cuda_dir);
|
||||
cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote { cmd = cmd.args(["--host", "0.0.0.0"]); }
|
||||
if let Some(ref dir) = effective_models_dir { cmd = cmd.env("VOICEBOX_MODELS_DIR", dir); }
|
||||
match cmd.spawn() {
|
||||
Ok(r) => { gpu_spawn = Some(Ok(r)); }
|
||||
Err(e) => { println!("CUDA spawn failed ({}), falling back to CPU", e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(result) = gpu_spawn {
|
||||
result
|
||||
} else {
|
||||
// Fall back to bundled CPU sidecar
|
||||
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote { sidecar = sidecar.args(["--host", "0.0.0.0"]); }
|
||||
if let Some(ref dir) = effective_models_dir { sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir); }
|
||||
println!("Spawning bundled CPU server process...");
|
||||
sidecar.spawn()
|
||||
}
|
||||
cmd.spawn()
|
||||
} else {
|
||||
// Use the bundled CPU sidecar
|
||||
// Override forces CPU — use bundled sidecar, GPU binary stays on disk
|
||||
println!("Backend override=cpu: using bundled CPU sidecar");
|
||||
sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]);
|
||||
if is_remote {
|
||||
sidecar = sidecar.args(["--host", "0.0.0.0"]);
|
||||
@@ -490,7 +625,6 @@ async fn start_server(
|
||||
if let Some(ref dir) = effective_models_dir {
|
||||
sidecar = sidecar.env("VOICEBOX_MODELS_DIR", dir);
|
||||
}
|
||||
println!("Spawning server process...");
|
||||
sidecar.spawn()
|
||||
};
|
||||
|
||||
@@ -762,9 +896,9 @@ async fn restart_server(
|
||||
println!("restart_server: waiting for port release...");
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
// Start server again (will auto-detect CUDA binary and use stored models_dir)
|
||||
// Start server again (will auto-detect GPU binary and use stored models_dir)
|
||||
println!("restart_server: starting server...");
|
||||
start_server(app, state, None, None).await
|
||||
start_server(app, state.clone(), None, None).await
|
||||
}
|
||||
|
||||
#[command]
|
||||
@@ -773,6 +907,19 @@ fn set_keep_server_running(state: State<'_, ServerState>, keep_running: bool) {
|
||||
*state.keep_running_on_close.lock().unwrap() = keep_running;
|
||||
}
|
||||
|
||||
#[command]
|
||||
fn set_backend_override(
|
||||
app: tauri::AppHandle,
|
||||
state: State<'_, ServerState>,
|
||||
backend: Option<String>,
|
||||
) {
|
||||
println!("set_backend_override called with: {:?}", backend);
|
||||
if let Ok(data_dir) = app.path().app_data_dir() {
|
||||
write_persisted_backend_override(&data_dir, backend.as_deref());
|
||||
}
|
||||
*state.backend_override.lock().unwrap() = backend;
|
||||
}
|
||||
|
||||
#[command]
|
||||
async fn start_system_audio_capture(
|
||||
state: State<'_, audio_capture::AudioCaptureState>,
|
||||
@@ -1239,6 +1386,7 @@ pub fn run() {
|
||||
server_pid: Mutex::new(None),
|
||||
keep_running_on_close: Mutex::new(false),
|
||||
models_dir: Mutex::new(None),
|
||||
backend_override: Mutex::new(None),
|
||||
})
|
||||
.manage(audio_capture::AudioCaptureState::new())
|
||||
.manage(audio_output::AudioOutputState::new())
|
||||
@@ -1357,6 +1505,7 @@ pub fn run() {
|
||||
stop_server,
|
||||
restart_server,
|
||||
set_keep_server_running,
|
||||
set_backend_override,
|
||||
start_system_audio_capture,
|
||||
stop_system_audio_capture,
|
||||
is_system_audio_supported,
|
||||
|
||||
Reference in New Issue
Block a user