mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 13:20:39 -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]>
204 lines
6.4 KiB
Python
204 lines
6.4 KiB
Python
"""
|
|
Tests for the ROCm backend download service.
|
|
|
|
Mocks httpx to verify download, extraction, and progress reporting
|
|
without hitting the network.
|
|
"""
|
|
|
|
import json
|
|
import tarfile
|
|
import tempfile
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.services import rocm
|
|
from backend.utils.progress import get_progress_manager
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_progress_manager():
|
|
"""Reset the global progress manager before each test."""
|
|
import backend.utils.progress
|
|
backend.utils.progress._progress_manager = None
|
|
yield
|
|
backend.utils.progress._progress_manager = None
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_backends_dir(tmp_path: Path, monkeypatch):
|
|
"""Patch get_data_dir so downloads land in a temp directory."""
|
|
monkeypatch.setattr(rocm, "get_backends_dir", lambda: tmp_path / "backends")
|
|
return tmp_path / "backends"
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_tar_gz():
|
|
"""Create an in-memory .tar.gz archive containing a dummy file."""
|
|
buf = BytesIO()
|
|
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
data = b"fake binary content"
|
|
info = tarfile.TarInfo(name="voicebox-server-rocm.exe")
|
|
info.size = len(data)
|
|
tar.addfile(info, BytesIO(data))
|
|
buf.seek(0)
|
|
return buf.read()
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_sha256():
|
|
"""Return a dummy SHA-256 hex string."""
|
|
return "a" * 64
|
|
|
|
|
|
class FakeResponse:
|
|
"""Minimal fake for httpx.Response."""
|
|
|
|
def __init__(self, content: bytes = b"", status_code: int = 200, headers: dict | None = None):
|
|
self.content = content
|
|
self.status_code = status_code
|
|
self.headers = headers or {}
|
|
|
|
def raise_for_status(self):
|
|
if self.status_code >= 400:
|
|
raise Exception(f"HTTP {self.status_code}")
|
|
|
|
def iter_bytes(self, chunk_size: int = 1024):
|
|
for i in range(0, len(self.content), chunk_size):
|
|
yield self.content[i : i + chunk_size]
|
|
|
|
async def aiter_bytes(self, chunk_size: int = 1024):
|
|
for i in range(0, len(self.content), chunk_size):
|
|
yield self.content[i : i + chunk_size]
|
|
|
|
@property
|
|
def text(self):
|
|
return self.content.decode()
|
|
|
|
|
|
class FakeHttpxClient:
|
|
"""Minimal fake for httpx.AsyncClient."""
|
|
|
|
def __init__(self, responses: dict[str, FakeResponse]):
|
|
self._responses = responses
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
async def head(self, url: str):
|
|
return self._responses.get(url, FakeResponse(status_code=404))
|
|
|
|
async def get(self, url: str):
|
|
return self._responses.get(url, FakeResponse(status_code=404))
|
|
|
|
def stream(self, method: str, url: str):
|
|
resp = self._responses.get(url, FakeResponse(status_code=404))
|
|
resp.raise_for_status()
|
|
|
|
class _Streamer:
|
|
async def __aenter__(self):
|
|
return resp
|
|
|
|
async def __aexit__(self, *args):
|
|
return False
|
|
|
|
async def aiter_bytes(self, chunk_size: int = 1024):
|
|
for i in range(0, len(resp.content), chunk_size):
|
|
yield resp.content[i : i + chunk_size]
|
|
|
|
return _Streamer()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_rocm_status_not_installed(mock_backends_dir):
|
|
status = rocm.get_rocm_status()
|
|
assert status["available"] is False
|
|
assert status["active"] is False
|
|
assert status["binary_path"] is None
|
|
assert status["downloading"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_download_rocm_binary_progress_reporting(mock_backends_dir, fake_tar_gz, fake_sha256):
|
|
"""
|
|
Verify that download_rocm_binary():
|
|
1. Downloads the server archive and ROCm libs archive.
|
|
2. Extracts them into the backends/rocm directory.
|
|
3. Reports progress via the progress_manager.
|
|
"""
|
|
import hashlib
|
|
|
|
server_sha = hashlib.sha256(fake_tar_gz).hexdigest()
|
|
libs_sha = hashlib.sha256(fake_tar_gz).hexdigest()
|
|
|
|
responses = {
|
|
"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/voicebox-server-rocm.tar.gz": FakeResponse(
|
|
content=fake_tar_gz,
|
|
headers={"content-length": str(len(fake_tar_gz))},
|
|
),
|
|
"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/voicebox-server-rocm.tar.gz.sha256": FakeResponse(
|
|
content=f"{server_sha} voicebox-server-rocm.tar.gz\n".encode(),
|
|
),
|
|
f"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/rocm-libs-{rocm.ROCM_LIBS_VERSION}.tar.gz": FakeResponse(
|
|
content=fake_tar_gz,
|
|
headers={"content-length": str(len(fake_tar_gz))},
|
|
),
|
|
f"https://github.com/jamiepine/voicebox/releases/download/v0.2.3/rocm-libs-{rocm.ROCM_LIBS_VERSION}.tar.gz.sha256": FakeResponse(
|
|
content=f"{libs_sha} rocm-libs.tar.gz\n".encode(),
|
|
),
|
|
}
|
|
|
|
fake_client = FakeHttpxClient(responses)
|
|
|
|
with patch("httpx.AsyncClient", return_value=fake_client):
|
|
await rocm.download_rocm_binary(version="v0.2.3")
|
|
|
|
# Verify extraction
|
|
rocm_dir = rocm.get_rocm_dir()
|
|
assert (rocm_dir / "voicebox-server-rocm.exe").exists()
|
|
|
|
# Verify manifest written
|
|
manifest_path = rocm.get_rocm_libs_manifest_path()
|
|
assert manifest_path.exists()
|
|
data = json.loads(manifest_path.read_text())
|
|
assert data["version"] == rocm.ROCM_LIBS_VERSION
|
|
|
|
# Verify progress was reported
|
|
progress = get_progress_manager().get_progress("rocm-backend")
|
|
assert progress is not None
|
|
assert progress["status"] == "complete"
|
|
assert progress["progress"] == 100.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_rocm_active(mock_backends_dir, monkeypatch):
|
|
monkeypatch.setenv("VOICEBOX_BACKEND_VARIANT", "rocm")
|
|
assert rocm.is_rocm_active() is True
|
|
|
|
monkeypatch.setenv("VOICEBOX_BACKEND_VARIANT", "cpu")
|
|
assert rocm.is_rocm_active() is False
|
|
|
|
monkeypatch.delenv("VOICEBOX_BACKEND_VARIANT", raising=False)
|
|
assert rocm.is_rocm_active() is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_rocm_binary(mock_backends_dir, fake_tar_gz):
|
|
"""Test deleting the ROCm backend directory."""
|
|
rocm_dir = rocm.get_rocm_dir()
|
|
rocm_dir.mkdir(parents=True, exist_ok=True)
|
|
(rocm_dir / "dummy.txt").write_text("hello")
|
|
|
|
result = await rocm.delete_rocm_binary()
|
|
assert result is True
|
|
assert not rocm_dir.exists()
|
|
|
|
# Deleting again should return False
|
|
result = await rocm.delete_rocm_binary()
|
|
assert result is False
|