diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1da7da39..d102274c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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) diff --git a/.gitignore b/.gitignore index 05f7ef0d..4fef6ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist/ build/ *.egg-info/ *.egg +*.spec target/ *.app *.dmg diff --git a/app/src/components/ServerSettings/CudaDownloadSection.tsx b/app/src/components/ServerSettings/CudaDownloadSection.tsx new file mode 100644 index 00000000..d6c22207 --- /dev/null +++ b/app/src/components/ServerSettings/CudaDownloadSection.tsx @@ -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(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 ( + + + GPU Acceleration + + + {/* Status badges */} +
+ + Mode: {cuda_active ? 'CUDA' : 'CPU'} + + + GPU: {gpuAvailable ? 'Available' : 'Not Available'} + +
+ + {/* Already downloaded status */} + {cuda_available && !downloadingCuda && ( + + + + CUDA support is active. GPU acceleration enabled. + + + )} + + {/* Download section - only show if not already downloaded */} + {!cuda_available && !downloadError && !downloadingCuda && ( +
+

+ Download CUDA support for 4-5x faster inference with your NVIDIA GPU +

+ +
+ )} + + {/* Progress display */} + {downloadingCuda && ( +
+ +
+ )} + + {/* Error with retry button */} + {downloadError && ( +
+ + + {downloadError} + + +
+ )} +
+
+ ); +} diff --git a/backend/providers/__init__.py b/backend/providers/__init__.py index b8b96e00..57e5583e 100644 --- a/backend/providers/__init__.py +++ b/backend/providers/__init__.py @@ -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}") diff --git a/backend/providers/installer.py b/backend/providers/installer.py index a41f421e..0dcca1ec 100644 --- a/backend/providers/installer.py +++ b/backend/providers/installer.py @@ -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 diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index 76bde729..00000000 --- a/data/.gitkeep +++ /dev/null @@ -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 diff --git a/data/cache/.gitkeep b/data/cache/.gitkeep deleted file mode 100644 index d66284ff..00000000 --- a/data/cache/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# Voice prompt cache files diff --git a/providers/pytorch-cpu/build.py b/providers/pytorch-cpu/build.py index e71996a7..a443f343 100644 --- a/providers/pytorch-cpu/build.py +++ b/providers/pytorch-cpu/build.py @@ -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', diff --git a/providers/pytorch-cuda/build.py b/providers/pytorch-cuda/build.py index 1eec21a8..c45f1196 100644 --- a/providers/pytorch-cuda/build.py +++ b/providers/pytorch-cuda/build.py @@ -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',