feat(windows): Native AMD ROCm GPU Acceleration (Resolves #531) (#538)

* 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:
Juan C Muñoz P
2026-06-30 15:43:18 -07:00
committed by GitHub
co-authored by Jamie Pine
parent c2282b256a
commit e766c7cbfb
32 changed files with 2967 additions and 309 deletions
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress } from '@/lib/api/types';
import type { CudaDownloadProgress, RocmDownloadProgress } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -21,6 +21,9 @@ export function GpuAcceleration() {
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
null,
);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Query CUDA backend status
@@ -36,10 +39,26 @@ export function GpuAcceleration() {
enabled: !!health, // Only fetch when backend is reachable
});
// Query ROCm backend status
const {
data: rocmStatus,
isLoading: _rocmStatusLoading,
refetch: refetchRocmStatus,
} = useQuery({
queryKey: ['rocm-status', serverUrl],
queryFn: () => apiClient.getRocmStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health, // Only fetch when backend is reachable
});
// Derived state
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
const rocmAvailable = rocmStatus?.available ?? false;
const rocmDownloading = rocmStatus?.downloading ?? false;
// Clean up health poll on unmount
useEffect(() => {
@@ -51,7 +70,7 @@ export function GpuAcceleration() {
};
}, []);
// SSE progress tracking during download
// SSE progress tracking during CUDA download
useEffect(() => {
if (!cudaDownloading || !serverUrl) {
return;
@@ -88,6 +107,43 @@ export function GpuAcceleration() {
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
// SSE progress tracking during ROCm download
useEffect(() => {
if (!rocmDownloading || !serverUrl) {
return;
}
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RocmDownloadProgress;
setRocmDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setRocmDownloadProgress(null);
refetchRocmStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || 'Download failed');
setRocmDownloadProgress(null);
refetchRocmStatus();
}
} catch (e) {
console.error('Error parsing ROCm progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
};
return () => {
eventSource.close();
};
}, [rocmDownloading, serverUrl, refetchRocmStatus]);
// Start aggressive health polling during restart
const startHealthPolling = useCallback(() => {
if (healthPollRef.current) return;
@@ -113,7 +169,7 @@ export function GpuAcceleration() {
}, 1000);
}, [queryClient]);
const handleDownload = async () => {
const handleDownloadCuda = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
@@ -128,6 +184,21 @@ export function GpuAcceleration() {
}
};
const handleDownloadRocm = async () => {
setError(null);
try {
await apiClient.downloadRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Failed to start download';
if (msg.includes('already downloaded')) {
refetchRocmStatus();
} else {
setError(msg);
}
}
};
const handleRestart = async () => {
setError(null);
setRestartPhase('stopping');
@@ -154,18 +225,17 @@ export function GpuAcceleration() {
}
};
const handleSwitchToCpu = async () => {
// To switch to CPU: delete the CUDA binary, then restart.
// start_server always prefers CUDA if present, so we must remove it first.
const handleSwitchToCpuFromCuda = async () => {
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
// Tell Rust launcher to skip GPU binary detection on next start.
// We cannot delete an active .exe on Windows, so we override instead.
await platform.lifecycle.setBackendOverride('cpu');
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
// Invoke resolved — server is likely ready
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
@@ -184,7 +254,36 @@ export function GpuAcceleration() {
}
};
const handleDelete = async () => {
const handleSwitchToCpuFromRocm = async () => {
setError(null);
setRestartPhase('stopping');
try {
// Tell Rust launcher to skip GPU binary detection on next start.
// We cannot delete an active .exe on Windows, so we override instead.
await platform.lifecycle.setBackendOverride('cpu');
setRestartPhase('waiting');
startHealthPolling();
await platform.lifecycle.restartServer();
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setRestartPhase('ready');
queryClient.invalidateQueries();
setTimeout(() => setRestartPhase('idle'), 2000);
} catch (e: unknown) {
setRestartPhase('idle');
if (healthPollRef.current) {
clearInterval(healthPollRef.current);
healthPollRef.current = null;
}
setError(e instanceof Error ? e.message : 'Failed to switch to CPU');
refetchRocmStatus();
}
};
const handleDeleteCuda = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
@@ -194,6 +293,16 @@ export function GpuAcceleration() {
}
};
const handleDeleteRocm = async () => {
setError(null);
try {
await apiClient.deleteRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to delete ROCm backend');
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -205,7 +314,7 @@ export function GpuAcceleration() {
// Don't render until health data is available
if (!health) return null;
// If the system already has native GPU (MPS, etc.), only show info - no CUDA needed
// If the system already has native GPU (MPS, ROCm active, etc.), only show info - no download needed
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
@@ -241,8 +350,6 @@ export function GpuAcceleration() {
)}
</div>
{/* Native GPU detected - no CUDA download needed */}
{/* Currently running CUDA - show switch back to CPU */}
{isCurrentlyCuda && platform.metadata.isTauri && (
<>
@@ -261,7 +368,12 @@ export function GpuAcceleration() {
Running with CUDA GPU acceleration. Switch back to CPU if needed (you can
re-download later).
</p>
<Button onClick={handleSwitchToCpu} variant="outline" className="w-full" size="sm">
<Button
onClick={handleSwitchToCpuFromCuda}
variant="outline"
className="w-full"
size="sm"
>
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
@@ -276,39 +388,207 @@ export function GpuAcceleration() {
</>
)}
{/* CUDA download/manage section - show when no native GPU and not currently running CUDA */}
{!hasNativeGpu && !isCurrentlyCuda && (
{/* Currently running ROCm - show switch back to CPU */}
{isCurrentlyRocm && platform.metadata.isTauri && (
<>
{/* Download progress (manual download or auto-update) */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
)}
{restartPhase !== 'idle' ? (
<div className="flex items-center gap-2 p-3 rounded-lg bg-primary/5 border">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">
{restartPhase === 'stopping' && 'Stopping server...'}
{restartPhase === 'waiting' && 'Restarting server...'}
{restartPhase === 'ready' && 'Server restarted successfully!'}
</span>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Running with ROCm GPU acceleration for AMD. Switch back to CPU if needed (you can
re-download later).
</p>
<Button
onClick={handleSwitchToCpuFromRocm}
variant="outline"
className="w-full"
size="sm"
>
<RotateCw className="h-4 w-4 mr-2" />
Switch to CPU Backend
</Button>
</div>
)}
{error && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
</>
)}
{/* Backend download/manage sections - show when no native GPU and not currently running GPU */}
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
{/* CUDA Section */}
<div className="space-y-4">
<div className="text-sm font-medium">NVIDIA (CUDA)</div>
{/* CUDA Download progress */}
{cudaDownloading && downloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{downloadProgress.filename ||
(cudaAvailable
? 'Updating CUDA backend...'
: 'Downloading CUDA backend...')}
</span>
</div>
{downloadProgress.total > 0 && (
<span className="text-muted-foreground">
{downloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{downloadProgress.total > 0 && (
<>
<Progress value={downloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(downloadProgress.current)} /{' '}
{formatBytes(downloadProgress.total)}
</div>
</>
)}
</div>
)}
{/* CUDA Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownloadCuda} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{cudaAvailable && (
<Button
onClick={handleDeleteCuda}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</div>
{/* Divider */}
<div className="border-t" />
{/* ROCm Section */}
<div className="space-y-4">
<div className="text-sm font-medium">AMD (ROCm)</div>
{/* ROCm Download progress */}
{rocmDownloading && rocmDownloadProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>
{rocmDownloadProgress.filename ||
(rocmAvailable
? 'Updating ROCm backend...'
: 'Downloading ROCm backend...')}
</span>
</div>
{rocmDownloadProgress.total > 0 && (
<span className="text-muted-foreground">
{rocmDownloadProgress.progress.toFixed(1)}%
</span>
)}
</div>
{rocmDownloadProgress.total > 0 && (
<>
<Progress value={rocmDownloadProgress.progress} className="h-2" />
<div className="text-xs text-muted-foreground">
{formatBytes(rocmDownloadProgress.current)} /{' '}
{formatBytes(rocmDownloadProgress.total)}
</div>
</>
)}
</div>
)}
{/* ROCm Actions */}
{restartPhase === 'idle' && !rocmDownloading && (
<div className="space-y-2">
{!rocmAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the ROCm backend (~2-3 GB) for AMD GPU acceleration. Requires an
AMD Radeon GPU with ROCm support.
</p>
<Button onClick={handleDownloadRocm} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download AMD ROCm Backend
</Button>
</div>
)}
{rocmAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
ROCm backend is downloaded and ready. Restart the server to enable AMD GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to ROCm Backend
</Button>
</div>
)}
{rocmAvailable && (
<Button
onClick={handleDeleteRocm}
variant="ghost"
className="w-full text-muted-foreground hover:text-destructive"
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove ROCm Backend
</Button>
)}
</div>
)}
</div>
{/* Restart in progress */}
{restartPhase !== 'idle' && (
@@ -329,52 +609,6 @@ export function GpuAcceleration() {
<span>{error}</span>
</div>
)}
{/* Actions */}
{restartPhase === 'idle' && !cudaDownloading && (
<div className="space-y-2">
{/* Not downloaded yet - show download button */}
{!cudaAvailable && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download the CUDA backend (~2.4 GB) for NVIDIA GPU acceleration. Requires an
NVIDIA GPU with CUDA support.
</p>
<Button onClick={handleDownload} className="w-full" size="sm">
<Download className="h-4 w-4 mr-2" />
Download CUDA Backend
</Button>
</div>
)}
{/* Downloaded but not active - show switch button */}
{cudaAvailable && platform.metadata.isTauri && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
CUDA backend is downloaded and ready. Restart the server to enable GPU
acceleration.
</p>
<Button onClick={handleRestart} className="w-full" size="sm">
<RotateCw className="h-4 w-4 mr-2" />
Switch to CUDA Backend
</Button>
</div>
)}
{/* Delete option when downloaded (and not active) */}
{cudaAvailable && (
<Button
onClick={handleDelete}
variant="ghost"
className="w-full text-muted-foreground "
size="sm"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove CUDA Backend
</Button>
)}
</div>
)}
</>
)}
</CardContent>
+316 -99
View File
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { apiClient } from '@/lib/api/client';
import type { CudaDownloadProgress, HealthResponse } from '@/lib/api/types';
import type { CudaDownloadProgress, RocmDownloadProgress, HealthResponse } from '@/lib/api/types';
import { useServerHealth } from '@/lib/hooks/useServer';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerStore } from '@/stores/serverStore';
@@ -50,7 +50,10 @@ function GpuInfoCard({ health }: { health: HealthResponse }) {
: null;
const gpuBackend = hasGpu ? health.gpu_type!.replace(/\s*\(.+\)$/, '') : null;
const isApple = gpuBackend === 'MPS' || gpuBackend === 'Metal';
const showBackendVariant = health.backend_variant && health.backend_variant !== 'cpu';
const showBackendVariant =
health.backend_variant &&
health.backend_variant !== 'cpu' &&
health.backend_variant.toLowerCase() !== gpuBackend?.toLowerCase();
return (
<div className="rounded-lg border border-border/60 p-4">
@@ -115,10 +118,14 @@ export function GpuPage() {
const [restartPhase, setRestartPhase] = useState<RestartPhase>('idle');
const [error, setError] = useState<string | null>(null);
const [cudaStreaming, setCudaStreaming] = useState(false);
const [rocmStreaming, setRocmStreaming] = useState(false);
const [downloadProgress, setDownloadProgress] = useState<CudaDownloadProgress | null>(null);
const [rocmDownloadProgress, setRocmDownloadProgress] = useState<RocmDownloadProgress | null>(
null,
);
const healthPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Hold the latest `t` in a ref so the CUDA progress SSE effect below doesn't
// tear down and reconnect the EventSource every time the language changes.
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
@@ -136,9 +143,27 @@ export function GpuPage() {
enabled: !!health,
});
const {
data: rocmStatus,
isLoading: _rocmStatusLoading,
refetch: refetchRocmStatus,
} = useQuery({
queryKey: ['rocm-status', serverUrl],
queryFn: () => apiClient.getRocmStatus(),
refetchInterval: (query) => (query.state.status === 'pending' ? false : 10000),
retry: 1,
enabled: !!health,
});
const isCurrentlyCuda = health?.backend_variant === 'cuda';
const isCurrentlyRocm = health?.backend_variant === 'rocm';
const cudaAvailable = cudaStatus?.available ?? false;
const cudaDownloading = cudaStatus?.downloading ?? false;
const rocmAvailable = rocmStatus?.available ?? false;
const rocmDownloading = rocmStatus?.downloading ?? false;
// The ROCm backend only applies to AMD GPUs on Windows. Show the section when
// the backend detects applicable hardware, or it is already downloaded/active.
const supportsRocm = (health?.supports_rocm ?? false) || rocmAvailable || isCurrentlyRocm;
useEffect(() => {
return () => {
@@ -150,7 +175,7 @@ export function GpuPage() {
}, []);
useEffect(() => {
if (!cudaDownloading || !serverUrl) return;
if ((!cudaDownloading && !cudaStreaming) || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/cuda-progress`);
@@ -162,11 +187,13 @@ export function GpuPage() {
if (data.status === 'complete') {
eventSource.close();
setDownloadProgress(null);
setCudaStreaming(false);
refetchCudaStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setDownloadProgress(null);
setCudaStreaming(false);
refetchCudaStatus();
}
} catch (e) {
@@ -176,12 +203,50 @@ export function GpuPage() {
eventSource.onerror = () => {
eventSource.close();
setCudaStreaming(false);
};
return () => {
eventSource.close();
};
}, [cudaDownloading, serverUrl, refetchCudaStatus]);
}, [cudaDownloading, cudaStreaming, serverUrl, refetchCudaStatus]);
useEffect(() => {
if ((!rocmDownloading && !rocmStreaming) || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/backend/rocm-progress`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as RocmDownloadProgress;
setRocmDownloadProgress(data);
if (data.status === 'complete') {
eventSource.close();
setRocmDownloadProgress(null);
setRocmStreaming(false);
refetchRocmStatus();
} else if (data.status === 'error') {
eventSource.close();
setError(data.error || tRef.current('settings.gpu.errors.downloadFailed'));
setRocmDownloadProgress(null);
setRocmStreaming(false);
refetchRocmStatus();
}
} catch (e) {
console.error('Error parsing ROCm progress event:', e);
}
};
eventSource.onerror = () => {
eventSource.close();
setRocmStreaming(false);
};
return () => {
eventSource.close();
};
}, [rocmDownloading, rocmStreaming, serverUrl, refetchRocmStatus]);
const clearHealthPolling = useCallback(() => {
if (healthPollRef.current) {
@@ -224,10 +289,11 @@ export function GpuPage() {
[platform, startHealthPolling, clearHealthPolling],
);
const handleDownload = async () => {
const handleDownloadCuda = async () => {
setError(null);
try {
await apiClient.downloadCudaBackend();
setCudaStreaming(true);
refetchCudaStatus();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
@@ -239,28 +305,64 @@ export function GpuPage() {
}
};
const handleRestart = async () => {
const handleDownloadRocm = async () => {
setError(null);
try {
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
await apiClient.downloadRocmBackend();
setRocmStreaming(true);
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
const msg = e instanceof Error ? e.message : t('settings.gpu.errors.downloadStart');
if (msg.includes('already downloaded')) {
refetchRocmStatus();
} else {
setError(msg);
}
}
};
const handleSwitchToCpu = async () => {
setError(null);
setRestartPhase('stopping');
try {
await apiClient.deleteCudaBackend();
await platform.lifecycle.setBackendOverride('cpu');
await restartServerWithPolling(t('settings.gpu.errors.switchCpu'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.switchCpu'));
refetchCudaStatus();
refetchRocmStatus();
}
};
const handleSwitchToCuda = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('cuda');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
refetchCudaStatus();
}
};
const handleDelete = async () => {
const handleSwitchToRocm = async () => {
setError(null);
setRestartPhase('stopping');
try {
await platform.lifecycle.setBackendOverride('rocm');
await restartServerWithPolling(t('settings.gpu.errors.restartFailed'));
} catch (e: unknown) {
setRestartPhase('idle');
setError(e instanceof Error ? e.message : t('settings.gpu.errors.restartFailed'));
refetchRocmStatus();
}
};
const handleDeleteCuda = async () => {
setError(null);
try {
await apiClient.deleteCudaBackend();
@@ -270,6 +372,16 @@ export function GpuPage() {
}
};
const handleDeleteRocm = async () => {
setError(null);
try {
await apiClient.deleteRocmBackend();
refetchRocmStatus();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('settings.gpu.errors.deleteRocm'));
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -283,6 +395,7 @@ export function GpuPage() {
const hasNativeGpu =
health.gpu_available &&
!isCurrentlyCuda &&
!isCurrentlyRocm &&
health.gpu_type &&
!health.gpu_type.includes('CUDA');
@@ -290,33 +403,188 @@ export function GpuPage() {
<div className="space-y-8 max-w-2xl">
<GpuInfoCard health={health} />
{!hasNativeGpu && !isCurrentlyCuda && (
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{cudaDownloading && downloadProgress && (
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
{!hasNativeGpu && !isCurrentlyCuda && !isCurrentlyRocm && (
<>
<SettingSection
title={t('settings.gpu.cuda.title')}
description={t('settings.gpu.cuda.description')}
>
{cudaDownloading && downloadProgress && (
<SettingRow title={t('settings.gpu.cuda.downloading')}>
<div className="space-y-1.5">
<Progress value={downloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{downloadProgress.filename ||
(cudaAvailable
? t('settings.gpu.cuda.updating')
: t('settings.gpu.cuda.downloadingShort'))}
</span>
<span>
{downloadProgress.total > 0
? `${formatBytes(downloadProgress.current)} / ${formatBytes(downloadProgress.total)}`
: `${downloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</div>
</SettingRow>
)}
</SettingRow>
)}
{restartPhase !== 'idle' && (
{restartPhase !== 'idle' && (
<SettingRow
title={
restartPhase === 'ready'
? t('settings.gpu.restart.ready')
: restartPhase === 'waiting'
? t('settings.gpu.restart.waiting')
: t('settings.gpu.restart.stopping')
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
)}
{error && (
<SettingRow title={t('common.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownloadCuda} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleSwitchToCuda} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDeleteCuda}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
{supportsRocm && (
<SettingSection
title={t('settings.gpu.rocm.title')}
description={t('settings.gpu.rocm.description')}
>
{rocmDownloading && rocmDownloadProgress && (
<SettingRow title={t('settings.gpu.rocm.downloading')}>
<div className="space-y-1.5">
<Progress value={rocmDownloadProgress.progress} className="h-2" />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{rocmDownloadProgress.filename ||
(rocmAvailable
? t('settings.gpu.rocm.updating')
: t('settings.gpu.rocm.downloadingShort'))}
</span>
<span>
{rocmDownloadProgress.total > 0
? `${formatBytes(rocmDownloadProgress.current)} / ${formatBytes(rocmDownloadProgress.total)}`
: `${rocmDownloadProgress.progress.toFixed(1)}%`}
</span>
</div>
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !rocmDownloading && (
<>
{!rocmAvailable && !isCurrentlyRocm && (
<SettingRow
title={t('settings.gpu.downloadRocm.title')}
description={t('settings.gpu.downloadRocm.description')}
action={
<Button onClick={handleDownloadRocm} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.downloadRocm.button')}
</Button>
}
/>
)}
{rocmAvailable && !isCurrentlyRocm && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToRocm.title')}
description={t('settings.gpu.switchToRocm.description')}
action={
<Button onClick={handleSwitchToRocm} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToRocm.button')}
</Button>
}
/>
)}
{rocmAvailable && !isCurrentlyRocm && (
<SettingRow
title={t('settings.gpu.removeRocm.title')}
description={t('settings.gpu.removeRocm.description')}
action={
<Button
onClick={handleDeleteRocm}
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.removeRocm.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
</>
)}
{(isCurrentlyCuda || isCurrentlyRocm) && platform.metadata.isTauri && (
<SettingSection
title={isCurrentlyCuda ? t('settings.gpu.cuda.activeTitle') : t('settings.gpu.rocm.activeTitle')}
description={t('settings.gpu.activeBackend.description')}
>
{restartPhase !== 'idle' ? (
<SettingRow
title={
restartPhase === 'ready'
@@ -327,8 +595,18 @@ export function GpuPage() {
}
action={<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
/>
) : (
<SettingRow
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
)}
{error && (
<SettingRow title={t('common.error')}>
<div className="flex items-center gap-2 text-sm text-destructive">
@@ -337,67 +615,6 @@ export function GpuPage() {
</div>
</SettingRow>
)}
{restartPhase === 'idle' && !cudaDownloading && (
<>
{!cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.download.title')}
description={t('settings.gpu.download.description')}
action={
<Button onClick={handleDownload} size="sm">
<Download className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.download.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCuda.title')}
description={t('settings.gpu.switchToCuda.description')}
action={
<Button onClick={handleRestart} size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCuda.button')}
</Button>
}
/>
)}
{isCurrentlyCuda && platform.metadata.isTauri && (
<SettingRow
title={t('settings.gpu.switchToCpu.title')}
description={t('settings.gpu.switchToCpu.description')}
action={
<Button onClick={handleSwitchToCpu} variant="outline" size="sm">
<RotateCw className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.switchToCpu.button')}
</Button>
}
/>
)}
{cudaAvailable && !isCurrentlyCuda && (
<SettingRow
title={t('settings.gpu.remove.title')}
description={t('settings.gpu.remove.description')}
action={
<Button
onClick={handleDelete}
variant="ghost"
size="sm"
className="text-muted-foreground "
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
{t('settings.gpu.remove.button')}
</Button>
}
/>
)}
</>
)}
</SettingSection>
)}
+39 -7
View File
@@ -760,8 +760,13 @@
}
},
"general": {
"docs": { "title": "Read the Docs" },
"discord": { "title": "Join the Discord", "subtitle": "Get help & share voices" },
"docs": {
"title": "Read the Docs"
},
"discord": {
"title": "Join the Discord",
"subtitle": "Get help & share voices"
},
"serverUrl": {
"title": "Server URL",
"description": "The address of your voicebox backend server.",
@@ -1091,11 +1096,15 @@
"active": "Active",
"cuda": {
"title": "CUDA Backend",
"activeTitle": "CUDA Backend Active",
"description": "NVIDIA GPU acceleration via a downloadable CUDA backend.",
"downloading": "Downloading CUDA backend…",
"downloadingShort": "Downloading…",
"updating": "Updating…"
},
"activeBackend": {
"description": "GPU acceleration is currently enabled."
},
"restart": {
"ready": "Server restarted successfully",
"waiting": "Restarting server…",
@@ -1113,10 +1122,9 @@
},
"switchToCpu": {
"title": "Switch to CPU backend",
"description": "Disable GPU acceleration. You can re-download CUDA later.",
"description": "Disable GPU acceleration. You can re-download the GPU backend later.",
"button": "Switch"
},
"remove": {
}, "remove": {
"title": "Remove CUDA backend",
"description": "Delete the downloaded CUDA binary to free disk space.",
"button": "Remove"
@@ -1126,9 +1134,33 @@
"downloadStart": "Failed to start download",
"restartFailed": "Restart failed",
"switchCpu": "Failed to switch to CPU",
"deleteCuda": "Failed to delete CUDA backend"
"deleteCuda": "Failed to delete CUDA backend",
"deleteRocm": "Failed to delete ROCm backend"
},
"footer": "Voicebox automatically detects and uses the best available GPU on your system. On Apple Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal Performance Shaders (MPS), with no additional setup required. On Windows and Linux with NVIDIA GPUs, you can download an optional CUDA backend for hardware-accelerated inference. AMD ROCm, Intel XPU, and DirectML are also supported where available through PyTorch. When no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower."
"footer": "Voicebox automatically detects and uses the best available GPU on your system. On Apple Silicon Macs, the MLX backend runs natively on the Neural Engine and GPU via Metal Performance Shaders (MPS), with no additional setup required. On Windows, you can download optional CUDA (NVIDIA) or ROCm (AMD) backends for hardware-accelerated inference. Intel XPU and DirectML are also supported where available through PyTorch. When no GPU is detected, Voicebox falls back to CPU — all engines still work, just slower.",
"rocm": {
"title": "AMD ROCm Backend",
"activeTitle": "ROCm Backend Active",
"description": "AMD GPU acceleration via a downloadable ROCm backend.",
"downloading": "Downloading ROCm backend…",
"downloadingShort": "Downloading…",
"updating": "Updating…"
},
"downloadRocm": {
"title": "Download AMD ROCm backend",
"description": "~2-3 GB download. Requires an AMD Radeon GPU with ROCm support.",
"button": "Download"
},
"switchToRocm": {
"title": "Switch to ROCm backend",
"description": "ROCm backend is downloaded and ready. Restart to enable.",
"button": "Restart"
},
"removeRocm": {
"title": "Remove ROCm backend",
"description": "Delete the downloaded ROCm binary to free disk space.",
"button": "Remove"
}
},
"logs": {
"title": "Server Logs",
+18
View File
@@ -20,6 +20,7 @@ import type {
PresetVoice,
PersonalityTextResponse,
ProfileSampleResponse,
RocmStatus,
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
@@ -693,6 +694,23 @@ class ApiClient {
});
}
// ROCm Backend Management
async getRocmStatus(): Promise<RocmStatus> {
return this.request<RocmStatus>('/backend/rocm-status');
}
async downloadRocmBackend(): Promise<{ message: string; progress_key: string }> {
return this.request<{ message: string; progress_key: string }>('/backend/download-rocm', {
method: 'POST',
});
}
async deleteRocmBackend(): Promise<{ message: string }> {
return this.request<{ message: string }>('/backend/rocm', {
method: 'DELETE',
});
}
// Stories
async listStories(): Promise<StoryResponse[]> {
return this.request<StoryResponse[]>('/stories');
+1 -1
View File
@@ -9,7 +9,7 @@ export type ModelStatus = {
model_name: string;
display_name: string;
downloaded: boolean;
downloading?: boolean; // True if download is in progress
downloading?: boolean; // True if download is in progress
size_mb?: number | null;
loaded?: boolean;
};
+22 -1
View File
@@ -269,7 +269,8 @@ export interface HealthResponse {
gpu_type?: string;
vram_used_mb?: number;
backend_type?: string;
backend_variant?: string; // "cpu" or "cuda"
backend_variant?: string; // "cpu", "cuda", or "rocm"
supports_rocm?: boolean; // AMD GPU on Windows — the ROCm backend is applicable
}
export interface CudaDownloadProgress {
@@ -291,6 +292,26 @@ export interface CudaStatus {
download_progress?: CudaDownloadProgress;
}
export interface RocmDownloadProgress {
model_name: string;
current: number;
total: number;
progress: number;
filename?: string;
status: 'downloading' | 'extracting' | 'complete' | 'error';
timestamp: string;
error?: string;
}
export interface RocmStatus {
available: boolean; // ROCm binary exists on disk
active: boolean; // Currently running the ROCm binary
binary_path?: string;
rocm_libs_version?: string;
downloading: boolean; // Download in progress
download_progress?: RocmDownloadProgress;
}
export interface ModelProgress {
model_name: string;
current: number;
+1
View File
@@ -60,6 +60,7 @@ export interface PlatformLifecycle {
stopServer(): Promise<void>;
restartServer(modelsDir?: string | null): Promise<string>;
setKeepServerRunning(keep: boolean): Promise<void>;
setBackendOverride(backend?: string | null): Promise<void>;
setupWindowCloseHandler(): Promise<void>;
subscribeToServerLogs(callback: (entry: ServerLogEntry) => void): () => void;
onServerReady?: () => void;