diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e94bcd70..e1bcd3e3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -340,3 +340,64 @@ jobs:
name: voicebox-server-cuda-windows
path: backend/dist/voicebox-server-cuda/
retention-days: 7
+
+ build-rocm-windows:
+ runs-on: windows-latest
+ permissions:
+ contents: write
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ # ROCm wheels are cp312-cp312-specific — build_binary.py --rocm enforces this.
+ python-version: "3.12"
+ cache: "pip"
+
+ - name: Install Python dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install pyinstaller
+ pip install -r backend/requirements.txt
+ pip install --no-deps chatterbox-tts
+ pip install --no-deps hume-tada
+
+ - name: Build ROCm server binary (onedir)
+ shell: bash
+ working-directory: backend
+ # build_binary.py --rocm pulls the official AMD Radeon torch + rocm_sdk
+ # wheels (rocm-rel-7.2.1) itself when ROCm torch is not already present,
+ # then restores the dev torch afterwards.
+ run: python build_binary.py --rocm
+
+ - name: Package into server core + ROCm libs archives
+ shell: bash
+ run: |
+ python scripts/package_rocm.py \
+ backend/dist/voicebox-server-rocm/ \
+ --output release-assets/ \
+ --rocm-libs-version rocm7.2-v1 \
+ --torch-compat ">=2.9.0,<2.10.0"
+
+ - name: Upload archives to GitHub Release
+ if: startsWith(github.ref, 'refs/tags/')
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ release-assets/voicebox-server-rocm.tar.gz
+ release-assets/voicebox-server-rocm.tar.gz.sha256
+ release-assets/rocm-libs-rocm7.2-v1.tar.gz
+ release-assets/rocm-libs-rocm7.2-v1.tar.gz.sha256
+ release-assets/rocm-libs.json
+ draft: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload onedir as workflow artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: voicebox-server-rocm-windows
+ path: backend/dist/voicebox-server-rocm/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index bcc1927c..853c5060 100644
Binary files a/.gitignore and b/.gitignore differ
diff --git a/app/src/components/ServerSettings/GpuAcceleration.tsx b/app/src/components/ServerSettings/GpuAcceleration.tsx
index d058ffc3..46e0d4bd 100644
--- a/app/src/components/ServerSettings/GpuAcceleration.tsx
+++ b/app/src/components/ServerSettings/GpuAcceleration.tsx
@@ -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('idle');
const [error, setError] = useState(null);
const [downloadProgress, setDownloadProgress] = useState(null);
+ const [rocmDownloadProgress, setRocmDownloadProgress] = useState(
+ null,
+ );
const healthPollRef = useRef | 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() {
)}
- {/* 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).
-