mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Compare commits
52
Commits
feat/capture
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51f49dea19 | ||
|
|
80610d880e | ||
|
|
397051ba44 | ||
|
|
1ba935e83b | ||
|
|
6a6f4643da | ||
|
|
44ef8daba3 | ||
|
|
68ece25a80 | ||
|
|
1db0fdf645 | ||
|
|
2a001fd63f | ||
|
|
e5813304ef | ||
|
|
a5773807a5 | ||
|
|
ed54347e81 | ||
|
|
624f6a2140 | ||
|
|
669f85024f | ||
|
|
52f8d8dd38 | ||
|
|
fb1e16d2ce | ||
|
|
f750596364 | ||
|
|
91cd6df108 | ||
|
|
484a39ad9f | ||
|
|
3bfcbdc819 | ||
|
|
190bc5e8a8 | ||
|
|
80af641b61 | ||
|
|
6936789a88 | ||
|
|
f3eca34d33 | ||
|
|
30db291b01 | ||
|
|
71b51366bc | ||
|
|
258b92c9c0 | ||
|
|
e6cf50c7f7 | ||
|
|
2dc3b075d5 | ||
|
|
05d90790f8 | ||
|
|
4a6b5da793 | ||
|
|
2c9d02af62 | ||
|
|
b680097dfb | ||
|
|
f2cf2a729d | ||
|
|
b542768429 | ||
|
|
e766c7cbfb | ||
|
|
c2282b256a | ||
|
|
cabef1bfe0 | ||
|
|
3835b63bd8 | ||
|
|
da79e37ef5 | ||
|
|
6e4989313c | ||
|
|
42b9cae216 | ||
|
|
b9bb2f075c | ||
|
|
e294b9c8f0 | ||
|
|
c1814a2870 | ||
|
|
7d9a384ee4 | ||
|
|
c6a59f4477 | ||
|
|
4f13123b95 | ||
|
|
21c7e373d3 | ||
|
|
45b64e0233 | ||
|
|
b35b90961d | ||
|
|
7df366d0c8 |
+2
-1
@@ -8,7 +8,8 @@ tauri/
|
||||
landing/
|
||||
docs/
|
||||
mlx-test/
|
||||
scripts/
|
||||
scripts/*
|
||||
!scripts/rocm-entrypoint.sh
|
||||
|
||||
# Dependencies & build artifacts (rebuilt in Docker)
|
||||
node_modules/
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
package.json text eol=lf
|
||||
scripts/*.sh text eol=lf
|
||||
@@ -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
|
||||
|
||||
BIN
Binary file not shown.
@@ -5,6 +5,17 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Linux
|
||||
|
||||
- **ROCm setup works on Linux AMD systems.** Docker ROCm builds now keep PyTorch
|
||||
on the ROCm wheel index during dependency installation, so later installs do
|
||||
not replace it with CUDA wheels. The ROCm compose overlay no longer assumes
|
||||
Ubuntu render/video group IDs; the container joins the groups that own the GPU
|
||||
device nodes at startup. Native Linux setup now picks ROCm wheels for AMD GPUs
|
||||
and CUDA wheels for NVIDIA GPUs before installing backend dependencies.
|
||||
|
||||
## [0.5.0] - 2026-04-22
|
||||
|
||||
**The Capture release.** Voicebox stops being just a voice-cloning studio and becomes a full AI voice studio. Hold a key anywhere on your machine, speak, release — the transcript lands in the focused text field. Flip the primitive around and any MCP-aware agent — Claude Code, Cursor, Spacebot — speaks back through an on-screen pill in one of your cloned voices. A local LLM sits between the two, so transcripts come out clean and voice profiles can carry a personality that reshapes what the agent says before it gets spoken.
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ On Windows, to build with CUDA support for local testing:
|
||||
just build-local # Build CPU + CUDA server binaries + Tauri installer
|
||||
```
|
||||
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/sh.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app.
|
||||
|
||||
Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`.
|
||||
|
||||
|
||||
+39
-9
@@ -1,8 +1,15 @@
|
||||
# ============================================================
|
||||
# Voicebox — Local TTS Server with Web UI (CPU)
|
||||
# Voicebox — Local TTS Server with Web UI
|
||||
# 3-stage build: Frontend → Python deps → Runtime
|
||||
#
|
||||
# Build variants:
|
||||
# CPU (default): docker compose up --build
|
||||
# ROCm (AMD GPU): docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
|
||||
# ============================================================
|
||||
|
||||
# Top-level ARG so it is visible to all stages.
|
||||
ARG PYTORCH_VARIANT=cpu
|
||||
|
||||
# === Stage 1: Build frontend ===
|
||||
FROM oven/bun:1 AS frontend
|
||||
|
||||
@@ -13,8 +20,11 @@ COPY package.json bun.lock CHANGELOG.md ./
|
||||
COPY app/ ./app/
|
||||
COPY web/ ./web/
|
||||
|
||||
# Strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
# Normalize line endings first (a Windows CRLF checkout would otherwise
|
||||
# defeat the `-z 's/,\n ]/…/'` match below, since it's LF-anchored), then
|
||||
# strip workspaces not needed for web build, and fix trailing comma
|
||||
RUN sed -i 's/\r$//' package.json && \
|
||||
sed -i '/"tauri"/d; /"landing"/d' package.json && \
|
||||
sed -i -z 's/,\n ]/\n ]/' package.json
|
||||
RUN bun install --no-save
|
||||
# Build frontend (skip tsc — upstream has pre-existing type errors)
|
||||
@@ -24,6 +34,9 @@ RUN cd web && bunx --bun vite build
|
||||
# === Stage 2: Build Python dependencies ===
|
||||
FROM python:3.11-slim AS backend-builder
|
||||
|
||||
# Re-declare ARG inside the stage (Docker scoping requirement).
|
||||
ARG PYTORCH_VARIANT=cpu
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -34,6 +47,19 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN pip install --no-cache-dir --upgrade pip
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
|
||||
# ROCm wheel index. Default 6.3 (RDNA1/2/3); set ROCM_VERSION=7.2 for RDNA4.
|
||||
ARG ROCM_VERSION=6.3
|
||||
|
||||
# For ROCm, make the PyTorch ROCm index primary so every install below resolves
|
||||
# torch to ROCm wheels instead of the default CUDA build.
|
||||
RUN if [ "$PYTORCH_VARIANT" = "rocm" ]; then \
|
||||
pip install --no-cache-dir --prefix=/install \
|
||||
--index-url "https://download.pytorch.org/whl/rocm${ROCM_VERSION}" \
|
||||
torch torchaudio && \
|
||||
printf '[global]\nindex-url = https://download.pytorch.org/whl/rocm%s\nextra-index-url = https://pypi.org/simple\n' "$ROCM_VERSION" > /etc/pip.conf; \
|
||||
fi
|
||||
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
RUN pip install --no-cache-dir --prefix=/install --no-deps chatterbox-tts
|
||||
RUN pip install --no-cache-dir --prefix=/install --no-deps hume-tada
|
||||
@@ -44,16 +70,17 @@ RUN pip install --no-cache-dir --prefix=/install \
|
||||
# === Stage 3: Runtime ===
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Create non-root user for security
|
||||
# Create non-root user; the entrypoint joins GPU device groups at runtime.
|
||||
RUN groupadd -r voicebox && \
|
||||
useradd -r -g voicebox -m -s /bin/bash voicebox
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only runtime system dependencies
|
||||
# Install only runtime system dependencies (gosu drops root in the entrypoint)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed Python packages from builder stage
|
||||
@@ -69,9 +96,6 @@ COPY --from=frontend --chown=voicebox:voicebox /build/web/dist /app/frontend/
|
||||
RUN mkdir -p /app/data/generations /app/data/profiles /app/data/cache \
|
||||
&& chown -R voicebox:voicebox /app/data
|
||||
|
||||
# Switch to non-root user
|
||||
USER voicebox
|
||||
|
||||
# Expose the API port
|
||||
EXPOSE 17493
|
||||
|
||||
@@ -79,5 +103,11 @@ EXPOSE 17493
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=60s \
|
||||
CMD curl -f http://localhost:17493/health || exit 1
|
||||
|
||||
# Start the FastAPI server
|
||||
# Entrypoint joins GPU groups then drops to the voicebox user.
|
||||
# Normalize CRLF (a Windows checkout otherwise leaves the shebang as
|
||||
# `#!/bin/sh\r`, which Linux can't resolve — reported as a misleading
|
||||
# "no such file or directory" even though the file exists).
|
||||
COPY --chmod=755 scripts/rocm-entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "17493"]
|
||||
|
||||
@@ -28,6 +28,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/21213" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21213" alt="jamiepine%2Fvoicebox | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://voicebox.sh">voicebox.sh</a> •
|
||||
<a href="https://docs.voicebox.sh">Docs</a> •
|
||||
@@ -266,7 +270,8 @@ Use cases: agent dev loops (dictate a question, hear the answer in a cloned voic
|
||||
| Platform | Backend | Notes |
|
||||
| ------------------------ | -------------- | ---------------------------------------------- |
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
|
||||
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
|
||||
| Windows (any GPU) | DirectML | Universal Windows GPU support |
|
||||
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Responsible Use
|
||||
|
||||
Voicebox is a local-first AI voice studio. It can clone voices from short audio samples, generate speech, and make AI agents speak through voice profiles. That capability is useful for accessibility, creative production, prototyping, game development, and personal tools, but it can also be misused.
|
||||
|
||||
Voicebox does not and cannot independently verify who owns a voice sample. You are responsible for making sure you have the right to use every voice you clone, import, or generate with.
|
||||
|
||||
## Allowed Uses
|
||||
|
||||
- Cloning your own voice.
|
||||
- Cloning a voice with explicit permission from the speaker.
|
||||
- Using licensed, public-domain, or otherwise legally authorized voice material.
|
||||
- Building accessibility tools, creative projects, games, podcasts, prototypes, and local workflows where the speaker's rights are respected.
|
||||
|
||||
## Prohibited Uses
|
||||
|
||||
- Impersonating someone without permission.
|
||||
- Fraud, scams, phishing, social engineering, or bypassing voice authentication.
|
||||
- Harassment, threats, intimidation, or non-consensual sexual content.
|
||||
- Misleading political, legal, financial, medical, or emergency communications.
|
||||
- Commercial use of a person's voice without the legal right to do so.
|
||||
- Removing or bypassing responsible-use acknowledgements in order to misuse the software.
|
||||
|
||||
## Disclosure And Compliance
|
||||
|
||||
If you publish or distribute synthetic audio, disclose that it is AI-generated where required by law, platform policy, or audience expectations. Developers building products on top of Voicebox should treat consent records, disclosure, and jurisdiction-specific requirements as part of their own application design.
|
||||
|
||||
Voicebox runs locally to protect user privacy. That privacy model does not remove your responsibility to respect other people's voices.
|
||||
@@ -139,7 +139,7 @@ export function EngineModelSelector({ form, compact, selectedProfile }: EngineMo
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent side={compact ? 'top' : undefined}>
|
||||
{availableOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value} className={itemClass}>
|
||||
{opt.label}
|
||||
|
||||
@@ -555,7 +555,7 @@ export function FloatingGenerateBox({
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all w-full">
|
||||
<SelectValue placeholder={t('generation.voiceSelector.placeholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent side="top">
|
||||
{profiles?.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id} className="text-xs">
|
||||
{profile.name}
|
||||
@@ -582,7 +582,7 @@ export function FloatingGenerateBox({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectContent side="top">
|
||||
{engineLangs.map((lang) => (
|
||||
<SelectItem key={lang.value} value={lang.value} className="text-xs">
|
||||
{lang.label}
|
||||
@@ -610,7 +610,7 @@ export function FloatingGenerateBox({
|
||||
<SelectTrigger className="h-8 text-xs bg-card border-border rounded-full hover:bg-background/50 transition-all">
|
||||
<SelectValue placeholder={t('generation.effects.none')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent side="top">
|
||||
<SelectItem value="none" className="text-xs">
|
||||
{t('generation.effects.none')}
|
||||
</SelectItem>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||
import { SPONSORS } from '@/lib/sponsors';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
|
||||
function FadeIn({ delay = 0, children }: { delay?: number; children: ReactNode }) {
|
||||
@@ -117,36 +116,6 @@ export function AboutPage() {
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
{SPONSORS.length > 0 && (
|
||||
<FadeIn delay={400}>
|
||||
<div className="pt-4 flex flex-col items-center gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-muted-foreground/60">
|
||||
Sponsored by
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{SPONSORS.map((sponsor) => (
|
||||
<a
|
||||
key={sponsor.name}
|
||||
href={sponsor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={sponsor.name}
|
||||
className="group flex h-12 min-w-[120px] items-center justify-center rounded-lg border border-border/60 bg-card/50 px-4 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<img
|
||||
src={sponsor.logoSrc}
|
||||
alt={sponsor.logoAlt ?? sponsor.name}
|
||||
className={`h-5 w-auto max-w-[100px] object-contain opacity-80 transition-opacity group-hover:opacity-100 ${
|
||||
sponsor.invertOnDark ? 'dark:brightness-0 dark:invert' : ''
|
||||
}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
)}
|
||||
|
||||
<FadeIn delay={480}>
|
||||
<p className="text-xs text-muted-foreground/40 pt-4">
|
||||
<Trans
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Cloud, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
|
||||
// "Log in with browser" device pairing. The backend opens the system browser
|
||||
// and completes the code exchange; here we just kick it off and poll status
|
||||
// until the link goes live. The API key never touches the frontend.
|
||||
export function CloudSection() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [polling, setPolling] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['cloud-status'],
|
||||
queryFn: () => apiClient.getCloudStatus(),
|
||||
refetchInterval: polling ? 2000 : false,
|
||||
});
|
||||
|
||||
const connected = status?.connected ?? false;
|
||||
|
||||
// Once the browser flow completes, stop polling and celebrate.
|
||||
useEffect(() => {
|
||||
if (connected && polling) {
|
||||
setPolling(false);
|
||||
toast({
|
||||
title: 'Connected to Voicebox Cloud',
|
||||
description: `Linked as ${status?.device_name ?? 'this device'}.`,
|
||||
});
|
||||
}
|
||||
}, [connected, polling, status?.device_name, toast]);
|
||||
|
||||
// Give up after two minutes so an abandoned browser flow doesn't leave the
|
||||
// button stuck on "Waiting for browser…". The backend state stays valid for
|
||||
// ten, so the user can simply start again.
|
||||
useEffect(() => {
|
||||
if (!polling) return;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPolling(false);
|
||||
toast({
|
||||
title: 'Sign-in timed out',
|
||||
description: 'The browser sign-in was not completed. Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}, 120_000);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [polling, toast]);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => apiClient.startCloudLogin(),
|
||||
onSuccess: () => {
|
||||
setPolling(true);
|
||||
toast({
|
||||
title: 'Continue in your browser',
|
||||
description: 'Authorize this device, then return here.',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({
|
||||
title: 'Could not start sign-in',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
}),
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: () => apiClient.disconnectCloud(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-status'] });
|
||||
toast({
|
||||
title: 'Disconnected',
|
||||
description:
|
||||
'This device is no longer linked. The key stays valid until revoked in your account.',
|
||||
});
|
||||
},
|
||||
onError: (error: Error) =>
|
||||
toast({ title: 'Could not disconnect', description: error.message, variant: 'destructive' }),
|
||||
});
|
||||
|
||||
const busy = startLogin.isPending || polling;
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title="Voicebox Cloud"
|
||||
description="End-to-end encrypted backup & sync across your devices."
|
||||
>
|
||||
<SettingRow
|
||||
title={connected ? 'Connected' : 'Account'}
|
||||
description={
|
||||
connected
|
||||
? `Linked as ${status?.device_name ?? 'this device'}${
|
||||
status?.key_prefix ? ` · ${status.key_prefix}…` : ''
|
||||
}`
|
||||
: 'Log in to back up and sync your captures and generations.'
|
||||
}
|
||||
action={
|
||||
connected ? (
|
||||
<Button
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{disconnect.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
Disconnecting…
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={busy} onClick={() => startLogin.mutate()} size="sm">
|
||||
{busy ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
{polling ? 'Waiting for browser…' : 'Opening…'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="h-3.5 w-3.5 mr-1.5" />
|
||||
Log in with browser
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{connected && (
|
||||
<SettingRow
|
||||
title="Manage"
|
||||
description="Revoke this device, add API keys, or manage billing from your account."
|
||||
>
|
||||
<a
|
||||
className="text-sm text-accent hover:underline"
|
||||
href={status?.dashboard_url ?? 'https://voicebox.sh/account'}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open account dashboard ↗
|
||||
</a>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingSection>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||
import { useServerHealth } from '@/lib/hooks/useServer';
|
||||
import { usePlatform } from '@/platform/PlatformContext';
|
||||
import { useServerStore } from '@/stores/serverStore';
|
||||
import { CloudSection } from './CloudSection';
|
||||
import { LanguageSelect } from './LanguageSelect';
|
||||
import { SettingRow, SettingSection } from './SettingRow';
|
||||
import { ThemeSelect } from './ThemeSelect';
|
||||
@@ -207,6 +208,8 @@ export function GeneralPage() {
|
||||
/>
|
||||
</SettingSection>
|
||||
|
||||
<CloudSection />
|
||||
|
||||
<ApiReferenceCard serverUrl={serverUrl} />
|
||||
|
||||
{platform.metadata.isTauri && <UpdatesSection />}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,15 +2,25 @@ import i18n from 'i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import en from './locales/en/translation.json';
|
||||
import es from './locales/es/translation.json';
|
||||
import fr from './locales/fr/translation.json';
|
||||
import it from './locales/it/translation.json';
|
||||
import ja from './locales/ja/translation.json';
|
||||
import ko from './locales/ko/translation.json';
|
||||
import ptBR from './locales/pt-BR/translation.json';
|
||||
import zhCN from './locales/zh-CN/translation.json';
|
||||
import zhTW from './locales/zh-TW/translation.json';
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'es', label: 'Español' },
|
||||
{ code: 'pt-BR', label: 'Português (Brasil)' },
|
||||
{ code: 'ja', label: '日本語' },
|
||||
{ code: 'ko', label: '한국어' },
|
||||
{ code: 'zh-CN', label: '简体中文' },
|
||||
{ code: 'zh-TW', label: '繁體中文' },
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'it', label: 'Italiano' },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof SUPPORTED_LANGUAGES)[number]['code'];
|
||||
@@ -21,9 +31,14 @@ i18n
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
'pt-BR': { translation: ptBR },
|
||||
ja: { translation: ja },
|
||||
ko: { translation: ko },
|
||||
'zh-CN': { translation: zhCN },
|
||||
'zh-TW': { translation: zhTW },
|
||||
fr: { translation: fr },
|
||||
it: { translation: it },
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: SUPPORTED_LANGUAGES.map((l) => l.code),
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ import type {
|
||||
PresetVoice,
|
||||
PersonalityTextResponse,
|
||||
ProfileSampleResponse,
|
||||
RocmStatus,
|
||||
StoryCreate,
|
||||
StoryDetailResponse,
|
||||
StoryItemBatchUpdate,
|
||||
@@ -50,6 +51,8 @@ import type {
|
||||
MCPClientBinding,
|
||||
MCPClientBindingListResponse,
|
||||
MCPClientBindingUpsert,
|
||||
CloudLoginStartResponse,
|
||||
CloudStatus,
|
||||
} from './types';
|
||||
|
||||
function formatErrorDetail(detail: unknown, fallback: string): string {
|
||||
@@ -693,6 +696,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');
|
||||
@@ -920,6 +940,21 @@ class ApiClient {
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
// Cloud (backup & sync) — browser-based device login. startCloudLogin opens
|
||||
// the system browser server-side; the UI then polls getCloudStatus until the
|
||||
// backend completes the exchange and the link goes live.
|
||||
async getCloudStatus(): Promise<CloudStatus> {
|
||||
return this.request<CloudStatus>('/cloud/status');
|
||||
}
|
||||
|
||||
async startCloudLogin(): Promise<CloudLoginStartResponse> {
|
||||
return this.request<CloudLoginStartResponse>('/cloud/login/start', { method: 'POST' });
|
||||
}
|
||||
|
||||
async disconnectCloud(): Promise<CloudStatus> {
|
||||
return this.request<CloudStatus>('/cloud/disconnect', { method: 'POST' });
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
@@ -286,11 +287,34 @@ export interface CudaDownloadProgress {
|
||||
export interface CudaStatus {
|
||||
available: boolean; // CUDA binary exists on disk
|
||||
active: boolean; // Currently running the CUDA binary
|
||||
binary_path?: string;
|
||||
binary_path: string | null;
|
||||
cuda_libs_version: string | null;
|
||||
download_supported: boolean; // Platform has a matching release asset
|
||||
unsupported_reason: string | null;
|
||||
downloading: boolean; // Download in progress
|
||||
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;
|
||||
@@ -521,3 +545,18 @@ export interface MCPClientBindingUpsert {
|
||||
export interface MCPClientBindingListResponse {
|
||||
items: MCPClientBinding[];
|
||||
}
|
||||
|
||||
/* ─── Cloud (backup & sync) ───────────────────────────────────────────── */
|
||||
|
||||
export interface CloudLoginStartResponse {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface CloudStatus {
|
||||
connected: boolean;
|
||||
device_name: string | null;
|
||||
account_user_id: string | null;
|
||||
key_prefix: string | null;
|
||||
connected_at: string | null;
|
||||
dashboard_url: string;
|
||||
}
|
||||
|
||||
@@ -47,12 +47,14 @@ export function useExportGeneration() {
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGeneration(generationId);
|
||||
|
||||
// Create safe filename from text
|
||||
// Create safe filename from text. Append a short id so exports of
|
||||
// similarly-worded generations don't collide on the same filename
|
||||
// (the first 30 chars are frequently identical).
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `generation-${safeText}.voicebox.zip`;
|
||||
const filename = `generation-${safeText}-${generationId.substring(0, 8)}.voicebox.zip`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
@@ -73,12 +75,14 @@ export function useExportGenerationAudio() {
|
||||
mutationFn: async ({ generationId, text }: { generationId: string; text: string }) => {
|
||||
const blob = await apiClient.exportGenerationAudio(generationId);
|
||||
|
||||
// Create safe filename from text
|
||||
// Create safe filename from text. Append a short id so exports of
|
||||
// similarly-worded generations don't collide on the same filename
|
||||
// (the first 30 chars are frequently identical).
|
||||
const safeText = text
|
||||
.substring(0, 30)
|
||||
.replace(/[^a-z0-9]/gi, '-')
|
||||
.toLowerCase();
|
||||
const filename = `${safeText}.wav`;
|
||||
const filename = `${safeText}-${generationId.substring(0, 8)}.wav`;
|
||||
|
||||
await platform.filesystem.saveFile(filename, blob, [
|
||||
{
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export type Sponsor = {
|
||||
name: string;
|
||||
url: string;
|
||||
logoSrc: string;
|
||||
logoAlt?: string;
|
||||
/** Set true for solid-black logos that need to flip white in dark mode. */
|
||||
invertOnDark?: boolean;
|
||||
};
|
||||
|
||||
export const SPONSORS: Sponsor[] = [];
|
||||
+19
-14
@@ -1,5 +1,5 @@
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { ja, zhCN, zhTW } from 'date-fns/locale';
|
||||
import { es, fr, ja, zhCN, zhTW } from 'date-fns/locale';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
@@ -10,38 +10,43 @@ export function formatDuration(seconds: number): string {
|
||||
|
||||
function getDateLocale() {
|
||||
switch (i18n.language) {
|
||||
case 'es':
|
||||
return es;
|
||||
case 'ja':
|
||||
return ja;
|
||||
case 'zh-CN':
|
||||
return zhCN;
|
||||
case 'zh-TW':
|
||||
return zhTW;
|
||||
case 'fr':
|
||||
return fr;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
let dateObj: Date;
|
||||
if (typeof date === 'string') {
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
dateObj = new Date(`${dateStr}Z`);
|
||||
} else {
|
||||
dateObj = new Date(dateStr);
|
||||
}
|
||||
} else {
|
||||
dateObj = date;
|
||||
// Backend timestamps are naive UTC — append `Z` so JS doesn't parse a
|
||||
// timezone-less date-time string as local time.
|
||||
function parseServerDate(date: string | Date): Date {
|
||||
if (typeof date !== 'string') {
|
||||
return date;
|
||||
}
|
||||
const dateStr = date.trim();
|
||||
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
|
||||
return new Date(`${dateStr}Z`);
|
||||
}
|
||||
return new Date(dateStr);
|
||||
}
|
||||
|
||||
return formatDistance(dateObj, new Date(), {
|
||||
export function formatDate(date: string | Date): string {
|
||||
return formatDistance(parseServerDate(date), new Date(), {
|
||||
addSuffix: true,
|
||||
locale: getDateLocale(),
|
||||
}).replace(/^about /i, '');
|
||||
}
|
||||
|
||||
export function formatAbsoluteDate(date: string | Date): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const dateObj = parseServerDate(date);
|
||||
return dateObj.toLocaleString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -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;
|
||||
|
||||
+64
-2
@@ -3,6 +3,8 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -36,9 +38,67 @@ logging.basicConfig(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# AMD GPU environment variables must be set before torch import
|
||||
# An empty HSA_OVERRIDE_GFX_VERSION poisons the ROCm HSA runtime. It is
|
||||
# treated as "force-empty" and no GPU is detected, even natively supported
|
||||
# ones (e.g. gfx1201 / RX 9070 on ROCm 7.2). docker-compose can't
|
||||
# conditionally omit an env var, so we clean it up here before torch loads.
|
||||
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
|
||||
os.environ.pop("HSA_OVERRIDE_GFX_VERSION", None)
|
||||
|
||||
# AMD GPU environment variables must be set before torch import
|
||||
# Only set HSA_OVERRIDE_GFX_VERSION for older GPUs that need it.
|
||||
# RDNA 3+ (gfx1100+) and RDNA 4 (gfx1200+) are natively supported by ROCm
|
||||
# and the override can cause suboptimal performance or errors.
|
||||
if not os.environ.get("HSA_OVERRIDE_GFX_VERSION"):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["rocminfo"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Collect all GPUs found in rocminfo output
|
||||
gfx_versions = []
|
||||
for line in result.stdout.splitlines():
|
||||
line_lower = line.lower()
|
||||
if "gfx" in line_lower:
|
||||
match = re.search(r"(gfx\d+)", line_lower)
|
||||
if match:
|
||||
gfx_versions.append(match.group(1))
|
||||
|
||||
if gfx_versions:
|
||||
# Check if any GPU needs the override (RDNA 2 and older)
|
||||
# Use the oldest GPU (lowest gfx number) for the decision
|
||||
try:
|
||||
gfx_nums = []
|
||||
for v in gfx_versions:
|
||||
m = re.search(r"\d+", v)
|
||||
if m:
|
||||
gfx_nums.append(int(m.group()))
|
||||
if gfx_nums:
|
||||
oldest_num = min(gfx_nums)
|
||||
oldest_gfx = gfx_versions[gfx_nums.index(oldest_num)]
|
||||
if oldest_num < 1100:
|
||||
os.environ["HSA_OVERRIDE_GFX_VERSION"] = "10.3.0"
|
||||
logger.info(
|
||||
"AMD GPU detected (%s), setting HSA_OVERRIDE_GFX_VERSION=10.3.0 for compatibility. All GPUs: %s",
|
||||
oldest_gfx,
|
||||
", ".join(gfx_versions),
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"AMD GPU detected (%s), native ROCm support available, skipping HSA_OVERRIDE_GFX_VERSION. All GPUs: %s",
|
||||
oldest_gfx,
|
||||
", ".join(gfx_versions),
|
||||
)
|
||||
except (ValueError, AttributeError) as e:
|
||||
logger.info("Could not parse GPU version from rocminfo output: %s", e)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
|
||||
logger.info(
|
||||
"Could not detect AMD GPU via rocminfo, skipping automatic HSA_OVERRIDE_GFX_VERSION configuration: %s",
|
||||
e,
|
||||
)
|
||||
if not os.environ.get("MIOPEN_LOG_LEVEL"):
|
||||
os.environ["MIOPEN_LOG_LEVEL"] = "4"
|
||||
|
||||
@@ -273,8 +333,10 @@ async def _run_startup(application: FastAPI) -> None:
|
||||
logger.warning("GPU COMPATIBILITY: %s", _cuda_warning)
|
||||
|
||||
from .services.cuda import check_and_update_cuda_binary
|
||||
from .services.rocm import check_and_update_rocm_binary
|
||||
|
||||
create_background_task(check_and_update_cuda_binary())
|
||||
create_background_task(check_and_update_rocm_binary())
|
||||
|
||||
try:
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
@@ -56,6 +56,7 @@ class ModelConfig:
|
||||
model_size: str = "default"
|
||||
size_mb: int = 0
|
||||
needs_trim: bool = False
|
||||
retries_runaway: bool = False
|
||||
supports_instruct: bool = False
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
|
||||
@@ -232,6 +233,10 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
repo_1_7b = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||
repo_0_6b = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||
|
||||
# mlx-audio can continue after an EOS miss with silence followed by
|
||||
# codec noise. Retry only the affected text as smaller chunks.
|
||||
retries_runaway = backend_type == "mlx"
|
||||
|
||||
return [
|
||||
ModelConfig(
|
||||
model_name="qwen-tts-1.7B",
|
||||
@@ -240,6 +245,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
hf_repo_id=repo_1_7b,
|
||||
model_size="1.7B",
|
||||
size_mb=3500,
|
||||
retries_runaway=retries_runaway,
|
||||
supports_instruct=False, # Base model drops instruct silently
|
||||
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||
),
|
||||
@@ -250,6 +256,7 @@ def _get_qwen_model_configs() -> list[ModelConfig]:
|
||||
hf_repo_id=repo_0_6b,
|
||||
model_size="0.6B",
|
||||
size_mb=1200,
|
||||
retries_runaway=retries_runaway,
|
||||
supports_instruct=False,
|
||||
languages=["zh", "en", "ja", "ko", "de", "fr", "ru", "pt", "es", "it"],
|
||||
),
|
||||
@@ -504,6 +511,14 @@ def engine_needs_trim(engine: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def engine_retries_runaway(engine: str) -> bool:
|
||||
"""Whether unstable output should be retried in smaller chunks."""
|
||||
for cfg in get_tts_model_configs():
|
||||
if cfg.engine == engine:
|
||||
return cfg.retries_runaway
|
||||
return False
|
||||
|
||||
|
||||
def engine_has_model_sizes(engine: str) -> bool:
|
||||
"""Whether this engine supports multiple model sizes (only Qwen currently)."""
|
||||
configs = [c for c in get_tts_model_configs() if c.engine == engine]
|
||||
|
||||
@@ -138,6 +138,11 @@ def check_cuda_compatibility() -> tuple[bool, str | None]:
|
||||
if not torch.cuda.is_available():
|
||||
return True, None
|
||||
|
||||
# ROCm/HIP uses the cuda frontend but has different architecture names (gfx*).
|
||||
# Skip NVIDIA-specific compute capability checks on AMD hardware.
|
||||
if hasattr(torch.version, "hip") and torch.version.hip:
|
||||
return True, None
|
||||
|
||||
major, minor = torch.cuda.get_device_capability(0)
|
||||
capability = f"{major}.{minor}"
|
||||
device_name = torch.cuda.get_device_name(0)
|
||||
|
||||
@@ -146,7 +146,15 @@ class HumeTadaBackend:
|
||||
)
|
||||
|
||||
# Determine dtype — use bf16 on CUDA/XPU for ~50% memory savings
|
||||
if device == "cuda" and torch.cuda.is_bf16_supported():
|
||||
# On ROCm/AMD, torch.cuda.is_bf16_supported() works via the HIP abstraction,
|
||||
# but we wrap it defensively in case an older build lacks the symbol.
|
||||
_bf16_ok = False
|
||||
if device == "cuda":
|
||||
try:
|
||||
_bf16_ok = torch.cuda.is_bf16_supported()
|
||||
except Exception:
|
||||
_bf16_ok = False
|
||||
if _bf16_ok:
|
||||
model_dtype = torch.bfloat16
|
||||
elif device == "xpu":
|
||||
# Intel Arc (Alchemist+) supports bf16 natively
|
||||
@@ -240,9 +248,13 @@ class HumeTadaBackend:
|
||||
audio = audio.T # (samples, channels) -> (channels, samples)
|
||||
audio = audio.to(device)
|
||||
|
||||
# Encode with forced alignment
|
||||
# Encode with forced alignment.
|
||||
# Must run under inference_mode: encoder params still require
|
||||
# grad by default, and an autograd graph across the DAC/Snake
|
||||
# stack can balloon VRAM far past the model footprint (#890).
|
||||
text_arg = [reference_text] if reference_text else None
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
with torch.inference_mode():
|
||||
prompt = self.encoder(audio, text=text_arg, sample_rate=sr)
|
||||
|
||||
# Serialize EncoderOutput to a dict of CPU tensors for caching
|
||||
prompt_dict = {}
|
||||
|
||||
@@ -96,11 +96,16 @@ KOKORO_VOICES = [
|
||||
("pf_dora", "Dora", "female", "pt"),
|
||||
("pm_alex", "Alex", "male", "pt"),
|
||||
("pm_santa", "Santa", "male", "pt"),
|
||||
# Chinese
|
||||
# Chinese female
|
||||
("zf_xiaobei", "Xiaobei", "female", "zh"),
|
||||
("zf_xiaoni", "Xiaoni", "female", "zh"),
|
||||
("zf_xiaoxiao", "Xiaoxiao", "female", "zh"),
|
||||
("zf_xiaoyi", "Xiaoyi", "female", "zh"),
|
||||
# Chinese male
|
||||
("zm_yunjian", "Yunjian", "male", "zh"),
|
||||
("zm_yunxi", "Yunxi", "male", "zh"),
|
||||
("zm_yunxia", "Yunxia", "male", "zh"),
|
||||
("zm_yunyang", "Yunyang", "male", "zh"),
|
||||
]
|
||||
|
||||
# Map our ISO language codes to Kokoro lang_code characters
|
||||
|
||||
@@ -19,7 +19,6 @@ from .base import (
|
||||
manual_seed,
|
||||
model_load_progress,
|
||||
)
|
||||
from ..utils.hf_offline_patch import force_offline_if_cached
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,15 +102,19 @@ class PyTorchQwenLLMBackend:
|
||||
|
||||
with model_load_progress(progress_model_name, is_cached):
|
||||
logger.info("Loading Qwen3 %s on %s...", model_size, self.device)
|
||||
with force_offline_if_cached(is_cached, progress_model_name):
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(repo)
|
||||
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
|
||||
self.model = AutoModelForCausalLM.from_pretrained(
|
||||
repo,
|
||||
dtype=dtype,
|
||||
)
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
# Loads run with the process's default HF_HUB_OFFLINE state.
|
||||
# Forcing offline for cached models flips process-global state
|
||||
# and silently switches every concurrent download/load on other
|
||||
# threads to offline mode (issue #841) — the same regression
|
||||
# removed app-wide in #524/#530.
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(repo)
|
||||
dtype = torch.float16 if self.device in ("cuda", "mps") else torch.float32
|
||||
self.model = AutoModelForCausalLM.from_pretrained(
|
||||
repo,
|
||||
dtype=dtype,
|
||||
)
|
||||
self.model.to(self.device)
|
||||
self.model.eval()
|
||||
|
||||
self._current_model_size = model_size
|
||||
self.model_size = model_size
|
||||
@@ -223,8 +226,8 @@ class MLXQwenLLMBackend:
|
||||
|
||||
with model_load_progress(progress_model_name, is_cached):
|
||||
logger.info("Loading Qwen3 %s via MLX...", model_size)
|
||||
with force_offline_if_cached(is_cached, progress_model_name):
|
||||
loaded = mlx_load(repo)
|
||||
# See the PyTorch loader comment — no offline forcing (issue #841).
|
||||
loaded = mlx_load(repo)
|
||||
|
||||
# mlx_lm.load returns (model, tokenizer) by default and
|
||||
# (model, tokenizer, config) when return_config=True.
|
||||
|
||||
+252
-54
@@ -22,24 +22,34 @@ def is_apple_silicon():
|
||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||
|
||||
|
||||
def build_server(cuda=False):
|
||||
def build_server(cuda=False, rocm=False):
|
||||
"""Build Python server as standalone binary.
|
||||
|
||||
Args:
|
||||
cuda: If True, build with CUDA support and name the binary
|
||||
voicebox-server-cuda instead of voicebox-server.
|
||||
rocm: If True, build with ROCm support and name the binary
|
||||
voicebox-server-rocm instead of voicebox-server.
|
||||
"""
|
||||
if cuda and rocm:
|
||||
raise ValueError("Cannot build with both CUDA and ROCm support")
|
||||
|
||||
backend_dir = Path(__file__).parent
|
||||
|
||||
binary_name = "voicebox-server-cuda" if cuda else "voicebox-server"
|
||||
if rocm:
|
||||
binary_name = "voicebox-server-rocm"
|
||||
elif cuda:
|
||||
binary_name = "voicebox-server-cuda"
|
||||
else:
|
||||
binary_name = "voicebox-server"
|
||||
|
||||
# PyInstaller arguments
|
||||
# CUDA builds use --onedir so we can split the output into two archives:
|
||||
# CUDA and ROCm builds use --onedir so we can split the output into two archives:
|
||||
# 1. Server core (~200-400MB) — versioned with the app
|
||||
# 2. CUDA libs (~2GB) — versioned independently (only redownloaded on
|
||||
# CUDA toolkit / torch major version changes)
|
||||
# 2. GPU libs (~2GB) — versioned independently (only redownloaded on
|
||||
# GPU toolkit / torch major version changes)
|
||||
# CPU builds remain --onefile for simplicity.
|
||||
pack_mode = "--onedir" if cuda else "--onefile"
|
||||
pack_mode = "--onedir" if (cuda or rocm) else "--onefile"
|
||||
args = [
|
||||
"server.py", # Use server.py as entry point instead of main.py
|
||||
pack_mode,
|
||||
@@ -320,22 +330,77 @@ def build_server(cuda=False):
|
||||
]
|
||||
)
|
||||
|
||||
# Add CUDA-specific hidden imports
|
||||
if cuda:
|
||||
logger.info("Building with CUDA support")
|
||||
if sys.version_info >= (3, 13):
|
||||
args.extend(["--hidden-import", "audioop"])
|
||||
|
||||
# Add CUDA/ROCm-specific hidden imports
|
||||
if cuda or rocm:
|
||||
variant = "ROCm" if rocm else "CUDA"
|
||||
logger.info("Building with %s support", variant)
|
||||
gpu_hidden = [
|
||||
"--hidden-import",
|
||||
"torch.cuda",
|
||||
]
|
||||
# cudnn is NVIDIA-specific; ROCm uses MIOpen under the abstraction layer
|
||||
if cuda:
|
||||
gpu_hidden.extend(
|
||||
[
|
||||
"--hidden-import",
|
||||
"torch.backends.cudnn",
|
||||
]
|
||||
)
|
||||
args.extend(gpu_hidden)
|
||||
|
||||
if rocm:
|
||||
# rocm_sdk imports its backend packages dynamically via
|
||||
# importlib.import_module(py_package_name), which PyInstaller's
|
||||
# static analyzer cannot see. We must collect them explicitly —
|
||||
# otherwise only the pure-python rocm_sdk wrapper ships and
|
||||
# rocm_sdk.find_libraries crashes with UnboundLocalError at boot.
|
||||
#
|
||||
# The backend packages also contain the HIP/MIOpen/hipBLAS DLLs
|
||||
# under bin/ (plus ~750 MB of tensile kernel files under
|
||||
# bin/rocblas/library and bin/hipblaslt/library) — collect-all
|
||||
# walks the tree recursively so both DLLs and kernel data are
|
||||
# bundled. See rocm_sdk/_dist_info.py for the package mapping.
|
||||
args.extend(
|
||||
[
|
||||
"--collect-all",
|
||||
"rocm_sdk",
|
||||
"--collect-all",
|
||||
"_rocm_sdk_core",
|
||||
"--collect-all",
|
||||
"_rocm_sdk_libraries_custom",
|
||||
"--collect-all",
|
||||
"rocm_sdk_core",
|
||||
"--collect-all",
|
||||
"rocm_sdk_libraries_custom",
|
||||
"--hidden-import",
|
||||
"torch.cuda",
|
||||
"_rocm_sdk_core",
|
||||
"--hidden-import",
|
||||
"torch.backends.cudnn",
|
||||
"_rocm_sdk_libraries_custom",
|
||||
"--hidden-import",
|
||||
"rocm_sdk_core",
|
||||
"--hidden-import",
|
||||
"rocm_sdk_libraries_custom",
|
||||
"--copy-metadata",
|
||||
"rocm",
|
||||
"--copy-metadata",
|
||||
"rocm-sdk-core",
|
||||
"--copy-metadata",
|
||||
"rocm-sdk-libraries-custom",
|
||||
# Repair rocm_sdk.find_libraries (masks UnboundLocalError
|
||||
# with a readable ModuleNotFoundError on missing backends).
|
||||
"--runtime-hook",
|
||||
"pyi_rth_rocm_sdk.py",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
|
||||
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||
# modules and the binary DLLs.
|
||||
|
||||
# Exclude NVIDIA CUDA packages from non-CUDA builds to keep binary small.
|
||||
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||
# modules and the binary DLLs. This applies to CPU and ROCm builds.
|
||||
if not cuda:
|
||||
nvidia_packages = [
|
||||
"nvidia",
|
||||
"nvidia.cublas",
|
||||
@@ -354,8 +419,8 @@ def build_server(cuda=False):
|
||||
for pkg in nvidia_packages:
|
||||
args.extend(["--exclude-module", pkg])
|
||||
|
||||
# Add MLX-specific imports if building on Apple Silicon (never for CUDA builds)
|
||||
if is_apple_silicon() and not cuda:
|
||||
# Add MLX-specific imports if building on Apple Silicon (never for GPU builds)
|
||||
if is_apple_silicon() and not cuda and not rocm:
|
||||
logger.info("Building for Apple Silicon - including MLX dependencies")
|
||||
args.extend(
|
||||
[
|
||||
@@ -399,7 +464,7 @@ def build_server(cuda=False):
|
||||
"mlx_lm",
|
||||
]
|
||||
)
|
||||
elif not cuda:
|
||||
elif not cuda and not rocm:
|
||||
logger.info("Building for non-Apple Silicon platform - PyTorch only")
|
||||
|
||||
dist_dir = str(backend_dir / "dist")
|
||||
@@ -420,43 +485,128 @@ def build_server(cuda=False):
|
||||
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:
|
||||
logger.info("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
|
||||
# If CUDA or ROCm torch is installed (local dev), swap to CPU torch before
|
||||
# building, then restore afterwards. This prevents PyInstaller from bundling
|
||||
# GPU libraries into the CPU binary.
|
||||
restore_torch = None
|
||||
try:
|
||||
if not cuda and not rocm and platform.system() == "Windows":
|
||||
import subprocess
|
||||
|
||||
cuda_result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"], capture_output=True, text=True
|
||||
)
|
||||
rocm_result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.hip or '')"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if cuda_result.stdout.strip():
|
||||
restore_torch = "cuda"
|
||||
logger.info("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",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
elif rocm_result.stdout.strip():
|
||||
restore_torch = "rocm"
|
||||
logger.info("ROCm 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",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# For ROCm builds on Windows, ensure ROCm torch is installed.
|
||||
if rocm and platform.system() == "Windows":
|
||||
import subprocess
|
||||
|
||||
if sys.implementation.name != "cpython" or sys.version_info[:2] != (3, 12):
|
||||
raise RuntimeError(
|
||||
"ROCm wheels are cp312-cp312-specific; "
|
||||
f"got {sys.implementation.name} {sys.version.split()[0]}. "
|
||||
"Use CPython 3.12 to build the ROCm binary."
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.hip or '')"], capture_output=True, text=True
|
||||
)
|
||||
has_rocm_torch = bool(result.stdout.strip())
|
||||
if not has_rocm_torch:
|
||||
logger.info("ROCm torch not detected — installing ROCm torch for ROCm build...")
|
||||
|
||||
# Determine what to restore BEFORE overwriting the environment
|
||||
cuda_result = subprocess.run(
|
||||
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if cuda_result.stdout.strip():
|
||||
restore_torch = "cuda"
|
||||
else:
|
||||
restore_torch = "cpu"
|
||||
|
||||
# Now overwrite the environment safely
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_core-7.2.1-py3-none-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_devel-7.2.1-py3-none-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm_sdk_libraries_custom-7.2.1-py3-none-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/rocm-7.2.1.tar.gz",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torch-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchaudio-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchvision-0.24.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Run PyInstaller
|
||||
PyInstaller.__main__.run(args)
|
||||
finally:
|
||||
# Restore CUDA torch if we swapped it out (even on build failure)
|
||||
if restore_cuda:
|
||||
# Restore torch if we swapped it out (even on build failure)
|
||||
if restore_torch == "cuda":
|
||||
logger.info("Restoring CUDA torch...")
|
||||
import subprocess
|
||||
|
||||
@@ -472,10 +622,52 @@ def build_server(cuda=False):
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cu128",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
elif restore_torch == "rocm":
|
||||
logger.info("Restoring ROCm torch...")
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torch-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchaudio-2.9.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/torchvision-0.24.1%2Brocm7.2.1-cp312-cp312-win_amd64.whl",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
elif restore_torch == "cpu":
|
||||
logger.info("Restoring CPU torch...")
|
||||
import subprocess
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"torch",
|
||||
"torchvision",
|
||||
"torchaudio",
|
||||
"--index-url",
|
||||
"https://download.pytorch.org/whl/cpu",
|
||||
"--force-reinstall",
|
||||
"--no-deps",
|
||||
"-q",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
logger.info("Binary built in %s", backend_dir / "dist" / binary_name)
|
||||
|
||||
@@ -577,6 +769,11 @@ if __name__ == "__main__":
|
||||
action="store_true",
|
||||
help="Build CUDA-enabled binary (voicebox-server-cuda)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rocm",
|
||||
action="store_true",
|
||||
help="Build ROCm-enabled binary (voicebox-server-rocm) for AMD GPUs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shim",
|
||||
action="store_true",
|
||||
@@ -586,4 +783,5 @@ if __name__ == "__main__":
|
||||
if cli_args.shim:
|
||||
build_shim()
|
||||
else:
|
||||
build_server(cuda=cli_args.cuda)
|
||||
build_server(cuda=cli_args.cuda, rocm=cli_args.rocm)
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ def resolve_storage_path(path: str | Path | None) -> Path | None:
|
||||
return None
|
||||
|
||||
stored_path = Path(path)
|
||||
# Empty paths (e.g. failed generations) must not resolve to the data
|
||||
# dir itself, which exists and would defeat the callers' 404 guards.
|
||||
# Path("") is truthy, so check parts rather than the raw value.
|
||||
if not stored_path.parts:
|
||||
return None
|
||||
if stored_path.is_absolute():
|
||||
rebased_path = _path_relative_to_any_data_dir(stored_path)
|
||||
if rebased_path is not None:
|
||||
@@ -138,3 +143,17 @@ def get_models_dir() -> Path:
|
||||
path = _data_dir / "models"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
# Voicebox Cloud (backup & sync). Two hosts: the web app owns auth + device
|
||||
# pairing (voicebox.sh), the API owns sync + account endpoints
|
||||
# (api.voicebox.sh). Override both for local development, e.g.
|
||||
# VOICEBOX_CLOUD_URL=http://localhost:17592 VOICEBOX_CLOUD_API_URL=http://localhost:17593
|
||||
def get_cloud_web_url() -> str:
|
||||
"""Base URL of the Voicebox Cloud web app (auth + /connect + exchange)."""
|
||||
return os.environ.get("VOICEBOX_CLOUD_URL", "https://voicebox.sh").rstrip("/")
|
||||
|
||||
|
||||
def get_cloud_api_url() -> str:
|
||||
"""Base URL of the Voicebox Cloud API (bearer-authenticated sync/account)."""
|
||||
return os.environ.get("VOICEBOX_CLOUD_API_URL", "https://api.voicebox.sh").rstrip("/")
|
||||
|
||||
@@ -11,6 +11,7 @@ from .models import (
|
||||
Capture,
|
||||
CaptureSettings,
|
||||
ChannelDeviceMapping,
|
||||
CloudSettings,
|
||||
EffectPreset,
|
||||
Generation,
|
||||
GenerationSettings,
|
||||
@@ -32,6 +33,7 @@ __all__ = [
|
||||
"Capture",
|
||||
"CaptureSettings",
|
||||
"ChannelDeviceMapping",
|
||||
"CloudSettings",
|
||||
"EffectPreset",
|
||||
"Generation",
|
||||
"GenerationSettings",
|
||||
|
||||
@@ -234,6 +234,28 @@ class GenerationSettings(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class CloudSettings(Base):
|
||||
"""Singleton row holding the link to a Voicebox Cloud account.
|
||||
|
||||
Populated by the "Log in with browser" pairing flow (see services/cloud.py):
|
||||
the browser hands back a one-time code, which the backend exchanges for an
|
||||
``api_key`` it stores here. The key is a bearer credential for
|
||||
api.voicebox.sh — auth only, never an encryption key (E2E key material lives
|
||||
elsewhere). Stored in the local app database alongside the user's other data;
|
||||
moving it to the OS keychain is a future hardening step. The ``id`` is
|
||||
always 1; a null ``api_key`` means "not connected".
|
||||
"""
|
||||
|
||||
__tablename__ = "cloud_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, default=1)
|
||||
api_key = Column(String, nullable=True)
|
||||
device_name = Column(String, nullable=True)
|
||||
account_user_id = Column(String, nullable=True)
|
||||
connected_at = Column(DateTime, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class MCPClientBinding(Base):
|
||||
"""Per-MCP-client settings (voice profile, engine, personality default).
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import base64 as b64
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
@@ -49,6 +49,7 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
engine: str | None = None,
|
||||
personality: bool | None = None,
|
||||
language: str | None = None,
|
||||
model_size: Literal["1.7B", "0.6B", "1B", "3B"] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Speak ``text`` in a voice profile.
|
||||
|
||||
@@ -61,6 +62,12 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
LLM before TTS. When omitted, the per-client binding's
|
||||
``default_personality`` flag decides; when that is unset, the
|
||||
default is plain TTS.
|
||||
|
||||
``model_size`` selects a model variant for engines that ship more
|
||||
than one — ``qwen`` and ``qwen_custom_voice`` accept "1.7B" (default)
|
||||
or "0.6B"; ``tada`` accepts "1B" or "3B". Other engines ignore it.
|
||||
Omit to use the engine default. Requesting a smaller variant (e.g.
|
||||
"0.6B") is faster and avoids reloading a heavier model between calls.
|
||||
"""
|
||||
from ..database.models import MCPClientBinding
|
||||
|
||||
@@ -99,6 +106,7 @@ def register_tools(mcp: FastMCP) -> None:
|
||||
engine=resolved_engine,
|
||||
language=language,
|
||||
personality=use_persona,
|
||||
model_size=model_size,
|
||||
db=db,
|
||||
)
|
||||
finally:
|
||||
@@ -228,18 +236,23 @@ async def _speak(
|
||||
engine: str | None,
|
||||
language: str | None,
|
||||
personality: bool,
|
||||
model_size: str | None = None,
|
||||
db,
|
||||
) -> dict[str, Any]:
|
||||
"""Delegate to POST /generate — the route handles personality-rewrite
|
||||
internally when ``personality=true`` and the profile has a prompt."""
|
||||
from ..routes.generations import generate_speech
|
||||
|
||||
# model_size=None is intentional: generate_speech normalizes it to the
|
||||
# engine default (see routes/generations.py), so an omitted size behaves
|
||||
# exactly like the REST /generate endpoint with no model_size in the body.
|
||||
req = models.GenerationRequest(
|
||||
profile_id=profile_id,
|
||||
text=text,
|
||||
language=language or "en",
|
||||
engine=engine,
|
||||
personality=personality,
|
||||
model_size=model_size,
|
||||
)
|
||||
generation = await generate_speech(req, db)
|
||||
return _speak_response(generation, profile_name, source="mcp")
|
||||
|
||||
+23
-1
@@ -442,7 +442,8 @@ class HealthResponse(BaseModel):
|
||||
gpu_type: Optional[str] = None # GPU type (CUDA, MPS, or None)
|
||||
vram_used_mb: Optional[float] = None
|
||||
backend_type: Optional[str] = None # Backend type (mlx or pytorch)
|
||||
backend_variant: Optional[str] = None # Binary variant (cpu or cuda)
|
||||
backend_variant: Optional[str] = None # Binary variant (cpu, cuda, or rocm)
|
||||
supports_rocm: bool = False # AMD GPU on Windows — the ROCm backend is applicable
|
||||
gpu_compatibility_warning: Optional[str] = None # Warning if GPU arch unsupported
|
||||
|
||||
|
||||
@@ -793,3 +794,24 @@ class AvailableEffectsResponse(BaseModel):
|
||||
"""Response listing all available effect types."""
|
||||
|
||||
effects: List[AvailableEffect]
|
||||
|
||||
|
||||
# ─── Cloud (backup & sync) ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class CloudLoginStartResponse(BaseModel):
|
||||
"""Returned when the desktop kicks off browser login. The backend has
|
||||
already opened the browser; the URL is included for fallback/debugging."""
|
||||
|
||||
authorize_url: str
|
||||
|
||||
|
||||
class CloudStatusResponse(BaseModel):
|
||||
"""Current link between this device and a Voicebox Cloud account."""
|
||||
|
||||
connected: bool
|
||||
device_name: Optional[str] = None
|
||||
account_user_id: Optional[str] = None
|
||||
key_prefix: Optional[str] = None
|
||||
connected_at: Optional[datetime] = None
|
||||
dashboard_url: str
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Runtime hook: repair rocm_sdk.find_libraries under PyInstaller.
|
||||
|
||||
rocm_sdk 7.2.x ships a find_libraries() with a latent bug: when the
|
||||
backend package (_rocm_sdk_core / _rocm_sdk_libraries_{target}) cannot
|
||||
be imported, the except clause records the miss but falls through to
|
||||
`py_root = Path(py_module.__file__).parent`, where py_module was never
|
||||
assigned. This surfaces as UnboundLocalError instead of the intended
|
||||
ModuleNotFoundError, masking the real cause.
|
||||
|
||||
Frozen apps trip this because rocm_sdk imports the backend packages
|
||||
dynamically via importlib, which PyInstaller's static analyzer cannot
|
||||
see. We re-collect those packages in build_binary.py; this hook is
|
||||
defense-in-depth: it replaces find_libraries with a corrected version
|
||||
so any future missing-package case surfaces a readable error.
|
||||
"""
|
||||
|
||||
|
||||
def _patch_rocm_sdk():
|
||||
try:
|
||||
import rocm_sdk
|
||||
from rocm_sdk import _dist_info
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name not in {"rocm_sdk", "rocm_sdk._dist_info"}:
|
||||
raise
|
||||
return
|
||||
|
||||
import importlib
|
||||
import platform
|
||||
from pathlib import Path
|
||||
|
||||
def find_libraries(*shortnames):
|
||||
paths = []
|
||||
missing_extras = set()
|
||||
is_windows = platform.system() == "Windows"
|
||||
for shortname in shortnames:
|
||||
try:
|
||||
lib_entry = _dist_info.ALL_LIBRARIES[shortname]
|
||||
except KeyError:
|
||||
raise ModuleNotFoundError(f"Unknown rocm library '{shortname}'") from None
|
||||
|
||||
if is_windows and not lib_entry.dll_pattern:
|
||||
continue
|
||||
|
||||
package = lib_entry.package
|
||||
target_family = None
|
||||
if package.is_target_specific:
|
||||
target_family = _dist_info.determine_target_family()
|
||||
py_package_name = package.get_py_package_name(target_family)
|
||||
try:
|
||||
py_module = importlib.import_module(py_package_name)
|
||||
except ModuleNotFoundError as e:
|
||||
if e.name != py_package_name:
|
||||
raise
|
||||
missing_extras.add(package.logical_name)
|
||||
continue
|
||||
|
||||
py_root = Path(py_module.__file__).parent
|
||||
if is_windows:
|
||||
relpath = py_root / lib_entry.windows_relpath
|
||||
entry_pattern = lib_entry.dll_pattern
|
||||
else:
|
||||
relpath = py_root / lib_entry.posix_relpath
|
||||
entry_pattern = lib_entry.so_pattern
|
||||
matching_paths = sorted(relpath.glob(entry_pattern))
|
||||
if len(matching_paths) == 0:
|
||||
raise FileNotFoundError(
|
||||
f"Could not find rocm library '{shortname}' at path "
|
||||
f"'{relpath},' no match for pattern '{entry_pattern}'"
|
||||
)
|
||||
paths.append(matching_paths[0])
|
||||
|
||||
if missing_extras:
|
||||
raise ModuleNotFoundError(
|
||||
f"Missing required rocm backend packages: "
|
||||
f"{', '.join(sorted(missing_extras))}. The frozen build did "
|
||||
f"not bundle _rocm_sdk_core / _rocm_sdk_libraries_<target>. "
|
||||
f"Check build_binary.py --collect-all flags."
|
||||
)
|
||||
return paths
|
||||
|
||||
rocm_sdk.find_libraries = find_libraries
|
||||
|
||||
|
||||
_patch_rocm_sdk()
|
||||
@@ -16,7 +16,8 @@ miniaudio>=1.59
|
||||
# mlx_audio.stt.load) works fine on transformers 4.57.x in practice.
|
||||
#
|
||||
# Install it via `pip install --no-deps mlx-audio==0.4.1` after this file
|
||||
# (see .github/workflows/release.yml). Most other mlx-audio runtime deps
|
||||
# (see .github/workflows/release.yml and the setup-python recipe in the
|
||||
# justfile). Most other mlx-audio runtime deps
|
||||
# (huggingface_hub, librosa, mlx-lm, numba, numpy, protobuf, pyloudnorm,
|
||||
# sounddevice, tqdm) are already in requirements.txt or pulled in by
|
||||
# other engines.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
--extra-index-url https://repo.radeon.com/rocm/windows/rocm-rel-7.2.1/
|
||||
torch==2.9.1+rocm7.2.1
|
||||
torchaudio==2.9.1+rocm7.2.1
|
||||
torchvision==0.24.1+rocm7.2.1
|
||||
@@ -53,6 +53,7 @@ en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_
|
||||
unidic-lite>=1.0.8
|
||||
|
||||
# Audio processing
|
||||
audioop-lts>=0.2.1; python_version >= "3.13"
|
||||
librosa>=0.10.0
|
||||
soundfile>=0.12.0
|
||||
numpy>=1.24.0,<2.0
|
||||
|
||||
@@ -20,9 +20,11 @@ def register_routers(app: FastAPI) -> None:
|
||||
from .settings import router as settings_router
|
||||
from .tasks import router as tasks_router
|
||||
from .cuda import router as cuda_router
|
||||
from .rocm import router as rocm_router
|
||||
from .speak import router as speak_router
|
||||
from .mcp_bindings import router as mcp_bindings_router
|
||||
from .events import router as events_router
|
||||
from .cloud import router as cloud_router
|
||||
|
||||
app.include_router(health_router)
|
||||
app.include_router(profiles_router)
|
||||
@@ -39,6 +41,8 @@ def register_routers(app: FastAPI) -> None:
|
||||
app.include_router(settings_router)
|
||||
app.include_router(tasks_router)
|
||||
app.include_router(cuda_router)
|
||||
app.include_router(rocm_router)
|
||||
app.include_router(speak_router)
|
||||
app.include_router(mcp_bindings_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(cloud_router)
|
||||
|
||||
@@ -34,7 +34,7 @@ async def get_version_audio(version_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Version not found")
|
||||
|
||||
audio_path = config.resolve_storage_path(version.audio_path)
|
||||
if audio_path is None or not audio_path.exists():
|
||||
if audio_path is None or not audio_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
return FileResponse(
|
||||
@@ -52,8 +52,13 @@ async def get_audio(generation_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Generation not found")
|
||||
|
||||
audio_path = config.resolve_storage_path(generation.audio_path)
|
||||
if audio_path is None or not audio_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
if audio_path is None or not audio_path.is_file():
|
||||
detail = (
|
||||
"Generation failed; no audio available"
|
||||
if generation.status == "failed"
|
||||
else "Audio file not found"
|
||||
)
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
|
||||
return FileResponse(
|
||||
audio_path,
|
||||
@@ -72,7 +77,7 @@ async def get_sample_audio(sample_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Sample not found")
|
||||
|
||||
audio_path = config.resolve_storage_path(sample.audio_path)
|
||||
if audio_path is None or not audio_path.exists():
|
||||
if audio_path is None or not audio_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
|
||||
return FileResponse(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Voicebox Cloud device login routes.
|
||||
|
||||
The browser-based pairing flow:
|
||||
1. POST /cloud/login/start — opens the browser to the cloud authorize page.
|
||||
2. GET /cloud/callback — the browser lands here with a one-time code;
|
||||
the backend exchanges it for an API key.
|
||||
3. GET /cloud/status — the UI polls this to learn when it connected.
|
||||
4. POST /cloud/disconnect — forget the local credential.
|
||||
"""
|
||||
|
||||
import socket
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import models
|
||||
from ..database import get_db
|
||||
from ..services import cloud as cloud_service
|
||||
|
||||
router = APIRouter(prefix="/cloud", tags=["cloud"])
|
||||
|
||||
|
||||
def _callback_url(request: Request) -> str:
|
||||
# Always loopback — the cloud only redirects codes to 127.0.0.1/localhost.
|
||||
port = request.url.port or 17493
|
||||
return f"http://127.0.0.1:{port}/cloud/callback"
|
||||
|
||||
|
||||
@router.post("/login/start", response_model=models.CloudLoginStartResponse)
|
||||
async def start_cloud_login(request: Request):
|
||||
device_name = socket.gethostname() or "Desktop"
|
||||
authorize_url = cloud_service.start_login(_callback_url(request), device_name)
|
||||
return models.CloudLoginStartResponse(authorize_url=authorize_url)
|
||||
|
||||
|
||||
@router.get("/callback", response_class=HTMLResponse)
|
||||
async def cloud_callback(
|
||||
request: Request,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ok, message = await cloud_service.handle_callback(db, code=code, state=state)
|
||||
heading = "You're connected" if ok else "Couldn't connect"
|
||||
accent = "#16a34a" if ok else "#dc2626"
|
||||
sub = (
|
||||
"Voicebox is now linked to your account. You can close this tab and return to the app."
|
||||
if ok
|
||||
else message
|
||||
)
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Voicebox Cloud</title>
|
||||
<style>
|
||||
body {{ margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; background:#0b0b0d; color:#e7e7ea; }}
|
||||
.card {{ max-width:28rem; padding:2.5rem; text-align:center; }}
|
||||
h1 {{ font-size:1.5rem; margin:0 0 .5rem; color:{accent}; }}
|
||||
p {{ color:#a1a1aa; line-height:1.5; }}
|
||||
</style></head>
|
||||
<body><div class="card"><h1>{heading}</h1><p>{sub}</p></div></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200 if ok else 400)
|
||||
|
||||
|
||||
@router.get("/status", response_model=models.CloudStatusResponse)
|
||||
async def cloud_status(db: Session = Depends(get_db)):
|
||||
return models.CloudStatusResponse(**cloud_service.get_status(db))
|
||||
|
||||
|
||||
@router.post("/disconnect", response_model=models.CloudStatusResponse)
|
||||
async def cloud_disconnect(db: Session = Depends(get_db)):
|
||||
cloud_service.disconnect(db)
|
||||
return models.CloudStatusResponse(**cloud_service.get_status(db))
|
||||
@@ -26,6 +26,10 @@ async def download_cuda_backend():
|
||||
"""Download the CUDA backend binary."""
|
||||
from ..services import cuda
|
||||
|
||||
unsupported_reason = cuda.get_cuda_download_unsupported_reason()
|
||||
if unsupported_reason:
|
||||
raise HTTPException(status_code=409, detail=unsupported_reason)
|
||||
|
||||
if cuda.get_cuda_binary_path() is not None:
|
||||
raise HTTPException(status_code=409, detail="CUDA backend already downloaded")
|
||||
|
||||
|
||||
@@ -321,7 +321,13 @@ async def stream_speech(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Generate speech and stream the WAV audio directly without saving to disk."""
|
||||
from ..backends import get_tts_backend_for_engine, ensure_model_cached_or_raise, load_engine_model, engine_needs_trim
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
engine_retries_runaway,
|
||||
ensure_model_cached_or_raise,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
|
||||
profile = await profiles.get_profile(data.profile_id, db)
|
||||
if not profile:
|
||||
@@ -347,10 +353,15 @@ async def stream_speech(
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
|
||||
trim_fn = None
|
||||
runaway_detector = None
|
||||
if engine_needs_trim(engine):
|
||||
from ..utils.audio import trim_tts_output
|
||||
|
||||
trim_fn = trim_tts_output
|
||||
if engine_retries_runaway(engine):
|
||||
from ..utils.audio import has_tts_runaway
|
||||
|
||||
runaway_detector = has_tts_runaway
|
||||
|
||||
audio, sample_rate = await generate_chunked(
|
||||
tts_model,
|
||||
@@ -362,6 +373,7 @@ async def stream_speech(
|
||||
max_chunk_chars=data.max_chunk_chars,
|
||||
crossfade_ms=data.crossfade_ms,
|
||||
trim_fn=trim_fn,
|
||||
runaway_detector=runaway_detector,
|
||||
)
|
||||
|
||||
effects_chain_config = None
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
from .. import config, models
|
||||
from ..services import tts
|
||||
from ..database import get_db
|
||||
from ..utils.platform_detect import get_backend_type
|
||||
from ..utils.platform_detect import get_backend_type, is_amd_gpu_windows
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -103,7 +103,10 @@ async def health():
|
||||
|
||||
gpu_type = None
|
||||
if has_cuda:
|
||||
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
if hasattr(torch.version, "hip") and torch.version.hip:
|
||||
gpu_type = f"ROCm ({torch.cuda.get_device_name(0)})"
|
||||
else:
|
||||
gpu_type = f"CUDA ({torch.cuda.get_device_name(0)})"
|
||||
elif has_mps:
|
||||
gpu_type = "MPS (Apple Silicon)"
|
||||
elif backend_type == "mlx":
|
||||
@@ -164,6 +167,15 @@ async def health():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
default_variant = "cpu"
|
||||
if has_cuda:
|
||||
if hasattr(torch.version, "hip") and torch.version.hip:
|
||||
default_variant = "rocm"
|
||||
else:
|
||||
default_variant = "cuda"
|
||||
elif has_xpu:
|
||||
default_variant = "xpu"
|
||||
|
||||
return models.HealthResponse(
|
||||
status="healthy",
|
||||
model_loaded=model_loaded,
|
||||
@@ -173,10 +185,8 @@ async def health():
|
||||
gpu_type=gpu_type,
|
||||
vram_used_mb=vram_used,
|
||||
backend_type=backend_type,
|
||||
backend_variant=os.environ.get(
|
||||
"VOICEBOX_BACKEND_VARIANT",
|
||||
"cuda" if torch.cuda.is_available() else ("xpu" if has_xpu else "cpu"),
|
||||
),
|
||||
backend_variant=os.environ.get("VOICEBOX_BACKEND_VARIANT", default_variant),
|
||||
supports_rocm=is_amd_gpu_windows(),
|
||||
gpu_compatibility_warning=gpu_compat_warning,
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +151,9 @@ async def export_generation(
|
||||
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
|
||||
if not safe_text:
|
||||
safe_text = "generation"
|
||||
filename = f"generation-{safe_text}.voicebox.zip"
|
||||
# Append a short id so exports of similarly-worded generations don't collide
|
||||
# on the same filename (the first 30 chars are frequently identical).
|
||||
filename = f"generation-{safe_text}-{generation_id[:8]}.voicebox.zip"
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(zip_bytes),
|
||||
@@ -180,7 +182,9 @@ async def export_generation_audio(
|
||||
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
|
||||
if not safe_text:
|
||||
safe_text = "generation"
|
||||
filename = f"{safe_text}.wav"
|
||||
# Append a short id so exports of similarly-worded generations don't collide
|
||||
# on the same filename (the first 30 chars are frequently identical).
|
||||
filename = f"{safe_text}-{generation_id[:8]}.wav"
|
||||
|
||||
return FileResponse(
|
||||
audio_path,
|
||||
|
||||
@@ -231,7 +231,10 @@ async def get_model_status():
|
||||
backend_type = get_backend_type()
|
||||
task_manager = get_task_manager()
|
||||
|
||||
active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
|
||||
# Pending only — an errored task stays in the active list for the
|
||||
# error/retry UI, but reporting it as "downloading" here would mask
|
||||
# the model's real cache state until the app restarts (issue #925).
|
||||
active_download_names = {task.model_name for task in task_manager.get_pending_downloads()}
|
||||
|
||||
try:
|
||||
from huggingface_hub import scan_cache_dir
|
||||
|
||||
@@ -232,7 +232,7 @@ async def upload_profile_avatar(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload or update avatar image for a profile."""
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename).suffix) as tmp:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(file.filename or "").suffix) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""ROCm backend management endpoints."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ..services.task_queue import create_background_task
|
||||
from ..utils.progress import get_progress_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/backend/rocm-status")
|
||||
async def get_rocm_status():
|
||||
"""Get ROCm backend download/availability status."""
|
||||
from ..services import rocm
|
||||
|
||||
return rocm.get_rocm_status()
|
||||
|
||||
|
||||
@router.post("/backend/download-rocm")
|
||||
async def download_rocm_backend():
|
||||
"""Download the ROCm backend binary."""
|
||||
from ..services import rocm
|
||||
|
||||
progress_manager = get_progress_manager()
|
||||
existing = progress_manager.get_progress(rocm.PROGRESS_KEY)
|
||||
if existing and existing.get("status") in {"downloading", "extracting"}:
|
||||
raise HTTPException(status_code=409, detail="ROCm backend download already in progress")
|
||||
|
||||
async def _download():
|
||||
try:
|
||||
await rocm.download_rocm_binary()
|
||||
except Exception as e:
|
||||
logger.error("ROCm download failed: %s", e)
|
||||
|
||||
create_background_task(_download())
|
||||
return {"message": "ROCm backend download started", "progress_key": rocm.PROGRESS_KEY}
|
||||
|
||||
|
||||
@router.delete("/backend/rocm")
|
||||
async def delete_rocm_backend():
|
||||
"""Delete the downloaded ROCm backend binary."""
|
||||
from ..services import rocm
|
||||
|
||||
if rocm.is_rocm_active():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete ROCm backend while it is active. Switch to CPU first.",
|
||||
)
|
||||
|
||||
deleted = await rocm.delete_rocm_binary()
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="No ROCm backend found to delete")
|
||||
|
||||
return {"message": "ROCm backend deleted"}
|
||||
|
||||
|
||||
@router.get("/backend/rocm-progress")
|
||||
async def get_rocm_download_progress():
|
||||
"""Get ROCm backend download progress via Server-Sent Events."""
|
||||
progress_manager = get_progress_manager()
|
||||
|
||||
async def event_generator():
|
||||
async for event in progress_manager.subscribe("rocm-backend"):
|
||||
yield event
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -15,6 +15,10 @@ router = APIRouter()
|
||||
|
||||
UPLOAD_CHUNK_SIZE = 1024 * 1024 # 1MB
|
||||
|
||||
# Same set profiles.py accepts for voice samples. librosa picks its decoder from the
|
||||
# file extension, so the temp file has to keep the uploaded one.
|
||||
ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".aac", ".webm", ".opus"}
|
||||
|
||||
|
||||
@router.post("/transcribe", response_model=models.TranscriptionResponse)
|
||||
async def transcribe_audio(
|
||||
@@ -23,18 +27,33 @@ async def transcribe_audio(
|
||||
model: str | None = Form(None),
|
||||
):
|
||||
"""Transcribe audio file to text."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
uploaded_ext = Path(file.filename or "").suffix.lower()
|
||||
file_suffix = uploaded_ext if uploaded_ext in ALLOWED_AUDIO_EXTS else ".wav"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=file_suffix, delete=False) as tmp:
|
||||
while chunk := await file.read(UPLOAD_CHUNK_SIZE):
|
||||
tmp.write(chunk)
|
||||
tmp_path = tmp.name
|
||||
|
||||
stt_path = tmp_path
|
||||
try:
|
||||
from ..utils.audio import load_audio
|
||||
from ..utils.audio import load_audio, save_audio
|
||||
from ..backends import WHISPER_HF_REPOS
|
||||
|
||||
audio, sr = await asyncio.to_thread(load_audio, tmp_path)
|
||||
duration = len(audio) / sr
|
||||
|
||||
# The STT backend (mlx_audio.stt -> miniaudio) only decodes
|
||||
# WAV/FLAC/MP3/Vorbis, so browser recordings uploaded as WebM/Opus
|
||||
# fail with "unsupported file format" (issue: web-mode dictation).
|
||||
# librosa already decoded the file above (it falls back to
|
||||
# audioread/ffmpeg for exotic containers), so re-encode that PCM to a
|
||||
# temp WAV and hand *that* to Whisper. WAV inputs pass through
|
||||
# unchanged.
|
||||
if file_suffix != ".wav":
|
||||
stt_path = f"{tmp_path}.stt.wav"
|
||||
await asyncio.to_thread(save_audio, audio, stt_path, sr)
|
||||
|
||||
whisper_model = transcribe.get_whisper_model()
|
||||
model_size = model if model else whisper_model.model_size
|
||||
|
||||
@@ -69,7 +88,7 @@ async def transcribe_audio(
|
||||
},
|
||||
)
|
||||
|
||||
text = await whisper_model.transcribe(tmp_path, language, model_size)
|
||||
text = await whisper_model.transcribe(stt_path, language, model_size)
|
||||
|
||||
return models.TranscriptionResponse(
|
||||
text=text,
|
||||
@@ -82,3 +101,5 @@ async def transcribe_audio(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
Path(tmp_path).unlink(missing_ok=True)
|
||||
if stt_path != tmp_path:
|
||||
Path(stt_path).unlink(missing_ok=True)
|
||||
|
||||
+13
-10
@@ -7,6 +7,7 @@ absolute imports instead of relative imports.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
|
||||
# On Windows with --noconsole (PyInstaller), sys.stdout/stderr are None.
|
||||
# They can also be broken file objects in some edge cases.
|
||||
@@ -47,6 +48,17 @@ if "--version" in sys.argv:
|
||||
print(f"voicebox-server {__version__}")
|
||||
sys.exit(0)
|
||||
|
||||
# Detect backend variant from binary name BEFORE importing backend modules
|
||||
# so that env-var guards in app.py (e.g. HSA_OVERRIDE_GFX_VERSION) fire at import time.
|
||||
_binary_name = os.path.basename(sys.executable).lower()
|
||||
if re.search(r"voicebox-server-rocm(\.exe)?$", _binary_name):
|
||||
os.environ["VOICEBOX_BACKEND_VARIANT"] = "rocm"
|
||||
elif re.search(r"voicebox-server-cuda(\.exe)?$", _binary_name):
|
||||
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
|
||||
else:
|
||||
os.environ.setdefault("VOICEBOX_BACKEND_VARIANT", "cpu")
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
# Set up logging FIRST, before any imports that might fail
|
||||
@@ -260,16 +272,7 @@ if __name__ == "__main__":
|
||||
if args.parent_pid is not None and args.parent_pid <= 0:
|
||||
parser.error("--parent-pid must be a positive integer")
|
||||
|
||||
# Detect backend variant from binary name
|
||||
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
|
||||
import os
|
||||
binary_name = os.path.basename(sys.executable).lower()
|
||||
if "cuda" in binary_name:
|
||||
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cuda"
|
||||
logger.info("Backend variant: CUDA")
|
||||
else:
|
||||
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
|
||||
logger.info("Backend variant: CPU")
|
||||
logger.info(f"Backend variant: {os.environ.get('VOICEBOX_BACKEND_VARIANT', 'cpu').upper()}")
|
||||
|
||||
# Register parent watchdog to start after server is fully ready
|
||||
if args.parent_pid is not None:
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Voicebox Cloud device login — the "Log in with browser" flow.
|
||||
|
||||
The desktop opens the browser to ``{web}/connect``; the user authorizes while
|
||||
signed in; the cloud redirects a single-use code back to this backend's loopback
|
||||
callback. We exchange that code (server-to-server, over TLS) for a ``voicebox_…``
|
||||
API key, verify the key against the API, and store it locally. The key never
|
||||
travels through a browser URL, and an unfinished flow leaves nothing behind.
|
||||
|
||||
The ``state`` we mint and round-trip prevents login-CSRF: a callback whose state
|
||||
we didn't issue (e.g. an attacker tricking the user into hitting the loopback
|
||||
callback with their own code) is rejected.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
import webbrowser
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import config
|
||||
from ..database import CloudSettings as DBCloudSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLETON_ID = 1
|
||||
PENDING_TTL_SECONDS = 600 # the whole browser flow must finish within 10 min
|
||||
|
||||
# state -> expiry epoch. In-memory: a single backend process owns the flow, and a
|
||||
# dropped pairing should simply be restarted.
|
||||
_pending: dict[str, float] = {}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.time()
|
||||
for state, expiry in list(_pending.items()):
|
||||
if expiry < now:
|
||||
_pending.pop(state, None)
|
||||
|
||||
|
||||
def _json_dict(response: httpx.Response) -> dict | None:
|
||||
"""Parsed JSON body, or None when it isn't a JSON object."""
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _consume_state(state: str) -> bool:
|
||||
"""Validate and single-use-consume a pending state."""
|
||||
_prune()
|
||||
expiry = _pending.pop(state, None)
|
||||
return expiry is not None and expiry >= time.time()
|
||||
|
||||
|
||||
def start_login(callback_url: str, device_name: str) -> str:
|
||||
"""Mint a state, build the authorize URL, and open the browser.
|
||||
|
||||
Returns the authorize URL (also opened here) so the caller can surface it as
|
||||
a fallback if the browser didn't open.
|
||||
"""
|
||||
state = secrets.token_urlsafe(24)
|
||||
_prune()
|
||||
_pending[state] = time.time() + PENDING_TTL_SECONDS
|
||||
|
||||
params = urlencode({"redirect_uri": callback_url, "state": state, "name": device_name})
|
||||
authorize_url = f"{config.get_cloud_web_url()}/connect?{params}"
|
||||
|
||||
try:
|
||||
webbrowser.open(authorize_url)
|
||||
except Exception: # pragma: no cover - platform dependent
|
||||
logger.exception("failed to open browser for cloud login")
|
||||
|
||||
return authorize_url
|
||||
|
||||
|
||||
async def handle_callback(db: Session, code: str, state: str) -> tuple[bool, str]:
|
||||
"""Exchange the code for an API key and store it. Returns (ok, message)."""
|
||||
if not _consume_state(state):
|
||||
return False, "This sign-in link is invalid or has expired. Start again from the app."
|
||||
if not code:
|
||||
return False, "Missing authorization code."
|
||||
|
||||
web = config.get_cloud_web_url()
|
||||
api = config.get_cloud_api_url()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
exchanged = await client.post(f"{web}/api/connect/exchange", json={"code": code})
|
||||
if exchanged.status_code != 200:
|
||||
logger.warning("cloud exchange rejected code: %s", exchanged.status_code)
|
||||
return False, "Could not complete sign-in — the code was rejected."
|
||||
payload = _json_dict(exchanged)
|
||||
if payload is None:
|
||||
logger.warning("cloud exchange returned a non-JSON payload")
|
||||
return False, "Voicebox Cloud returned an unexpected response."
|
||||
api_key = payload.get("key")
|
||||
device_name = payload.get("label")
|
||||
if not api_key:
|
||||
return False, "Voicebox Cloud did not return a key."
|
||||
|
||||
# Confirm the freshly minted key actually authenticates the API.
|
||||
me = await client.get(
|
||||
f"{api}/v1/account/me",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
if me.status_code != 200:
|
||||
logger.warning("minted key failed verification: %s", me.status_code)
|
||||
return False, "Sign-in succeeded but the key could not be verified."
|
||||
# The 200 above proves the key works; the user id is best-effort.
|
||||
data = (_json_dict(me) or {}).get("data")
|
||||
account_user_id = data.get("userId") if isinstance(data, dict) else None
|
||||
except httpx.HTTPError:
|
||||
logger.exception("network error during cloud exchange")
|
||||
return False, "Could not reach Voicebox Cloud. Check your connection and try again."
|
||||
|
||||
_store_key(db, api_key=api_key, device_name=device_name, account_user_id=account_user_id)
|
||||
logger.info("connected to Voicebox Cloud as device %r", device_name)
|
||||
return True, "Connected"
|
||||
|
||||
|
||||
def _get_or_create_row(db: Session) -> DBCloudSettings:
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).first()
|
||||
if row is None:
|
||||
row = DBCloudSettings(id=SINGLETON_ID)
|
||||
db.add(row)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Another request created the singleton concurrently.
|
||||
db.rollback()
|
||||
row = db.query(DBCloudSettings).filter(DBCloudSettings.id == SINGLETON_ID).one()
|
||||
else:
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _store_key(db: Session, *, api_key: str, device_name: str | None, account_user_id: str | None):
|
||||
from datetime import datetime
|
||||
|
||||
row = _get_or_create_row(db)
|
||||
row.api_key = api_key
|
||||
row.device_name = device_name
|
||||
row.account_user_id = account_user_id
|
||||
row.connected_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_status(db: Session) -> dict:
|
||||
"""Local view of the cloud link — never returns the full key."""
|
||||
row = _get_or_create_row(db)
|
||||
connected = bool(row.api_key)
|
||||
# Prefix only: "voicebox_" (9) + 8 chars, matching the cloud's key_prefix.
|
||||
key_prefix = row.api_key[:17] if row.api_key else None
|
||||
return {
|
||||
"connected": connected,
|
||||
"device_name": row.device_name if connected else None,
|
||||
"account_user_id": row.account_user_id if connected else None,
|
||||
"key_prefix": key_prefix,
|
||||
"connected_at": row.connected_at if connected else None,
|
||||
"dashboard_url": f"{config.get_cloud_web_url()}/account",
|
||||
}
|
||||
|
||||
|
||||
def disconnect(db: Session) -> None:
|
||||
"""Forget the local credential. The key remains valid on the server until
|
||||
revoked from the account dashboard — surface that in the UI."""
|
||||
row = _get_or_create_row(db)
|
||||
row.api_key = None
|
||||
row.device_name = None
|
||||
row.account_user_id = None
|
||||
row.connected_at = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_api_key(db: Session) -> str | None:
|
||||
"""The stored bearer key, for the (future) sync client. None if not linked."""
|
||||
row = _get_or_create_row(db)
|
||||
return row.api_key
|
||||
@@ -21,9 +21,9 @@ import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .. import __version__
|
||||
from ..config import get_data_dir
|
||||
from ..utils.progress import get_progress_manager
|
||||
from .. import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +31,8 @@ GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
|
||||
|
||||
PROGRESS_KEY = "cuda-backend"
|
||||
|
||||
CUDA_DOWNLOAD_UNSUPPORTED_REASON = "Downloadable CUDA backend releases are currently only published for Windows."
|
||||
|
||||
# The current expected CUDA libs version. Bump this when we change the
|
||||
# CUDA toolkit version or torch's CUDA dependency changes (e.g. cu126 -> cu128).
|
||||
CUDA_LIBS_VERSION = "cu128-v1"
|
||||
@@ -63,6 +65,25 @@ def get_cuda_exe_name() -> str:
|
||||
return "voicebox-server-cuda"
|
||||
|
||||
|
||||
def is_cuda_download_supported() -> bool:
|
||||
"""Return whether this platform has a matching CUDA release asset."""
|
||||
return sys.platform == "win32"
|
||||
|
||||
|
||||
def get_cuda_download_unsupported_reason() -> str | None:
|
||||
"""Explain why this platform cannot use the release-download flow."""
|
||||
if is_cuda_download_supported():
|
||||
return None
|
||||
return CUDA_DOWNLOAD_UNSUPPORTED_REASON
|
||||
|
||||
|
||||
def ensure_cuda_download_supported() -> None:
|
||||
"""Raise if downloading would fetch an asset built for another platform."""
|
||||
reason = get_cuda_download_unsupported_reason()
|
||||
if reason:
|
||||
raise RuntimeError(reason)
|
||||
|
||||
|
||||
def get_cuda_binary_path() -> Optional[Path]:
|
||||
"""Return path to the CUDA executable if it exists inside the onedir."""
|
||||
p = get_cuda_dir() / get_cuda_exe_name()
|
||||
@@ -103,12 +124,15 @@ def get_cuda_status() -> dict:
|
||||
cuda_path = get_cuda_binary_path()
|
||||
progress = progress_manager.get_progress(PROGRESS_KEY)
|
||||
cuda_libs_version = get_installed_cuda_libs_version()
|
||||
unsupported_reason = get_cuda_download_unsupported_reason()
|
||||
|
||||
return {
|
||||
"available": cuda_path is not None,
|
||||
"active": is_cuda_active(),
|
||||
"binary_path": str(cuda_path) if cuda_path else None,
|
||||
"cuda_libs_version": cuda_libs_version,
|
||||
"download_supported": unsupported_reason is None,
|
||||
"unsupported_reason": unsupported_reason,
|
||||
"downloading": progress is not None and progress.get("status") == "downloading",
|
||||
"download_progress": progress,
|
||||
}
|
||||
@@ -257,6 +281,8 @@ async def download_cuda_binary(version: Optional[str] = None):
|
||||
|
||||
async def _download_cuda_binary_locked(version: Optional[str] = None):
|
||||
"""Inner implementation of download_cuda_binary, called under _download_lock."""
|
||||
ensure_cuda_download_supported()
|
||||
|
||||
import httpx
|
||||
|
||||
if version is None:
|
||||
@@ -387,6 +413,11 @@ async def check_and_update_cuda_binary():
|
||||
if not cuda_path:
|
||||
return # No CUDA binary installed, nothing to update
|
||||
|
||||
unsupported_reason = get_cuda_download_unsupported_reason()
|
||||
if unsupported_reason:
|
||||
logger.info("Skipping CUDA backend auto-update: %s", unsupported_reason)
|
||||
return
|
||||
|
||||
need_server = _needs_server_download()
|
||||
need_libs = _needs_cuda_libs_download()
|
||||
|
||||
|
||||
@@ -48,9 +48,14 @@ async def run_generation(
|
||||
This is the single entry point for all background generation work.
|
||||
It is designed to be enqueued via ``services.task_queue.enqueue_generation``.
|
||||
"""
|
||||
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
engine_retries_runaway,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from ..utils.audio import normalize_audio, save_audio, trim_tts_output
|
||||
from ..utils.audio import has_tts_runaway, normalize_audio, save_audio, trim_tts_output
|
||||
|
||||
task_manager = get_task_manager()
|
||||
bg_db = next(get_db())
|
||||
@@ -72,12 +77,14 @@ async def run_generation(
|
||||
|
||||
await history.update_generation_status(generation_id, "generating", bg_db)
|
||||
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
|
||||
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None
|
||||
|
||||
gen_kwargs: dict = dict(
|
||||
language=language,
|
||||
seed=seed if mode != "regenerate" else None,
|
||||
instruct=instruct,
|
||||
trim_fn=trim_fn,
|
||||
runaway_detector=runaway_detector,
|
||||
)
|
||||
if max_chunk_chars is not None:
|
||||
gen_kwargs["max_chunk_chars"] = max_chunk_chars
|
||||
@@ -267,9 +274,14 @@ async def generate_audio_sync(
|
||||
normalize, then encodes in-memory via :func:`tts.audio_to_wav_bytes`
|
||||
(same helper ``/generate/stream`` uses).
|
||||
"""
|
||||
from ..backends import load_engine_model, get_tts_backend_for_engine, engine_needs_trim
|
||||
from ..backends import (
|
||||
engine_needs_trim,
|
||||
engine_retries_runaway,
|
||||
get_tts_backend_for_engine,
|
||||
load_engine_model,
|
||||
)
|
||||
from ..utils.chunked_tts import generate_chunked
|
||||
from ..utils.audio import normalize_audio, trim_tts_output
|
||||
from ..utils.audio import has_tts_runaway, normalize_audio, trim_tts_output
|
||||
from . import tts
|
||||
|
||||
bg_db = next(get_db())
|
||||
@@ -287,12 +299,14 @@ async def generate_audio_sync(
|
||||
bg_db.close()
|
||||
|
||||
trim_fn = trim_tts_output if engine_needs_trim(engine) else None
|
||||
runaway_detector = has_tts_runaway if engine_retries_runaway(engine) else None
|
||||
|
||||
gen_kwargs: dict = dict(
|
||||
language=language,
|
||||
seed=seed,
|
||||
instruct=instruct,
|
||||
trim_fn=trim_fn,
|
||||
runaway_detector=runaway_detector,
|
||||
)
|
||||
if max_chunk_chars is not None:
|
||||
gen_kwargs["max_chunk_chars"] = max_chunk_chars
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
"""
|
||||
ROCm backend download, assembly, and verification.
|
||||
|
||||
Downloads two archives from GitHub Releases:
|
||||
1. Server core (voicebox-server-rocm.tar.gz) — the exe + non-AMD deps,
|
||||
versioned with the app.
|
||||
2. ROCm libs (rocm-libs-{version}.tar.gz) — AMD runtime libraries,
|
||||
versioned independently (only redownloaded on ROCm toolkit bump).
|
||||
|
||||
Both archives are extracted into {data_dir}/backends/rocm/ which forms the
|
||||
complete PyInstaller --onedir directory structure that torch expects.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..config import get_data_dir
|
||||
from ..utils.progress import get_progress_manager
|
||||
from .. import __version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_RELEASES_URL = "https://github.com/jamiepine/voicebox/releases/download"
|
||||
|
||||
PROGRESS_KEY = "rocm-backend"
|
||||
|
||||
# The current expected ROCm libs version. Bump this when we change the
|
||||
# ROCm toolkit version or torch's ROCm dependency changes (e.g. rocm7.2 -> rocm7.4).
|
||||
ROCM_LIBS_VERSION = "rocm7.2-v1"
|
||||
|
||||
# Prevents concurrent download_rocm_binary() calls from racing on the same
|
||||
# temp file. The auto-update background task and the manual HTTP endpoint
|
||||
# can both invoke download_rocm_binary(); without this lock the progress-
|
||||
# manager status check is a TOCTOU race.
|
||||
_download_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def get_backends_dir() -> Path:
|
||||
"""Directory where downloaded backend binaries are stored."""
|
||||
d = get_data_dir() / "backends"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def get_rocm_dir() -> Path:
|
||||
"""Directory where the ROCm backend (onedir) is extracted."""
|
||||
d = get_backends_dir() / "rocm"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def get_rocm_exe_name() -> str:
|
||||
"""Platform-specific ROCm executable filename."""
|
||||
if sys.platform == "win32":
|
||||
return "voicebox-server-rocm.exe"
|
||||
return "voicebox-server-rocm"
|
||||
|
||||
|
||||
def get_rocm_binary_path() -> Optional[Path]:
|
||||
"""Return path to the ROCm executable if it exists inside the onedir."""
|
||||
p = get_rocm_dir() / get_rocm_exe_name()
|
||||
if p.exists():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def get_rocm_libs_manifest_path() -> Path:
|
||||
"""Path to the rocm-libs.json manifest inside the ROCm dir."""
|
||||
return get_rocm_dir() / "rocm-libs.json"
|
||||
|
||||
|
||||
def get_installed_rocm_libs_version() -> Optional[str]:
|
||||
"""Read the installed ROCm libs version from rocm-libs.json, or None."""
|
||||
manifest_path = get_rocm_libs_manifest_path()
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(manifest_path.read_text())
|
||||
return data.get("version")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not read rocm-libs.json: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_rocm_active() -> bool:
|
||||
"""Check if the current process is the ROCm binary.
|
||||
|
||||
The ROCm binary sets this env var on startup (see server.py).
|
||||
"""
|
||||
return os.environ.get("VOICEBOX_BACKEND_VARIANT") == "rocm"
|
||||
|
||||
|
||||
def get_rocm_status() -> dict:
|
||||
"""Get current ROCm backend status for the API."""
|
||||
progress_manager = get_progress_manager()
|
||||
rocm_path = get_rocm_binary_path()
|
||||
progress = progress_manager.get_progress(PROGRESS_KEY)
|
||||
rocm_libs_version = get_installed_rocm_libs_version()
|
||||
|
||||
return {
|
||||
"available": rocm_path is not None,
|
||||
"active": is_rocm_active(),
|
||||
"binary_path": str(rocm_path) if rocm_path else None,
|
||||
"rocm_libs_version": rocm_libs_version,
|
||||
"downloading": progress is not None and progress.get("status") == "downloading",
|
||||
"download_progress": progress,
|
||||
}
|
||||
|
||||
|
||||
def _needs_server_download(version: Optional[str] = None) -> bool:
|
||||
"""Check if the server core archive needs to be (re)downloaded."""
|
||||
rocm_path = get_rocm_binary_path()
|
||||
if not rocm_path:
|
||||
return True
|
||||
# Check if the binary version matches the expected app version
|
||||
installed = get_rocm_binary_version()
|
||||
expected = version or __version__
|
||||
if expected.startswith("v"):
|
||||
expected = expected[1:]
|
||||
return installed != expected
|
||||
|
||||
|
||||
def _needs_rocm_libs_download() -> bool:
|
||||
"""Check if the ROCm libs archive needs to be (re)downloaded."""
|
||||
installed = get_installed_rocm_libs_version()
|
||||
if installed is None:
|
||||
return True
|
||||
return installed != ROCM_LIBS_VERSION
|
||||
|
||||
|
||||
async def _download_and_extract_archive(
|
||||
client,
|
||||
url: str,
|
||||
sha256_url: Optional[str],
|
||||
dest_dir: Path,
|
||||
label: str,
|
||||
progress_offset: int,
|
||||
total_size: int,
|
||||
):
|
||||
"""Download a .tar.gz archive and extract it into dest_dir.
|
||||
|
||||
Args:
|
||||
client: httpx.AsyncClient
|
||||
url: URL of the .tar.gz archive
|
||||
sha256_url: URL of the .sha256 checksum file (optional)
|
||||
dest_dir: Directory to extract into
|
||||
label: Human-readable label for progress updates
|
||||
progress_offset: Byte offset for progress reporting (when downloading
|
||||
multiple archives sequentially)
|
||||
total_size: Total bytes across all downloads (for progress bar)
|
||||
"""
|
||||
progress = get_progress_manager()
|
||||
temp_path = dest_dir / f".download-{label.replace(' ', '-')}.tmp"
|
||||
|
||||
# Clean up leftover partial download
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
# Fetch expected checksum (fail-fast: never extract an unverified archive)
|
||||
expected_sha = None
|
||||
if sha256_url:
|
||||
try:
|
||||
sha_resp = await client.get(sha256_url)
|
||||
sha_resp.raise_for_status()
|
||||
expected_sha = sha_resp.text.strip().split()[0]
|
||||
logger.info(f"{label}: expected SHA-256: {expected_sha[:16]}...")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"{label}: failed to fetch checksum from {sha256_url}") from e
|
||||
|
||||
# Stream download, verify, and extract — always clean up temp file
|
||||
downloaded = 0
|
||||
try:
|
||||
async with client.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
with open(temp_path, "wb") as f:
|
||||
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=progress_offset + downloaded,
|
||||
total=total_size,
|
||||
filename=f"Downloading {label}",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Verify integrity
|
||||
if expected_sha:
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=progress_offset + downloaded,
|
||||
total=total_size,
|
||||
filename=f"Verifying {label}...",
|
||||
status="downloading",
|
||||
)
|
||||
sha256 = hashlib.sha256()
|
||||
with open(temp_path, "rb") as f:
|
||||
while True:
|
||||
data = f.read(1024 * 1024)
|
||||
if not data:
|
||||
break
|
||||
sha256.update(data)
|
||||
actual = sha256.hexdigest()
|
||||
if actual != expected_sha:
|
||||
raise ValueError(
|
||||
f"{label} integrity check failed: expected {expected_sha[:16]}..., got {actual[:16]}..."
|
||||
)
|
||||
logger.info(f"{label}: integrity verified")
|
||||
|
||||
# Extract (use data filter for path traversal protection on Python 3.12+)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=progress_offset + downloaded,
|
||||
total=total_size,
|
||||
filename=f"Extracting {label}...",
|
||||
status="downloading",
|
||||
)
|
||||
with tarfile.open(temp_path, "r:gz") as tar:
|
||||
tar.extractall(path=dest_dir, filter="data")
|
||||
|
||||
logger.info(f"{label}: extracted to {dest_dir}")
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
return downloaded
|
||||
|
||||
|
||||
async def download_rocm_binary(version: Optional[str] = None):
|
||||
"""Download the ROCm backend (server core + ROCm libs if needed).
|
||||
|
||||
Downloads both archives from GitHub Releases, extracts them into
|
||||
{data_dir}/backends/rocm/, and writes the rocm-libs.json manifest.
|
||||
|
||||
Only downloads what's needed:
|
||||
- Server core: always redownloaded (versioned with app)
|
||||
- ROCm libs: only if missing or version mismatch
|
||||
|
||||
Args:
|
||||
version: Version tag (e.g. "v0.3.0"). Defaults to current app version.
|
||||
"""
|
||||
if _download_lock.locked():
|
||||
logger.info("ROCm download already in progress, skipping duplicate request")
|
||||
return
|
||||
async with _download_lock:
|
||||
await _download_rocm_binary_locked(version)
|
||||
|
||||
|
||||
async def _download_rocm_binary_locked(version: Optional[str] = None):
|
||||
"""Inner implementation of download_rocm_binary, called under _download_lock."""
|
||||
import httpx
|
||||
|
||||
if version is None:
|
||||
version = f"v{__version__}"
|
||||
|
||||
progress = get_progress_manager()
|
||||
rocm_dir = get_rocm_dir()
|
||||
|
||||
need_server = _needs_server_download(version)
|
||||
need_libs = _needs_rocm_libs_download()
|
||||
|
||||
if not need_server and not need_libs:
|
||||
logger.info("ROCm backend is up to date, nothing to download")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting ROCm backend download for {version} "
|
||||
f"(server={'yes' if need_server else 'cached'}, "
|
||||
f"libs={'yes' if need_libs else 'cached'})"
|
||||
)
|
||||
progress.update_progress(
|
||||
PROGRESS_KEY,
|
||||
current=0,
|
||||
total=0,
|
||||
filename="Preparing download...",
|
||||
status="downloading",
|
||||
)
|
||||
|
||||
# Server core and libs archive are both published under the app-version
|
||||
# release tag; the libs content version is encoded in the filename only.
|
||||
server_base_url = f"{GITHUB_RELEASES_URL}/{version}"
|
||||
libs_base_url = server_base_url
|
||||
server_archive = "voicebox-server-rocm.tar.gz"
|
||||
libs_archive = f"rocm-libs-{ROCM_LIBS_VERSION}.tar.gz"
|
||||
|
||||
# Always stage when any download is needed, then atomically rename over
|
||||
# rocm_dir on success. This prevents a failed mid-extraction from leaving
|
||||
# rocm_dir in a partially-installed state that still passes the
|
||||
# get_rocm_binary_path() existence check. Existing files are pre-copied
|
||||
# into staging so partial updates (e.g. libs-only or server-only) preserve
|
||||
# whatever isn't being re-downloaded.
|
||||
use_staging = need_server or need_libs
|
||||
staging_dir = get_backends_dir() / "rocm-staging"
|
||||
|
||||
if use_staging:
|
||||
if staging_dir.exists():
|
||||
shutil.rmtree(staging_dir)
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Preserve existing files (server or libs) that don't need re-downloading.
|
||||
# Extracted archives will overwrite only what we actually download.
|
||||
if rocm_dir.exists():
|
||||
shutil.copytree(rocm_dir, staging_dir, dirs_exist_ok=True)
|
||||
extract_dir = staging_dir
|
||||
else:
|
||||
extract_dir = rocm_dir
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client:
|
||||
# Estimate total download size
|
||||
total_size = 0
|
||||
if need_server:
|
||||
try:
|
||||
head = await client.head(f"{server_base_url}/{server_archive}")
|
||||
total_size += int(head.headers.get("content-length", 0))
|
||||
except Exception:
|
||||
pass
|
||||
if need_libs:
|
||||
try:
|
||||
head = await client.head(f"{libs_base_url}/{libs_archive}")
|
||||
total_size += int(head.headers.get("content-length", 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Total download size: {total_size / 1024 / 1024:.1f} MB")
|
||||
|
||||
offset = 0
|
||||
|
||||
# Download server core
|
||||
if need_server:
|
||||
server_downloaded = await _download_and_extract_archive(
|
||||
client,
|
||||
url=f"{server_base_url}/{server_archive}",
|
||||
sha256_url=f"{server_base_url}/{server_archive}.sha256",
|
||||
dest_dir=extract_dir,
|
||||
label="ROCm server",
|
||||
progress_offset=offset,
|
||||
total_size=total_size,
|
||||
)
|
||||
offset += server_downloaded
|
||||
|
||||
# Make executable on Unix
|
||||
exe_path = extract_dir / get_rocm_exe_name()
|
||||
if sys.platform != "win32" and exe_path.exists():
|
||||
exe_path.chmod(0o755)
|
||||
|
||||
# Download ROCm libs
|
||||
if need_libs:
|
||||
await _download_and_extract_archive(
|
||||
client,
|
||||
url=f"{libs_base_url}/{libs_archive}",
|
||||
sha256_url=f"{libs_base_url}/{libs_archive}.sha256",
|
||||
dest_dir=extract_dir,
|
||||
label="ROCm libraries",
|
||||
progress_offset=offset,
|
||||
total_size=total_size,
|
||||
)
|
||||
|
||||
# Write local rocm-libs.json manifest
|
||||
manifest = {"version": ROCM_LIBS_VERSION}
|
||||
(extract_dir / "rocm-libs.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||
|
||||
# Atomic swap: replace rocm_dir with the fully-extracted staging dir
|
||||
if use_staging:
|
||||
backup_dir = get_backends_dir() / "rocm-backup"
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
if rocm_dir.exists():
|
||||
rocm_dir.rename(backup_dir)
|
||||
try:
|
||||
staging_dir.rename(rocm_dir)
|
||||
except Exception:
|
||||
if backup_dir.exists() and not rocm_dir.exists():
|
||||
backup_dir.rename(rocm_dir)
|
||||
raise
|
||||
else:
|
||||
if backup_dir.exists():
|
||||
shutil.rmtree(backup_dir)
|
||||
|
||||
logger.info(f"ROCm backend ready at {rocm_dir}")
|
||||
progress.mark_complete(PROGRESS_KEY)
|
||||
|
||||
except Exception as e:
|
||||
if use_staging and staging_dir.exists():
|
||||
shutil.rmtree(staging_dir)
|
||||
logger.error(f"ROCm backend download failed: {e}")
|
||||
progress.mark_error(PROGRESS_KEY, str(e))
|
||||
raise
|
||||
|
||||
|
||||
def get_rocm_binary_version() -> Optional[str]:
|
||||
"""Get the version of the installed ROCm binary, or None if not installed."""
|
||||
import subprocess
|
||||
|
||||
rocm_path = get_rocm_binary_path()
|
||||
if not rocm_path:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(rocm_path), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(rocm_path.parent), # Run from the onedir directory
|
||||
)
|
||||
# Output format: "voicebox-server 0.3.0"
|
||||
for line in result.stdout.strip().splitlines():
|
||||
if "voicebox-server" in line:
|
||||
return line.split()[-1]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get ROCm binary version: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_and_update_rocm_binary():
|
||||
"""Check if the ROCm binary is outdated and auto-download if so.
|
||||
|
||||
Called on server startup. Checks both server version and ROCm libs
|
||||
version. Downloads only what's needed.
|
||||
"""
|
||||
rocm_path = get_rocm_binary_path()
|
||||
if not rocm_path:
|
||||
return # No ROCm binary installed, nothing to update
|
||||
|
||||
if is_rocm_active():
|
||||
logger.info("ROCm backend is active; skipping auto-update to avoid replacing the running backend")
|
||||
return
|
||||
|
||||
need_server = _needs_server_download()
|
||||
need_libs = _needs_rocm_libs_download()
|
||||
|
||||
if not need_server and not need_libs:
|
||||
logger.info(f"ROCm binary is up to date (server=v{__version__}, libs={get_installed_rocm_libs_version()})")
|
||||
return
|
||||
|
||||
reasons = []
|
||||
if need_server:
|
||||
rocm_version = get_rocm_binary_version()
|
||||
reasons.append(f"server v{rocm_version} != v{__version__}")
|
||||
if need_libs:
|
||||
installed_libs = get_installed_rocm_libs_version()
|
||||
reasons.append(f"libs {installed_libs} != {ROCM_LIBS_VERSION}")
|
||||
|
||||
logger.info(f"ROCm backend needs update ({', '.join(reasons)}). Auto-downloading...")
|
||||
|
||||
try:
|
||||
await download_rocm_binary()
|
||||
except Exception as e:
|
||||
logger.error(f"Auto-update of ROCm binary failed: {e}")
|
||||
|
||||
|
||||
async def delete_rocm_binary() -> bool:
|
||||
"""Delete the downloaded ROCm backend directory. Returns True if deleted."""
|
||||
import shutil
|
||||
|
||||
rocm_dir = get_rocm_dir()
|
||||
if rocm_dir.exists() and any(rocm_dir.iterdir()):
|
||||
shutil.rmtree(rocm_dir)
|
||||
logger.info(f"Deleted ROCm backend directory: {rocm_dir}")
|
||||
return True
|
||||
return False
|
||||
@@ -125,12 +125,24 @@ async def list_stories(
|
||||
"""
|
||||
stories = db.query(DBStory).order_by(DBStory.updated_at.desc()).all()
|
||||
|
||||
if not stories:
|
||||
return []
|
||||
|
||||
# Batch-fetch all story item counts in one query to avoid an N+1 pattern
|
||||
# (previously there was one COUNT query per story in the loop below).
|
||||
story_ids = [s.id for s in stories]
|
||||
count_rows = (
|
||||
db.query(DBStoryItem.story_id, func.count(DBStoryItem.id).label("cnt"))
|
||||
.filter(DBStoryItem.story_id.in_(story_ids))
|
||||
.group_by(DBStoryItem.story_id)
|
||||
.all()
|
||||
)
|
||||
item_counts = {row.story_id: row.cnt for row in count_rows}
|
||||
|
||||
result = []
|
||||
for story in stories:
|
||||
item_count = db.query(func.count(DBStoryItem.id)).filter(DBStoryItem.story_id == story.id).scalar()
|
||||
|
||||
response = StoryResponse.model_validate(story)
|
||||
response.item_count = item_count
|
||||
response.item_count = item_counts.get(story.id, 0)
|
||||
result.append(response)
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Phase 2.1 Test: AMD GPU detection on Windows.
|
||||
|
||||
Validates is_amd_gpu_windows() via mocked WMI and torch queries.
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_amd_gpu_detect.py -v
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.utils.platform_detect import is_amd_gpu_windows
|
||||
|
||||
|
||||
class TestAmdGpuWindows:
|
||||
"""Unit tests for is_amd_gpu_windows with mocks."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_detection_cache(self):
|
||||
# is_amd_gpu_windows is memoized; reset between cases so each mock takes effect.
|
||||
is_amd_gpu_windows.cache_clear()
|
||||
yield
|
||||
is_amd_gpu_windows.cache_clear()
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Linux")
|
||||
def test_returns_false_on_linux(self, _mock_system):
|
||||
"""Non-Windows platforms should always return False."""
|
||||
assert is_amd_gpu_windows() is False
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
|
||||
@patch(
|
||||
"backend.utils.platform_detect.subprocess.run",
|
||||
return_value=MagicMock(stdout="1\n", returncode=0),
|
||||
)
|
||||
def test_detects_amd_via_wmi(self, _mock_run, _mock_system):
|
||||
"""WMI reporting an AMD adapter should return True."""
|
||||
assert is_amd_gpu_windows() is True
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
|
||||
@patch(
|
||||
"backend.utils.platform_detect.subprocess.run",
|
||||
return_value=MagicMock(stdout="0\n", returncode=0),
|
||||
)
|
||||
def test_no_amd_via_wmi(self, _mock_run, _mock_system):
|
||||
"""WMI reporting zero AMD adapters should return False."""
|
||||
assert is_amd_gpu_windows() is False
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
|
||||
@patch(
|
||||
"backend.utils.platform_detect.subprocess.run",
|
||||
side_effect=Exception("WMI not available"),
|
||||
)
|
||||
@patch("torch.cuda.is_available", return_value=True)
|
||||
@patch(
|
||||
"torch.cuda.get_device_name",
|
||||
return_value="AMD Radeon RX 7800 XT",
|
||||
)
|
||||
def test_fallback_to_torch_radeon(self, _mock_name, _mock_avail, _mock_run, _mock_system):
|
||||
"""When WMI fails, torch.cuda.get_device_name('Radeon') should return True."""
|
||||
assert is_amd_gpu_windows() is True
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
|
||||
@patch(
|
||||
"backend.utils.platform_detect.subprocess.run",
|
||||
side_effect=Exception("WMI not available"),
|
||||
)
|
||||
@patch("torch.cuda.is_available", return_value=True)
|
||||
@patch(
|
||||
"torch.cuda.get_device_name",
|
||||
return_value="NVIDIA GeForce RTX 4090",
|
||||
)
|
||||
def test_fallback_to_torch_nvidia(self, _mock_name, _mock_avail, _mock_run, _mock_system):
|
||||
"""When WMI fails, torch.cuda.get_device_name('NVIDIA') should return False."""
|
||||
assert is_amd_gpu_windows() is False
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
|
||||
@patch(
|
||||
"backend.utils.platform_detect.subprocess.run",
|
||||
side_effect=Exception("WMI not available"),
|
||||
)
|
||||
@patch("torch.cuda.is_available", return_value=False)
|
||||
def test_no_torch_cuda(self, _mock_avail, _mock_run, _mock_system):
|
||||
"""When WMI fails and torch.cuda is unavailable, should return False."""
|
||||
assert is_amd_gpu_windows() is False
|
||||
|
||||
@patch("backend.utils.platform_detect.platform.system", return_value="Windows")
|
||||
@patch(
|
||||
"backend.utils.platform_detect.subprocess.run",
|
||||
side_effect=Exception("WMI not available"),
|
||||
)
|
||||
def test_torch_not_installed(self, _mock_run, _mock_system):
|
||||
"""When torch is not installed, should return False without crashing."""
|
||||
with patch.dict("sys.modules", {"torch": None}):
|
||||
assert is_amd_gpu_windows() is False
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Regression tests for GET /audio/{generation_id} on failed generations.
|
||||
|
||||
A failed generation stores an empty ``audio_path``. Previously,
|
||||
``config.resolve_storage_path("")`` resolved to the data directory itself,
|
||||
which exists, so the route's 404 guard passed and ``FileResponse`` raised
|
||||
``RuntimeError: File at path .../data is not a file`` — a 500 instead of
|
||||
a clean 404.
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_audio_failed_generation.py -v
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
# Repo root on sys.path so ``backend`` imports as a package (the audio
|
||||
# routes use package-relative imports).
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
from backend import config
|
||||
from backend.database import (
|
||||
Base,
|
||||
Generation,
|
||||
GenerationVersion,
|
||||
ProfileSample,
|
||||
VoiceProfile,
|
||||
get_db,
|
||||
)
|
||||
from backend.routes.audio import router as audio_router
|
||||
|
||||
|
||||
def test_resolve_storage_path_empty_returns_none():
|
||||
"""An empty stored path must not resolve to the data dir itself."""
|
||||
assert config.resolve_storage_path("") is None
|
||||
assert config.resolve_storage_path(None) is None
|
||||
# Path("") is truthy, so it must be rejected via its (empty) parts.
|
||||
assert config.resolve_storage_path(Path("")) is None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""Minimal app with only the audio routes and a temp sqlite DB."""
|
||||
monkeypatch.setattr(config, "_data_dir", tmp_path)
|
||||
# An existing directory that a stored audio_path may wrongly point to.
|
||||
(tmp_path / "somedir").mkdir()
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'test.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
session = testing_session_local()
|
||||
profile = VoiceProfile(id="profile-1", name="Test Profile")
|
||||
session.add(profile)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
Generation(
|
||||
id="gen-failed-empty",
|
||||
profile_id="profile-1",
|
||||
text="failed generation",
|
||||
audio_path="",
|
||||
status="failed",
|
||||
error="engine exploded",
|
||||
),
|
||||
Generation(
|
||||
id="gen-failed-null",
|
||||
profile_id="profile-1",
|
||||
text="failed generation",
|
||||
audio_path=None,
|
||||
status="failed",
|
||||
),
|
||||
Generation(
|
||||
id="gen-missing-file",
|
||||
profile_id="profile-1",
|
||||
text="completed but file deleted",
|
||||
audio_path="generations/does-not-exist.wav",
|
||||
status="completed",
|
||||
),
|
||||
Generation(
|
||||
id="gen-with-version",
|
||||
profile_id="profile-1",
|
||||
text="generation with a broken version",
|
||||
audio_path="somedir",
|
||||
status="completed",
|
||||
),
|
||||
GenerationVersion(
|
||||
id="version-dir",
|
||||
generation_id="gen-with-version",
|
||||
label="original",
|
||||
audio_path="somedir",
|
||||
),
|
||||
ProfileSample(
|
||||
id="sample-dir",
|
||||
profile_id="profile-1",
|
||||
audio_path="somedir",
|
||||
reference_text="sample pointing at a directory",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(audio_router)
|
||||
|
||||
def override_get_db():
|
||||
db = testing_session_local()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("generation_id", ["gen-failed-empty", "gen-failed-null"])
|
||||
def test_failed_generation_returns_404(client, generation_id):
|
||||
"""Failed generations (empty/null audio_path) get a clean 404, not a 500."""
|
||||
response = client.get(f"/audio/{generation_id}")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Generation failed; no audio available"
|
||||
|
||||
|
||||
def test_missing_audio_file_returns_404(client):
|
||||
"""A completed generation whose file vanished still 404s."""
|
||||
response = client.get("/audio/gen-missing-file")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Audio file not found"
|
||||
|
||||
|
||||
def test_unknown_generation_returns_404(client):
|
||||
response = client.get("/audio/no-such-generation")
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Generation not found"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"/audio/gen-with-version",
|
||||
"/audio/version/version-dir",
|
||||
"/samples/sample-dir",
|
||||
],
|
||||
)
|
||||
def test_audio_path_pointing_at_directory_returns_404(client, url):
|
||||
"""A stored path resolving to an existing directory must 404, not 500.
|
||||
|
||||
Guards the is_file() checks: a directory passes exists() and would
|
||||
crash FileResponse.
|
||||
"""
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Audio file not found"
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Regression tests for issue #852: audioop removed from Python 3.13 stdlib.
|
||||
|
||||
Voice sample validation imports audioop transitively (librosa → audioread).
|
||||
The audioop-lts backport must be declared in requirements and bundled in
|
||||
PyInstaller builds on 3.13+.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from build_binary import build_server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backend_dir():
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestAudioopRequirements:
|
||||
def test_requirements_declare_audioop_lts_for_python_313(self, backend_dir):
|
||||
content = (backend_dir / "requirements.txt").read_text()
|
||||
assert re.search(
|
||||
r"^audioop-lts.*python_version\s*>=\s*['\"]3\.13['\"]",
|
||||
content,
|
||||
re.MULTILINE,
|
||||
), "requirements.txt must pin audioop-lts for Python 3.13+"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 13), reason="Python 3.13+ only")
|
||||
class TestAudioopRuntime:
|
||||
def test_audioop_importable(self):
|
||||
import audioop # noqa: F401
|
||||
|
||||
def test_validate_reference_wav_does_not_fail_on_missing_audioop(self, tmp_path):
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from utils.audio import validate_and_load_reference_audio
|
||||
|
||||
sr = 24000
|
||||
t = np.arange(int(sr * 3), dtype=np.float32) / sr
|
||||
audio = (0.3 * np.sin(2 * np.pi * 220 * t)).astype(np.float32)
|
||||
path = tmp_path / "reference.wav"
|
||||
sf.write(str(path), audio, sr)
|
||||
|
||||
ok, err, out_audio, out_sr = validate_and_load_reference_audio(str(path))
|
||||
|
||||
assert ok, err
|
||||
assert out_audio is not None
|
||||
assert out_sr == sr
|
||||
assert "audioop" not in (err or "").lower()
|
||||
|
||||
|
||||
class TestAudioopBuildArgs:
|
||||
@staticmethod
|
||||
def _hidden_imports(args):
|
||||
imports = []
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--hidden-import" and i + 1 < len(args):
|
||||
imports.append(args[i + 1])
|
||||
return imports
|
||||
|
||||
def test_pyinstaller_includes_audioop_on_python_313(self):
|
||||
class FakeVersionInfo(tuple):
|
||||
@property
|
||||
def major(self):
|
||||
return self[0]
|
||||
|
||||
@property
|
||||
def minor(self):
|
||||
return self[1]
|
||||
|
||||
@property
|
||||
def micro(self):
|
||||
return self[2]
|
||||
|
||||
fake_313 = FakeVersionInfo((3, 13, 0, "final", 0))
|
||||
|
||||
with (
|
||||
patch("build_binary.PyInstaller.__main__.run") as mock_run,
|
||||
patch("build_binary.platform.system", return_value="Linux"),
|
||||
patch("build_binary.is_apple_silicon", return_value=False),
|
||||
patch("build_binary.os.chdir"),
|
||||
patch("build_binary.sys.version_info", fake_313),
|
||||
):
|
||||
build_server()
|
||||
args = mock_run.call_args[0][0]
|
||||
|
||||
assert "audioop" in self._hidden_imports(args)
|
||||
|
||||
def test_pyinstaller_omits_audioop_on_python_312(self):
|
||||
class FakeVersionInfo(tuple):
|
||||
@property
|
||||
def major(self):
|
||||
return self[0]
|
||||
|
||||
@property
|
||||
def minor(self):
|
||||
return self[1]
|
||||
|
||||
@property
|
||||
def micro(self):
|
||||
return self[2]
|
||||
|
||||
fake_312 = FakeVersionInfo((3, 12, 0, "final", 0))
|
||||
|
||||
with (
|
||||
patch("build_binary.PyInstaller.__main__.run") as mock_run,
|
||||
patch("build_binary.platform.system", return_value="Linux"),
|
||||
patch("build_binary.is_apple_silicon", return_value=False),
|
||||
patch("build_binary.os.chdir"),
|
||||
patch("build_binary.sys.version_info", fake_312),
|
||||
):
|
||||
build_server()
|
||||
args = mock_run.call_args[0][0]
|
||||
|
||||
assert "audioop" not in self._hidden_imports(args)
|
||||
@@ -0,0 +1,32 @@
|
||||
import sys as py_sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.services import cuda
|
||||
|
||||
|
||||
def test_cuda_status_reports_unsupported_linux_download(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(cuda.sys, "platform", "linux")
|
||||
monkeypatch.setattr(cuda, "get_data_dir", lambda: tmp_path)
|
||||
|
||||
status = cuda.get_cuda_status()
|
||||
|
||||
assert status["available"] is False
|
||||
assert status["download_supported"] is False
|
||||
assert status["unsupported_reason"] == cuda.CUDA_DOWNLOAD_UNSUPPORTED_REASON
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cuda_download_rejects_linux_before_network(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(cuda.sys, "platform", "linux")
|
||||
monkeypatch.setattr(cuda, "get_data_dir", lambda: tmp_path)
|
||||
|
||||
class UnexpectedClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError("unsupported platforms should not start a release download")
|
||||
|
||||
monkeypatch.setitem(py_sys.modules, "httpx", types.SimpleNamespace(AsyncClient=UnexpectedClient))
|
||||
|
||||
with pytest.raises(RuntimeError, match="currently only published for Windows"):
|
||||
await cuda._download_cuda_binary_locked("v0.5.0")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Ensure TADA voice-prompt encoding disables autograd (#890)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
|
||||
from backend.backends.hume_backend import HumeTadaBackend
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeEncoderOutput:
|
||||
emb: torch.Tensor
|
||||
|
||||
|
||||
class _GradTrackingEncoder:
|
||||
"""Raises unless called under torch.inference_mode()."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.called_under_inference_mode = False
|
||||
|
||||
def __call__(self, audio, text=None, sample_rate=None):
|
||||
self.called_under_inference_mode = torch.is_inference_mode_enabled()
|
||||
if not self.called_under_inference_mode:
|
||||
raise AssertionError("encoder forward must run under inference_mode")
|
||||
# Touch a requires_grad tensor the way Snake1d alpha would.
|
||||
alpha = torch.nn.Parameter(torch.ones(1, device=audio.device))
|
||||
_ = audio.mean() * alpha
|
||||
return _FakeEncoderOutput(emb=torch.zeros(1, 4, device=audio.device))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_voice_prompt_runs_encoder_under_inference_mode(tmp_path, monkeypatch):
|
||||
wav = tmp_path / "ref.wav"
|
||||
sf.write(str(wav), np.zeros(24000, dtype=np.float32), 24000)
|
||||
|
||||
backend = HumeTadaBackend()
|
||||
backend.model = object() # mark loaded
|
||||
backend.model_size = "1B"
|
||||
backend._device = "cpu"
|
||||
encoder = _GradTrackingEncoder()
|
||||
backend.encoder = encoder
|
||||
|
||||
monkeypatch.setattr(backend, "load_model", AsyncMock(return_value=None))
|
||||
monkeypatch.setattr(
|
||||
"backend.backends.hume_backend.get_cached_voice_prompt",
|
||||
lambda key: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"backend.backends.hume_backend.cache_voice_prompt",
|
||||
lambda key, value: None,
|
||||
)
|
||||
|
||||
prompt, from_cache = await backend.create_voice_prompt(
|
||||
str(wav),
|
||||
reference_text="hello world",
|
||||
use_cache=False,
|
||||
)
|
||||
|
||||
assert from_cache is False
|
||||
assert encoder.called_under_inference_mode is True
|
||||
assert isinstance(prompt["emb"], torch.Tensor)
|
||||
assert prompt["emb"].device.type == "cpu"
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Tests for the voicebox.speak MCP tool's ``model_size`` plumbing (issue #884).
|
||||
|
||||
The MCP speak path used to build its ``GenerationRequest`` without a
|
||||
``model_size``, so every agent-triggered generation silently fell back to the
|
||||
schema default ("1.7B") — there was no way to reach 0.6B (or TADA's 1B/3B)
|
||||
through MCP. These tests pin the fix: ``_speak`` now forwards ``model_size``
|
||||
straight into the request, matching the REST ``/generate`` surface.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import backend.routes.generations as generations
|
||||
from backend.mcp_server import tools
|
||||
|
||||
|
||||
class _FakeGeneration:
|
||||
"""Minimal stand-in for GenerationResponse consumed by ``_speak_response``."""
|
||||
|
||||
def model_dump(self, mode="json"):
|
||||
return {"id": "gen-test", "status": "generating"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured_request(monkeypatch):
|
||||
"""Replace the real (torch-backed) generate_speech with a capturing stub.
|
||||
|
||||
``_speak`` imports ``generate_speech`` lazily from ``routes.generations``,
|
||||
so patching the attribute on that module intercepts the call and lets us
|
||||
inspect the ``GenerationRequest`` it would have run.
|
||||
"""
|
||||
captured = {}
|
||||
|
||||
async def fake_generate_speech(req, db):
|
||||
captured["req"] = req
|
||||
return _FakeGeneration()
|
||||
|
||||
monkeypatch.setattr(generations, "generate_speech", fake_generate_speech)
|
||||
# Isolate the unit from the MCP event bus — _speak_response fires a
|
||||
# speak-start event we don't care about here.
|
||||
monkeypatch.setattr(tools.mcp_events, "publish", lambda *a, **k: None)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_forwards_explicit_model_size(captured_request):
|
||||
await tools._speak(
|
||||
profile_id="p1",
|
||||
profile_name="Morgan",
|
||||
text="hello",
|
||||
engine="qwen",
|
||||
language="en",
|
||||
personality=False,
|
||||
model_size="0.6B",
|
||||
db=None,
|
||||
)
|
||||
assert captured_request["req"].model_size == "0.6B"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_omitted_model_size_is_none(captured_request):
|
||||
# Omitted → None; generate_speech normalizes None to the engine default,
|
||||
# so this reproduces the pre-fix behaviour for callers that don't ask.
|
||||
await tools._speak(
|
||||
profile_id="p1",
|
||||
profile_name="Morgan",
|
||||
text="hello",
|
||||
engine="qwen",
|
||||
language="en",
|
||||
personality=False,
|
||||
db=None,
|
||||
)
|
||||
assert captured_request["req"].model_size is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_speak_rejects_invalid_model_size(captured_request):
|
||||
# The GenerationRequest schema pattern is the single source of truth for
|
||||
# valid sizes; a bad value is rejected before any generation runs.
|
||||
with pytest.raises(ValidationError):
|
||||
await tools._speak(
|
||||
profile_id="p1",
|
||||
profile_name="Morgan",
|
||||
text="hello",
|
||||
engine="qwen",
|
||||
language="en",
|
||||
personality=False,
|
||||
model_size="9B",
|
||||
db=None,
|
||||
)
|
||||
assert "req" not in captured_request
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Smoke test for the MLX backend dependencies on Apple Silicon.
|
||||
|
||||
Guards the `--no-deps` install of mlx-audio/mlx-lm done by `just setup-python`
|
||||
and release.yml: those packages skip their declared dependencies (transformers
|
||||
>=5.x conflict), so a missing transitive dep only surfaces at import time.
|
||||
This test fails fast if the MLX STT/TTS entry points the backend uses stop
|
||||
importing (e.g. the `miniaudio` regression from issue #505).
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_mlx_smoke.py -v
|
||||
"""
|
||||
|
||||
import platform
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (sys.platform == "darwin" and platform.machine() == "arm64"),
|
||||
reason="MLX packages are only installed on Apple Silicon macOS",
|
||||
)
|
||||
|
||||
|
||||
def test_mlx_core_runs():
|
||||
"""The MLX runtime itself works (Metal array op)."""
|
||||
import mlx.core as mx
|
||||
|
||||
assert mx.array([1, 2]).sum().item() == 3
|
||||
|
||||
|
||||
def test_mlx_audio_tts_entry_point():
|
||||
"""`from mlx_audio.tts import load` — used by MLXBackend.load_model_async."""
|
||||
from mlx_audio.tts import load
|
||||
|
||||
assert callable(load)
|
||||
|
||||
|
||||
def test_mlx_audio_stt_entry_point():
|
||||
"""`from mlx_audio.stt import load` — used by the Whisper MLX STT path.
|
||||
|
||||
Importing mlx_audio.stt also pulls in miniaudio, so this catches the
|
||||
ModuleNotFoundError from issue #505 on fresh installs.
|
||||
"""
|
||||
from mlx_audio.stt import load
|
||||
|
||||
assert callable(load)
|
||||
|
||||
|
||||
def test_mlx_lm_entry_points():
|
||||
"""`mlx_lm.load` / `mlx_lm.generate` — used by qwen_llm_backend."""
|
||||
from mlx_lm import generate, load
|
||||
|
||||
assert callable(load)
|
||||
assert callable(generate)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Errored downloads must not be reported as still downloading.
|
||||
|
||||
A failed download intentionally stays in the TaskManager with
|
||||
``status="error"`` so ``/tasks/active`` can surface the error and retry
|
||||
UI — but ``/models/status`` derives its ``downloading`` flag from the
|
||||
same list. Without a status filter, one failed download shows the model
|
||||
as "downloading" forever and masks its real cache state until the app
|
||||
restarts (issue #925, symptom reports like #181).
|
||||
"""
|
||||
|
||||
from backend.utils.tasks import TaskManager
|
||||
|
||||
|
||||
def test_errored_download_is_not_pending():
|
||||
tm = TaskManager()
|
||||
tm.start_download("whisper-turbo")
|
||||
assert [t.model_name for t in tm.get_pending_downloads()] == ["whisper-turbo"]
|
||||
|
||||
tm.error_download("whisper-turbo", "boom")
|
||||
|
||||
assert tm.get_pending_downloads() == []
|
||||
# Still visible to /tasks/active for the error/retry UI.
|
||||
active = tm.get_active_downloads()
|
||||
assert [t.model_name for t in active] == ["whisper-turbo"]
|
||||
assert active[0].status == "error"
|
||||
assert active[0].error == "boom"
|
||||
|
||||
|
||||
def test_retry_after_error_is_pending_again():
|
||||
tm = TaskManager()
|
||||
tm.start_download("qwen3-4b")
|
||||
tm.error_download("qwen3-4b", "boom")
|
||||
tm.start_download("qwen3-4b")
|
||||
assert [t.model_name for t in tm.get_pending_downloads()] == ["qwen3-4b"]
|
||||
|
||||
|
||||
def test_completed_download_is_removed_everywhere():
|
||||
tm = TaskManager()
|
||||
tm.start_download("whisper-turbo")
|
||||
tm.complete_download("whisper-turbo")
|
||||
assert tm.get_pending_downloads() == []
|
||||
assert tm.get_active_downloads() == []
|
||||
|
||||
|
||||
def test_cancel_dismisses_errored_download():
|
||||
tm = TaskManager()
|
||||
tm.start_download("whisper-turbo")
|
||||
tm.error_download("whisper-turbo", "boom")
|
||||
assert tm.cancel_download("whisper-turbo") is True
|
||||
assert tm.get_active_downloads() == []
|
||||
assert tm.get_pending_downloads() == []
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Tests for scripts/package_rocm.py — the ROCm onedir → server + libs splitter.
|
||||
|
||||
The classifier can't be validated against a real AMD build on CI hardware, so
|
||||
these tests pin the file-classification rules against a synthetic onedir layout
|
||||
that mirrors the PyInstaller --rocm output (torch/lib HIP DLLs + bundled
|
||||
rocm_sdk runtime packages).
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_package_rocm.py -v
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_PACKAGE_ROCM = Path(__file__).resolve().parents[2] / "scripts" / "package_rocm.py"
|
||||
_spec = importlib.util.spec_from_file_location("package_rocm", _PACKAGE_ROCM)
|
||||
package_rocm = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(package_rocm)
|
||||
|
||||
|
||||
class TestIsRocmFile:
|
||||
"""Classification of individual files into core vs ROCm libs."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rel_path",
|
||||
[
|
||||
"_internal/torch/lib/amdhip64.dll",
|
||||
"_internal/torch/lib/rocblas.dll",
|
||||
"_internal/torch/lib/hipblaslt.dll",
|
||||
"_internal/torch/lib/miopen.dll",
|
||||
"_internal/_rocm_sdk_core/amd_comgr.dll",
|
||||
"_internal/_rocm_sdk_libraries_custom/lib/rocblas/library/TensileLibrary.dat",
|
||||
"_internal/_rocm_sdk_libraries_custom/lib/miopen/db/kernels.kdb",
|
||||
# Windows path separators must be handled too.
|
||||
"_internal\\torch\\lib\\rccl.dll",
|
||||
],
|
||||
)
|
||||
def test_runtime_files_are_rocm(self, rel_path):
|
||||
assert package_rocm.is_rocm_file(rel_path) is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"rel_path",
|
||||
[
|
||||
"voicebox-server-rocm.exe",
|
||||
"_internal/python312.dll",
|
||||
"_internal/torch/lib/torch_cpu.dll",
|
||||
"_internal/torch/lib/c10.dll",
|
||||
# Pure-python rocm_sdk glue stays in the core, even under an SDK dir.
|
||||
"_internal/rocm_sdk/__init__.py",
|
||||
"_internal/_rocm_sdk_core/_dist_info.py",
|
||||
"_internal/torch/_inductor/codegen/something.py",
|
||||
],
|
||||
)
|
||||
def test_core_files_are_not_rocm(self, rel_path):
|
||||
assert package_rocm.is_rocm_file(rel_path) is False
|
||||
|
||||
|
||||
def _write(path: Path, content: bytes = b"x"):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
|
||||
|
||||
class TestPackage:
|
||||
"""End-to-end split of a synthetic onedir into the two archives."""
|
||||
|
||||
def test_split_and_manifest(self, tmp_path):
|
||||
onedir = tmp_path / "voicebox-server-rocm"
|
||||
_write(onedir / "voicebox-server-rocm.exe")
|
||||
_write(onedir / "_internal" / "python312.dll")
|
||||
_write(onedir / "_internal" / "rocm_sdk" / "__init__.py")
|
||||
_write(onedir / "_internal" / "torch" / "lib" / "torch_cpu.dll")
|
||||
_write(onedir / "_internal" / "torch" / "lib" / "amdhip64.dll")
|
||||
_write(onedir / "_internal" / "_rocm_sdk_core" / "miopen.dll")
|
||||
_write(
|
||||
onedir
|
||||
/ "_internal"
|
||||
/ "_rocm_sdk_libraries_custom"
|
||||
/ "lib"
|
||||
/ "rocblas"
|
||||
/ "library"
|
||||
/ "TensileLibrary.dat"
|
||||
)
|
||||
|
||||
out = tmp_path / "release-assets"
|
||||
package_rocm.package(onedir, out, "rocm7.2-v1", ">=2.9.0,<2.10.0")
|
||||
|
||||
server = out / "voicebox-server-rocm.tar.gz"
|
||||
libs = out / "rocm-libs-rocm7.2-v1.tar.gz"
|
||||
assert server.exists()
|
||||
assert libs.exists()
|
||||
assert (out / "voicebox-server-rocm.tar.gz.sha256").exists()
|
||||
assert (out / "rocm-libs-rocm7.2-v1.tar.gz.sha256").exists()
|
||||
|
||||
with tarfile.open(libs) as tar:
|
||||
lib_names = set(tar.getnames())
|
||||
with tarfile.open(server) as tar:
|
||||
core_names = set(tar.getnames())
|
||||
|
||||
assert "_internal/torch/lib/amdhip64.dll" in lib_names
|
||||
assert "_internal/_rocm_sdk_core/miopen.dll" in lib_names
|
||||
assert (
|
||||
"_internal/_rocm_sdk_libraries_custom/lib/rocblas/library/TensileLibrary.dat"
|
||||
in lib_names
|
||||
)
|
||||
assert "voicebox-server-rocm.exe" in core_names
|
||||
assert "_internal/torch/lib/torch_cpu.dll" in core_names
|
||||
assert "_internal/rocm_sdk/__init__.py" in core_names
|
||||
# Archives must be disjoint.
|
||||
assert lib_names.isdisjoint(core_names)
|
||||
|
||||
def test_empty_rocm_set_exits(self, tmp_path):
|
||||
onedir = tmp_path / "voicebox-server-rocm"
|
||||
_write(onedir / "voicebox-server-rocm.exe")
|
||||
_write(onedir / "_internal" / "torch" / "lib" / "torch_cpu.dll")
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
package_rocm.package(onedir, tmp_path / "out", "rocm7.2-v1", ">=2.9.0,<2.10.0")
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Regression coverage for runaway MLX Qwen TTS output."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from backend.backends import engine_needs_trim, engine_retries_runaway
|
||||
from backend.utils.audio import has_tts_runaway
|
||||
from backend.utils.chunked_tts import generate_chunked
|
||||
|
||||
SAMPLE_RATE = 1000
|
||||
|
||||
|
||||
def test_mlx_qwen_enables_runaway_retry_without_aggressive_trim():
|
||||
with patch("backend.backends.get_backend_type", return_value="mlx"):
|
||||
assert engine_needs_trim("qwen") is False
|
||||
assert engine_retries_runaway("qwen") is True
|
||||
|
||||
|
||||
def test_pytorch_qwen_keeps_runaway_retry_disabled():
|
||||
with patch("backend.backends.get_backend_type", return_value="pytorch"):
|
||||
assert engine_needs_trim("qwen") is False
|
||||
assert engine_retries_runaway("qwen") is False
|
||||
|
||||
|
||||
def test_detector_flags_long_internal_silence():
|
||||
speech = np.full(2 * SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
runaway_gap = np.zeros(2500, dtype=np.float32)
|
||||
hallucinated_noise = np.full(2 * SAMPLE_RATE, 0.8, dtype=np.float32)
|
||||
audio = np.concatenate([speech, runaway_gap, hallucinated_noise])
|
||||
|
||||
assert has_tts_runaway(audio, SAMPLE_RATE) is True
|
||||
|
||||
|
||||
def test_detector_ignores_normal_internal_pause():
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
normal_pause = np.zeros(1200, dtype=np.float32)
|
||||
audio = np.concatenate([speech, normal_pause, speech])
|
||||
|
||||
assert has_tts_runaway(audio, SAMPLE_RATE) is False
|
||||
|
||||
|
||||
def test_trailing_silence_is_not_a_runaway():
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
trailing_silence = np.zeros(2 * SAMPLE_RATE, dtype=np.float32)
|
||||
|
||||
assert (
|
||||
has_tts_runaway(
|
||||
np.concatenate([speech, trailing_silence]),
|
||||
SAMPLE_RATE,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runaway_chunk_is_retried_as_smaller_chunks():
|
||||
class FakeBackend:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def generate(self, text, *_args):
|
||||
self.calls.append(text)
|
||||
if len(text) > 200:
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
silence = np.zeros(2500, dtype=np.float32)
|
||||
noise = np.full(SAMPLE_RATE, 0.8, dtype=np.float32)
|
||||
return np.concatenate([speech, silence, noise]), SAMPLE_RATE
|
||||
return np.full(SAMPLE_RATE, 0.2, dtype=np.float32), SAMPLE_RATE
|
||||
|
||||
backend = FakeBackend()
|
||||
text = f"{'A' * 119}. {'B' * 119}."
|
||||
|
||||
audio, sample_rate = await generate_chunked(
|
||||
backend,
|
||||
text,
|
||||
{},
|
||||
max_chunk_chars=800,
|
||||
crossfade_ms=50,
|
||||
runaway_detector=has_tts_runaway,
|
||||
)
|
||||
|
||||
assert sample_rate == SAMPLE_RATE
|
||||
assert backend.calls == [text, f"{'A' * 119}.", f"{'B' * 119}."]
|
||||
assert len(audio) == 1950
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_runaway_fails_instead_of_returning_corrupt_audio():
|
||||
class AlwaysRunawayBackend:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def generate(self, text, *_args):
|
||||
self.calls.append(text)
|
||||
speech = np.full(SAMPLE_RATE, 0.2, dtype=np.float32)
|
||||
silence = np.zeros(2500, dtype=np.float32)
|
||||
noise = np.full(SAMPLE_RATE, 0.8, dtype=np.float32)
|
||||
return np.concatenate([speech, silence, noise]), SAMPLE_RATE
|
||||
|
||||
backend = AlwaysRunawayBackend()
|
||||
text = f"{'A' * 119}. {'B' * 119}."
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="remained unstable after retrying smaller text chunks",
|
||||
):
|
||||
await generate_chunked(
|
||||
backend,
|
||||
text,
|
||||
{},
|
||||
max_chunk_chars=800,
|
||||
runaway_detector=has_tts_runaway,
|
||||
)
|
||||
|
||||
assert [len(call) for call in backend.calls] == [241, 120, 100]
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Phase 2.2 Test: Backend ROCm compatibility.
|
||||
|
||||
Validates that check_cuda_compatibility() and other backend utilities
|
||||
behave correctly on ROCm/AMD hardware.
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_rocm_backends.py -v
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCheckCudaCompatibility:
|
||||
"""Unit tests for check_cuda_compatibility with ROCm awareness."""
|
||||
|
||||
def test_no_gpu_returns_compatible(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=False):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_rocm_skips_compute_check(self):
|
||||
"""On ROCm, the NVIDIA compute-capability check should be skipped."""
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", "6.2.41133"):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_cuda_compatible_arch(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(8, 6)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 3060"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86", "sm_89"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is True
|
||||
assert warning is None
|
||||
|
||||
def test_cuda_incompatible_arch(self):
|
||||
from backend.backends.base import check_cuda_compatibility
|
||||
|
||||
with patch("torch.cuda.is_available", return_value=True):
|
||||
with patch("torch.version.hip", None):
|
||||
with patch("torch.cuda.get_device_capability", return_value=(9, 0)):
|
||||
with patch("torch.cuda.get_device_name", return_value="NVIDIA GeForce RTX 4090"):
|
||||
with patch.object(
|
||||
__import__("torch").cuda, "_get_arch_list",
|
||||
return_value=["sm_80", "sm_86"],
|
||||
create=True,
|
||||
):
|
||||
compatible, warning = check_cuda_compatibility()
|
||||
assert compatible is False
|
||||
assert warning is not None
|
||||
assert "not supported" in warning
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Phase 1.2 Test: ROCm build script configuration.
|
||||
|
||||
Validates that build_binary.py --rocm generates the correct PyInstaller
|
||||
arguments and optionally performs a true E2E build.
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_rocm_build.py -v
|
||||
python -m pytest backend/tests/test_rocm_build.py -v -m "slow" # include E2E
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from build_binary import build_server
|
||||
|
||||
|
||||
class TestRocmBuildArgs:
|
||||
"""Validate PyInstaller arguments for ROCm builds."""
|
||||
|
||||
@pytest.fixture
|
||||
def captured_args(self):
|
||||
"""Run build_server(rocm=True) with mocked PyInstaller and return args."""
|
||||
with (
|
||||
patch("build_binary.PyInstaller.__main__.run") as mock_run,
|
||||
patch("build_binary.platform.system", return_value="Linux"),
|
||||
patch("build_binary.os.chdir"),
|
||||
):
|
||||
build_server(rocm=True)
|
||||
return mock_run.call_args[0][0]
|
||||
|
||||
def test_binary_name(self, captured_args):
|
||||
idx = captured_args.index("--name")
|
||||
assert captured_args[idx + 1] == "voicebox-server-rocm"
|
||||
|
||||
def test_pack_mode_is_onedir(self, captured_args):
|
||||
assert "--onedir" in captured_args
|
||||
assert "--onefile" not in captured_args
|
||||
|
||||
def test_hidden_imports_cuda(self, captured_args):
|
||||
"""ROCm builds must include torch.cuda hidden imports."""
|
||||
assert "torch.cuda" in captured_args
|
||||
|
||||
def test_no_cudnn_hidden_import_for_rocm(self, captured_args):
|
||||
"""ROCm builds must NOT include NVIDIA-specific cudnn hidden imports."""
|
||||
assert "torch.backends.cudnn" not in captured_args
|
||||
|
||||
def test_nvidia_excludes_present(self, captured_args):
|
||||
"""ROCm builds must exclude nvidia packages to avoid bundling ~3GB of bloat."""
|
||||
excludes = []
|
||||
for i, arg in enumerate(captured_args):
|
||||
if arg == "--exclude-module":
|
||||
excludes.append(captured_args[i + 1])
|
||||
assert "nvidia" in excludes
|
||||
assert "nvidia.cudnn" in excludes
|
||||
|
||||
|
||||
class TestRocmBuildCli:
|
||||
"""Validate CLI argument parsing for --rocm."""
|
||||
|
||||
def test_rocm_flag_parses(self):
|
||||
build_script = Path(__file__).parent.parent / "build_binary.py"
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(build_script), "--rocm", "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "--rocm" in result.stdout
|
||||
|
||||
def test_cannot_combine_cuda_and_rocm(self):
|
||||
"""Building with both CUDA and ROCm should raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Cannot build with both CUDA and ROCm"):
|
||||
build_server(cuda=True, rocm=True)
|
||||
|
||||
|
||||
@pytest.mark.slow()
|
||||
@pytest.mark.skipif(sys.platform != "win32", reason="ROCm build E2E only runs on Windows")
|
||||
class TestRocmBuildE2E:
|
||||
"""
|
||||
True end-to-end build test.
|
||||
Executes build_binary.py --rocm, verifies the binary exists, and runs it
|
||||
with --help to confirm it boots without import errors.
|
||||
"""
|
||||
|
||||
def test_rocm_binary_compiles_and_runs(self, tmp_path):
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
build_script = backend_dir / "build_binary.py"
|
||||
dist_dir = backend_dir / "dist"
|
||||
binary_dir = dist_dir / "voicebox-server-rocm"
|
||||
binary_exe = binary_dir / "voicebox-server-rocm.exe"
|
||||
|
||||
# Clean previous dist if it exists to ensure a fresh build
|
||||
if binary_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(binary_dir)
|
||||
|
||||
# Run the full build (this can take several minutes)
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(build_script), "--rocm"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(backend_dir),
|
||||
timeout=900,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"Build failed with stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
assert binary_exe.exists(), (
|
||||
f"Expected binary not found at {binary_exe}"
|
||||
)
|
||||
|
||||
# Run the binary with --help to ensure it boots without import errors
|
||||
run_result = subprocess.run(
|
||||
[str(binary_exe), "--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# A frozen binary may not have argparse help, but it should not crash
|
||||
# with a ModuleNotFoundError or similar import error.
|
||||
assert "ModuleNotFoundError" not in run_result.stderr
|
||||
assert "ImportError" not in run_result.stderr
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Phase 1.1 Test: ROCm requirements installation.
|
||||
|
||||
Validates that requirements-rocm.txt correctly installs ROCm-enabled PyTorch
|
||||
and that torch.cuda.is_available() returns True on AMD hardware.
|
||||
|
||||
Usage:
|
||||
python -m pytest backend/tests/test_rocm_requirements.py -v
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _has_amd_hardware():
|
||||
"""Check if AMD GPU hardware is present on Windows."""
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-Command",
|
||||
"Get-WmiObject Win32_VideoController | "
|
||||
"Where-Object {$_.AdapterCompatibility -like '*AMD*'} | "
|
||||
"Measure-Object | Select-Object -ExpandProperty Count",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip()) > 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def backend_dir():
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
class TestRocmRequirements:
|
||||
"""Validate requirements-rocm.txt content and installation."""
|
||||
|
||||
def test_requirements_file_exists(self, backend_dir):
|
||||
req_file = backend_dir / "requirements-rocm.txt"
|
||||
assert req_file.exists(), "requirements-rocm.txt must exist"
|
||||
|
||||
def test_requirements_file_content(self, backend_dir):
|
||||
import re
|
||||
req_file = backend_dir / "requirements-rocm.txt"
|
||||
content = req_file.read_text()
|
||||
assert "rocm7.2" in content, "Must point to ROCm 7.2 extra index"
|
||||
# Parse exact package names to avoid false positives from URL substrings
|
||||
package_names = re.findall(r"^([A-Za-z][A-Za-z0-9_-]*)", content, re.MULTILINE)
|
||||
assert "torch" in package_names, "Must include torch package"
|
||||
assert "torchaudio" in package_names, "Must include torchaudio package"
|
||||
assert "torchvision" in package_names, "Must include torchvision package"
|
||||
|
||||
@pytest.mark.timeout(900)
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("VOICEBOX_TEST_ROCM_INSTALL"),
|
||||
reason="Set VOICEBOX_TEST_ROCM_INSTALL=1 to run the heavy install test",
|
||||
)
|
||||
def test_rocm_torch_installs_and_detects_amd(self, backend_dir):
|
||||
"""
|
||||
Create a temporary venv, install requirements-rocm.txt, and verify
|
||||
torch.cuda.is_available() returns True on AMD hardware.
|
||||
"""
|
||||
req_file = backend_dir / "requirements-rocm.txt"
|
||||
has_amd = _has_amd_hardware()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
venv_dir = Path(tmpdir) / "venv"
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "venv", str(venv_dir)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
if sys.platform == "win32":
|
||||
venv_python = venv_dir / "Scripts" / "python.exe"
|
||||
else:
|
||||
venv_python = venv_dir / "bin" / "python"
|
||||
|
||||
# Upgrade pip to avoid resolver issues
|
||||
subprocess.run(
|
||||
[str(venv_python), "-m", "pip", "install", "--upgrade", "pip"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Install ROCm requirements
|
||||
subprocess.run(
|
||||
[str(venv_python), "-m", "pip", "install", "-r", str(req_file)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Verify torch imports and cuda availability
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(venv_python),
|
||||
"-c",
|
||||
"import torch; print(torch.__version__); print(torch.cuda.is_available())",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
lines = result.stdout.strip().splitlines()
|
||||
assert len(lines) >= 2, f"Unexpected output: {result.stdout}"
|
||||
torch_version = lines[0]
|
||||
cuda_available = lines[1] == "True"
|
||||
|
||||
# The honest test: on AMD hardware ROCm torch should report cuda available
|
||||
if has_amd:
|
||||
assert cuda_available, (
|
||||
f"AMD hardware detected but torch.cuda.is_available() returned False. "
|
||||
f"torch version: {torch_version}, stderr: {result.stderr}"
|
||||
)
|
||||
else:
|
||||
assert not cuda_available, (
|
||||
f"No AMD hardware detected but torch.cuda.is_available() returned True. "
|
||||
f"torch version: {torch_version}"
|
||||
)
|
||||
@@ -110,6 +110,43 @@ def save_audio(
|
||||
raise OSError(f"Failed to save audio to {path}: {e}") from e
|
||||
|
||||
|
||||
def has_tts_runaway(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = 24000,
|
||||
frame_ms: int = 20,
|
||||
silence_threshold_db: float = -40.0,
|
||||
max_internal_silence_ms: int = 2000,
|
||||
) -> bool:
|
||||
"""Detect speech followed by a long silence and then more output.
|
||||
|
||||
This shape is a reliable signal that a TTS model missed EOS and resumed
|
||||
with hallucinated speech or codec noise. Leading and trailing silence do
|
||||
not count because they are not bounded by non-silent audio.
|
||||
"""
|
||||
frame_len = int(sample_rate * frame_ms / 1000)
|
||||
if frame_len == 0 or len(audio) < frame_len:
|
||||
return False
|
||||
|
||||
n_frames = len(audio) // frame_len
|
||||
threshold_linear = 10 ** (silence_threshold_db / 20)
|
||||
max_silence_frames = int(max_internal_silence_ms / frame_ms)
|
||||
seen_speech = False
|
||||
consecutive_silence = 0
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = audio[i * frame_len : (i + 1) * frame_len]
|
||||
is_speech = np.sqrt(np.mean(frame**2)) >= threshold_linear
|
||||
if is_speech:
|
||||
if seen_speech and consecutive_silence >= max_silence_frames:
|
||||
return True
|
||||
seen_speech = True
|
||||
consecutive_silence = 0
|
||||
elif seen_speech:
|
||||
consecutive_silence += 1
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def trim_tts_output(
|
||||
audio: np.ndarray,
|
||||
sample_rate: int = 24000,
|
||||
|
||||
@@ -20,6 +20,8 @@ logger = logging.getLogger("voicebox.chunked-tts")
|
||||
# Default chunk size in characters. Can be overridden per-request via
|
||||
# the ``max_chunk_chars`` field on GenerationRequest.
|
||||
DEFAULT_MAX_CHUNK_CHARS = 800
|
||||
MAX_RUNAWAY_RETRIES = 2
|
||||
MIN_RUNAWAY_RETRY_CHARS = 100
|
||||
|
||||
# Common abbreviations that should NOT be treated as sentence endings.
|
||||
# Lowercase for case-insensitive matching.
|
||||
@@ -211,6 +213,7 @@ async def generate_chunked(
|
||||
max_chunk_chars: int = DEFAULT_MAX_CHUNK_CHARS,
|
||||
crossfade_ms: int = 50,
|
||||
trim_fn=None,
|
||||
runaway_detector=None,
|
||||
) -> Tuple[np.ndarray, int]:
|
||||
"""Generate audio with automatic chunking for long text.
|
||||
|
||||
@@ -239,25 +242,75 @@ async def generate_chunked(
|
||||
Optional ``(audio, sample_rate) -> audio`` post-processing
|
||||
function applied to each chunk before concatenation (e.g.
|
||||
``trim_tts_output`` for Chatterbox engines).
|
||||
runaway_detector : callable | None
|
||||
Optional ``(audio, sample_rate) -> bool`` detector. When it flags
|
||||
unstable output, the affected text is split in half and retried.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(audio, sample_rate) : Tuple[np.ndarray, int]
|
||||
"""
|
||||
async def generate_one(
|
||||
chunk_text: str,
|
||||
chunk_seed: int | None,
|
||||
retry_depth: int = 0,
|
||||
) -> tuple[np.ndarray, int]:
|
||||
chunk_audio, chunk_sr = await backend.generate(
|
||||
chunk_text,
|
||||
voice_prompt,
|
||||
language,
|
||||
chunk_seed,
|
||||
instruct,
|
||||
)
|
||||
|
||||
if runaway_detector is not None and runaway_detector(chunk_audio, chunk_sr):
|
||||
if retry_depth >= MAX_RUNAWAY_RETRIES or len(chunk_text) <= MIN_RUNAWAY_RETRY_CHARS:
|
||||
raise RuntimeError(
|
||||
"TTS output remained unstable after retrying smaller text chunks"
|
||||
)
|
||||
|
||||
retry_max_chars = max(MIN_RUNAWAY_RETRY_CHARS, len(chunk_text) // 2)
|
||||
retry_chunks = split_text_into_chunks(chunk_text, retry_max_chars)
|
||||
if len(retry_chunks) <= 1:
|
||||
raise RuntimeError("Unable to split unstable TTS output for retry")
|
||||
|
||||
logger.warning(
|
||||
"Detected unstable TTS output for %d chars; retrying as %d smaller chunks",
|
||||
len(chunk_text),
|
||||
len(retry_chunks),
|
||||
)
|
||||
retry_audio: list[np.ndarray] = []
|
||||
for i, retry_text in enumerate(retry_chunks):
|
||||
retry_seed = (
|
||||
chunk_seed + ((retry_depth + 1) * 1000) + i
|
||||
if chunk_seed is not None
|
||||
else None
|
||||
)
|
||||
audio, sample_rate = await generate_one(
|
||||
retry_text,
|
||||
retry_seed,
|
||||
retry_depth + 1,
|
||||
)
|
||||
retry_audio.append(np.asarray(audio, dtype=np.float32))
|
||||
|
||||
return (
|
||||
concatenate_audio_chunks(
|
||||
retry_audio,
|
||||
sample_rate,
|
||||
crossfade_ms=crossfade_ms,
|
||||
),
|
||||
sample_rate,
|
||||
)
|
||||
|
||||
if trim_fn is not None:
|
||||
chunk_audio = trim_fn(chunk_audio, chunk_sr)
|
||||
return np.asarray(chunk_audio, dtype=np.float32), chunk_sr
|
||||
|
||||
chunks = split_text_into_chunks(text, max_chunk_chars)
|
||||
|
||||
if len(chunks) <= 1:
|
||||
# Short text — single-shot fast path
|
||||
audio, sample_rate = await backend.generate(
|
||||
text,
|
||||
voice_prompt,
|
||||
language,
|
||||
seed,
|
||||
instruct,
|
||||
)
|
||||
if trim_fn is not None:
|
||||
audio = trim_fn(audio, sample_rate)
|
||||
return audio, sample_rate
|
||||
return await generate_one(text, seed)
|
||||
|
||||
# Long text — chunked generation
|
||||
logger.info(
|
||||
@@ -281,17 +334,12 @@ async def generate_chunked(
|
||||
# always produces the same output.
|
||||
chunk_seed = (seed + i) if seed is not None else None
|
||||
|
||||
chunk_audio, chunk_sr = await backend.generate(
|
||||
chunk_audio, chunk_sr = await generate_one(
|
||||
chunk_text,
|
||||
voice_prompt,
|
||||
language,
|
||||
chunk_seed,
|
||||
instruct,
|
||||
)
|
||||
if trim_fn is not None:
|
||||
chunk_audio = trim_fn(chunk_audio, chunk_sr)
|
||||
|
||||
audio_chunks.append(np.asarray(chunk_audio, dtype=np.float32))
|
||||
audio_chunks.append(chunk_audio)
|
||||
if sample_rate is None:
|
||||
sample_rate = chunk_sr
|
||||
|
||||
|
||||
@@ -3,19 +3,72 @@ Platform detection for backend selection.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def is_apple_silicon() -> bool:
|
||||
"""
|
||||
Check if running on Apple Silicon (arm64 macOS).
|
||||
|
||||
|
||||
Returns:
|
||||
True if on Apple Silicon, False otherwise
|
||||
"""
|
||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def is_amd_gpu_windows() -> bool:
|
||||
"""
|
||||
Check if the primary GPU on Windows is an AMD Radeon card.
|
||||
|
||||
Uses WMI to query Win32_VideoController, with a fallback to
|
||||
torch.cuda.get_device_name(0) if WMI is unavailable. This is
|
||||
useful for deciding whether the ROCm backend is appropriate.
|
||||
|
||||
Result is cached since it shells out to PowerShell and the GPU
|
||||
does not change at runtime — safe to call from the health path.
|
||||
|
||||
Returns:
|
||||
True if an AMD GPU is detected on Windows, False otherwise.
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return False
|
||||
|
||||
# Primary method: WMI query for AMD adapters
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"powershell",
|
||||
"-Command",
|
||||
"Get-CimInstance Win32_VideoController | "
|
||||
"Where-Object {$_.AdapterCompatibility -like '*AMD*'} | "
|
||||
"Measure-Object | Select-Object -ExpandProperty Count",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
if int(result.stdout.strip()) > 0:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fallback: torch.cuda.get_device_name(0) (works for ROCm/HIP too)
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
name = torch.cuda.get_device_name(0)
|
||||
if "Radeon" in name or "AMD" in name:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_backend_type() -> Literal["mlx", "pytorch"]:
|
||||
"""
|
||||
Detect the best backend for the current platform.
|
||||
|
||||
@@ -67,6 +67,19 @@ class TaskManager:
|
||||
def get_active_downloads(self) -> List[DownloadTask]:
|
||||
"""Get all active downloads."""
|
||||
return list(self._active_downloads.values())
|
||||
|
||||
def get_pending_downloads(self) -> List[DownloadTask]:
|
||||
"""Get downloads that are still in flight.
|
||||
|
||||
Excludes errored tasks, which stay in the active list so the
|
||||
error/retry UI can show them but must not be reported as
|
||||
"downloading" by /models/status.
|
||||
"""
|
||||
return [
|
||||
task
|
||||
for task in self._active_downloads.values()
|
||||
if task.status in ("downloading", "extracting")
|
||||
]
|
||||
|
||||
def get_active_generations(self) -> List[GenerationTask]:
|
||||
"""Get all active generations."""
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"app": {
|
||||
"name": "@voicebox/app",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -75,7 +75,7 @@
|
||||
},
|
||||
"landing": {
|
||||
"name": "@voicebox/landing",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@fontsource/space-grotesk": "^5.2.10",
|
||||
"@icons-pack/react-simple-icons": "^13.13.0",
|
||||
@@ -85,7 +85,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "^16.1.3",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
@@ -104,7 +106,7 @@
|
||||
},
|
||||
"tauri": {
|
||||
"name": "@voicebox/tauri",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -127,7 +129,7 @@
|
||||
},
|
||||
"web": {
|
||||
"name": "@voicebox/web",
|
||||
"version": "0.4.2",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"react": "^18.3.0",
|
||||
@@ -663,7 +665,7 @@
|
||||
|
||||
"arg": ["[email protected]", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"aria-hidden": ["[email protected]", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
@@ -759,6 +761,8 @@
|
||||
|
||||
"espree": ["[email protected]", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
|
||||
|
||||
"esprima": ["[email protected]", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="],
|
||||
|
||||
"esquery": ["[email protected]", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["[email protected]", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
@@ -767,6 +771,8 @@
|
||||
|
||||
"esutils": ["[email protected]", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend-shallow": ["[email protected]", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
|
||||
|
||||
"fast-deep-equal": ["[email protected]", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["[email protected]", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
@@ -815,6 +821,8 @@
|
||||
|
||||
"graphemer": ["[email protected]", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"gray-matter": ["[email protected]", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
|
||||
|
||||
"has-flag": ["[email protected]", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hasown": ["[email protected]", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
@@ -839,6 +847,8 @@
|
||||
|
||||
"is-core-module": ["[email protected]", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extendable": ["[email protected]", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"is-extglob": ["[email protected]", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["[email protected]", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
@@ -855,7 +865,7 @@
|
||||
|
||||
"js-tokens": ["[email protected]", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
"js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="],
|
||||
|
||||
"jsesc": ["[email protected]", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
@@ -869,6 +879,8 @@
|
||||
|
||||
"keyv": ["[email protected]", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["[email protected]", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"levn": ["[email protected]", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["[email protected]", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
@@ -913,6 +925,8 @@
|
||||
|
||||
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"merge2": ["[email protected]", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["[email protected]", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
@@ -1033,6 +1047,8 @@
|
||||
|
||||
"scheduler": ["[email protected]", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"section-matter": ["[email protected]", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["[email protected]", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
@@ -1051,8 +1067,12 @@
|
||||
|
||||
"source-map-js": ["[email protected]", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sprintf-js": ["[email protected]", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"strip-ansi": ["[email protected]", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom-string": ["[email protected]", "", {}, "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g=="],
|
||||
|
||||
"strip-json-comments": ["[email protected]", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"styled-jsx": ["[email protected]", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
@@ -1131,6 +1151,8 @@
|
||||
|
||||
"zustand": ["[email protected]", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/[email protected]", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/[email protected]", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
@@ -1185,6 +1207,8 @@
|
||||
|
||||
"chokidar/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"eslint/js-yaml": ["[email protected]", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"fast-glob/glob-parent": ["[email protected]", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"motion/framer-motion": ["[email protected]", "", { "dependencies": { "motion-dom": "^12.29.0", "motion-utils": "^12.27.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1gEFGXHYV2BD42ZPTFmSU9buehppU+bCuOnHU0AD18DKh9j4DuTx47MvqY5ax+NNWRtK32qIcJf1UxKo1WwjWg=="],
|
||||
@@ -1195,8 +1219,12 @@
|
||||
|
||||
"tinyglobby/picomatch": ["[email protected]", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"@eslint/eslintrc/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"eslint/js-yaml/argparse": ["[email protected]", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["[email protected]", "", { "dependencies": { "motion-utils": "^12.27.2" } }, "sha512-3eiz9bb32yvY8Q6XNM4AwkSOBPgU//EIKTZwsSWgA9uzbPBhZJeScCVcBuwwYVqhfamewpv7ZNmVKTGp5qnzkA=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["[email protected]", "", {}, "sha512-B55gcoL85Mcdt2IEStY5EEAsrMSVE2sI14xQ/uAdPL+mfQxhKKFaEag9JmfxedJOR4vZpBGoPeC/Gm13I/4g5Q=="],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# ROCm (AMD GPU) overlay for Voicebox
|
||||
#
|
||||
# docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
|
||||
#
|
||||
# Requires ROCm drivers on the host:
|
||||
# https://rocm.docs.amd.com/projects/install-on-linux
|
||||
# RDNA4 (RX 9000): export ROCM_VERSION=7.2 (default 6.3 covers RDNA1-3).
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
PYTORCH_VARIANT: rocm
|
||||
ROCM_VERSION: ${ROCM_VERSION:-6.3}
|
||||
|
||||
devices:
|
||||
- /dev/kfd
|
||||
- /dev/dri
|
||||
|
||||
environment:
|
||||
# HSA_OVERRIDE_GFX_VERSION forces the ROCm runtime to treat the GPU as a
|
||||
# specific GFX version when auto-detection fails or the GPU is newer than
|
||||
# the ROCm release. app.py sets 10.3.0 (RDNA2) by default; override here
|
||||
# for your GPU family:
|
||||
# RDNA4 / RX 9000 series: 12.0.0
|
||||
# (requires ROCM_VERSION=7.2)
|
||||
# RDNA3 / RX 7000 series / Strix Halo: 11.0.0
|
||||
# RDNA2 / RX 6000 series: 10.3.0
|
||||
# RDNA1 / RX 5000 series: 10.1.0
|
||||
# Vega / GCN5: 9.0.0
|
||||
- HSA_OVERRIDE_GFX_VERSION=${HSA_OVERRIDE_GFX_VERSION:-}
|
||||
|
||||
# Tune the ROCm memory allocator
|
||||
- PYTORCH_HIP_ALLOC_CONF=garbage_collection_threshold:0.8,max_split_size_mb:512
|
||||
|
||||
# Redirect MIOpen kernel cache to a writable, persistent directory.
|
||||
# Without this, MIOpen may fail to write its cache and throw
|
||||
# miopenStatusUnknownError on fresh containers.
|
||||
- MIOPEN_USER_DB_PATH=/app/data/cache/miopen_db
|
||||
- MIOPEN_CUSTOM_CACHE_DIR=/app/data/cache/miopen_cache
|
||||
|
||||
# Use fast heuristics for kernel selection instead of exhaustive
|
||||
# benchmarking. On RDNA4, exhaustive mode tries kernels that fail to
|
||||
# allocate workspace memory (ptr: 0 size: 0), causing system stuttering
|
||||
# on every generation even when the cache is present.
|
||||
- MIOPEN_FIND_MODE=FAST
|
||||
+7
-2
@@ -1,3 +1,7 @@
|
||||
# Voicebox — CPU build (default)
|
||||
# For AMD ROCm GPU acceleration use the overlay:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.rocm.yml up --build
|
||||
|
||||
services:
|
||||
voicebox:
|
||||
build: .
|
||||
@@ -5,8 +9,9 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
# Bind to localhost only for security
|
||||
- "127.0.0.1:17493:17493"
|
||||
# Host-side moved to 17600 so the dev/installed Voicebox can keep 17493.
|
||||
# Container still listens on its native port internally.
|
||||
- "127.0.0.1:17600:17493"
|
||||
|
||||
volumes:
|
||||
# Bind-mount for generated audio (customize the host path as needed)
|
||||
|
||||
+254
-61
@@ -1,6 +1,6 @@
|
||||
# Voicebox Project Status & Roadmap
|
||||
|
||||
> Last updated: 2026-04-18 | Current version: **v0.4.1** | 232 open issues | 12 open PRs
|
||||
> Last updated: 2026-07-02 | Current version: **v0.5.0** | 402 open issues | 88 open PRs | 1.3M downloads · 34.8k stars
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +86,46 @@ POST /generate
|
||||
|
||||
## Current State
|
||||
|
||||
### Since v0.5.0 — Two-Month Pulse (2026-04-25 → 2026-06-27)
|
||||
|
||||
**The repo went quiet while demand kept climbing.** 0.5.0 (the Capture release) shipped 2026-04-25. In the two months since, only **2 PRs merged** (#544 the release itself, #550 a remote-URL fix) while **150 new issues** were opened and the open-PR queue more than tripled to **88**. The community kept contributing — translations, new engines, GPU fixes — but nothing's been reviewed or merged. This is a review-and-merge backlog, not a build backlog.
|
||||
|
||||
| Metric | At v0.4.1 (2026-04-18) | Now (2026-06-27) | Δ |
|
||||
|--------|------------------------|------------------|---|
|
||||
| Open issues | 232 | 402 | +170 |
|
||||
| Open PRs | 12 | 88 | +76 |
|
||||
| GitHub stars | ~28k | 34.8k | +~7k |
|
||||
| Downloads | — | 1.3M | — |
|
||||
|
||||
**What the two months actually produced (all unmerged):**
|
||||
- **A flood of community translations** — pt-BR, de-DE, Russian (+docs), Arabic+RTL, Spanish (+docs site), French, Cantonese. ~12 i18n PRs sitting on the i18next foundation that landed in 0.5.0.
|
||||
- **GPU coverage PRs** — AMD ROCm on Windows (#538), Intel XPU (#539), DirectML for Intel iGPU (#674), Blackwell diagnostic (#653), MLX threading fix (#789).
|
||||
- **A 17-PR hardening dump** from one contributor (@neuron-tech-ai, all 2026-05-14): CI pipeline, Biome, OpenAI-compatible `/v1/audio/speech`, SQLite WAL + indexes, LIKE-injection / upload-limit / N+1 fixes, platform gating. High value, entirely unreviewed.
|
||||
- **New engine PRs** — MiniMax cloud, MOSS-TTS-Nano, Fun-CosyVoice3, Parakeet STT.
|
||||
- **Two giant Linux PRs** — vendored `tao` patch for the Wayland startup panic (#748), and an SRT2Voice workflow (#673).
|
||||
|
||||
**0.5.0 regressions worth triaging first:**
|
||||
- macOS Apple Silicon — all TTS models crash the server on load (#606, #615), MLX falls back to CPU on M4/M5 (#706, #650).
|
||||
- Capture cutoffs at 30s for imported audio (#609, #626); paste broken in 0.5.0 (#762).
|
||||
- MCP rough edges — dotted tool names violate Claude Desktop's name pattern (#790), audio scrambled over MCP (#780).
|
||||
- Refinement silently translates non-English transcripts to English (#603).
|
||||
|
||||
**Funding model — `$VOICEBOX` token (#806):** the two-month gap was a solo-dev decision about long-term sustainability, not neglect or a compromise. `$VOICEBOX` (Solana) is the **official, dev-controlled** token and the chosen revenue path — donations/sponsors didn't cover full-time work. The app stays 100% free, open-source, local-first, no subscriptions. Dev supply is being bought back and burned (done twice), liquidity locked. It has funded ~2–3 months of full-time work, so the cadence resumes this week. The #806 thread was a community concern, addressed transparently and resolved amicably; keep an eye out for actual impersonator/community tokens, which are a separate thing.
|
||||
|
||||
**Other trust/security signals:** macOS malware-flag reports continue (#369); a DNS-rebinding / Host-header exposure on the local API+MCP server was reported with fixes attached (#778).
|
||||
|
||||
---
|
||||
|
||||
### What's Shipped (v0.5.0 — the Capture release)
|
||||
|
||||
Shipped 2026-04-25 (PR #544). Voicebox went from a voice-cloning studio to a full voice studio — dictation in, agent speech out, a local LLM in the middle.
|
||||
|
||||
- **Dictation** — global hotkey capture (push-to-talk + toggle chords), on-screen pill with live state, auto-paste into the focused field with clipboard save/restore, chord-picker UI. Scoped Accessibility permission (transcripts still land if paste is denied).
|
||||
- **MCP server** at `http://127.0.0.1:17493/mcp` — `voicebox.speak` / `.transcribe` / `.list_captures` / `.list_profiles`. Streamable HTTP primary transport, stdio sidecar shim, per-client voice binding via `X-Voicebox-Client-Id`. Speaking pill always shows agent-initiated output.
|
||||
- **Personality** — voice profiles carry an optional ≤2000-char persona. Compose (shuffle an in-character line) and Speak-in-character (rewrite input before TTS), both on a local Qwen3 LLM that doubles as the refinement model.
|
||||
- **Refinement** — on-device Qwen3 strips fillers, fixes punctuation, optional self-correction rewrites; Whisper hallucination-loop stripping at a 6-token threshold; per-capture flag snapshots; model picker (0.6B / 1.7B / 4B).
|
||||
- **`POST /speak` REST wrapper** and **i18next foundation** (English + zh-CN) also landed.
|
||||
|
||||
### What's Shipped (v0.4.x)
|
||||
|
||||
**New since v0.3.0:**
|
||||
@@ -178,6 +218,19 @@ POST /generate
|
||||
|
||||
**Integration shape if we revive it:** Zero-shot cloning maps naturally to the Chatterbox-style backend (store `ref_audio` + `ref_text` paths in the voice prompt dict, process at generate time). Est. ~250 lines for `voxcpm_backend.py` + one `ModelConfig` entry + engine registration in `backends/__init__.py`. Frontend UI gating is the bigger lift.
|
||||
|
||||
### Funded Roadmap (2026-H2)
|
||||
|
||||
`$VOICEBOX` funded ~2–3 months of full-time work; cadence resumes the week of 2026-06-27. Direction committed publicly in #806:
|
||||
|
||||
| Item | Notes |
|
||||
|------|-------|
|
||||
| **Resume merge/release cadence** | Clear the 88-PR backlog, regular commits + releases — this is the immediate focus (see Tier 1) |
|
||||
| **Mobile companion app** | New surface; already drawing issues (#773 iPhone logout) |
|
||||
| **Encrypted cloud backup/sync** | For voice profiles + generations — first cloud feature; stays opt-in, local-first remains default |
|
||||
| **More TTS models** | Engine candidates in the Landscape section below; community PRs #507/#766/#777 in queue |
|
||||
| **Better GPU support** | Blackwell/sm_120, ROCm, DirectML, Intel — incl. paying testers for hardware the dev lacks |
|
||||
| **Bug fixes** | 0.5.0 regression cluster first (macOS load crash, capture cutoffs, MCP, refinement) |
|
||||
|
||||
### What's In-Flight
|
||||
|
||||
| Feature | Branch/PR | Status |
|
||||
@@ -186,7 +239,7 @@ POST /generate
|
||||
| Engine sprawl cleanup | issue #419 | First-class vs experimental TTS backends distinction |
|
||||
| Frontend tech-debt burn-down | issue #421 | Biome + a11y debt before gating CI |
|
||||
| Docker registry auto-publish | PR #463, issue #453 | ghcr.io image on tag push |
|
||||
| New model research | `voicebox-new-models` branch | Evaluating Fish Speech, XTTS-v2, Pocket TTS, VibeVoice, Fish Audio S2, index-tts2 |
|
||||
| New model research | `voicebox-new-models` branch | Evaluating Fish Speech, XTTS-v2, Pocket TTS, VibeVoice, Fish Audio S2, index-tts2. **2026-06-27 sweep** added dots.tts, LongCat-AudioDiT, SoproTTS, NeuTTS, Nemotron/Cohere STT — see Landscape → New Candidate Sweep |
|
||||
|
||||
### TTS Engine Comparison
|
||||
|
||||
@@ -231,64 +284,144 @@ POST /generate
|
||||
|
||||
## Open PRs — Triage & Analysis
|
||||
|
||||
### Recently Merged (Since Last Update — 2026-03-18 → 2026-04-18)
|
||||
**88 open PRs, only 2 merged since 0.5.0.** The queue is the single biggest lever right now — a lot of finished community work is waiting on review. Clustered below by theme. Counts are approximate; a PR can span clusters.
|
||||
|
||||
### Merged since 0.5.0
|
||||
|
||||
| PR | Title | Merged |
|
||||
|----|-------|--------|
|
||||
| **#481** | fix(build): pin transformers in MLX requirements to prevent 5.x upgrade | 2026-04-19 |
|
||||
| **#470** | fix(api-client): declare moved + errors on migrateModels response type | 2026-04-18 |
|
||||
| **#457** | fix(linux): use pactl to detect PipeWire/PulseAudio monitor | 2026-04-18 |
|
||||
| **#450** | docs: clarify paralinguistic tag support in quick start | 2026-04-18 |
|
||||
| **#447** | fix: delete version rows and files in delete_generations_by_profile | 2026-04-18 |
|
||||
| **#444** | Fix generation cancellation flow | 2026-04-18 |
|
||||
| **#440** | fix(paths): strip legacy "data/" prefix when resolving stored paths | 2026-04-18 |
|
||||
| **#439** | Fix migration dialog hanging when no models are present | 2026-04-18 |
|
||||
| **#438** | fix(build): repair frozen-binary imports for kokoro/chatterbox-multilingual/scipy/transformers | 2026-04-18 |
|
||||
| **#433** | fix: warn user when no models to migrate during storage change | 2026-04-18 |
|
||||
| **#425** | Add NUMBA_CACHE_DIR environment variable | 2026-04-16 |
|
||||
| **#424** | fix: avoid ScreenCaptureKit launch crash on macOS 11 | 2026-04-16 |
|
||||
| **#418** | Frontend quality gates + TypeScript hardening | 2026-04-18 |
|
||||
| **#416** | fix(deps): relax PyTorch requirement for macOS Intel (x86_64) | 2026-04-16 |
|
||||
| **#412** | feat(history): add "Clear failed" button | 2026-04-16 |
|
||||
| **#405** | fix: keep cpal Stream alive until playback completes | 2026-04-16 |
|
||||
| **#403** | fix: prevent intermittent clip splitting failures | 2026-04-16 |
|
||||
| **#402** | fix: reliably keep server alive after GUI close on Windows | 2026-04-16 |
|
||||
| **#401** | feat: add Blackwell GPU (sm_120) CUDA support | 2026-04-16 |
|
||||
| **#394** | fix(history): populate status/error/engine fields from DB row | 2026-04-16 |
|
||||
| **#384** | Fix: Resolve ModuleNotFoundError in effects service | 2026-04-16 |
|
||||
| **#361** | fix: torch.from_numpy crash with numpy 2.x in frozen binary | 2026-04-16 |
|
||||
| **#345** | Fix: "Failed to Save" preset error by resolving backend import path | 2026-03-22 |
|
||||
| **#344** | fix: include changelog in docker web build | 2026-03-27 |
|
||||
| **#332** | Fix links in Get Started section of index.mdx | 2026-03-21 |
|
||||
| **#328** | feat: add Qwen CustomVoice preset engine | 2026-03-27 |
|
||||
| **#325** | feat: Kokoro 82M TTS engine + voice profile type system | 2026-03-20 |
|
||||
| **#321** | fix: allows deletion of failed generations | 2026-03-19 |
|
||||
| **#320** | feat: Intel Arc (XPU) GPU support | 2026-03-21 |
|
||||
| **#319** | fix: GUI startup with external server + data refresh on server switch | 2026-03-27 |
|
||||
| **#318** | fix: force offline mode when loading cached models (Qwen TTS & Whisper) | 2026-03-21 |
|
||||
| **#316** | Upgrade CUDA backend from cu126 to cu128, fix GPU settings UI | 2026-03-18 |
|
||||
| **#550** | Fix web API URL for remote access | 2026-04-25 |
|
||||
| **#544** | feat: 0.5.0 Capture release — dictation, MCP, personalities | 2026-04-25 |
|
||||
|
||||
### Currently Open (12 PRs)
|
||||
### i18n / translations (~12) — easy wins, unblock a large user segment
|
||||
|
||||
| PR | Title | Status | Notes |
|
||||
|----|-------|--------|-------|
|
||||
| **#465** | docs: define tier-1 and tier-2 platform support targets | Community PR | Pairs with issue #420. Important for scoping. |
|
||||
| **#463** | feat(actions): add docker-registry.yml for automatic ghcr.io publishing | Community PR | Pairs with issue #453. Low risk. |
|
||||
| **#443** | fix: prevent infinite retry loop in offline mode (#434) | Community PR | Fixes reported bug. |
|
||||
| **#430** | feat: add MiniMax TTS provider support | Community PR | Cloud TTS provider — new direction (external API). Superset of #331? |
|
||||
| **#331** | feat: add MiniMax Cloud TTS as a built-in engine | Community PR | Likely superseded by #430. Dedupe. |
|
||||
| **#311** | feat: add CosyVoice2/3 TTS engine | **Close** | Abandoned — output quality too poor. |
|
||||
| **#253** | Enhance speech tokenizer with 48kHz version | Community PR | Qwen tokenizer upgrade. Still worth reviewing. |
|
||||
| **#227** | fix: harden input validation & file safety | Community PR | Coupled to #225 (custom models). |
|
||||
| **#225** | feat: custom HuggingFace voice model support | Community PR | Needs rework for multi-engine arch. |
|
||||
| **#195** | feat: per-profile LoRA fine-tuning | Draft | Complex. 15 new endpoints. |
|
||||
| **#154** | feat: Audiobook tab | Community PR | Chunked generation now shipped (#266). |
|
||||
| **#91** | fix: CoreAudio device enumeration | Draft | macOS audio device handling. |
|
||||
The i18next + zh-CN foundation shipped in 0.5.0; these stack on it. Triage as a batch.
|
||||
|
||||
| PR | Locale / scope |
|
||||
|----|----------------|
|
||||
| #528 | pt-BR translation |
|
||||
| #571 | de-DE translation |
|
||||
| #599 / #600 / #601 | Russian — app, landing routes, docs |
|
||||
| #569 | Arabic + RTL layout fixes |
|
||||
| #798 / #799 / #801 | Spanish — locale, README/CONTRIBUTING/SECURITY, docs site (see #800 for approach alignment) |
|
||||
| #802 | French translation |
|
||||
| #776 | Cantonese language option |
|
||||
| #688 | Compose follows the selected language |
|
||||
|
||||
### New engines / models (~7)
|
||||
|
||||
| PR | Engine | Notes |
|
||||
|----|--------|-------|
|
||||
| #507 | MOSS-TTS-Nano (0.1B, 20 langs, CPU realtime) | Matches our cross-platform criteria — top engine candidate |
|
||||
| #331 / #430 | MiniMax Cloud TTS | Two PRs, same provider — **dedupe**. External-API direction. |
|
||||
| #777 | Fun-CosyVoice3 (draft) | We abandoned CosyVoice2/3 once on quality — re-evaluate output before reviving |
|
||||
| #766 | Parakeet as STT model | Whisper alternative |
|
||||
| #563 | 4-bit quantized Qwen + Russian abbreviations | Smaller/faster Qwen |
|
||||
| #225 | Custom HuggingFace voice models | Long-lived; needs rework for multi-engine arch |
|
||||
| #195 | Per-profile LoRA fine-tuning (draft, +6.2k) | Complex, 15 endpoints — addresses #185/#224 demand |
|
||||
|
||||
### GPU / hardware (~9)
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #538 | Native AMD ROCm on Windows (+2.8k, resolves #531) |
|
||||
| #539 | Optional IPEX + native Intel XPU detection for PyTorch 2.9+ |
|
||||
| #674 | DirectML for Intel iGPU (Iris/UHD/Arc) — pairs with demand in #676, #759 |
|
||||
| #653 | Blackwell GPU arch-mismatch diagnostic — directly targets the sm_120 cluster |
|
||||
| #789 | Run MLX load+inference on one thread (fixes #699) — likely fixes the M-series load crashes |
|
||||
| #560 / #561 | Linux NVIDIA auto-detect + Linux CUDA backend build |
|
||||
| #736 / #785 / #769 | ROCm `HSA_OVERRIDE_GFX_VERSION` cleanup (resolves #469) |
|
||||
| #770 | Fix CUDA downloads on unsupported platforms |
|
||||
|
||||
### Capture / transcription / refinement (~6) — fixes 0.5.0 regressions
|
||||
|
||||
| PR | Fix |
|
||||
|----|-----|
|
||||
| #602 | Re-encode uploaded audio as PCM WAV before Whisper — likely fixes the 30s import cutoff (#609/#626) |
|
||||
| #616 | Enable long-form Whisper on the PyTorch path |
|
||||
| #629 | Preserve source language in refinement (fixes #603 English translation) |
|
||||
| #637 | MCP/REST generations incorrectly trigger autoplay |
|
||||
| #712 | Personality LLM respects the selected refinement model |
|
||||
| #796 | Capture preview placement setting (#698) |
|
||||
|
||||
### Long-form / stories / streaming (~5)
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #154 | Audiobook tab with chunked generation (predates shipped chunking — reconcile) |
|
||||
| #673 | SRT2Voice workflow (+14k) — large, subtitle-driven generation |
|
||||
| #787 | m4b/mp3 story export with auto chapter markers |
|
||||
| #642 | `stream=true` immediate-audio mode for `GET /tts` |
|
||||
| #804 | Stream MLX TTS audio chunks (draft) |
|
||||
|
||||
### Linux / Wayland (~5)
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #748 | Vendor-patch tao 0.34.8 for Wayland startup panic (+39k — large vendored diff, verify approach) |
|
||||
| #624 | Linux tauri schema + build deps (+6.2k) |
|
||||
| #747 | Avoid abort when hiding the dictate pill |
|
||||
| #622 / #768 | Linux audio monitor selection / thread-unsafe `PULSE_SOURCE` |
|
||||
| #677 | HF cache permissions + startup fallback |
|
||||
|
||||
### Hardening / CI / perf — the @neuron-tech-ai batch (all 2026-05-14, ~17 PRs)
|
||||
|
||||
One contributor opened a large, coherent quality suite in a single day. Review as a group; #662 is the headline.
|
||||
|
||||
| PR | Scope |
|
||||
|----|-------|
|
||||
| #662 | LIKE injection, upload-size enforcement, N+1 queries, SSE reconnect, memory leaks, model display names (+6.8k) |
|
||||
| #654 | CI pipeline + pre-commit hooks + Biome config + test suite |
|
||||
| #656 | OpenAI-compatible `/v1/audio/speech` + `/v1/models` (addresses #10) |
|
||||
| #657 | Platform gating on `ModelConfig` + UI (addresses bottleneck #6 / issue #419) |
|
||||
| #666 / #667 | DB indexes on hot FKs + SQLite WAL + busy timeout |
|
||||
| #659 / #660 / #661 / #663 / #665 / #668 | datetime.utcnow→UTC, MediaRecorder crash + sample reorder, fail-fast on missing model, batch story counts, Metal warmup, drop debug logs |
|
||||
| #652 / #655 / #658 / #664 | AGENTS.md, docs GitHub Pages, avatar size limit, non-fatal actool |
|
||||
|
||||
### Build / dev tooling / docker (~6)
|
||||
|
||||
#764 uv for backend env · #632 docker GPU build + cache + fastmcp (+7.9k) · #630 ROCm docker overlay · #463 ghcr.io auto-publish · #543 / #681 setup-script fixes · #584 docker permission fix
|
||||
|
||||
### Smaller fixes worth grabbing
|
||||
|
||||
#786 remove 50k char limit (#464) · #621 broken-pipe crashes on model load · #743 harden mac generation status + MLX threading · #788 missing male Mandarin Kokoro voices · #794 build mcp shim on Windows · #527 Chatterbox exaggeration + CFG sliders · #253 48kHz speech tokenizer
|
||||
|
||||
### Stale / low-signal — close or request changes
|
||||
|
||||
#91 (draft, Feb, CoreAudio, +6.2k unrebased) · #649 ("fix this errors") · #623 / #782 (badges / package tweaks) · #311-style abandoned engines — verify before merging anything older than ~April against the 0.5.0 codebase.
|
||||
|
||||
---
|
||||
|
||||
## Open Issues — Categorized
|
||||
|
||||
**402 open, +150 in the two months since 0.5.0.** Demand snapshot from a keyword sweep over all open titles (buckets overlap):
|
||||
|
||||
| Theme | ~Open | Signal |
|
||||
|-------|-------|--------|
|
||||
| New model / engine requests | ~79 | Largest category. Voxtral, OmniVoice, VibeVoice, VoxCPM2, CosyVoice3, Dramabox, Parakeet, GGUF, ONNX/Piper export |
|
||||
| CUDA / GPU / Blackwell | ~53 | Still the #1 *bug* driver — sm_120 "no kernel image", ROCm, DirectML, Intel Arc, VRAM/load times |
|
||||
| Model download / server startup | ~42 | Stuck downloads, "server process ended unexpectedly", `loading_model` hangs |
|
||||
| Capture / dictation / transcribe | ~31 | New surface from 0.5.0 — 30s cutoffs, paste, mic permission, refinement translation |
|
||||
| Language / locale requests | ~27 | Bengali, Ukrainian, Filipino, Indonesian, Cantonese, zh-TW; plus UI localization |
|
||||
| Fine-tune / clone quality | ~22 | #185 (top-engagement issue), accent leakage, "finetunes not working" |
|
||||
| Long-form / chunking / export | ~16 | Pause control, speed control, audiobook export, >50k chars |
|
||||
| Linux / Wayland | ~11 | Build failures, Wayland panics, CUDA-on-Linux packaging |
|
||||
| MCP / agent / API | ~9 | Dotted tool names (#790), scrambled audio (#780), OpenAI compat (#10) |
|
||||
| Security / trust | ~4 | DNS-rebinding (#778), malware flag (#369); funding via official $VOICEBOX token (#806) |
|
||||
|
||||
**Highest-engagement open issues:** #185 Fine-tune instructions (32c) · #98 Connecting to Download (16c) · #301 CUDA generation failure (18c) · #20 Model download failed (13c) · #364 Voxtral-TTS FR (11r) · #341 Arch Linux build · #513 server startup failed (12c) · #138 ONNX/Piper export (9r) · #10 OpenAI API compat.
|
||||
|
||||
### New since 0.5.0 — clusters to triage first
|
||||
|
||||
- **macOS Apple Silicon load crashes (regression):** #606, #615 — all TTS models crash the server on load; #706, #650 — MLX falls back to CPU / 7-min VRAM load on M4/M5. PR #789 (single-thread MLX, fixes #699) and #743 are the candidate fixes. **Highest priority — breaks the primary platform.**
|
||||
- **Capture cutoffs & paste:** #609, #626 — transcription stops at 30s for imported audio (PR #602 re-encodes WAV); #762 — paste broken in 0.5.0; #698, #577 — capture/output folder locations.
|
||||
- **MCP integration:** #790 — dotted tool names violate Claude Desktop's `^[a-zA-Z0-9_-]{1,64}$`; #780 — audio scrambled over MCP; #728 — CUDA re-downloads on cold start.
|
||||
- **Refinement:** #603 — silently translates non-English transcripts to English (PR #629 preserves source language).
|
||||
- **GPU expansion requests:** #676 DirectML (AMD/Intel), #759 Intel Arc, #684 RTX 5060 Ti CUDA 13, #774 CUDA 11.x for older cards, #767 Linux CUDA installs Windows `.exe`.
|
||||
- **New engines/langs:** #791 OmniVoice, #633 VoxCPM2, #690 Dramabox, #638 Bengali, #754 zh-TW, #761 Filipino.
|
||||
- **Open plugin interface (#771):** request for a community engine/provider plugin API — ties into engine-sprawl (#419) and platform-gating work.
|
||||
- **Trust/security:** #806 — `$VOICEBOX` is the official dev-backed funding token (concern raised and resolved on-thread; see funding note above); #778 DNS-rebinding/Host-header exposure on local API+MCP (fixes attached) — genuine security item; #369 macOS malware flag (ongoing).
|
||||
|
||||
### GPU / Hardware Detection — still the top category
|
||||
|
||||
**RTX 50-series (Blackwell / sm_120) cluster — NEW:** #417, #400, #396, #395, #390, #362 all report `cudaErrorNoKernelImageForDevice` / "no kernel image available." sm_120 support shipped in PR #401 + cu128 in PR #316, but users on upgraded installs still hit it — likely stale CUDA binary. Needs a diagnostic that detects binary/GPU-arch mismatch and prompts re-download.
|
||||
@@ -472,6 +605,43 @@ Notable:
|
||||
4. **Instruct support fills a real gap** (#173, #224, #303). Qwen CustomVoice partially addresses it with preset speakers; zero-shot clone-with-instruct is still unmet.
|
||||
5. **Long-form + streaming are user-requested** (#363, #365, #464). Candidates with native streaming (Pocket TTS, Fish Speech) get extra weight.
|
||||
|
||||
### New Candidate Sweep (2026-06-27)
|
||||
|
||||
A follow-up deep-research pass, filtered against everything already tracked — the shipped engines plus MOSS-TTS-Nano, Pocket TTS, IndicF5, VibeVoice, Voxtral, Fish/Fish Audio, XTTS-v2, index-tts2, VoxCPM2, OmniVoice, MioTTS, Oolel, Faster-Qwen, Orpheus/Sesame, MiniMax, RVC, Parakeet, Qwen3-ASR, Moshi, GLM-4-Voice, Qwen2.5-Omni — kept only where a **newer sibling/variant** changes the evaluation. Same criteria as the 04-18 cycle: cross-platform, PyPI/clean packaging, permissive license, quality, instruct/style control, long-form, streaming.
|
||||
|
||||
**Top new TTS candidates**
|
||||
|
||||
| Candidate | Add as | Why it matters | Caveat |
|
||||
|-----------|--------|----------------|--------|
|
||||
| **[dots.tts](https://github.com/rednote-hilab/dots.tts)** (soar / mf) | **Top new TTS candidate** | 2B fully-continuous end-to-end autoregressive TTS, 48 kHz AudioVAE output, zero-shot cloning via prompt audio/text, Apache-2.0 code+checkpoints, MeanFlow-distilled variant for low latency. Freshest "serious clone engine" not yet on the roadmap. | Git-source install with constraints, not clean PyPI. Needs Windows/macOS packaging + VRAM/CPU smoke test; probably experimental until platform gating exists. |
|
||||
| **[MOSS-TTS family](https://github.com/OpenMOSS/MOSS-TTS)** / v1.5 / Local-Transformer-v1.5 | **Upgrade the MOSS-Nano entry into a MOSS family epic** | We track only Nano, but MOSS now spans MOSS-TTS, TTSD (long multi-speaker dialogue), VoiceGenerator (text-prompt voice design), TTS-Realtime, SoundEffect. v1.5 adds broader languages, long-reference cloning, pause control, 48 kHz stereo, MLX/vLLM support, Apache-2.0. | Full 4B/8B variants aren't the lightweight Nano win. Treat as several engines/features, not one checkbox. |
|
||||
| **[LongCat-AudioDiT](https://arxiv.org/html/2603.29339v1)** | **High-priority Apple Silicon candidate** | 3.5B non-autoregressive diffusion TTS in waveform latent space, zero-shot cloning, already has an MLX conversion usable via `mlx_audio` — unusually aligned with our Apple Silicon base. | zh/en only, not realtime. Quality play, not low-latency agent speech. |
|
||||
| **[SoproTTS](https://github.com/samuel-vitorino/sopro)** | **Lightweight CPU/streaming cloned TTS** | 135M zero-shot cloning, `pip install -U sopro`, streaming + non-streaming APIs, 3–12s reference, claimed 250 ms TTFA / 0.05 RTF on M3 CPU. Strong local-first/low-maintenance fit. | English-focused, self-described as inconsistent — quality-test before promoting past experimental. |
|
||||
| **[NeuTTS Air / Nano](https://github.com/neuphonic/neutts)** | **GGUF/on-device cloned TTS** | On-device instant cloning, GGUF-ready, ~3s reference, laptop/phone/Pi targets. Air is Apache-2.0. | Needs a GGUF/llama.cpp-style wrapper, not a normal PyTorch backend. Nano has a separate NeuTTS Open License — split needs review. |
|
||||
| **[X-Voice](https://github.com/sunnyxrxrx/X-Voice)** | **Small multilingual clone** | 0.4B multilingual zero-shot cloning, 30 languages, IPA-style unified rep, claims no prompt-transcript requirement — targets a real cloning-UX pain point. | Verify license, packaging, production-readiness of weights/code. |
|
||||
| **[FireRedTTS-2](https://huggingface.co/FireRedTeam/FireRedTTS2)** | **Stories / podcast / multi-speaker** | Apache-2.0 long-form streaming, 3-min / 4-speaker dialogue, cross-lingual code-switching cloning, low first-packet latency. | Stories-editor engine more than a general default. Needs platform/VRAM testing. |
|
||||
| **[Maya1](https://huggingface.co/maya-research/maya1)** | **Expressive English voice-design** | 3B Apache-2.0, voice design, streaming, emotion/style tags, vLLM-compatible, 24 kHz, single-GPU. Good "voice personalities" / game-dialogue fit. | English-only, 16 GB+ VRAM — platform gating required. |
|
||||
|
||||
**MOSS is now a family, not one checkbox.** The single `MOSS-TTS-Nano` row above should become an epic: keep Nano as the CPU-friendly model, and track v1.5 / Local-Transformer-v1.5, Realtime, TTSD, VoiceGenerator, and SoundEffect as siblings under it.
|
||||
|
||||
**STT / capture candidates** (feed the planned streaming-transcription roadmap)
|
||||
|
||||
| Candidate | Add as | Why it matters | Caveat |
|
||||
|-----------|--------|----------------|--------|
|
||||
| **[Nemotron 3.5 ASR Streaming 0.6B](https://huggingface.co/mlx-community/nemotron-3.5-asr-streaming-0.6b)** | **Top new STT candidate** | Cache-aware streaming FastConformer-RNNT, 40 language-locales, punctuation/caps, language-ID conditioning, MLX conversion path — strongest fit for planned streaming transcription. | NVIDIA-origin; verify license + non-CUDA (MLX/CPU) performance. |
|
||||
| **[Cohere Transcribe 03-2026](https://huggingface.co/blog/CohereLabs/cohere-transcribe-03-2026-release)** | **High-quality offline STT** | 2B Apache-2.0, 14 languages, ONNX/INT8 exports across CPU / Apple Silicon / GPU. Cleanest-looking offline `/transcribe` + captures candidate. | Less clearly a streaming dictation model than Nemotron. |
|
||||
| **[ARK-ASR 3B / 0.6B](https://huggingface.co/AutoArk-AI/ARK-ASR-3B)** | **Multilingual STT watch** | New family, broad European/Asian coverage, strong leaderboard claims, INT8 ONNX for edge. | Very new; likely `trust_remote_code`. Validate stability first. |
|
||||
| **[IBM Granite Speech 4.1 2B / NAR](https://huggingface.co/ibm-granite/granite-speech-4.1-2b)** | **ASR + speech translation** | Compact multilingual ASR + bidirectional speech translation (en/fr/de/es/pt/ja); NAR variant for latency-sensitive work. | More compelling if we expand into translation, not just dictation. |
|
||||
|
||||
**Watch-list / blocked** (license or platform work must land first): LEMAS-TTS, Supertonic 3, KugelAudio, GLM-TTS, KittenTTS, TinyTTS (preset/on-device, not cloning); Sarashina2.2, Higgs Audio v3, T5Gemma-TTS, Step-Audio-EditX, MisoTTS (non-commercial terms or CUDA-heavy); MegaTTS3 (incomplete WaveVAE encoder distribution); PFluxTTS, LongCat-Next (paper-only / too broad). **Low-hanging Qwen-family variants:** `Qwen3-TTS-VoiceDesign` (fills text-to-voice-design with minimal churn) and ZipVoice/ZipVoice-Dialog (only if it brings zh-en/dialogue behavior our shipped LuxTTS doesn't already expose).
|
||||
|
||||
**Roadmap patch from this sweep** (reflected in Tier 3 below):
|
||||
1. Replace the `MOSS-TTS-Nano` checkbox with a **MOSS-TTS family** epic (Nano tracked separately as the CPU model).
|
||||
2. New Tier-3 TTS candidates, in order: **dots.tts → LongCat-AudioDiT → SoproTTS → NeuTTS → X-Voice → FireRedTTS-2 → Maya1**.
|
||||
3. New STT expansion candidates, in order: **Nemotron 3.5 → Cohere Transcribe → ARK-ASR → Granite Speech**.
|
||||
4. Keep Sarashina2.2, Higgs v3, T5Gemma, Step-Audio-EditX, MisoTTS, MegaTTS3, PFluxTTS blocked/watch-only.
|
||||
5. **Do platform gating (bottleneck #6 / `ModelConfig.requires`) before shipping GPU-only engines** — Maya1, Step-Audio-EditX, MisoTTS, and probably dots.tts stay experimental until it exists.
|
||||
|
||||
### Adding a New Engine (Now Straightforward)
|
||||
|
||||
With the model config registry and shared `EngineModelSelector` component, adding a new TTS engine requires:
|
||||
@@ -523,19 +693,21 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
|
||||
|
||||
## Recommended Priorities
|
||||
|
||||
### Tier 1 — Ship Now
|
||||
### Tier 1 — Ship Now (the next release is mostly a merge-and-fix pass)
|
||||
|
||||
The two-month gap means the highest-leverage work isn't new code — it's reviewing the 88-PR queue and shipping the 0.5.0 regression fixes that are already written.
|
||||
|
||||
| Priority | PR/Item | Impact | Effort |
|
||||
|----------|---------|--------|--------|
|
||||
| 1 | **RTX 50-series / Blackwell diagnostic** — detect stale CUDA binary vs GPU arch, prompt re-download (#417, #400, #396, #395, #390, #362) | Large cluster of user-blocking errors | Medium |
|
||||
| 2 | **CustomVoice download failures** (#475, #445) | New engine blocked on MAC/Win — regression triage | Medium |
|
||||
| 3 | **50k char limit on GPU** (#464) | Regression — chunking should handle this | Medium |
|
||||
| 4 | Close PR #311 (CosyVoice) and dedupe #331/#430 (MiniMax) | Housekeeping | None |
|
||||
| 5 | **PR #443** — infinite offline retry loop | Bug fix, reviewable | Low |
|
||||
| 6 | **PR #465** — define tier-1 / tier-2 platforms | Unblocks engine-sprawl decision (#419) | Low |
|
||||
| 7 | **PR #463** — docker registry auto-publish | Community PR, low risk | Low |
|
||||
| 8 | **#253** — 48kHz speech tokenizer | Quality improvement for Qwen | Medium |
|
||||
| 9 | **Kokoro profile UX** (#360) — partially addressed by auto-switch | Polish | Low |
|
||||
| 1 | **macOS Apple Silicon load crash** (#606, #615, #706, #650) — review/merge PR #789 (single-thread MLX) + #743 | Breaks the primary platform on 0.5.0 | Low (PRs exist) |
|
||||
| 2 | **Capture 30s import cutoff** (#609, #626) — review PR #602; paste-broken #762 | Core 0.5.0 feature degraded | Low–Medium |
|
||||
| 3 | **Refinement translates to English** (#603) — merge PR #629 | Silent data loss for non-English users | Low |
|
||||
| 4 | **MCP dotted tool names** (#790) — breaks Claude Desktop; scrambled audio #780 | Flagship integration broken for some clients | Low–Medium |
|
||||
| 5 | **Blackwell / sm_120 diagnostic** — review PR #653; stale-binary re-download path | Largest GPU bug cluster | Medium |
|
||||
| 6 | **Drain the i18n batch** (#528, #571, #599–601, #569, #798–801, #802, #776) | ~12 finished PRs, large user segment | Low (review-bound) |
|
||||
| 7 | **Review the @neuron-tech-ai hardening batch** — start with #662, #657 (platform gating), #656 (OpenAI API), #654 (CI) | Security + perf + bottleneck #6 in one sweep | Medium (review-bound) |
|
||||
| 8 | **Remove 50k char limit** (#464) — merge PR #786; tune chunk boundaries | Long-standing regression | Low |
|
||||
| 9 | Housekeeping — dedupe MiniMax #331/#430, re-evaluate CosyVoice #777, close spam/empty issues (#805, #775) | Triage hygiene | Low |
|
||||
|
||||
### Tier 2 — Feature Work
|
||||
|
||||
@@ -553,9 +725,11 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
|
||||
|
||||
### Tier 3 — Future Engines (cross-platform preferred)
|
||||
|
||||
Committed ordering (04-18 cycle), then the 2026-06-27 sweep additions. See Landscape → New Candidate Sweep for full rationale.
|
||||
|
||||
| Priority | Item | Notes |
|
||||
|----------|------|-------|
|
||||
| 1 | **MOSS-TTS-Nano** | 0.1B, Apache 2.0, 4-core CPU realtime, 48 kHz stereo, streaming, 20 langs, released 2026-04-13. Best alignment with our criteria. Verify install ergonomics before committing. |
|
||||
| 1 | **MOSS-TTS family** (was MOSS-TTS-Nano) | Nano first: 0.1B, Apache 2.0, 4-core CPU realtime, 48 kHz stereo, streaming, 20 langs. Best alignment with our criteria. Then track v1.5 / Realtime / TTSD / VoiceGenerator / SoundEffect as siblings under one epic. |
|
||||
| 2 | **Pocket TTS** (Kyutai) | CPU-first 100M model. MIT. Fills streaming gap without CUDA dependency. Several European langs added by Feb 2026. |
|
||||
| 3 | **IndicF5** | Fills Indian-language gap (#339). Closes many language-request issues. |
|
||||
| 4 | **VibeVoice** (Microsoft, #172) | 1.5B, long-form multi-speaker (up to 90 min, 4 speakers). Strong Stories-editor fit. |
|
||||
@@ -564,6 +738,25 @@ Seven TTS engines shipped, more candidates queued. Issue #419 asks for a first-c
|
||||
| 7 | **XTTS-v2** | 17+ langs, mature pip. CPML likely kills commercial use — verify. |
|
||||
| 8 | **index-tts2** (#370) | Unvetted. |
|
||||
| — | ~~**VoxCPM2**~~ | **Backlogged** — CUDA-only upstream. Revisit when tier system ships or MPS bugs are fixed upstream. |
|
||||
| — | *New (06-27 sweep), in order* → | |
|
||||
| 9 | **dots.tts** | 2B end-to-end AR, 48 kHz, Apache-2.0 + fast MeanFlow variant. Top new candidate. Git-source install — smoke-test packaging + VRAM; likely experimental until platform gating exists. |
|
||||
| 10 | **LongCat-AudioDiT** | 3.5B diffusion, has an MLX/`mlx_audio` path — best Apple Silicon fit. zh/en only, not realtime. |
|
||||
| 11 | **SoproTTS** | 135M, `pip install sopro`, streaming, ~250 ms TTFA / 0.05 RTF on M3 CPU. Quality-test first. |
|
||||
| 12 | **NeuTTS Air/Nano** | On-device GGUF cloning, ~3s reference. Needs a GGUF wrapper; Air is Apache-2.0, Nano license split needs review. |
|
||||
| 13 | **X-Voice** | 0.4B, 30 langs, no prompt-transcript required. Verify license/packaging. |
|
||||
| 14 | **FireRedTTS-2** | Apache-2.0 long-form multi-speaker/podcast streaming. Stories-editor engine; needs VRAM testing. |
|
||||
| 15 | **Maya1** | 3B Apache-2.0 expressive voice-design, emotion tags. English-only, 16 GB+ VRAM — gate behind platform tiers. |
|
||||
|
||||
### Tier 3b — STT / Capture Candidates (06-27 sweep)
|
||||
|
||||
Feeds the planned streaming-transcription roadmap; Whisper alternatives.
|
||||
|
||||
| Priority | Item | Notes |
|
||||
|----------|------|-------|
|
||||
| 1 | **Nemotron 3.5 ASR Streaming 0.6B** | Cache-aware streaming FastConformer-RNNT, 40 locales, MLX path. Strongest streaming-dictation fit. Verify license + non-CUDA perf. |
|
||||
| 2 | **Cohere Transcribe 03-2026** | 2B Apache-2.0, 14 langs, ONNX/INT8 across CPU/Apple Silicon/GPU. Cleanest offline `/transcribe` candidate. |
|
||||
| 3 | **ARK-ASR 3B / 0.6B** | Broad multilingual, INT8 ONNX for edge. Very new; likely `trust_remote_code` — validate stability. |
|
||||
| 4 | **IBM Granite Speech 4.1 2B / NAR** | ASR + speech translation (en/fr/de/es/pt/ja). Compelling if we expand into translation. |
|
||||
|
||||
### ~~Previously Prioritized — Now Done~~
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class ModelConfig:
|
||||
model_size: str = "default"
|
||||
size_mb: int = 0
|
||||
needs_trim: bool = False
|
||||
retries_runaway: bool = False
|
||||
supports_instruct: bool = False
|
||||
languages: list[str] = field(default_factory=lambda: ["en"])
|
||||
```
|
||||
@@ -59,6 +60,7 @@ Registry helpers in `backends/__init__.py` replace what used to be per-engine `i
|
||||
- `get_tts_model_configs()` — only TTS variants
|
||||
- `get_model_config(model_name)` — lookup by name
|
||||
- `engine_needs_trim(engine)` — whether output should run through `trim_tts_output()`
|
||||
- `engine_retries_runaway(engine)` — whether unstable output should be retried as smaller chunks
|
||||
- `load_engine_model(engine, model_size)` — downloads + loads, handles engines with multiple sizes
|
||||
- `get_tts_backend_for_engine(engine)` — thread-safe backend factory with double-checked locking
|
||||
|
||||
@@ -152,7 +154,7 @@ The request path from frontend to audio file:
|
||||
|
||||
6. **Inference** — the engine's `generate()` returns `(audio_array, sample_rate)`.
|
||||
|
||||
7. **Post-process** — if `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
|
||||
7. **Validate and post-process** — engines with `retries_runaway=True` retry unstable output as smaller chunks. If `engine_needs_trim(engine)` is True, `trim_tts_output()` strips trailing silence. Effects chains (if any) are applied per generation version, not the clean version.
|
||||
|
||||
8. **Persist** — audio is written to the generations directory, a row is inserted into the `generations` table, and the response includes the generation metadata.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ This page is for the cases where it doesn't:
|
||||
| **Windows + NVIDIA** | PyTorch CUDA (cu128) | Auto-downloads the CUDA backend binary on first use |
|
||||
| **Windows + Intel Arc** | PyTorch XPU (IPEX) | New in 0.4 — works with Arc A-series and B-series |
|
||||
| **Windows generic GPU** | DirectML | Universal Windows GPU support; slower than CUDA |
|
||||
| **Linux + NVIDIA** | PyTorch CUDA (cu128) | Same auto-download flow as Windows |
|
||||
| **Linux + NVIDIA** | PyTorch CUDA (cu128) | Use a local/remote Python backend with CUDA PyTorch |
|
||||
| **Linux + AMD** | PyTorch ROCm | Auto-configures `HSA_OVERRIDE_GFX_VERSION` |
|
||||
| **Linux + Intel Arc** | PyTorch XPU (IPEX) | |
|
||||
| **Any (no GPU)** | PyTorch CPU | Works everywhere; expect 5-50x slower than GPU |
|
||||
@@ -46,7 +46,7 @@ On M-series Macs, Voicebox ships an MLX-optimized backend that uses the Apple Ne
|
||||
|
||||
The Whisper Turbo + MLX combo dropped transcription latency from ~20s to ~2-3s on M-series chips (see CHANGELOG entry for v0.1.10).
|
||||
|
||||
## Windows / Linux + NVIDIA — The CUDA Backend Swap
|
||||
## Windows + NVIDIA — The CUDA Backend Swap
|
||||
|
||||
Voicebox doesn't bundle CUDA into the main installer (it would balloon downloads to multi-gigabyte territory for users who don't have an NVIDIA GPU). Instead, when you first need it, the app downloads a separate **CUDA backend binary** that contains the PyTorch + CUDA runtime.
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ No cloud fallback, no bring-your-own-API-key. Local is the product.
|
||||
| Platform | Backend | Notes |
|
||||
|----------|---------|-------|
|
||||
| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster via Neural Engine |
|
||||
| Windows / Linux (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Windows (NVIDIA) | PyTorch (CUDA) | Auto-downloads CUDA binary from within the app |
|
||||
| Linux (NVIDIA) | PyTorch (CUDA) | Use a local/remote Python backend with CUDA PyTorch |
|
||||
| Linux (AMD) | PyTorch (ROCm) | Auto-configures HSA_OVERRIDE_GFX_VERSION |
|
||||
| Windows (any GPU) | DirectML | Universal Windows GPU support |
|
||||
| Intel Arc | IPEX/XPU | Intel discrete GPU acceleration |
|
||||
|
||||
@@ -14,12 +14,12 @@ Make sure you have [installed Voicebox](/overview/installation) and launched the
|
||||
Voice profiles are the foundation of Voicebox. Each profile contains voice samples that the AI uses to clone the voice.
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to Profiles">
|
||||
Click the **Profiles** tab in the sidebar
|
||||
<Step title="Navigate to Voices">
|
||||
Click the **Voices** tab in the sidebar
|
||||
</Step>
|
||||
|
||||
<Step title="Create New Profile">
|
||||
Click the **+ New Profile** button
|
||||
<Step title="Create New Voice">
|
||||
Click the **+ New Voice** button
|
||||
|
||||
Fill in the details:
|
||||
- **Name:** A descriptive name (e.g., "John Smith")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# Voicebox Cloud Roadmap
|
||||
|
||||
The post-mobile commercial trajectory. Captures the strategic arc beyond `mobile/PLAN.md` — what Voicebox becomes once the mobile companion ships and we start layering optional cloud services on top of the local-first base.
|
||||
|
||||
The desktop app stays free. Paid surface is the cloud layer, gated behind a Voicebox account, designed so the server sees as little as possible.
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 0 — Mobile companion (in progress)
|
||||
|
||||
See [`mobile/PLAN.md`](../../mobile/PLAN.md). Entirely local: paired-device keys live on the iPhone, traffic goes over Tailscale or LAN, no cloud account required. This is the wedge — it establishes the device-key primitive that every later phase reuses.
|
||||
|
||||
### Phase 1 — Backup & Sync (next big feature)
|
||||
|
||||
First introduction of a Voicebox cloud account. Server stores **only encrypted blobs**.
|
||||
|
||||
- **E2E encryption keyed off the device key** from the mobile pairing flow. Audio + transcript blobs are encrypted client-side before upload; the server never has the plaintext or the key.
|
||||
- **Quota by number of generations**, not by storage GB. Avoids "how many GB do you offer" framing and keeps tiering legible. (Word-count quotas are an alternative — closer to the ElevenLabs model — but generations are simpler to communicate.)
|
||||
- **What's synced:** captures (audio + transcripts), generations, voice profiles **as ciphertext**, settings.
|
||||
- **What's NOT synced:** voice profile audio in plaintext, refinement LLM context, anything that would let us reconstruct what a user said or who they sound like.
|
||||
- **Multi-device read:** the same paired-device key on a second device decrypts the backup. Recovery via printable key on first pairing.
|
||||
|
||||
The privacy framing is load-bearing. "We see encrypted blobs and that's it" is the commitment the rest of the cloud story rests on.
|
||||
|
||||
### Phase 2 — Private Voice Inference ("the OpenRouter for voice")
|
||||
|
||||
The big bet. Today there is no major neutral voice-inference provider — every cloud TTS service ships its own proprietary models. Open-source TTS models exist and keep getting better, but nobody runs them as a paid hosted catalog at scale.
|
||||
|
||||
Voicebox already has the distribution. The thesis is: the same users who chose local-first specifically to avoid sending voice data to ElevenLabs will pay a fair markup to run open-source voices on hosted GPUs **when they don't have local hardware** (mobile-only users, low-end laptops, "I just don't want to manage CUDA"), provided the privacy story stays consistent.
|
||||
|
||||
- **Catalog-first positioning.** Cloud can offer more voices than the desktop binary bundles (the bundle is already 500MB without CUDA, ~3GB with — there's a hard ceiling on what we can ship locally). Catalog grows over time.
|
||||
- **Pricing tiers (rough first cut):** $5 / $15 / $25 / month, plus Enterprise. Final numbers depend on benchmarking — see below.
|
||||
- **Unit economics work to do:** benchmark every open-source TTS engine in the lineup (Qwen3-TTS, Chatterbox Multilingual + Turbo, TADA, Kokoro, LuxTTS, plus future additions) for cost-per-generation on candidate hardware. Find the engines where our markup is comfortably below ElevenLabs's per-character cost.
|
||||
- **Privacy ceiling:** server-side inference cannot be cryptographically verified the way E2E backup can. The honest framing is "we don't log inputs, we don't train on your data, audited" — not "we mathematically can't see it." That's a real step down from Phase 1's guarantee, and the product has to be clear about it.
|
||||
- **Mobile + OS integrations.** Once cloud inference exists, the mobile app unlocks the same OS-level surfaces ElevenLabs has (keyboard-tied dictation, share-sheet TTS, Siri-equivalent). Local-first users still get them via paired desktop; cloud users get them without needing a desktop at all.
|
||||
|
||||
#### Inference architecture
|
||||
|
||||
**Compute layer.** Modal as the v1 platform — per-second billing, scale-to-zero, volume mounts for model weights, runs our existing Python code with a thin decorator. The ~30-50% premium over raw GPU cost is irrelevant at launch scale and small (less than one DevOps hire) at $1M ARR. Migrate engine-by-engine to bare-metal (Lambda Labs, Crusoe, CoreWeave) once any single engine has predictable demand. Hyperscalers (AWS / GCP) only for enterprise contracts that require it.
|
||||
|
||||
**Topology.**
|
||||
|
||||
```
|
||||
Client (desktop / mobile / API user)
|
||||
│
|
||||
▼ HTTPS, bearer auth
|
||||
Gateway ← R2: encrypted profile blobs
|
||||
│ ← D1 / Postgres: users, billing, quotas, profile metadata
|
||||
▼ internal RPC
|
||||
Per-engine Modal apps (Kokoro / Chatterbox / TADA / Whisper / …)
|
||||
```
|
||||
|
||||
**Gateway.** Cloudflare Workers + R2 + D1 for v1 — Workers handle auth and routing, R2 has no egress fees which matters when the payload is audio, D1 handles small relational state (users, quotas, profile metadata). Auth, billing, rate limits, profile resolution, engine routing, and log redaction all live in the gateway. Workers stay dumb: they receive a request with the profile envelope already in hand, run inference, stream audio back. The gateway is what makes engine migration painless — moving TADA to bare-metal later is a routing config change, not a client change.
|
||||
|
||||
**Model packaging.** Each `backend/backends/<engine>.py` class becomes a Modal `@app.cls` wrapper. Same inference code as desktop. Weights download on container build, live on a Modal Volume, get reused by warm containers. The PyInstaller-specific runtime hooks from 0.4.x (scipy / transformers / `torch._dynamo` workarounds for the frozen binary) factor into a `frozen.py` runtime hook the desktop build imports — cloud doesn't. Single source of truth for inference logic; two entry points for two runtimes.
|
||||
|
||||
**Streaming.** SSE over HTTPS, base64-encoded audio frames, interleaved status events (`queued` / `generating` / `done`), usage event at the end with `characters_consumed` and `seconds_generated`. Wire format identical to the desktop SSE pattern from 0.2.x — cloud is the same shape at a different URL.
|
||||
|
||||
**Latency budgets** (first audio chunk, warm / cold):
|
||||
|
||||
| Engine | Hardware | Warm | Cold | Pool strategy |
|
||||
| ---------------------------------- | --------- | ---- | ---- | ------------------------------ |
|
||||
| Kokoro, LuxTTS | CPU | <1s | ~5s | Scale-to-zero |
|
||||
| Chatterbox Turbo, Whisper Turbo | A10g / L4 | 1-3s | ~15s | Small warm pool, p95 sizing |
|
||||
| Qwen3-TTS, Chatterbox Multilingual | A10g / L4 | 2-5s | ~30s | Larger warm pool |
|
||||
| TADA-3B | A100 | ~5s | ~60s | Premium tier only, capped pool |
|
||||
|
||||
Scale-to-zero where cold start fits the budget. Hot engines need warm pools sized to p95 demand — that's where unit economics get sensitive. Reserved capacity only after a quarterly demand baseline.
|
||||
|
||||
**Profile pipeline.** Cloned voice → encrypted blob with user-account-key → uploaded to R2 cold storage → fetched into worker memory at job start → decrypted in memory only, never written to worker disk → discarded on worker idle. TTL applies at the R2 layer (cold storage retention); worker hot-path retention is bounded by warmup window. Embedding-only caching where the engine exposes a stable embedding interface; raw-audio caching is the fallback. Per-engine audit needed before launch — see open questions.
|
||||
|
||||
**Hybrid routing on the client.** Desktop, mobile, and MCP clients already speak `127.0.0.1:17493`. Add `VOICEBOX_API_URL` + `VOICEBOX_API_KEY` plus a routing function:
|
||||
|
||||
```
|
||||
if local_backend_reachable() and engine in local_engines:
|
||||
→ 127.0.0.1:17493
|
||||
else:
|
||||
→ api.voicebox.sh/v1
|
||||
```
|
||||
|
||||
Mobile-without-paired-desktop falls through to cloud automatically. Desktop without a usable GPU falls through for big engines, stays local for Kokoro. Same `voicebox.speak()` MCP call works either way. This is the differentiator versus ElevenLabs (cloud-only) and pure local-first competitors (no fallback).
|
||||
|
||||
#### Cloud-cached voice profiles
|
||||
|
||||
Inference latency makes it untenable to re-upload reference samples per call. Cloud caches the user's own voice profiles for the user's own inference, under tight guardrails:
|
||||
|
||||
- **Per-profile opt-in.** Profiles are local-only by default. A "Cloud-enabled" toggle (per-profile, never global, never automatic) is what triggers upload on first cloud generation. Mobile-without-paired-desktop is the main upgrade path here — without cached profiles, mobile cloud is preset-voices only.
|
||||
- **User-controlled TTL.** `Session only` / `24h` / `7d` / `30d` / `Never expire`. Conservative default (24h). Auto-purge on inactivity regardless of ceiling.
|
||||
- **Encrypted at rest under a user-account-key envelope.** Inference workers decrypt in memory only. Keys derived from the same identity primitive that backs Phase 1.
|
||||
- **Cache embeddings, not raw audio, where the engine supports it.** Speaker embeddings (Chatterbox-style) are derived vectors — cache *those* instead of the .wav. Smaller blast radius, not reconstructible to original speech. Per-engine audit needed before launch (Qwen3-TTS, Chatterbox Multilingual + Turbo, TADA all do speaker conditioning differently); raw-audio caching is the fallback when the engine doesn't expose a stable embedding interface.
|
||||
- **Consent attestation logged at upload.** "I have rights to this voice." Timestamped, retained. Doesn't shield from claims but it's the legal posture.
|
||||
- **Verifiable deletion.** `DELETE /v2/profiles/{id}/cloud-cache` from day one, enforced across replicas, surfaced in-app as a one-click action.
|
||||
- **Trust tier:** audited-no-log, encrypted at rest, user-controlled TTL — *not* the cryptographic guarantee Phase 1 backup carries. The product has to communicate this difference clearly so cached profiles don't bleed into the Phase 1 framing. The TTL control is the marketable differentiator versus ElevenLabs, which doesn't expose retention as a user lever at all.
|
||||
- **Legal line items.** GDPR Article 9 (biometrics are special category), BIPA ($1k–5k statutory damages per violation), Texas CUBI, Washington MHMD. Real consent flow, retention controls, deletion rights, breach notification, signed DPAs for enterprise. SOC 2 + pen test before this surface goes public — not optional.
|
||||
|
||||
### Phase 3 — Voice Marketplace (much later)
|
||||
|
||||
A marketplace where voice owners license their cloned voices for others to use, with revenue sharing. Possibly: "rent out your AI voice."
|
||||
|
||||
This is the only phase that requires hosting voice profiles, and it requires real licensing infrastructure first — consent verification, takedown flow, identity claims, revenue accounting. Until that exists, **Voicebox does not host voice profiles in cloud at all** (see constraint below). Marketplace is the long-term endgame, not the next quarter.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting constraints
|
||||
|
||||
### Voice profiles in cloud: owner-only, opt-in, time-bound
|
||||
|
||||
Three rules, in increasing strictness depending on phase:
|
||||
|
||||
- **Phase 1 (backup & sync):** profiles travel as ciphertext the server cannot decrypt. The server has no path to plaintext for any reason.
|
||||
- **Phase 2 (inference):** the user's own profiles can be cached for the user's own inference, but only with per-profile opt-in, user-controlled TTL, encryption at rest, and verifiable deletion. The server holds plaintext (or derived embeddings) under audited-no-log terms — a real downshift from Phase 1's cryptographic guarantee, and one the product has to communicate honestly.
|
||||
- **Phase 3 (marketplace):** hosting other users' voices for non-owners is gated on consent verification, licensing, takedown, and revenue accounting infrastructure. Until those exist, no profile is served to anyone but its owner. No shortcuts.
|
||||
|
||||
This protects two things at once:
|
||||
- **Legal posture.** Biometric voice data triggers GDPR Article 9, BIPA, Texas CUBI, Washington MHMD. The trust hierarchy above maps to the consent and retention story we can defend at each phase.
|
||||
- **Privacy positioning.** Phase 1 is "cryptographically can't see." Phase 2 is "audited won't see, with a timer you control." Both are honest, both sit above ElevenLabs's posture, and both have to be communicated as distinct trust tiers — not blurred together.
|
||||
|
||||
### Privacy is the moat, not a feature
|
||||
|
||||
The "private LLM users → ElevenLabs voice" workflow is incoherent: people pay to keep their text private and then hand their speech to a cloud vendor that trains on it. Voicebox is the consistent answer for that audience. Every cloud feature should be designed so a privacy-conscious user can adopt it without breaking that internal consistency — which is why Phase 1 is fully E2E and Phase 2 is "audited no-log" rather than "we have your audio but trust us."
|
||||
|
||||
### Revenue stack is multi-source
|
||||
|
||||
Subscriptions are not the only line. The full picture:
|
||||
|
||||
- **Subscriptions** — Phase 1 quotas + Phase 2 inference
|
||||
- **Corporate sponsorship** — `landing/src/app/sponsors/page.tsx`, $500/mo tier live in 0.5
|
||||
- **Individual donations** — Buy Me a Coffee
|
||||
- **Marketplace revenue share** — Phase 3, far off
|
||||
|
||||
Diversification matters because the desktop app stays free forever. Subscriptions never have to carry the whole product.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & "ease it onto them"
|
||||
|
||||
The deliberate ordering is privacy-additive: each phase introduces the next layer of cloud only after the user has had time to trust the previous one.
|
||||
|
||||
1. **Mobile (entirely local)** — no account, no cloud, just a companion to the desktop you already trust.
|
||||
2. **Backup & sync (cloud, fully E2E)** — first cloud account. Server sees nothing. Trust is bootstrapped on "we built the math so we can't see your data even if we wanted to."
|
||||
3. **Private inference (cloud, audited no-log)** — second cloud surface. Honest about the ceiling: server-side inference can't carry the same cryptographic guarantee, but the operational commitment is no logs, no training, audited.
|
||||
4. **Marketplace (cloud, profiles hosted with consent)** — only after licensing infra. The most invasive surface, gated behind real verification.
|
||||
|
||||
Skipping ahead breaks the trust ladder. Don't ship marketplace before backup & sync is mature; don't ship hosted inference before users are comfortable holding accounts at all.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Quota unit.** Generations vs. words vs. characters. Generations is the cleanest to communicate; words/characters maps onto how ElevenLabs prices and might be required for inference billing. Could be different units per phase (generations for backup, characters for inference).
|
||||
2. **Recovery key UX.** First pairing in Phase 1 needs to print a recovery key. How prominent? Force-display vs. hide-behind-link?
|
||||
3. **Inference billing model.** Per-character (ElevenLabs-style), per-generation (simpler), per-second-of-output (closest to GPU cost). Pick before pricing tiers are finalized.
|
||||
4. **Bring-your-own-key for inference?** Some privacy-conscious users may prefer to provide their own GPU credits / API keys to a third-party host through us. Worth considering for Enterprise.
|
||||
5. **Marketplace consent verification.** What's the bar? Notarized release? Real-time liveness check? Out of scope for Phase 1-2 but informs how the device key is structured today.
|
||||
6. **Default cloud-cache TTL.** 24h is the proposed conservative default. Worth A/B testing against `Session only` for first-time users — the "auto-purge after this session" framing might be a stronger trust signal than any number.
|
||||
7. **Embedding vs. raw-audio caching, per engine.** Chatterbox produces stable speaker embeddings; Qwen3-TTS, TADA, and others use different conditioning strategies. Audit needed before launch — embedding-only caching shrinks the legal/privacy surface meaningfully, but only where the engine exposes a clean embedding interface.
|
||||
8. **Single gateway region or multi-region?** Cloudflare is global by default, but Modal apps are primarily us-east / us-west. EU users hitting US compute = +100ms first-token latency, and GDPR pushes toward EU compute regardless. v1 single-region or hold launch for EU?
|
||||
9. **SSE vs WebSocket for streaming.** SSE works through any proxy and is what desktop already uses, so the wire format is shared for free. WebSocket is bidirectional and unlocks "interrupt mid-generation" and live duplex features later. Default: SSE for v1, WS as a follow-on.
|
||||
10. **Cloud Whisper in the v1 bundle?** Phase 2 was framed as TTS-only ("OpenRouter for voice"), but mobile dictation hitting cloud Whisper instead of a paired desktop is the obvious mobile-only feature. Same launch bundle, or hold for Phase 2.5?
|
||||
11. **Billing integration.** Stripe Metered + customer portal (~2 weeks of work, 2.9% fee) vs self-hosted (saves the fee, adds significant ongoing work). Default: Stripe.
|
||||
|
||||
---
|
||||
|
||||
## How this connects to mobile V1
|
||||
|
||||
The encryption story starts with the device key minted during mobile pairing (`mobile/PLAN.md` → "Pairing & transport"). That same key — or a key derived from it — is what encrypts cloud blobs in Phase 1. Don't treat the mobile pairing key as a one-off; design it as the root of the user's lifetime encryption identity, with rotation + multi-device-add flows in mind even if those don't ship until Phase 1.
|
||||
@@ -43,6 +43,26 @@ setup-python:
|
||||
fi
|
||||
echo "Installing Python dependencies..."
|
||||
{{ pip }} install --upgrade pip -q
|
||||
if [ "$(uname)" = "Linux" ]; then
|
||||
torch_index=""
|
||||
if [ -e /proc/driver/nvidia/version ] || [ -d /sys/module/nvidia ]; then
|
||||
echo "Detected NVIDIA GPU — installing CUDA PyTorch..."
|
||||
torch_index="https://download.pytorch.org/whl/cu128"
|
||||
elif [ -e /dev/kfd ]; then
|
||||
if [ -n "${VOICEBOX_ROCM_VERSION:-}" ]; then
|
||||
rocm_ver="$VOICEBOX_ROCM_VERSION"
|
||||
elif lspci 2>/dev/null | grep -qi "Navi 4"; then
|
||||
rocm_ver=7.2
|
||||
else
|
||||
rocm_ver=6.3
|
||||
fi
|
||||
echo "Detected AMD GPU — installing ROCm PyTorch (rocm${rocm_ver})..."
|
||||
torch_index="https://download.pytorch.org/whl/rocm${rocm_ver}"
|
||||
fi
|
||||
if [ -n "$torch_index" ]; then
|
||||
{{ pip }} install torch torchaudio --index-url "$torch_index"
|
||||
fi
|
||||
fi
|
||||
{{ pip }} install -r {{ backend_dir }}/requirements.txt
|
||||
# Chatterbox pins numpy<1.26 / torch==2.6 which break on Python 3.12+
|
||||
{{ pip }} install --no-deps chatterbox-tts
|
||||
@@ -52,6 +72,12 @@ setup-python:
|
||||
if [ "$(uname -m)" = "arm64" ] && [ "$(uname)" = "Darwin" ]; then
|
||||
echo "Detected Apple Silicon — installing MLX dependencies..."
|
||||
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||
# mlx-lm and mlx-audio declare transformers>=5.x, which conflicts with
|
||||
# our transformers<=4.57.x cap, so install them --no-deps (their other
|
||||
# runtime deps are covered by requirements.txt / requirements-mlx.txt —
|
||||
# see the note in requirements-mlx.txt and .github/workflows/release.yml)
|
||||
{{ pip }} install --no-deps mlx-lm==0.31.1
|
||||
{{ pip }} install --no-deps mlx-audio==0.4.1
|
||||
fi
|
||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||
{{ pip }} install pyinstaller ruff pytest pytest-asyncio -q
|
||||
@@ -69,10 +95,10 @@ setup-python:
|
||||
}
|
||||
Write-Host "Installing Python dependencies..."
|
||||
& "{{ python }}" -m pip install --upgrade pip -q
|
||||
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name
|
||||
Write-Host "Detected GPUs: $($gpus -join ', ')"
|
||||
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0
|
||||
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0
|
||||
$gpus = Get-CimInstance Win32_VideoController | Select-Object -ExpandProperty Name; \
|
||||
Write-Host "Detected GPUs: $($gpus -join ', ')"; \
|
||||
$hasNvidia = ($gpus | Where-Object { $_ -match 'NVIDIA' }).Count -gt 0; \
|
||||
$hasIntelArc = ($gpus | Where-Object { $_ -match 'Arc' }).Count -gt 0; \
|
||||
if ($hasNvidia) { \
|
||||
Write-Host "NVIDIA GPU detected — installing PyTorch with CUDA support..."; \
|
||||
& "{{ pip }}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128; \
|
||||
@@ -206,12 +232,16 @@ build-server: _ensure-venv
|
||||
build-server: _ensure-venv
|
||||
$ErrorActionPreference = "Stop"; \
|
||||
$env:PATH = "{{ venv_bin }};$env:PATH"; \
|
||||
& "{{ python }}" backend/build_binary.py; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
|
||||
$triple = (rustc --print host-tuple); \
|
||||
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
|
||||
& "{{ python }}" backend/build_binary.py; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
|
||||
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"; \
|
||||
& "{{ python }}" backend/build_binary.py --shim; \
|
||||
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --shim failed with exit code $LASTEXITCODE" }; \
|
||||
Copy-Item "backend/dist/voicebox-mcp.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-mcp-$triple.exe" -Force; \
|
||||
Write-Host "Copied sidecar: voicebox-mcp-$triple.exe"
|
||||
|
||||
# Build CUDA server binary and place in app data dir for local testing
|
||||
[windows]
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.36.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"lucide-react": "^0.316.0",
|
||||
"marked": "^18.0.5",
|
||||
"next": "^16.1.3",
|
||||
"postcss": "^8.4.33",
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg viewBox="0 0 1180 320" xmlns="http://www.w3.org/2000/svg"><path d="m367.44 153.84c0 52.32 33.6 88.8 80.16 88.8s80.16-36.48 80.16-88.8-33.6-88.8-80.16-88.8-80.16 36.48-80.16 88.8zm129.6 0c0 37.44-20.4 61.68-49.44 61.68s-49.44-24.24-49.44-61.68 20.4-61.68 49.44-61.68 49.44 24.24 49.44 61.68z"/><path d="m614.27 242.64c35.28 0 55.44-29.76 55.44-65.52s-20.16-65.52-55.44-65.52c-16.32 0-28.32 6.48-36.24 15.84v-13.44h-28.8v169.2h28.8v-56.4c7.92 9.36 19.92 15.84 36.24 15.84zm-36.96-69.12c0-23.76 13.44-36.72 31.2-36.72 20.88 0 32.16 16.32 32.16 40.32s-11.28 40.32-32.16 40.32c-17.76 0-31.2-13.2-31.2-36.48z"/><path d="m747.65 242.64c25.2 0 45.12-13.2 54-35.28l-24.72-9.36c-3.84 12.96-15.12 20.16-29.28 20.16-18.48 0-31.44-13.2-33.6-34.8h88.32v-9.6c0-34.56-19.44-62.16-55.92-62.16s-60 28.56-60 65.52c0 38.88 25.2 65.52 61.2 65.52zm-1.44-106.8c18.24 0 26.88 12 27.12 25.92h-57.84c4.32-17.04 15.84-25.92 30.72-25.92z"/><path d="m823.98 240h28.8v-73.92c0-18 13.2-27.6 26.16-27.6 15.84 0 22.08 11.28 22.08 26.88v74.64h28.8v-83.04c0-27.12-15.84-45.36-42.24-45.36-16.32 0-27.6 7.44-34.8 15.84v-13.44h-28.8z"/><path d="m1014.17 67.68-65.28 172.32h30.48l14.64-39.36h74.4l14.88 39.36h30.96l-65.28-172.32zm16.8 34.08 27.36 72h-54.24z"/><path d="m1163.69 68.18h-30.72v172.32h30.72z"/><path d="m297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,104 @@
|
||||
import {readFileSync} from "node:fs";
|
||||
import {join} from "node:path";
|
||||
import {ImageResponse} from "next/og";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
// Per-post Open Graph image, generated with Satori at build time (static export
|
||||
// of each post route) and served as PNG. Note: this runs in the Satori renderer,
|
||||
// which only understands inline styles + flexbox and a subset of CSS — no
|
||||
// Tailwind classes, no `filter: blur()`. Glows are done with radial gradients.
|
||||
|
||||
export const size = {width: 1200, height: 630};
|
||||
export const contentType = "image/png";
|
||||
export const alt = "Voicebox Blog";
|
||||
|
||||
// Pre-build an image for every post route (mirrors the page's static params).
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
// 8-bit PNG decodes reliably in Satori; the 1024px logos are 16-bit and don't.
|
||||
const logo = `data:image/png;base64,${readFileSync(
|
||||
join(process.cwd(), "public/apple-touch-icon.png"),
|
||||
).toString("base64")}`;
|
||||
|
||||
function titleFontSize(title: string): number {
|
||||
if (title.length <= 38) return 76;
|
||||
if (title.length <= 64) return 60;
|
||||
return 48;
|
||||
}
|
||||
|
||||
export default async function OgImage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
const title = post?.title ?? "Voicebox Blog";
|
||||
const meta = post
|
||||
? `${post.author} · ${formatDate(post.date)}`
|
||||
: "Open source voice cloning. Local-first.";
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
padding: 80,
|
||||
background:
|
||||
"radial-gradient(ellipse 80% 70% at 30% 30%, hsla(43,60%,50%,0.14) 0%, hsla(43,60%,50%,0.04) 40%, transparent 70%), linear-gradient(180deg, hsl(30,4%,6%) 0%, hsl(30,4%,4%) 100%)",
|
||||
}}
|
||||
>
|
||||
{/* Top: logo + eyebrow */}
|
||||
<div style={{display: "flex", alignItems: "center", gap: 24}}>
|
||||
{/* biome-ignore lint/performance/noImgElement: Satori only renders <img> */}
|
||||
<img src={logo} width={88} height={88} alt="" />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 26,
|
||||
letterSpacing: 6,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
color: "hsl(43, 60%, 58%)",
|
||||
}}
|
||||
>
|
||||
Voicebox Blog
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: titleFontSize(title),
|
||||
lineHeight: 1.1,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -1,
|
||||
color: "hsl(30, 10%, 94%)",
|
||||
maxWidth: 1000,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Footer meta */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
fontSize: 28,
|
||||
color: "hsl(30, 5%, 55%)",
|
||||
}}
|
||||
>
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {notFound} from "next/navigation";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, getPost, loadAllPosts} from "@/lib/blog";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return loadAllPosts().map((post) => ({slug: post.slug}));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}): Promise<Metadata> {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) return {title: "Post not found — Voicebox"};
|
||||
return {
|
||||
title: `${post.title} — Voicebox`,
|
||||
description: post.excerpt,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
type: "article",
|
||||
url: `https://voicebox.sh/blog/${post.slug}`,
|
||||
// og:image / twitter:image come from the colocated opengraph-image.tsx
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: post.title,
|
||||
description: post.excerpt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{slug: string}>;
|
||||
}) {
|
||||
const {slug} = await params;
|
||||
const post = getPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="font-mono text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
|
||||
>
|
||||
← Back to blog
|
||||
</Link>
|
||||
|
||||
<header className="mt-8 border-b border-border pb-10">
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
{post.title}
|
||||
</h1>
|
||||
<p className="mt-5 font-mono text-sm text-muted-foreground">
|
||||
by <span className="text-foreground">{post.author}</span> ·{" "}
|
||||
{formatDate(post.date)} · {post.readingMinutes} min read
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<article
|
||||
className="blog-prose mt-10"
|
||||
// Content is authored markdown from this repo, not user input.
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted local markdown
|
||||
dangerouslySetInnerHTML={{__html: post.html}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {formatDate, listPosts} from "@/lib/blog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog — Voicebox",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
openGraph: {
|
||||
title: "Voicebox Blog",
|
||||
description: "Notes from building Voicebox — the open-source AI voice studio.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/blog",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
export default function BlogIndexPage() {
|
||||
const posts = listPosts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl px-6 pt-32 pb-20">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
Blog
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tighter text-foreground">
|
||||
Notes from building Voicebox.
|
||||
</h1>
|
||||
<p className="mt-5 max-w-2xl text-lg text-muted-foreground">
|
||||
The story behind the project, what's shipping next, and the occasional
|
||||
look under the hood.
|
||||
</p>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="mt-16 border-t border-border pt-10 text-muted-foreground">
|
||||
Nothing published yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-16 border-t border-border">
|
||||
{posts.map((post) => (
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="group grid gap-4 border-b border-border py-10 md:grid-cols-[11rem_1fr] md:gap-10"
|
||||
>
|
||||
<div className="font-mono text-sm text-muted-foreground md:pt-1.5">
|
||||
<p>{formatDate(post.date)}</p>
|
||||
<p className="mt-1">{post.readingMinutes} min read</p>
|
||||
</div>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground transition-colors group-hover:text-accent">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt ? (
|
||||
<p className="mt-3 leading-7 text-muted-foreground">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
{post.tags.length > 0 ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-border/60 bg-card/40 px-2.5 py-0.5 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {ArrowRight, Cloud, KeyRound, Lock, ShieldCheck} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {CLOUD_FEATURES, CLOUD_NOTIFY_URL} from "@/lib/pricing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Cloud Backup & Sync — Voicebox",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync for your Voicebox library. We can't read your data — only your devices can. Optional, local-first, free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Cloud — encrypted backup & sync",
|
||||
description:
|
||||
"End-to-end encrypted backup and sync across desktop and mobile. The server is blind — only your devices can decrypt.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/cloud",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
icon: Lock,
|
||||
title: "Encrypted on your device",
|
||||
body: "Profiles, generations, and captures are encrypted locally with keys only you hold — before anything is uploaded.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Stored as opaque blobs",
|
||||
body: "The server keeps your encrypted objects and a sync feed. It can route and store them, but never decrypt them.",
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
title: "Only your devices decrypt",
|
||||
body: "Each device unwraps your master key on pairing. A recovery phrase you control lets you restore everything to a new one.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CloudPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-16">
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[500px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-6 inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/40 px-3 py-1">
|
||||
<Cloud className="h-3.5 w-3.5 text-accent" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Voicebox Cloud · coming soon
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter leading-[0.95] text-foreground md:text-6xl lg:text-7xl">
|
||||
Your studio, backed up and in sync.
|
||||
</h1>
|
||||
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
Optional, end-to-end encrypted backup and sync for your entire
|
||||
Voicebox library. We can't read a byte of it — only your devices
|
||||
can. Free for{" "}
|
||||
<Link href="/token" className="text-foreground underline-offset-4 hover:underline">
|
||||
$VOICEBOX
|
||||
</Link>{" "}
|
||||
holders.
|
||||
</p>
|
||||
|
||||
<div className="fade-in mt-10 flex flex-row items-center justify-center gap-3 sm:gap-4">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full bg-accent px-8 py-3.5 text-sm font-semibold uppercase tracking-wider text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3),inset_0_2px_0_rgba(255,255,255,0.2),inset_0_-2px_0_rgba(0,0,0,0.1)] transition-all hover:bg-accent-faint"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<a
|
||||
href={CLOUD_NOTIFY_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-card/40 backdrop-blur-sm px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
Get notified
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-5xl px-6">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{CLOUD_FEATURES.map((f) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{f.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{f.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── How it works ─────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-accent mb-4">
|
||||
How it works
|
||||
</div>
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Zero-knowledge by design.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{STEPS.map((step, i) => {
|
||||
const Icon = step.icon;
|
||||
return (
|
||||
<div
|
||||
key={step.title}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Icon className="h-5 w-5 text-accent" />
|
||||
<span className="font-mono text-xs text-muted-foreground/60">
|
||||
0{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{step.body}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Trust callout ────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="rounded-2xl border-2 border-accent/40 bg-card/60 backdrop-blur-sm p-8 md:p-10 text-center shadow-[0_8px_40px_hsl(43_60%_50%/0.08)]">
|
||||
<ShieldCheck className="h-7 w-7 text-accent mx-auto mb-4" />
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight text-foreground mb-3">
|
||||
We can't see your data. That's the point.
|
||||
</h2>
|
||||
<p className="text-muted-foreground leading-relaxed max-w-2xl mx-auto">
|
||||
Voicebox is local-first and privacy-first. The cloud keeps that
|
||||
promise: your library is encrypted before it leaves your device,
|
||||
the server stores only ciphertext, and the keys never leave your
|
||||
control. Same philosophy as the app — just backed up.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="rounded-full bg-accent px-6 py-3 text-sm font-semibold text-white shadow-[0_4px_20px_hsl(43_60%_50%/0.3)] transition-all hover:bg-accent-faint"
|
||||
>
|
||||
See pricing
|
||||
</Link>
|
||||
<Link
|
||||
href="/token"
|
||||
className="rounded-full border border-border/60 bg-card/40 px-6 py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-border"
|
||||
>
|
||||
Free for holders →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -142,6 +142,99 @@
|
||||
will-change: transform;
|
||||
} */
|
||||
|
||||
/* Blog post typography (rendered markdown via marked) */
|
||||
.blog-prose {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.75;
|
||||
}
|
||||
.blog-prose > * + * {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.blog-prose h2 {
|
||||
margin-top: 2.25em;
|
||||
margin-bottom: 0.75em;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose h3 {
|
||||
margin-top: 1.75em;
|
||||
margin-bottom: 0.5em;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose p,
|
||||
.blog-prose ul,
|
||||
.blog-prose ol,
|
||||
.blog-prose blockquote {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.blog-prose strong {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 600;
|
||||
}
|
||||
.blog-prose a {
|
||||
color: hsl(var(--foreground));
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: hsl(var(--accent) / 0.5);
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.blog-prose a:hover {
|
||||
color: hsl(var(--accent));
|
||||
}
|
||||
.blog-prose ul,
|
||||
.blog-prose ol {
|
||||
padding-left: 1.4em;
|
||||
}
|
||||
.blog-prose ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.blog-prose ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.blog-prose li + li {
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.blog-prose blockquote {
|
||||
border-left: 2px solid hsl(var(--accent) / 0.5);
|
||||
padding-left: 1.25em;
|
||||
font-style: italic;
|
||||
}
|
||||
.blog-prose code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.blog-prose pre {
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.1em 1.25em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.blog-prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.blog-prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 2.5em 0;
|
||||
}
|
||||
.blog-prose img {
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
/* Scrollbar hiding */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
|
||||
@@ -11,8 +11,9 @@ import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {Personalities} from "@/components/Personalities";
|
||||
import {AppleIcon, LinuxIcon, WindowsIcon} from "@/components/PlatformIcons";
|
||||
import {SponsorPromo} from "@/components/SponsorPromo";
|
||||
import {SupportedModels} from "@/components/SupportedModels";
|
||||
import {Testimonials} from "@/components/Testimonials";
|
||||
import {TokenTeaser} from "@/components/TokenTeaser";
|
||||
import {TutorialsSection} from "@/components/TutorialsSection";
|
||||
import {VoiceCreator} from "@/components/VoiceCreator";
|
||||
import {GITHUB_REPO} from "@/lib/constants";
|
||||
@@ -131,9 +132,6 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sponsor promo ────────────────────────────────────────── */}
|
||||
<SponsorPromo />
|
||||
|
||||
{/* ── Features ─────────────────────────────────────────────── */}
|
||||
<Features />
|
||||
|
||||
@@ -158,6 +156,9 @@ export default function Home() {
|
||||
{/* ── Supported models ─────────────────────────────────────── */}
|
||||
<SupportedModels />
|
||||
|
||||
{/* ── Testimonials ─────────────────────────────────────────── */}
|
||||
<Testimonials />
|
||||
|
||||
{/* ── Download Section ─────────────────────────────────────── */}
|
||||
<section id="download" className="border-t border-border py-24">
|
||||
<div className="mx-auto max-w-4xl px-6">
|
||||
@@ -241,6 +242,9 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── $VOICEBOX token (teaser → /token) ─────────────────────── */}
|
||||
<TokenTeaser />
|
||||
|
||||
{/* ── Footer ───────────────────────────────────────────────── */}
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {Coins} from "lucide-react";
|
||||
import type {Metadata} from "next";
|
||||
import Link from "next/link";
|
||||
import {Footer} from "@/components/Footer";
|
||||
import {Navbar} from "@/components/Navbar";
|
||||
import {PricingTiers} from "@/components/PricingTiers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Pricing — Voicebox",
|
||||
description:
|
||||
"Voicebox is free and open source forever. Optional, end-to-end encrypted cloud backup & sync — free for $VOICEBOX holders.",
|
||||
openGraph: {
|
||||
title: "Voicebox Pricing",
|
||||
description:
|
||||
"The app is free forever. Cloud backup & sync is an optional add-on — free for $VOICEBOX holders.",
|
||||
type: "website",
|
||||
url: "https://voicebox.sh/pricing",
|
||||
images: [{url: "/og.webp", width: 1200, height: 630}],
|
||||
},
|
||||
};
|
||||
|
||||
const FAQ = [
|
||||
{
|
||||
q: "Is the app really free?",
|
||||
a: "Yes — Voicebox is free and open source, forever. Cloning, dictation, every TTS engine, MCP, personalities: all of it runs locally with no account. The paid plans only add optional cloud backup & sync.",
|
||||
},
|
||||
{
|
||||
q: "What's encrypted in the cloud?",
|
||||
a: "Everything. Your profiles, generations, and captures are end-to-end encrypted on your device before upload. The server stores only ciphertext and can never read your data.",
|
||||
},
|
||||
{
|
||||
q: "Do $VOICEBOX holders really get Cloud free?",
|
||||
a: "Yes. Holding the token unlocks the Cloud tier at no cost. The app itself is free regardless — the token is an optional way to support the project.",
|
||||
},
|
||||
{
|
||||
q: "What counts toward storage?",
|
||||
a: "Your encrypted objects — generated audio, the original audio kept with each capture, and profile data. Plans differ mainly on storage, device count, and version-history length.",
|
||||
},
|
||||
{
|
||||
q: "Can I cancel anytime?",
|
||||
a: "Yes. Cloud is a subscription you can cancel whenever you like; your local library always stays on your machine and keeps working.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
{/* ── Hero ─────────────────────────────────────────────────── */}
|
||||
<section className="relative pt-32 pb-12">
|
||||
<div className="hero-glow hero-glow-fade pointer-events-none absolute inset-0 -top-32">
|
||||
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[900px] h-[460px] rounded-full bg-accent/12 blur-[140px]" />
|
||||
</div>
|
||||
<div className="relative mx-auto max-w-4xl px-6 text-center">
|
||||
<div className="fade-in mb-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-accent">
|
||||
Pricing
|
||||
</div>
|
||||
<h1 className="fade-in text-5xl font-bold tracking-tighter text-foreground md:text-6xl">
|
||||
The app is free. Forever.
|
||||
</h1>
|
||||
<p className="fade-in mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything that makes Voicebox great runs locally at no cost. Pay
|
||||
only if you want optional, encrypted cloud backup & sync — and
|
||||
holders get that free.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Tiers (with monthly/annual toggle) ───────────────────── */}
|
||||
<section className="pb-8">
|
||||
<PricingTiers />
|
||||
</section>
|
||||
|
||||
{/* ── Holder callout ───────────────────────────────────────── */}
|
||||
<section className="py-12">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<Link
|
||||
href="/token"
|
||||
className="group flex flex-col items-center gap-3 rounded-2xl border border-accent/30 bg-card/40 backdrop-blur-sm px-6 py-8 text-center transition-colors hover:border-accent/50"
|
||||
>
|
||||
<Coins className="h-6 w-6 text-accent" />
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground">
|
||||
Hold $VOICEBOX, get Cloud free.
|
||||
</h2>
|
||||
<p className="max-w-xl text-sm text-muted-foreground">
|
||||
The token is an optional way to back the project — and holders get
|
||||
the Cloud tier at no cost. Learn how it works and verify everything
|
||||
on-chain.
|
||||
</p>
|
||||
<span className="mt-1 text-sm font-medium text-accent group-hover:underline underline-offset-4">
|
||||
View the token →
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ──────────────────────────────────────────────────── */}
|
||||
<section className="border-t border-border py-20">
|
||||
<div className="mx-auto max-w-3xl px-6">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-semibold tracking-tight text-foreground">
|
||||
Questions
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FAQ.map((item) => (
|
||||
<div
|
||||
key={item.q}
|
||||
className="rounded-xl border border-border bg-card/40 backdrop-blur-sm p-6"
|
||||
>
|
||||
<h3 className="text-[15px] font-semibold text-foreground mb-2">
|
||||
{item.q}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{item.a}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground/70 mt-10 max-w-2xl mx-auto">
|
||||
Cloud pricing and limits are not final — they'll be confirmed at
|
||||
launch.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user