Enhance provider packaging and CUDA download functionality

- Added support for packaging provider archives in the release workflow, creating platform-specific zip and tar.gz files for distribution.
- Updated the `.gitignore` to exclude `.spec` files.
- Introduced a new `CudaDownloadSection` component to manage CUDA downloads, including progress tracking and error handling.
- Refactored provider download logic to handle archive extraction and cleanup after download.
- Improved subprocess output handling in the provider manager for better logging and error reporting.
This commit is contained in:
Jamie Pine
2026-02-02 04:06:21 -08:00
parent 732d35ca89
commit 61dabe7382
9 changed files with 352 additions and 73 deletions
+32 -16
View File
@@ -91,6 +91,30 @@ jobs:
cd providers/${{ matrix.provider }}
python build.py
- name: Package provider for distribution
shell: bash
run: |
cd providers/${{ matrix.provider }}/dist
# Add platform suffix for archive name
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
# On Windows, zip the directory
powershell Compress-Archive -Path "tts-provider-${{ matrix.provider }}/*" -DestinationPath "$ARCHIVE_NAME"
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
else
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
tar -czf "$ARCHIVE_NAME" tts-provider-${{ matrix.provider }}/
fi
echo "Created archive: $ARCHIVE_NAME"
ls -lh "$ARCHIVE_NAME"
- name: Upload provider to R2
shell: bash
env:
@@ -106,31 +130,23 @@ jobs:
aws configure set aws_secret_access_key $R2_SECRET_ACCESS_KEY
aws configure set region auto
# Determine binary name based on platform
# Determine archive name based on platform
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
BINARY_NAME="tts-provider-${{ matrix.provider }}.exe"
BINARY_PATH="providers/${{ matrix.provider }}/dist/tts-provider-${{ matrix.provider }}.exe"
else
BINARY_NAME="tts-provider-${{ matrix.provider }}"
BINARY_PATH="providers/${{ matrix.provider }}/dist/tts-provider-${{ matrix.provider }}"
fi
# Add platform suffix for clarity
if [ "${{ matrix.platform }}" == "windows-latest" ]; then
UPLOAD_NAME="tts-provider-${{ matrix.provider }}-windows.exe"
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-windows.zip"
elif [ "${{ matrix.platform }}" == "macos-latest" ]; then
UPLOAD_NAME="tts-provider-${{ matrix.provider }}-macos-arm64"
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-arm64.tar.gz"
elif [ "${{ matrix.platform }}" == "macos-15-intel" ]; then
UPLOAD_NAME="tts-provider-${{ matrix.provider }}-macos-x64"
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-macos-x64.tar.gz"
else
UPLOAD_NAME="tts-provider-${{ matrix.provider }}-linux"
ARCHIVE_NAME="tts-provider-${{ matrix.provider }}-linux.tar.gz"
fi
# Upload to R2 (bucket: voicebox)
aws s3 cp "$BINARY_PATH" "s3://voicebox/providers/v${{ env.PROVIDER_VERSION }}/$UPLOAD_NAME" \
aws s3 cp "providers/${{ matrix.provider }}/dist/$ARCHIVE_NAME" \
"s3://voicebox/providers/v${{ env.PROVIDER_VERSION }}/$ARCHIVE_NAME" \
--endpoint-url "$R2_ENDPOINT"
echo "Uploaded $UPLOAD_NAME to R2"
echo "Uploaded $ARCHIVE_NAME to R2"
# ============================================
# Build Main App (without bundled TTS on Win/Linux)
+1
View File
@@ -15,6 +15,7 @@ dist/
build/
*.egg-info/
*.egg
*.spec
target/
*.app
*.dmg
@@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, RefreshCw, CheckCircle, AlertCircle } from 'lucide-react';
import { relaunch } from '@tauri-apps/plugin-process';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ModelProgress } from './ModelProgress';
import { apiClient } from '@/lib/api/client';
import { useServerStore } from '@/stores/serverStore';
import { toast } from '@/components/ui/use-toast';
import { usePlatform } from '@/platform/PlatformContext';
import { useServerHealth } from '@/lib/hooks/useServer';
export function CudaDownloadSection() {
const platform = usePlatform();
const serverUrl = useServerStore((state) => state.serverUrl);
const { data: health } = useServerHealth();
const [downloadingCuda, setDownloadingCuda] = useState(false);
const [downloadError, setDownloadError] = useState<string | null>(null);
// Platform checks
const isWindows = navigator.userAgent.includes('Windows');
const gpuAvailable = health?.gpu_available ?? false;
// Only show this section on Windows + Tauri + GPU available
if (!platform.metadata.isTauri || !isWindows || !gpuAvailable) {
return null;
}
// Query CUDA status
const { data: cudaStatus, refetch } = useQuery({
queryKey: ['cudaStatus'],
queryFn: () => apiClient.getCudaStatus(),
enabled: isWindows && platform.metadata.isTauri && gpuAvailable,
retry: false,
});
// Handle download trigger
const handleDownload = async () => {
try {
setDownloadError(null);
await apiClient.triggerCudaDownload();
setDownloadingCuda(true);
} catch (error) {
setDownloadError(error instanceof Error ? error.message : 'Failed to start download');
}
};
// Handle retry
const handleRetry = () => {
setDownloadError(null);
handleDownload();
};
// Monitor download progress and auto-restart on completion
useEffect(() => {
if (!downloadingCuda || !serverUrl) return;
const eventSource = new EventSource(`${serverUrl}/models/progress/cuda-binary`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.status === 'complete' && data.progress >= 100) {
eventSource.close();
setDownloadingCuda(false);
// Show restart toast
toast({
title: 'CUDA Downloaded',
description: 'Restarting app to enable GPU acceleration...',
});
// Restart after 2 seconds
setTimeout(async () => {
await relaunch();
}, 2000);
}
if (data.status === 'error') {
eventSource.close();
setDownloadingCuda(false);
setDownloadError(data.error || 'Download failed');
}
} catch (error) {
console.error('Error parsing CUDA download progress:', error);
}
};
eventSource.onerror = () => {
eventSource.close();
setDownloadingCuda(false);
setDownloadError('Connection to server lost');
};
return () => eventSource.close();
}, [downloadingCuda, serverUrl]);
if (!cudaStatus) {
return null;
}
const { cuda_available, cuda_active, cuda_binary_size_mb } = cudaStatus;
return (
<Card>
<CardHeader>
<CardTitle>GPU Acceleration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Status badges */}
<div className="flex flex-wrap gap-2">
<Badge variant={cuda_active ? 'default' : 'secondary'}>
Mode: {cuda_active ? 'CUDA' : 'CPU'}
</Badge>
<Badge variant={gpuAvailable ? 'default' : 'secondary'}>
GPU: {gpuAvailable ? 'Available' : 'Not Available'}
</Badge>
</div>
{/* Already downloaded status */}
{cuda_available && !downloadingCuda && (
<Alert>
<CheckCircle className="h-4 w-4" />
<AlertDescription>
CUDA support is active. GPU acceleration enabled.
</AlertDescription>
</Alert>
)}
{/* Download section - only show if not already downloaded */}
{!cuda_available && !downloadError && !downloadingCuda && (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Download CUDA support for 4-5x faster inference with your NVIDIA GPU
</p>
<Button onClick={handleDownload} className="w-full">
Download CUDA Support ({cuda_binary_size_mb.toFixed(1)}GB)
</Button>
</div>
)}
{/* Progress display */}
{downloadingCuda && (
<div className="space-y-2">
<ModelProgress
modelName="cuda-binary"
displayName="CUDA Server Binary"
isDownloading={true}
/>
</div>
)}
{/* Error with retry button */}
{downloadError && (
<div className="space-y-2">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{downloadError}</AlertDescription>
</Alert>
<Button onClick={handleRetry} variant="outline" className="w-full">
<RefreshCw className="mr-2 h-4 w-4" />
Retry Download
</Button>
</div>
)}
</CardContent>
</Card>
);
}
+36 -6
View File
@@ -100,13 +100,43 @@ class ProviderManager:
stdout_content = ""
stderr_content = ""
# Try to read available output (works on Windows and Unix)
try:
if stdout_log.exists():
stdout_content = stdout_log.read_text()
if stderr_log.exists():
stderr_content = stderr_log.read_text()
except Exception as read_err:
logger.error(f"Failed to read provider logs: {read_err}")
# Use non-blocking read with timeout
import threading
import queue
def enqueue_output(stream, queue):
try:
for line in iter(stream.readline, ''):
queue.put(line)
except:
pass
stdout_queue = queue.Queue()
stderr_queue = queue.Queue()
if process.stdout:
t = threading.Thread(target=enqueue_output, args=(process.stdout, stdout_queue))
t.daemon = True
t.start()
if process.stderr:
t2 = threading.Thread(target=enqueue_output, args=(process.stderr, stderr_queue))
t2.daemon = True
t2.start()
# Give threads a moment to read
import time
time.sleep(0.5)
# Collect output
while not stdout_queue.empty():
stdout_lines.append(stdout_queue.get_nowait())
while not stderr_queue.empty():
stderr_lines.append(stderr_queue.get_nowait())
except Exception as ex:
logger.warning(f"Could not capture subprocess output: {ex}")
logger.error(f"Provider failed to start within 30 seconds")
logger.error(f"Check logs at: {logs_dir}")
+84 -41
View File
@@ -55,13 +55,13 @@ def _get_provider_binary_name(provider_type: str) -> str:
def _get_provider_download_name(provider_type: str) -> str:
"""Get the remote download filename for a provider type (includes platform suffix)."""
system = platform.system()
if system == "Windows":
platform_suffix = "windows"
ext = ".exe"
ext = ".zip"
elif system == "Linux":
platform_suffix = "linux"
ext = ""
ext = ".tar.gz"
elif system == "Darwin":
# Detect macOS architecture
machine = platform.machine()
@@ -69,10 +69,10 @@ def _get_provider_download_name(provider_type: str) -> str:
platform_suffix = "macos-arm64"
else:
platform_suffix = "macos-x64"
ext = ""
ext = ".tar.gz"
else:
raise ValueError(f"Provider downloads not supported on {system}")
return f"tts-provider-{provider_type}-{platform_suffix}{ext}"
@@ -84,97 +84,131 @@ def _get_provider_download_url(provider_type: str) -> str:
async def download_provider(provider_type: str) -> Path:
"""
Download a provider binary from Cloudflare R2.
Download and extract a provider archive from Cloudflare R2.
Args:
provider_type: Type of provider to download (e.g., "pytorch-cpu")
Returns:
Path to the downloaded provider binary
Path to the extracted provider binary
Raises:
ValueError: If provider_type is invalid
httpx.HTTPError: If download fails
"""
if provider_type not in ["pytorch-cpu", "pytorch-cuda"]:
raise ValueError(f"Provider type {provider_type} cannot be downloaded")
progress_manager = get_progress_manager()
task_manager = get_task_manager()
binary_name = _get_provider_binary_name(provider_type)
archive_name = _get_provider_download_name(provider_type)
download_url = _get_provider_download_url(provider_type)
destination = _get_providers_dir() / binary_name
providers_dir = _get_providers_dir()
archive_path = providers_dir / archive_name
# Start tracking download
task_manager.start_download(provider_type)
# Initialize progress state
progress_manager.update_progress(
model_name=provider_type,
current=0,
total=0, # Will be updated once we get Content-Length
filename=binary_name,
filename=archive_name,
status="downloading",
)
try:
# Download archive
async with httpx.AsyncClient(timeout=300.0) as client:
# First, get the file size
async with client.stream("GET", download_url) as response:
response.raise_for_status()
# Get total size from Content-Length header
total_size = int(response.headers.get("Content-Length", 0))
if total_size > 0:
progress_manager.update_progress(
model_name=provider_type,
current=0,
total=total_size,
filename=binary_name,
filename=archive_name,
status="downloading",
)
# Download with progress tracking
downloaded = 0
with open(destination, "wb") as f:
with open(archive_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
f.write(chunk)
downloaded += len(chunk)
# Update progress
progress_manager.update_progress(
model_name=provider_type,
current=downloaded,
total=total_size if total_size > 0 else downloaded,
filename=binary_name,
filename=archive_name,
status="downloading",
)
# Extract archive
progress_manager.update_progress(
model_name=provider_type,
current=downloaded,
total=downloaded,
filename="Extracting...",
status="downloading",
)
import zipfile
import tarfile
if archive_name.endswith('.zip'):
with zipfile.ZipFile(archive_path, 'r') as zip_ref:
zip_ref.extractall(providers_dir)
elif archive_name.endswith('.tar.gz'):
with tarfile.open(archive_path, 'r:gz') as tar_ref:
tar_ref.extractall(providers_dir)
else:
raise ValueError(f"Unsupported archive format: {archive_name}")
# Remove archive after extraction
archive_path.unlink()
# Get path to extracted binary
binary_path = get_provider_binary_path(provider_type)
if not binary_path:
raise ValueError(f"Provider binary not found after extraction")
# Make executable on Unix systems
if platform.system() != "Windows":
binary_path.chmod(0o755)
# Mark as complete
progress_manager.update_progress(
model_name=provider_type,
current=downloaded,
total=downloaded,
filename=binary_name,
filename=_get_provider_binary_name(provider_type),
status="complete",
)
task_manager.complete_download(provider_type)
# Make executable on Unix systems
if platform.system() != "Windows":
destination.chmod(0o755)
return destination
return binary_path
except Exception as e:
# Clean up archive if it exists
if archive_path.exists():
archive_path.unlink()
# Mark as error
progress_manager.update_progress(
model_name=provider_type,
current=0,
total=0,
filename=binary_name,
filename=archive_name,
status="error",
)
task_manager.error_download(provider_type, str(e))
@@ -184,19 +218,28 @@ async def download_provider(provider_type: str) -> Path:
def get_provider_binary_path(provider_type: str) -> Optional[Path]:
"""
Get the path to an installed provider binary.
Args:
provider_type: Type of provider
Returns:
Path to provider binary, or None if not installed
"""
providers_dir = _get_providers_dir()
binary_name = _get_provider_binary_name(provider_type)
provider_path = _get_providers_dir() / binary_name
# Check for --onedir structure (directory with binary inside)
provider_dir = providers_dir / f"tts-provider-{provider_type}"
if provider_dir.exists() and provider_dir.is_dir():
binary_path = provider_dir / binary_name
if binary_path.exists() and binary_path.is_file():
return binary_path
# Fallback: check for direct binary (legacy)
provider_path = providers_dir / binary_name
if provider_path.exists() and provider_path.is_file():
return provider_path
return None
-7
View File
@@ -1,7 +0,0 @@
# User data directory
# This directory contains:
# - profiles/ - Voice profile audio files
# - generations/ - Generated audio files
# - projects/ - Audio studio project files
# - voicebox.db - SQLite database
# - cache/ - Voice prompt cache files
-1
View File
@@ -1 +0,0 @@
# Voice prompt cache files
+13 -1
View File
@@ -16,7 +16,7 @@ def build_provider():
# PyInstaller arguments
args = [
'main.py',
'--onefile',
'--onedir', # Changed from --onefile to work around Windows extraction issues
'--name', 'tts-provider-pytorch-cpu',
]
@@ -51,6 +51,18 @@ def build_provider():
'--collect-submodules', 'jaraco',
'--hidden-import', 'fastapi',
'--hidden-import', 'uvicorn',
# Critical uvicorn imports for PyInstaller
'--hidden-import', 'uvicorn.logging',
'--hidden-import', 'uvicorn.loops',
'--hidden-import', 'uvicorn.loops.auto',
'--hidden-import', 'uvicorn.protocols',
'--hidden-import', 'uvicorn.protocols.http',
'--hidden-import', 'uvicorn.protocols.http.auto',
'--hidden-import', 'uvicorn.protocols.websockets',
'--hidden-import', 'uvicorn.protocols.websockets.auto',
'--hidden-import', 'uvicorn.lifespan',
'--hidden-import', 'uvicorn.lifespan.on',
'--collect-submodules', 'uvicorn',
'--hidden-import', 'soundfile',
'--hidden-import', 'numpy',
'--hidden-import', 'librosa',
+13 -1
View File
@@ -16,7 +16,7 @@ def build_provider():
# PyInstaller arguments
args = [
'main.py',
'--onefile',
'--onedir', # Changed from --onefile to work around Windows extraction issues
'--name', 'tts-provider-pytorch-cuda',
]
@@ -53,6 +53,18 @@ def build_provider():
'--collect-submodules', 'jaraco',
'--hidden-import', 'fastapi',
'--hidden-import', 'uvicorn',
# Critical uvicorn imports for PyInstaller
'--hidden-import', 'uvicorn.logging',
'--hidden-import', 'uvicorn.loops',
'--hidden-import', 'uvicorn.loops.auto',
'--hidden-import', 'uvicorn.protocols',
'--hidden-import', 'uvicorn.protocols.http',
'--hidden-import', 'uvicorn.protocols.http.auto',
'--hidden-import', 'uvicorn.protocols.websockets',
'--hidden-import', 'uvicorn.protocols.websockets.auto',
'--hidden-import', 'uvicorn.lifespan',
'--hidden-import', 'uvicorn.lifespan.on',
'--collect-submodules', 'uvicorn',
'--hidden-import', 'soundfile',
'--hidden-import', 'numpy',
'--hidden-import', 'librosa',