mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 07:05:14 -07:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83906c6c4b | ||
|
|
65f132e9c2 | ||
|
|
c211e52382 | ||
|
|
7c093130c6 | ||
|
|
8ffd5bc008 | ||
|
|
2542f64e1b | ||
|
|
9bde534860 | ||
|
|
97eb570b28 | ||
|
|
7d0557a099 | ||
|
|
60a03c56a9 | ||
|
|
d3393fb940 | ||
|
|
07c0aba883 | ||
|
|
77418a52ae | ||
|
|
46f6806e14 | ||
|
|
20851ccc2b | ||
|
|
0b17073345 | ||
|
|
17106b1e40 | ||
|
|
7fcca09f24 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 0.1.11
|
current_version = 0.1.12
|
||||||
commit = True
|
commit = True
|
||||||
tag = True
|
tag = True
|
||||||
tag_name = v{new_version}
|
tag_name = v{new_version}
|
||||||
|
|||||||
@@ -72,12 +72,18 @@ jobs:
|
|||||||
chmod +x scripts/build-server.sh
|
chmod +x scripts/build-server.sh
|
||||||
./scripts/build-server.sh
|
./scripts/build-server.sh
|
||||||
|
|
||||||
- name: Build Python server (Windows)
|
- name: Build CPU Python server (Windows)
|
||||||
if: matrix.platform == 'windows-latest'
|
if: matrix.platform == 'windows-latest'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
python build_binary.py
|
|
||||||
|
echo "Installing CPU-only PyTorch..."
|
||||||
|
pip uninstall -y torch torchvision torchaudio
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
echo "Building CPU server binary..."
|
||||||
|
python build_binary.py cpu
|
||||||
|
|
||||||
# Get platform tuple
|
# Get platform tuple
|
||||||
PLATFORM=$(rustc --print host-tuple)
|
PLATFORM=$(rustc --print host-tuple)
|
||||||
@@ -85,9 +91,31 @@ jobs:
|
|||||||
# Create binaries directory
|
# Create binaries directory
|
||||||
mkdir -p ../tauri/src-tauri/binaries
|
mkdir -p ../tauri/src-tauri/binaries
|
||||||
|
|
||||||
# Copy with platform suffix
|
# Copy CPU version (default for installer)
|
||||||
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
cp dist/voicebox-server.exe ../tauri/src-tauri/binaries/voicebox-server-${PLATFORM}.exe
|
||||||
echo "Built voicebox-server-${PLATFORM}.exe"
|
echo "Built CPU server: voicebox-server-${PLATFORM}.exe (~500MB)"
|
||||||
|
|
||||||
|
- name: Build CUDA Python server (Windows)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
echo "Installing CUDA PyTorch..."
|
||||||
|
pip uninstall -y torch torchvision torchaudio
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/cu121 --force-reinstall --no-deps
|
||||||
|
pip install torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
echo "Building CUDA server binary..."
|
||||||
|
python build_binary.py cuda
|
||||||
|
|
||||||
|
# Get platform tuple
|
||||||
|
PLATFORM=$(rustc --print host-tuple)
|
||||||
|
|
||||||
|
# Copy CUDA version for separate upload
|
||||||
|
mkdir -p cuda-release
|
||||||
|
cp dist/voicebox-server-cuda.exe cuda-release/voicebox-server-cuda-${PLATFORM}.exe
|
||||||
|
echo "Built CUDA server: voicebox-server-cuda-${PLATFORM}.exe (~3GB)"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -144,11 +172,41 @@ jobs:
|
|||||||
### Installation
|
### Installation
|
||||||
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
- **macOS (Apple Silicon)**: Download the `aarch64.dmg` file - uses MLX for fast native inference
|
||||||
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
- **macOS (Intel)**: Download the `x64.dmg` file - uses PyTorch
|
||||||
- **Windows**: Download the `.msi` installer
|
- **Windows**: Download the `.msi` installer - includes CPU-only inference (~500MB)
|
||||||
- **Linux**: Download the `.AppImage` or `.deb` package
|
- **Linux**: Download the `.AppImage` or `.deb` package
|
||||||
|
|
||||||
|
### NVIDIA GPU Acceleration (Windows)
|
||||||
|
Windows users with NVIDIA GPUs can enable CUDA for 4-5x faster inference:
|
||||||
|
1. Install the app normally (CPU version included in installer)
|
||||||
|
2. The app will detect your GPU and offer to download CUDA support automatically
|
||||||
|
3. Or manually download: [voicebox-server-cuda-x86_64-pc-windows-msvc.exe](https://downloads.voicebox.sh/cuda/__VERSION__/voicebox-server-cuda-x86_64-pc-windows-msvc.exe) (~2.4GB)
|
||||||
|
|
||||||
The app includes automatic updates - future updates will be installed automatically.
|
The app includes automatic updates - future updates will be installed automatically.
|
||||||
releaseDraft: true
|
releaseDraft: true
|
||||||
prerelease: false
|
prerelease: false
|
||||||
args: ${{ matrix.args }}
|
args: ${{ matrix.args }}
|
||||||
includeUpdaterJson: true
|
includeUpdaterJson: true
|
||||||
|
|
||||||
|
- name: Upload CUDA server to Cloudflare R2 (Windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
run: |
|
||||||
|
# Install AWS CLI if not available
|
||||||
|
pip install awscli
|
||||||
|
|
||||||
|
# Get version from tag
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
|
||||||
|
# Get platform tuple
|
||||||
|
PLATFORM=$(rustc --print host-tuple)
|
||||||
|
|
||||||
|
# Upload to R2
|
||||||
|
aws s3 cp backend/cuda-release/voicebox-server-cuda-${PLATFORM}.exe \
|
||||||
|
s3://voicebox/cuda/${VERSION}/voicebox-server-cuda-${PLATFORM}.exe \
|
||||||
|
--endpoint-url $R2_ENDPOINT \
|
||||||
|
--acl public-read
|
||||||
|
|
||||||
|
echo "CUDA binary uploaded to: https://downloads.voicebox.sh/cuda/${VERSION}/voicebox-server-cuda-${PLATFORM}.exe"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+15
-5
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { RouterProvider } from '@tanstack/react-router';
|
import { RouterProvider } from '@tanstack/react-router';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
import voiceboxLogo from '@/assets/voicebox-logo.png';
|
||||||
import ShinyText from '@/components/ShinyText';
|
import ShinyText from '@/components/ShinyText';
|
||||||
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
|
||||||
|
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
|
||||||
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import { router } from '@/router';
|
import { router } from '@/router';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
|
||||||
|
|
||||||
const LOADING_MESSAGES = [
|
const LOADING_MESSAGES = [
|
||||||
'Warming up tensors...',
|
'Warming up tensors...',
|
||||||
@@ -38,6 +39,9 @@ function App() {
|
|||||||
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
const [loadingMessageIndex, setLoadingMessageIndex] = useState(0);
|
||||||
const serverStartingRef = useRef(false);
|
const serverStartingRef = useRef(false);
|
||||||
|
|
||||||
|
// Automatically check for app updates on startup and show toast notifications
|
||||||
|
useAutoUpdater({ checkOnMount: true, showToast: true });
|
||||||
|
|
||||||
// Sync stored setting to Rust on startup
|
// Sync stored setting to Rust on startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (platform.metadata.isTauri) {
|
if (platform.metadata.isTauri) {
|
||||||
@@ -46,14 +50,18 @@ function App() {
|
|||||||
console.error('Failed to sync initial setting to Rust:', error);
|
console.error('Failed to sync initial setting to Rust:', error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
// Setup lifecycle callbacks
|
// Setup lifecycle callbacks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.lifecycle.onServerReady = () => {
|
platform.lifecycle.onServerReady = () => {
|
||||||
setServerReady(true);
|
setServerReady(true);
|
||||||
};
|
};
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.lifecycle]);
|
||||||
|
|
||||||
// Setup window close handler and auto-start server when running in Tauri (production only)
|
// Setup window close handler and auto-start server when running in Tauri (production only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,7 +119,9 @@ function App() {
|
|||||||
// Window close event handles server shutdown based on setting
|
// Window close event handles server shutdown based on setting
|
||||||
serverStartingRef.current = false;
|
serverStartingRef.current = false;
|
||||||
};
|
};
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context, only run once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauri, platform.lifecycle]);
|
||||||
|
|
||||||
// Cycle through loading messages every 3 seconds
|
// Cycle through loading messages every 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react';
|
import {
|
||||||
|
AudioWaveform,
|
||||||
|
Download,
|
||||||
|
FileArchive,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import type { HistoryResponse } from '@/lib/api/types';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,6 +26,7 @@ import {
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import {
|
import {
|
||||||
useDeleteGeneration,
|
useDeleteGeneration,
|
||||||
@@ -50,7 +58,11 @@ export function HistoryTable() {
|
|||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: historyData, isLoading, isFetching } = useHistory({
|
const {
|
||||||
|
data: historyData,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
@@ -280,6 +292,7 @@ export function HistoryTable() {
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={gen.text}
|
value={gen.text}
|
||||||
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
className="flex-1 resize-none text-sm text-muted-foreground select-text"
|
||||||
|
readOnly
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Download, Loader2, Trash2 } from 'lucide-react';
|
import { Download, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -17,7 +17,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
import { useModelDownloadToast } from '@/lib/hooks/useModelDownloadToast';
|
||||||
import { ModelProgress } from './ModelProgress';
|
|
||||||
|
|
||||||
export function ModelManagement() {
|
export function ModelManagement() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -27,15 +26,36 @@ export function ModelManagement() {
|
|||||||
|
|
||||||
const { data: modelStatus, isLoading } = useQuery({
|
const { data: modelStatus, isLoading } = useQuery({
|
||||||
queryKey: ['modelStatus'],
|
queryKey: ['modelStatus'],
|
||||||
queryFn: () => apiClient.getModelStatus(),
|
queryFn: async () => {
|
||||||
|
console.log('[Query] Fetching model status');
|
||||||
|
const result = await apiClient.getModelStatus();
|
||||||
|
console.log('[Query] Model status fetched:', result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
refetchInterval: 5000, // Refresh every 5 seconds
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Callbacks for download completion
|
||||||
|
const handleDownloadComplete = useCallback(() => {
|
||||||
|
console.log('[ModelManagement] Download complete, clearing state');
|
||||||
|
setDownloadingModel(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const handleDownloadError = useCallback(() => {
|
||||||
|
console.log('[ModelManagement] Download error, clearing state');
|
||||||
|
setDownloadingModel(null);
|
||||||
|
setDownloadingDisplayName(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Use progress toast hook for the downloading model
|
// Use progress toast hook for the downloading model
|
||||||
useModelDownloadToast({
|
useModelDownloadToast({
|
||||||
modelName: downloadingModel || '',
|
modelName: downloadingModel || '',
|
||||||
displayName: downloadingDisplayName || '',
|
displayName: downloadingDisplayName || '',
|
||||||
enabled: !!downloadingModel && !!downloadingDisplayName,
|
enabled: !!downloadingModel && !!downloadingDisplayName,
|
||||||
|
onComplete: handleDownloadComplete,
|
||||||
|
onError: handleDownloadError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@@ -45,44 +65,69 @@ export function ModelManagement() {
|
|||||||
sizeMb?: number;
|
sizeMb?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const downloadMutation = useMutation({
|
const handleDownload = async (modelName: string) => {
|
||||||
mutationFn: (modelName: string) => {
|
console.log('[Download] Button clicked for:', modelName, 'at', new Date().toISOString());
|
||||||
setDownloadingModel(modelName);
|
|
||||||
// Find display name from model status
|
// Find display name
|
||||||
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
const model = modelStatus?.models.find((m) => m.model_name === modelName);
|
||||||
setDownloadingDisplayName(model?.display_name || modelName);
|
const displayName = model?.display_name || modelName;
|
||||||
return apiClient.triggerModelDownload(modelName);
|
|
||||||
},
|
try {
|
||||||
onSuccess: () => {
|
// IMPORTANT: Call the API FIRST before setting state
|
||||||
// Download completed - clear state and refetch status
|
// Setting state enables the SSE EventSource in useModelDownloadToast,
|
||||||
setDownloadingModel(null);
|
// which can block/delay the download fetch due to HTTP/1.1 connection limits
|
||||||
setDownloadingDisplayName(null);
|
console.log('[Download] Calling download API for:', modelName);
|
||||||
|
const result = await apiClient.triggerModelDownload(modelName);
|
||||||
|
console.log('[Download] Download API responded:', result);
|
||||||
|
|
||||||
|
// NOW set state to enable SSE tracking (after download has started on backend)
|
||||||
|
setDownloadingModel(modelName);
|
||||||
|
setDownloadingDisplayName(displayName);
|
||||||
|
|
||||||
|
// Download initiated successfully - state will be cleared when SSE reports completion
|
||||||
|
// or by the polling interval detecting the model is downloaded
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||||
},
|
} catch (error) {
|
||||||
onError: (error: Error) => {
|
console.error('[Download] Download failed:', error);
|
||||||
setDownloadingModel(null);
|
setDownloadingModel(null);
|
||||||
setDownloadingDisplayName(null);
|
setDownloadingDisplayName(null);
|
||||||
toast({
|
toast({
|
||||||
title: 'Download failed',
|
title: 'Download failed',
|
||||||
description: error.message,
|
description: error instanceof Error ? error.message : 'Unknown error',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (modelName: string) => apiClient.deleteModel(modelName),
|
mutationFn: async (modelName: string) => {
|
||||||
onSuccess: () => {
|
console.log('[Delete] Deleting model:', modelName);
|
||||||
|
const result = await apiClient.deleteModel(modelName);
|
||||||
|
console.log('[Delete] Model deleted successfully:', modelName);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: async (_data, _modelName) => {
|
||||||
|
console.log('[Delete] onSuccess - showing toast and invalidating queries');
|
||||||
toast({
|
toast({
|
||||||
title: 'Model deleted',
|
title: 'Model deleted',
|
||||||
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
description: `${modelToDelete?.displayName || 'Model'} has been deleted successfully.`,
|
||||||
});
|
});
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
setModelToDelete(null);
|
setModelToDelete(null);
|
||||||
// Refetch status to update UI
|
// Invalidate AND explicitly refetch to ensure UI updates
|
||||||
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
// Using refetchType: 'all' ensures we refetch even if the query is stale
|
||||||
|
console.log('[Delete] Invalidating modelStatus query');
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['modelStatus'],
|
||||||
|
refetchType: 'all',
|
||||||
|
});
|
||||||
|
// Also explicitly refetch to guarantee fresh data
|
||||||
|
console.log('[Delete] Explicitly refetching modelStatus query');
|
||||||
|
await queryClient.refetchQueries({ queryKey: ['modelStatus'] });
|
||||||
|
console.log('[Delete] Query refetched');
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
|
console.log('[Delete] onError:', error);
|
||||||
toast({
|
toast({
|
||||||
title: 'Delete failed',
|
title: 'Delete failed',
|
||||||
description: error.message,
|
description: error.message,
|
||||||
@@ -124,7 +169,7 @@ export function ModelManagement() {
|
|||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.model_name}
|
key={model.model_name}
|
||||||
model={model}
|
model={model}
|
||||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
onDownload={() => handleDownload(model.model_name)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setModelToDelete({
|
setModelToDelete({
|
||||||
name: model.model_name,
|
name: model.model_name,
|
||||||
@@ -152,7 +197,7 @@ export function ModelManagement() {
|
|||||||
<ModelItem
|
<ModelItem
|
||||||
key={model.model_name}
|
key={model.model_name}
|
||||||
model={model}
|
model={model}
|
||||||
onDownload={() => downloadMutation.mutate(model.model_name)}
|
onDownload={() => handleDownload(model.model_name)}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
setModelToDelete({
|
setModelToDelete({
|
||||||
name: model.model_name,
|
name: model.model_name,
|
||||||
@@ -168,21 +213,6 @@ export function ModelManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress indicators */}
|
|
||||||
<div className="pt-4 border-t">
|
|
||||||
<h3 className="text-sm font-semibold mb-3 text-muted-foreground">
|
|
||||||
Download Progress
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{modelStatus.models.map((model) => (
|
|
||||||
<ModelProgress
|
|
||||||
key={model.model_name}
|
|
||||||
modelName={model.model_name}
|
|
||||||
displayName={model.display_name}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -235,16 +265,20 @@ interface ModelItemProps {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading?: boolean; // From server - true if download in progress
|
||||||
size_mb?: number;
|
size_mb?: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
};
|
};
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean; // Local state - true if user just clicked download
|
||||||
formatSize: (sizeMb?: number) => string;
|
formatSize: (sizeMb?: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: ModelItemProps) {
|
||||||
|
// Use server's downloading state OR local state (for immediate feedback before server updates)
|
||||||
|
const showDownloading = model.downloading || isDownloading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -255,20 +289,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
|||||||
Loaded
|
Loaded
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{model.downloaded && !model.loaded && (
|
{/* Only show Downloaded if actually downloaded AND not downloading */}
|
||||||
|
{model.downloaded && !model.loaded && !showDownloading && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
Downloaded
|
Downloaded
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{model.downloaded && model.size_mb && (
|
{model.downloaded && model.size_mb && !showDownloading && (
|
||||||
<div className="text-xs text-muted-foreground mt-1">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Size: {formatSize(model.size_mb)}
|
Size: {formatSize(model.size_mb)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{model.downloaded ? (
|
{model.downloaded && !showDownloading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<span>Ready</span>
|
<span>Ready</span>
|
||||||
@@ -283,19 +318,15 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : showDownloading ? (
|
||||||
<Button size="sm" onClick={onDownload} disabled={isDownloading} variant="outline">
|
<Button size="sm" variant="outline" disabled>
|
||||||
{isDownloading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
Downloading...
|
Downloading...
|
||||||
</>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Button size="sm" onClick={onDownload} variant="outline">
|
||||||
<Download className="h-4 w-4 mr-2" />
|
<Download className="h-4 w-4 mr-2" />
|
||||||
Download
|
Download
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ import { useServerStore } from '@/stores/serverStore';
|
|||||||
interface ModelProgressProps {
|
interface ModelProgressProps {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/** Only connect to SSE when actively downloading - prevents connection exhaustion */
|
||||||
|
isDownloading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
export function ModelProgress({ modelName, displayName, isDownloading = false }: ModelProgressProps) {
|
||||||
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
const [progress, setProgress] = useState<ModelProgressType | null>(null);
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!serverUrl) return;
|
// IMPORTANT: Only connect to SSE when this specific model is downloading
|
||||||
|
// Opening SSE connections for all models exhausts HTTP/1.1 connection limits (6 per origin)
|
||||||
|
// which causes other fetches (like the download trigger) to be queued/blocked
|
||||||
|
if (!serverUrl || !isDownloading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ModelProgress] Connecting SSE for ${modelName}`);
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
||||||
@@ -27,6 +36,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
|
|
||||||
// Close connection if complete or error
|
// Close connection if complete or error
|
||||||
if (data.status === 'complete' || data.status === 'error') {
|
if (data.status === 'complete' || data.status === 'error') {
|
||||||
|
console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -35,14 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [serverUrl, modelName]);
|
}, [serverUrl, modelName, isDownloading]);
|
||||||
|
|
||||||
// Don't render if no progress or if complete/error and some time has passed
|
// Don't render if no progress or if complete/error and some time has passed
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ export function UpdateStatus() {
|
|||||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
platform.metadata.getVersion()
|
platform.metadata
|
||||||
|
.getVersion()
|
||||||
.then(setCurrentVersion)
|
.then(setCurrentVersion)
|
||||||
.catch(() => setCurrentVersion('0.1.0'));
|
.catch(() => setCurrentVersion('Unknown'));
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
import type { UpdateStatus } from '@/platform/types';
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
@@ -7,9 +7,8 @@ export type { UpdateStatus };
|
|||||||
|
|
||||||
export function useAutoUpdater(checkOnMount = false) {
|
export function useAutoUpdater(checkOnMount = false) {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const [status, setStatus] = useState<UpdateStatus>(
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
platform.updater.getStatus(),
|
const hasCheckedRef = useRef(false);
|
||||||
);
|
|
||||||
|
|
||||||
// Subscribe to updater status changes
|
// Subscribe to updater status changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -17,25 +16,32 @@ export function useAutoUpdater(checkOnMount = false) {
|
|||||||
setStatus(newStatus);
|
setStatus(newStatus);
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [platform]);
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
const checkForUpdates = useCallback(async () => {
|
const checkForUpdates = useCallback(async () => {
|
||||||
await platform.updater.checkForUpdates();
|
await platform.updater.checkForUpdates();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
const downloadAndInstall = useCallback(async () => {
|
const downloadAndInstall = useCallback(async () => {
|
||||||
await platform.updater.downloadAndInstall();
|
await platform.updater.downloadAndInstall();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
const restartAndInstall = useCallback(async () => {
|
const restartAndInstall = useCallback(async () => {
|
||||||
await platform.updater.restartAndInstall();
|
await platform.updater.restartAndInstall();
|
||||||
}, [platform]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (checkOnMount && platform.metadata.isTauri) {
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
checkForUpdates();
|
checkForUpdates();
|
||||||
}
|
}
|
||||||
}, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { Download, RefreshCw } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ToastAction } from '@/components/ui/toast';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
import type { UpdateStatus } from '@/platform/types';
|
||||||
|
|
||||||
|
// Re-export UpdateStatus for backwards compatibility
|
||||||
|
export type { UpdateStatus };
|
||||||
|
|
||||||
|
interface UseAutoUpdaterOptions {
|
||||||
|
checkOnMount?: boolean;
|
||||||
|
showToast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAutoUpdater(options: boolean | UseAutoUpdaterOptions = false) {
|
||||||
|
// Support both old boolean API and new options object
|
||||||
|
const { checkOnMount, showToast } =
|
||||||
|
typeof options === 'boolean'
|
||||||
|
? { checkOnMount: options, showToast: false }
|
||||||
|
: { checkOnMount: options.checkOnMount ?? false, showToast: options.showToast ?? false };
|
||||||
|
|
||||||
|
const platform = usePlatform();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>(platform.updater.getStatus());
|
||||||
|
const hasCheckedRef = useRef(false);
|
||||||
|
const toastIdRef = useRef<string | null>(null);
|
||||||
|
const toastUpdateRef = useRef<
|
||||||
|
| ((props: {
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
duration?: number;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
open?: boolean;
|
||||||
|
action?: React.ReactElement<typeof ToastAction>;
|
||||||
|
}) => void)
|
||||||
|
| null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Subscribe to updater status changes
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = platform.updater.subscribe((newStatus) => {
|
||||||
|
setStatus(newStatus);
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
// Empty dependency array - platform is stable from context
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.subscribe]);
|
||||||
|
|
||||||
|
const checkForUpdates = useCallback(async () => {
|
||||||
|
await platform.updater.checkForUpdates();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.checkForUpdates]);
|
||||||
|
|
||||||
|
const downloadAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.downloadAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.downloadAndInstall]);
|
||||||
|
|
||||||
|
const restartAndInstall = useCallback(async () => {
|
||||||
|
await platform.updater.restartAndInstall();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.updater.restartAndInstall]);
|
||||||
|
|
||||||
|
// Check for updates on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
|
||||||
|
hasCheckedRef.current = true;
|
||||||
|
checkForUpdates().catch((error) => {
|
||||||
|
console.error('Auto update check failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Empty dependency array - only run once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
|
||||||
|
|
||||||
|
// Show toast when update is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!showToast ||
|
||||||
|
!status.available ||
|
||||||
|
status.downloading ||
|
||||||
|
status.readyToInstall ||
|
||||||
|
toastIdRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateNow = async () => {
|
||||||
|
await downloadAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toastResult = toast({
|
||||||
|
title: 'Update Available',
|
||||||
|
description: `Version ${status.version} is ready to download.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Update now" onClick={handleUpdateNow}>
|
||||||
|
Update Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
toastIdRef.current = toastResult.id;
|
||||||
|
// Type assertion needed because update function has broader type than our ref
|
||||||
|
toastUpdateRef.current = toastResult.update as typeof toastUpdateRef.current;
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.available,
|
||||||
|
status.downloading,
|
||||||
|
status.readyToInstall,
|
||||||
|
status.version,
|
||||||
|
downloadAndInstall,
|
||||||
|
toast,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when downloading
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.downloading || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercent = status.downloadProgress || 0;
|
||||||
|
const progressText =
|
||||||
|
status.downloadedBytes !== undefined &&
|
||||||
|
status.totalBytes !== undefined &&
|
||||||
|
status.totalBytes > 0
|
||||||
|
? `${(status.downloadedBytes / 1024 / 1024).toFixed(1)} MB / ${(status.totalBytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4 animate-pulse" />
|
||||||
|
<span>Downloading Update</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">Version {status.version}</div>
|
||||||
|
{progressPercent > 0 && (
|
||||||
|
<>
|
||||||
|
<Progress value={progressPercent} className="h-2" />
|
||||||
|
{progressText && <div className="text-xs text-muted-foreground">{progressText}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
duration: Infinity,
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
showToast,
|
||||||
|
status.downloading,
|
||||||
|
status.downloadProgress,
|
||||||
|
status.downloadedBytes,
|
||||||
|
status.totalBytes,
|
||||||
|
status.version,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update toast when ready to install
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.readyToInstall || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestartNow = async () => {
|
||||||
|
await restartAndInstall();
|
||||||
|
};
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Ready',
|
||||||
|
description: `Version ${status.version} has been downloaded and is ready to install.`,
|
||||||
|
duration: Infinity,
|
||||||
|
action: (
|
||||||
|
<ToastAction altText="Restart now" onClick={handleRestartNow}>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />
|
||||||
|
Restart Now
|
||||||
|
</ToastAction>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [showToast, status.readyToInstall, status.version, restartAndInstall]);
|
||||||
|
|
||||||
|
// Handle errors in toast
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showToast || !status.error || !toastIdRef.current || !toastUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toastUpdateRef.current({
|
||||||
|
title: 'Update Failed',
|
||||||
|
description: status.error,
|
||||||
|
variant: 'destructive',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toastIdRef.current = null;
|
||||||
|
toastUpdateRef.current = null;
|
||||||
|
}, 5000);
|
||||||
|
}, [showToast, status.error]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
checkForUpdates,
|
||||||
|
downloadAndInstall,
|
||||||
|
restartAndInstall,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -310,10 +310,13 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
|
||||||
return this.request<{ message: string }>('/models/download', {
|
console.log('[API] triggerModelDownload called for:', modelName, 'at', new Date().toISOString());
|
||||||
|
const result = await this.request<{ message: string }>('/models/download', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
|
||||||
});
|
});
|
||||||
|
console.log('[API] triggerModelDownload response:', result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteModel(modelName: string): Promise<{ message: string }> {
|
async deleteModel(modelName: string): Promise<{ message: string }> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ModelStatus = {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading?: boolean; // True if download is in progress
|
||||||
size_mb?: number | null;
|
size_mb?: number | null;
|
||||||
loaded?: boolean;
|
loaded?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface ModelStatus {
|
|||||||
model_name: string;
|
model_name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
|
downloading: boolean; // True if download is in progress
|
||||||
size_mb?: number;
|
size_mb?: number;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useServerStore } from '@/stores/serverStore';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import type { ModelProgress } from '@/lib/api/types';
|
import type { ModelProgress } from '@/lib/api/types';
|
||||||
|
import { useServerStore } from '@/stores/serverStore';
|
||||||
|
|
||||||
interface UseModelDownloadToastOptions {
|
interface UseModelDownloadToastOptions {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
onComplete?: () => void;
|
||||||
|
onError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,47 +21,64 @@ export function useModelDownloadToast({
|
|||||||
modelName,
|
modelName,
|
||||||
displayName,
|
displayName,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
}: UseModelDownloadToastOptions) {
|
}: UseModelDownloadToastOptions) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const serverUrl = useServerStore((state) => state.serverUrl);
|
const serverUrl = useServerStore((state) => state.serverUrl);
|
||||||
const toastIdRef = useRef<string | null>(null);
|
const toastIdRef = useRef<string | null>(null);
|
||||||
const toastUpdateRef = useRef<
|
// biome-ignore lint: Using any for toast update ref to handle complex toast types
|
||||||
((props: {
|
const toastUpdateRef = useRef<any>(null);
|
||||||
title?: React.ReactNode;
|
|
||||||
description?: React.ReactNode;
|
|
||||||
duration?: number;
|
|
||||||
variant?: 'default' | 'destructive';
|
|
||||||
open?: boolean;
|
|
||||||
}) => void) | null
|
|
||||||
>(null);
|
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
|
||||||
const formatBytes = (bytes: number): string => {
|
const formatBytes = useCallback((bytes: number): string => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('[useModelDownloadToast] useEffect triggered', {
|
||||||
|
enabled,
|
||||||
|
serverUrl,
|
||||||
|
modelName,
|
||||||
|
displayName,
|
||||||
|
});
|
||||||
|
|
||||||
if (!enabled || !serverUrl || !modelName) {
|
if (!enabled || !serverUrl || !modelName) {
|
||||||
|
console.log('[useModelDownloadToast] Not enabled, skipping');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
|
||||||
|
|
||||||
// Create initial toast
|
// Create initial toast
|
||||||
const toastResult = toast({
|
const toastResult = toast({
|
||||||
title: displayName,
|
title: displayName,
|
||||||
description: 'Starting download...',
|
description: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Connecting to download...</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
|
||||||
});
|
});
|
||||||
toastIdRef.current = toastResult.id;
|
toastIdRef.current = toastResult.id;
|
||||||
toastUpdateRef.current = toastResult.update;
|
toastUpdateRef.current = toastResult.update;
|
||||||
|
|
||||||
// Subscribe to progress updates via Server-Sent Events
|
// Subscribe to progress updates via Server-Sent Events
|
||||||
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
|
const eventSourceUrl = `${serverUrl}/models/progress/${modelName}`;
|
||||||
|
console.log('[useModelDownloadToast] Creating EventSource to:', eventSourceUrl);
|
||||||
|
const eventSource = new EventSource(eventSourceUrl);
|
||||||
|
|
||||||
|
eventSource.onopen = () => {
|
||||||
|
console.log('[useModelDownloadToast] EventSource connection opened for:', modelName);
|
||||||
|
};
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
console.log('[useModelDownloadToast] Received SSE message:', event.data);
|
||||||
try {
|
try {
|
||||||
const progress = JSON.parse(event.data) as ModelProgress;
|
const progress = JSON.parse(event.data) as ModelProgress;
|
||||||
|
|
||||||
@@ -86,7 +105,7 @@ export function useModelDownloadToast({
|
|||||||
break;
|
break;
|
||||||
case 'downloading':
|
case 'downloading':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
|
statusText = progress.filename || 'Downloading...';
|
||||||
break;
|
break;
|
||||||
case 'extracting':
|
case 'extracting':
|
||||||
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
statusIcon = <Loader2 className="h-4 w-4 animate-spin" />;
|
||||||
@@ -117,21 +136,40 @@ export function useModelDownloadToast({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Close connection and dismiss toast on completion or error
|
// Close connection and dismiss toast on completion or error
|
||||||
if (progress.status === 'complete' || progress.status === 'error') {
|
// Also treat progress >= 100% as complete
|
||||||
|
const isComplete = progress.status === 'complete' || progress.progress >= 100;
|
||||||
|
const isError = progress.status === 'error';
|
||||||
|
|
||||||
|
if (isComplete || isError) {
|
||||||
|
console.log('[useModelDownloadToast] Download finished:', {
|
||||||
|
isComplete,
|
||||||
|
isError,
|
||||||
|
progress: progress.progress,
|
||||||
|
});
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
// Auto-dismiss on completion after delay
|
// Update toast to show completion state before callbacks
|
||||||
if (progress.status === 'complete') {
|
if (isComplete && toastUpdateRef.current) {
|
||||||
setTimeout(() => {
|
|
||||||
if (toastIdRef.current && toastUpdateRef.current) {
|
|
||||||
toastUpdateRef.current({
|
toastUpdateRef.current({
|
||||||
open: false,
|
title: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
<span>{displayName}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
description: 'Download complete',
|
||||||
|
duration: 3000,
|
||||||
});
|
});
|
||||||
toastIdRef.current = null;
|
|
||||||
toastUpdateRef.current = null;
|
|
||||||
}
|
}
|
||||||
}, 5000);
|
|
||||||
|
// Call callbacks
|
||||||
|
if (isComplete && onComplete) {
|
||||||
|
console.log('[useModelDownloadToast] Download complete, calling onComplete callback');
|
||||||
|
onComplete();
|
||||||
|
} else if (isError && onError) {
|
||||||
|
console.log('[useModelDownloadToast] Download error, calling onError callback');
|
||||||
|
onError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,7 +179,8 @@ export function useModelDownloadToast({
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
eventSource.onerror = (error) => {
|
||||||
console.error('SSE error:', error);
|
console.error('[useModelDownloadToast] SSE error for:', modelName, error);
|
||||||
|
console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
@@ -162,13 +201,14 @@ export function useModelDownloadToast({
|
|||||||
|
|
||||||
// Cleanup on unmount or when disabled
|
// Cleanup on unmount or when disabled
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
// Note: We don't dismiss the toast here as it might still be showing completion state
|
// Note: We don't dismiss the toast here as it might still be showing completion state
|
||||||
};
|
};
|
||||||
}, [enabled, serverUrl, modelName, displayName, toast]);
|
}, [enabled, serverUrl, modelName, displayName, toast, formatBytes, onComplete, onError]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTracking: enabled && eventSourceRef.current !== null,
|
isTracking: enabled && eventSourceRef.current !== null,
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
# Backend package
|
# Backend package
|
||||||
|
|
||||||
__version__ = "0.1.11"
|
__version__ = "0.1.12"
|
||||||
|
|||||||
+138
-36
@@ -52,6 +52,47 @@ class MLXTTSBackend:
|
|||||||
|
|
||||||
return hf_model_id
|
return hf_model_id
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
model_path = self._get_model_path(model_size)
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin")) or
|
||||||
|
any(snapshots_dir.rglob("*.npz"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX TTS model.
|
Lazy load the MLX TTS model.
|
||||||
@@ -79,46 +120,63 @@ class MLXTTSBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
from mlx_audio.tts import load
|
# Get model path BEFORE importing mlx_audio
|
||||||
|
|
||||||
# Get model path
|
|
||||||
model_path = self._get_model_path(model_size)
|
model_path = self._get_model_path(model_size)
|
||||||
|
|
||||||
# Set up progress tracking
|
# Set up progress tracking
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
# Start tracking download task
|
# Check if model is already cached
|
||||||
task_manager = get_task_manager()
|
is_cached = self._is_model_cached(model_size)
|
||||||
task_manager.start_download(model_name)
|
|
||||||
|
# Set up progress callback
|
||||||
|
# If cached: filter out non-download progress
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
print(f"Loading MLX TTS model {model_size}...")
|
print(f"Loading MLX TTS model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# Only track download progress if model is NOT cached
|
||||||
|
if not is_cached:
|
||||||
|
# Start tracking download task
|
||||||
|
task_manager.start_download(model_name)
|
||||||
|
|
||||||
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
|
# This provides immediate feedback while HuggingFace fetches metadata
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1,
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up progress callback
|
# IMPORTANT: Patch tqdm BEFORE importing mlx_audio
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
# Otherwise mlx_audio caches reference to original tqdm
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker_context = tracker.patch_download()
|
||||||
|
tracker_context.__enter__()
|
||||||
|
|
||||||
|
# Import mlx_audio AFTER patching tqdm
|
||||||
|
from mlx_audio.tts import load
|
||||||
|
|
||||||
# Use progress tracker during download
|
|
||||||
with tracker.patch_download():
|
|
||||||
# Load MLX model (downloads automatically)
|
# Load MLX model (downloads automatically)
|
||||||
|
try:
|
||||||
self.model = load(model_path)
|
self.model = load(model_path)
|
||||||
|
finally:
|
||||||
|
# Exit the patch context
|
||||||
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
|
# Only mark download as complete if we were tracking it
|
||||||
|
if not is_cached:
|
||||||
|
progress_manager.mark_complete(model_name)
|
||||||
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
self._current_model_size = model_size
|
self._current_model_size = model_size
|
||||||
self.model_size = model_size
|
self.model_size = model_size
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(model_name)
|
|
||||||
task_manager.complete_download(model_name)
|
|
||||||
|
|
||||||
print(f"MLX TTS model {model_size} loaded successfully")
|
print(f"MLX TTS model {model_size} loaded successfully")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
@@ -332,6 +390,47 @@ class MLXSTTBackend:
|
|||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin")) or
|
||||||
|
any(snapshots_dir.rglob("*.npz"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the MLX Whisper model.
|
Lazy load the MLX Whisper model.
|
||||||
@@ -354,56 +453,59 @@ class MLXSTTBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing mlx_audio
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is already cached
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
# Set up progress callback and tracker
|
||||||
|
# If cached: filter out non-download progress
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing mlx_audio
|
# Patch tqdm BEFORE importing mlx_audio
|
||||||
# This is critical because mlx_audio imports huggingface_hub which imports tqdm
|
|
||||||
print("[DEBUG] Starting tqdm patch BEFORE mlx_audio import")
|
|
||||||
tracker_context = tracker.patch_download()
|
tracker_context = tracker.patch_download()
|
||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
print("[DEBUG] tqdm patched, now importing mlx_audio")
|
|
||||||
|
|
||||||
# NOW import mlx_audio - it will use our patched tqdm
|
# Import mlx_audio
|
||||||
from mlx_audio.stt import load
|
from mlx_audio.stt import load
|
||||||
|
|
||||||
# MLX Whisper uses the standard OpenAI models
|
# MLX Whisper uses the standard OpenAI models
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
|
||||||
# Start tracking download task
|
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(progress_model_name)
|
|
||||||
|
|
||||||
print(f"Loading MLX Whisper model {model_size}...")
|
print(f"Loading MLX Whisper model {model_size}...")
|
||||||
|
|
||||||
# Initialize progress state
|
# Only track download progress if model is NOT cached
|
||||||
|
if not is_cached:
|
||||||
|
# Start tracking download task
|
||||||
|
task_manager.start_download(progress_model_name)
|
||||||
|
|
||||||
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=progress_model_name,
|
model_name=progress_model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1,
|
total=0,
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.model = load(model_name)
|
self.model = load(model_name)
|
||||||
finally:
|
finally:
|
||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
self.model_size = model_size
|
# Only mark download as complete if we were tracking it
|
||||||
|
if not is_cached:
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(progress_model_name)
|
progress_manager.mark_complete(progress_model_name)
|
||||||
task_manager.complete_download(progress_model_name)
|
task_manager.complete_download(progress_model_name)
|
||||||
|
|
||||||
|
self.model_size = model_size
|
||||||
|
|
||||||
print(f"MLX Whisper model {model_size} loaded successfully")
|
print(f"MLX Whisper model {model_size} loaded successfully")
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
|
|||||||
@@ -58,6 +58,46 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
return hf_model_map[model_size]
|
return hf_model_map[model_size]
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
model_path = self._get_model_path(model_size)
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_path.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for {model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for {model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
Lazy load the TTS model with automatic downloading from HuggingFace Hub.
|
||||||
@@ -85,20 +125,24 @@ class PyTorchTTSBackend:
|
|||||||
def _load_model_sync(self, model_size: str):
|
def _load_model_sync(self, model_size: str):
|
||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing qwen_tts
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
model_name = f"qwen-tts-{model_size}"
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is already cached
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
# Set up progress callback and tracker
|
||||||
|
# If cached: filter out non-download progress (like "Segment 1/1" during generation)
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
progress_callback = create_hf_progress_callback(model_name, progress_manager)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing qwen_tts
|
# Patch tqdm BEFORE importing qwen_tts
|
||||||
tracker_context = tracker.patch_download()
|
tracker_context = tracker.patch_download()
|
||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
|
|
||||||
# NOW import qwen_tts - it will use our patched tqdm
|
# Import qwen_tts
|
||||||
from qwen_tts import Qwen3TTSModel
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
# Get model path (local or HuggingFace Hub ID)
|
# Get model path (local or HuggingFace Hub ID)
|
||||||
@@ -106,20 +150,21 @@ class PyTorchTTSBackend:
|
|||||||
|
|
||||||
print(f"Loading TTS model {model_size} on {self.device}...")
|
print(f"Loading TTS model {model_size} on {self.device}...")
|
||||||
|
|
||||||
|
# Only track download progress if model is NOT cached
|
||||||
|
if not is_cached:
|
||||||
# Start tracking download task
|
# Start tracking download task
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(model_name)
|
task_manager.start_download(model_name)
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load the model (tqdm is already patched from above)
|
# Load the model (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.model = Qwen3TTSModel.from_pretrained(
|
self.model = Qwen3TTSModel.from_pretrained(
|
||||||
model_path,
|
model_path,
|
||||||
@@ -130,7 +175,8 @@ class PyTorchTTSBackend:
|
|||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
# Mark as complete
|
# Only mark download as complete if we were tracking it
|
||||||
|
if not is_cached:
|
||||||
progress_manager.mark_complete(model_name)
|
progress_manager.mark_complete(model_name)
|
||||||
task_manager.complete_download(model_name)
|
task_manager.complete_download(model_name)
|
||||||
|
|
||||||
@@ -321,6 +367,46 @@ class PyTorchSTTBackend:
|
|||||||
"""Check if model is loaded."""
|
"""Check if model is loaded."""
|
||||||
return self.model is not None
|
return self.model is not None
|
||||||
|
|
||||||
|
def _is_model_cached(self, model_size: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the Whisper model is already cached locally AND fully downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_size: Model size to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if model is fully cached, False if missing or incomplete
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from huggingface_hub import constants as hf_constants
|
||||||
|
model_name = f"openai/whisper-{model_size}"
|
||||||
|
repo_cache = Path(hf_constants.HF_HUB_CACHE) / ("models--" + model_name.replace("/", "--"))
|
||||||
|
|
||||||
|
if not repo_cache.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
if blobs_dir.exists() and any(blobs_dir.glob("*.incomplete")):
|
||||||
|
print(f"[_is_model_cached] Found .incomplete files for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check that actual model weight files exist in snapshots
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
if snapshots_dir.exists():
|
||||||
|
has_weights = (
|
||||||
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
|
any(snapshots_dir.rglob("*.bin"))
|
||||||
|
)
|
||||||
|
if not has_weights:
|
||||||
|
print(f"[_is_model_cached] No model weights found for whisper-{model_size}, treating as not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[_is_model_cached] Error checking cache for whisper-{model_size}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
async def load_model_async(self, model_size: Optional[str] = None):
|
async def load_model_async(self, model_size: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Lazy load the Whisper model.
|
Lazy load the Whisper model.
|
||||||
@@ -349,14 +435,18 @@ class PyTorchSTTBackend:
|
|||||||
"""Synchronous model loading."""
|
"""Synchronous model loading."""
|
||||||
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
|
||||||
try:
|
try:
|
||||||
# IMPORTANT: Set up progress tracking BEFORE importing transformers
|
|
||||||
# This ensures tqdm is patched before any HuggingFace Hub imports
|
|
||||||
progress_manager = get_progress_manager()
|
progress_manager = get_progress_manager()
|
||||||
|
task_manager = get_task_manager()
|
||||||
progress_model_name = f"whisper-{model_size}"
|
progress_model_name = f"whisper-{model_size}"
|
||||||
|
|
||||||
|
# Check if model is already cached
|
||||||
|
is_cached = self._is_model_cached(model_size)
|
||||||
|
|
||||||
# Set up progress callback and tracker
|
# Set up progress callback and tracker
|
||||||
|
# If cached: filter out non-download progress
|
||||||
|
# If not cached: report all progress (we're actually downloading)
|
||||||
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
progress_callback = create_hf_progress_callback(progress_model_name, progress_manager)
|
||||||
tracker = HFProgressTracker(progress_callback)
|
tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
|
||||||
|
|
||||||
# Patch tqdm BEFORE importing transformers
|
# Patch tqdm BEFORE importing transformers
|
||||||
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
|
||||||
@@ -364,31 +454,29 @@ class PyTorchSTTBackend:
|
|||||||
tracker_context.__enter__()
|
tracker_context.__enter__()
|
||||||
print("[DEBUG] tqdm patched, now importing transformers")
|
print("[DEBUG] tqdm patched, now importing transformers")
|
||||||
|
|
||||||
# NOW import transformers - it will use our patched tqdm
|
# Import transformers
|
||||||
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
from transformers import WhisperProcessor, WhisperForConditionalGeneration
|
||||||
|
|
||||||
model_name = f"openai/whisper-{model_size}"
|
model_name = f"openai/whisper-{model_size}"
|
||||||
print(f"[DEBUG] Model name: {model_name}")
|
print(f"[DEBUG] Model name: {model_name}")
|
||||||
|
|
||||||
# Start tracking download task
|
|
||||||
task_manager = get_task_manager()
|
|
||||||
task_manager.start_download(progress_model_name)
|
|
||||||
print(f"[DEBUG] Task manager started download")
|
|
||||||
|
|
||||||
print(f"Loading Whisper model {model_size} on {self.device}...")
|
print(f"Loading Whisper model {model_size} on {self.device}...")
|
||||||
|
|
||||||
# Initialize progress state to show download has started
|
# Only track download progress if model is NOT cached
|
||||||
print(f"[DEBUG] Calling update_progress...")
|
if not is_cached:
|
||||||
|
# Start tracking download task
|
||||||
|
task_manager.start_download(progress_model_name)
|
||||||
|
|
||||||
|
# Initialize progress state so SSE endpoint has initial data to send
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=progress_model_name,
|
model_name=progress_model_name,
|
||||||
current=0,
|
current=0,
|
||||||
total=1, # Set to 1 initially, will be updated by callback
|
total=0, # Will be updated once actual total is known
|
||||||
filename="",
|
filename="Connecting to HuggingFace...",
|
||||||
status="downloading",
|
status="downloading",
|
||||||
)
|
)
|
||||||
print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
|
|
||||||
|
|
||||||
# Load models (tqdm is already patched from above)
|
# Load models (tqdm is patched, but filters out non-download progress)
|
||||||
try:
|
try:
|
||||||
self.processor = WhisperProcessor.from_pretrained(model_name)
|
self.processor = WhisperProcessor.from_pretrained(model_name)
|
||||||
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
|
||||||
@@ -396,13 +484,14 @@ class PyTorchSTTBackend:
|
|||||||
# Exit the patch context
|
# Exit the patch context
|
||||||
tracker_context.__exit__(None, None, None)
|
tracker_context.__exit__(None, None, None)
|
||||||
|
|
||||||
self.model.to(self.device)
|
# Only mark download as complete if we were tracking it
|
||||||
self.model_size = model_size
|
if not is_cached:
|
||||||
|
|
||||||
# Mark as complete
|
|
||||||
progress_manager.mark_complete(progress_model_name)
|
progress_manager.mark_complete(progress_model_name)
|
||||||
task_manager.complete_download(progress_model_name)
|
task_manager.complete_download(progress_model_name)
|
||||||
|
|
||||||
|
self.model.to(self.device)
|
||||||
|
self.model_size = model_size
|
||||||
|
|
||||||
print(f"Whisper model {model_size} loaded successfully")
|
print(f"Whisper model {model_size} loaded successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
+23
-5
@@ -5,6 +5,7 @@ PyInstaller build script for creating standalone Python server binary.
|
|||||||
import PyInstaller.__main__
|
import PyInstaller.__main__
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -13,15 +14,27 @@ def is_apple_silicon():
|
|||||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||||
|
|
||||||
|
|
||||||
def build_server():
|
def build_server(variant="cpu"):
|
||||||
"""Build Python server as standalone binary."""
|
"""Build Python server as standalone binary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
variant: 'cpu' for CPU-only build (~500MB) or 'cuda' for CUDA build (~3GB)
|
||||||
|
"""
|
||||||
backend_dir = Path(__file__).parent
|
backend_dir = Path(__file__).parent
|
||||||
|
|
||||||
|
if variant not in ['cpu', 'cuda']:
|
||||||
|
raise ValueError(f"Invalid variant: {variant}. Must be 'cpu' or 'cuda'")
|
||||||
|
|
||||||
|
# Set binary name based on variant
|
||||||
|
binary_name = f'voicebox-server-{variant}' if variant == 'cuda' else 'voicebox-server'
|
||||||
|
|
||||||
|
print(f"Building {variant.upper()} variant: {binary_name}")
|
||||||
|
|
||||||
# PyInstaller arguments
|
# PyInstaller arguments
|
||||||
args = [
|
args = [
|
||||||
'server.py', # Use server.py as entry point instead of main.py
|
'server.py', # Use server.py as entry point instead of main.py
|
||||||
'--onefile',
|
'--onefile',
|
||||||
'--name', 'voicebox-server',
|
'--name', binary_name,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add local qwen_tts path if specified (for editable installs)
|
# Add local qwen_tts path if specified (for editable installs)
|
||||||
@@ -101,8 +114,13 @@ def build_server():
|
|||||||
# Run PyInstaller
|
# Run PyInstaller
|
||||||
PyInstaller.__main__.run(args)
|
PyInstaller.__main__.run(args)
|
||||||
|
|
||||||
print(f"Binary built in {backend_dir / 'dist' / 'voicebox-server'}")
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Build complete: {variant.upper()} variant")
|
||||||
|
print(f"Binary: {backend_dir / 'dist' / binary_name}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
build_server()
|
# Accept variant as command line argument
|
||||||
|
variant = sys.argv[1] if len(sys.argv) > 1 else 'cpu'
|
||||||
|
build_server(variant)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build both CPU and CUDA server binaries for Windows
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo Building BOTH server binaries (CPU + CUDA)
|
||||||
|
echo This will take a while...
|
||||||
|
echo ============================================================
|
||||||
|
|
||||||
|
call build_cpu.bat
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo CPU build failed!
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
call build_cuda.bat
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo CUDA build failed!
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo Both binaries built successfully!
|
||||||
|
echo ============================================================
|
||||||
|
echo CPU binary: dist\voicebox-server.exe (~500MB)
|
||||||
|
echo CUDA binary: dist\voicebox-server-cuda.exe (~3GB)
|
||||||
|
echo ============================================================
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build CPU-only server binary for Windows
|
||||||
|
REM This creates a ~500MB binary without CUDA support
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo Building CPU-only server binary
|
||||||
|
echo ============================================================
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Step 1: Installing CPU-only PyTorch...
|
||||||
|
pip uninstall -y torch torchvision torchaudio
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Step 2: Building binary with PyInstaller...
|
||||||
|
python build_binary.py cpu
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Step 3: Restoring CUDA PyTorch for development...
|
||||||
|
pip uninstall -y torch torchvision torchaudio
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo CPU binary built successfully!
|
||||||
|
echo Location: dist\voicebox-server.exe
|
||||||
|
echo Size: ~500MB
|
||||||
|
echo ============================================================
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Build CPU-only server binary
|
||||||
|
# This creates a ~500MB binary without CUDA support
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo "Building CPU-only server binary"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Step 1: Installing CPU-only PyTorch..."
|
||||||
|
pip uninstall -y torch torchvision torchaudio || true
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Step 2: Building binary with PyInstaller..."
|
||||||
|
python build_binary.py cpu
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Step 3: Restoring CUDA PyTorch for development..."
|
||||||
|
pip uninstall -y torch torchvision torchaudio || true
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "CPU binary built successfully!"
|
||||||
|
echo "Location: dist/voicebox-server"
|
||||||
|
echo "Size: ~500MB"
|
||||||
|
echo "============================================================"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@echo off
|
||||||
|
REM Build CUDA server binary for Windows
|
||||||
|
REM This creates a ~3GB binary with CUDA support
|
||||||
|
|
||||||
|
echo ============================================================
|
||||||
|
echo Building CUDA server binary
|
||||||
|
echo ============================================================
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Step 1: Ensuring CUDA PyTorch is installed...
|
||||||
|
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121 --upgrade
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Step 2: Building binary with PyInstaller...
|
||||||
|
python build_binary.py cuda
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ============================================================
|
||||||
|
echo CUDA binary built successfully!
|
||||||
|
echo Location: dist\voicebox-server-cuda.exe
|
||||||
|
echo Size: ~3GB
|
||||||
|
echo ============================================================
|
||||||
+89
-30
@@ -1156,11 +1156,14 @@ async def get_model_progress(model_name: str):
|
|||||||
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
@app.get("/models/status", response_model=models.ModelStatusListResponse)
|
||||||
async def get_model_status():
|
async def get_model_status():
|
||||||
"""Get status of all available models."""
|
"""Get status of all available models."""
|
||||||
from huggingface_hub import hf_hub_download, constants as hf_constants
|
from huggingface_hub import constants as hf_constants
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
|
||||||
|
|
||||||
backend_type = get_backend_type()
|
backend_type = get_backend_type()
|
||||||
|
task_manager = get_task_manager()
|
||||||
|
|
||||||
|
# Get set of currently downloading model names
|
||||||
|
active_download_names = {task.model_name for task in task_manager.get_active_downloads()}
|
||||||
|
|
||||||
# Try to import scan_cache_dir (might not be available in older versions)
|
# Try to import scan_cache_dir (might not be available in older versions)
|
||||||
try:
|
try:
|
||||||
@@ -1189,10 +1192,11 @@ async def get_model_status():
|
|||||||
if backend_type == "mlx":
|
if backend_type == "mlx":
|
||||||
tts_1_7b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
tts_1_7b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16"
|
||||||
tts_0_6b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # Fallback to 1.7B
|
tts_0_6b_id = "mlx-community/Qwen3-TTS-12Hz-1.7B-Base-bf16" # Fallback to 1.7B
|
||||||
whisper_base_id = "mlx-community/whisper-base"
|
# MLX backend uses openai/whisper-* models, not mlx-community
|
||||||
whisper_small_id = "mlx-community/whisper-small"
|
whisper_base_id = "openai/whisper-base"
|
||||||
whisper_medium_id = "mlx-community/whisper-medium"
|
whisper_small_id = "openai/whisper-small"
|
||||||
whisper_large_id = "mlx-community/whisper-large"
|
whisper_medium_id = "openai/whisper-medium"
|
||||||
|
whisper_large_id = "openai/whisper-large"
|
||||||
else:
|
else:
|
||||||
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
||||||
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
tts_0_6b_id = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
|
||||||
@@ -1246,6 +1250,13 @@ async def get_model_status():
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Build a mapping of model_name -> hf_repo_id so we can check if shared repos are downloading
|
||||||
|
model_to_repo = {cfg["model_name"]: cfg["hf_repo_id"] for cfg in model_configs}
|
||||||
|
|
||||||
|
# Get the set of hf_repo_ids that are currently being downloaded
|
||||||
|
# This handles the case where multiple models share the same repo (e.g., 0.6B and 1.7B on MLX)
|
||||||
|
active_download_repos = {model_to_repo.get(name) for name in active_download_names if name in model_to_repo}
|
||||||
|
|
||||||
# Get HuggingFace cache info (if available)
|
# Get HuggingFace cache info (if available)
|
||||||
cache_info = None
|
cache_info = None
|
||||||
if use_scan_cache:
|
if use_scan_cache:
|
||||||
@@ -1268,6 +1279,30 @@ async def get_model_status():
|
|||||||
repo_id = config["hf_repo_id"]
|
repo_id = config["hf_repo_id"]
|
||||||
for repo in cache_info.repos:
|
for repo in cache_info.repos:
|
||||||
if repo.repo_id == repo_id:
|
if repo.repo_id == repo_id:
|
||||||
|
# Check if actual model weight files exist (not just config files)
|
||||||
|
# scan_cache_dir only shows completed files, so check if any are model weights
|
||||||
|
has_model_weights = False
|
||||||
|
for rev in repo.revisions:
|
||||||
|
for f in rev.files:
|
||||||
|
fname = f.file_name.lower()
|
||||||
|
if fname.endswith(('.safetensors', '.bin', '.pt', '.pth', '.npz')):
|
||||||
|
has_model_weights = True
|
||||||
|
break
|
||||||
|
if has_model_weights:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Also check for .incomplete files in blobs directory (downloads in progress)
|
||||||
|
has_incomplete = False
|
||||||
|
try:
|
||||||
|
cache_dir = hf_constants.HF_HUB_CACHE
|
||||||
|
blobs_dir = Path(cache_dir) / ("models--" + repo_id.replace("/", "--")) / "blobs"
|
||||||
|
if blobs_dir.exists():
|
||||||
|
has_incomplete = any(blobs_dir.glob("*.incomplete"))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Only mark as downloaded if we have model weights AND no incomplete files
|
||||||
|
if has_model_weights and not has_incomplete:
|
||||||
downloaded = True
|
downloaded = True
|
||||||
# Calculate size from cache info
|
# Calculate size from cache info
|
||||||
try:
|
try:
|
||||||
@@ -1284,42 +1319,40 @@ async def get_model_status():
|
|||||||
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
|
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
|
||||||
|
|
||||||
if repo_cache.exists():
|
if repo_cache.exists():
|
||||||
# Check for model files (bin, safetensors, or other common model files)
|
# Check for .incomplete files - if any exist, download is still in progress
|
||||||
# MLX models may use .npz or .safetensors
|
blobs_dir = repo_cache / "blobs"
|
||||||
|
has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
|
||||||
|
|
||||||
|
if not has_incomplete:
|
||||||
|
# Check for actual model weight files (not just index files)
|
||||||
|
# in the snapshots directory (symlinks to completed blobs)
|
||||||
|
snapshots_dir = repo_cache / "snapshots"
|
||||||
|
has_model_files = False
|
||||||
|
if snapshots_dir.exists():
|
||||||
has_model_files = (
|
has_model_files = (
|
||||||
any(repo_cache.rglob("*.bin")) or
|
any(snapshots_dir.rglob("*.bin")) or
|
||||||
any(repo_cache.rglob("*.safetensors")) or
|
any(snapshots_dir.rglob("*.safetensors")) or
|
||||||
any(repo_cache.rglob("*.pt")) or
|
any(snapshots_dir.rglob("*.pt")) or
|
||||||
any(repo_cache.rglob("*.pth")) or
|
any(snapshots_dir.rglob("*.pth")) or
|
||||||
any(repo_cache.rglob("*.npz")) or
|
any(snapshots_dir.rglob("*.npz"))
|
||||||
any(repo_cache.rglob("model.safetensors.index.json")) or
|
|
||||||
any(repo_cache.rglob("pytorch_model.bin.index.json"))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if has_model_files:
|
if has_model_files:
|
||||||
downloaded = True
|
downloaded = True
|
||||||
# Calculate size
|
# Calculate size (exclude .incomplete files)
|
||||||
try:
|
try:
|
||||||
total_size = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
|
total_size = sum(
|
||||||
|
f.stat().st_size for f in repo_cache.rglob("*")
|
||||||
|
if f.is_file() and not f.name.endswith('.incomplete')
|
||||||
|
)
|
||||||
size_mb = total_size / (1024 * 1024)
|
size_mb = total_size / (1024 * 1024)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Method 3: Try to check if model can be loaded locally (last resort)
|
# Method 3 removed - checking for config.json is too lenient
|
||||||
if not downloaded:
|
# Methods 1 and 2 properly verify that model weight files exist
|
||||||
try:
|
|
||||||
# Try to download with local_files_only=True to check if cached
|
|
||||||
hf_hub_download(
|
|
||||||
repo_id=config["hf_repo_id"],
|
|
||||||
filename="config.json", # Try a common file
|
|
||||||
local_files_only=True,
|
|
||||||
)
|
|
||||||
downloaded = True
|
|
||||||
except Exception:
|
|
||||||
# File not found locally, model not downloaded
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Check if loaded in memory
|
# Check if loaded in memory
|
||||||
try:
|
try:
|
||||||
@@ -1327,10 +1360,19 @@ async def get_model_status():
|
|||||||
except Exception:
|
except Exception:
|
||||||
loaded = False
|
loaded = False
|
||||||
|
|
||||||
|
# Check if this model (or its shared repo) is currently being downloaded
|
||||||
|
is_downloading = config["hf_repo_id"] in active_download_repos
|
||||||
|
|
||||||
|
# If downloading, don't report as downloaded (partial files exist)
|
||||||
|
if is_downloading:
|
||||||
|
downloaded = False
|
||||||
|
size_mb = None # Don't show partial size during download
|
||||||
|
|
||||||
statuses.append(models.ModelStatus(
|
statuses.append(models.ModelStatus(
|
||||||
model_name=config["model_name"],
|
model_name=config["model_name"],
|
||||||
display_name=config["display_name"],
|
display_name=config["display_name"],
|
||||||
downloaded=downloaded,
|
downloaded=downloaded,
|
||||||
|
downloading=is_downloading,
|
||||||
size_mb=size_mb,
|
size_mb=size_mb,
|
||||||
loaded=loaded,
|
loaded=loaded,
|
||||||
))
|
))
|
||||||
@@ -1341,10 +1383,14 @@ async def get_model_status():
|
|||||||
except Exception:
|
except Exception:
|
||||||
loaded = False
|
loaded = False
|
||||||
|
|
||||||
|
# Check if this model (or its shared repo) is currently being downloaded
|
||||||
|
is_downloading = config["hf_repo_id"] in active_download_repos
|
||||||
|
|
||||||
statuses.append(models.ModelStatus(
|
statuses.append(models.ModelStatus(
|
||||||
model_name=config["model_name"],
|
model_name=config["model_name"],
|
||||||
display_name=config["display_name"],
|
display_name=config["display_name"],
|
||||||
downloaded=False, # Assume not downloaded if check failed
|
downloaded=False, # Assume not downloaded if check failed
|
||||||
|
downloading=is_downloading,
|
||||||
size_mb=None,
|
size_mb=None,
|
||||||
loaded=loaded,
|
loaded=loaded,
|
||||||
))
|
))
|
||||||
@@ -1358,6 +1404,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
task_manager = get_task_manager()
|
task_manager = get_task_manager()
|
||||||
|
progress_manager = get_progress_manager()
|
||||||
|
|
||||||
model_configs = {
|
model_configs = {
|
||||||
"qwen-tts-1.7B": {
|
"qwen-tts-1.7B": {
|
||||||
@@ -1406,6 +1453,18 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
|
|||||||
# Start tracking download
|
# Start tracking download
|
||||||
task_manager.start_download(request.model_name)
|
task_manager.start_download(request.model_name)
|
||||||
|
|
||||||
|
# Initialize progress state so SSE endpoint has initial data to send.
|
||||||
|
# This fixes a race condition where the frontend connects to SSE before
|
||||||
|
# any progress callbacks have fired (especially for large models like Qwen
|
||||||
|
# where huggingface_hub takes time to fetch metadata for all files).
|
||||||
|
progress_manager.update_progress(
|
||||||
|
model_name=request.model_name,
|
||||||
|
current=0,
|
||||||
|
total=0, # Will be updated once actual total is known
|
||||||
|
filename="Connecting to HuggingFace...",
|
||||||
|
status="downloading",
|
||||||
|
)
|
||||||
|
|
||||||
# Start download in background task (don't await)
|
# Start download in background task (don't await)
|
||||||
asyncio.create_task(download_in_background())
|
asyncio.create_task(download_in_background())
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ class ModelStatus(BaseModel):
|
|||||||
model_name: str
|
model_name: str
|
||||||
display_name: str
|
display_name: str
|
||||||
downloaded: bool
|
downloaded: bool
|
||||||
|
downloading: bool = False # True if download is in progress
|
||||||
size_mb: Optional[float] = None
|
size_mb: Optional[float] = None
|
||||||
loaded: bool = False
|
loaded: bool = False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test CUDA binary compression to verify it fits under GitHub's 2GB release asset limit.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python test_cuda_compression.py [path/to/voicebox-server-cuda.exe]
|
||||||
|
|
||||||
|
If no path provided, looks for the binary in ./dist/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def format_size(bytes_size):
|
||||||
|
"""Format bytes into human-readable size."""
|
||||||
|
for unit in ['B', 'KB', 'MB', 'GB']:
|
||||||
|
if bytes_size < 1024.0:
|
||||||
|
return f"{bytes_size:.2f} {unit}"
|
||||||
|
bytes_size /= 1024.0
|
||||||
|
return f"{bytes_size:.2f} TB"
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_size(filepath):
|
||||||
|
"""Get file size in bytes."""
|
||||||
|
return os.path.getsize(filepath)
|
||||||
|
|
||||||
|
|
||||||
|
def compress_with_7z(input_file, output_file):
|
||||||
|
"""Compress file using 7z with maximum compression."""
|
||||||
|
print(f"\nCompressing with 7z (maximum compression)...")
|
||||||
|
print(f"This may take several minutes for a ~2.5GB file...\n")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'7z', 'a',
|
||||||
|
'-t7z', # 7z format
|
||||||
|
'-m0=lzma2', # LZMA2 compression
|
||||||
|
'-mx=9', # Maximum compression
|
||||||
|
'-mfb=64', # Fast bytes
|
||||||
|
'-md=32m', # Dictionary size
|
||||||
|
'-ms=on', # Solid archive
|
||||||
|
output_file,
|
||||||
|
input_file
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error during compression: {e}")
|
||||||
|
print(f"stderr: {e.stderr}")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("ERROR: 7z not found. Please install 7-Zip:")
|
||||||
|
print(" Windows: https://www.7-zip.org/download.html")
|
||||||
|
print(" macOS: brew install p7zip")
|
||||||
|
print(" Linux: apt-get install p7zip-full")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Find CUDA binary
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
cuda_binary = Path(sys.argv[1])
|
||||||
|
else:
|
||||||
|
# Look in dist directory
|
||||||
|
dist_dir = Path(__file__).parent / 'dist'
|
||||||
|
candidates = list(dist_dir.glob('voicebox-server-cuda*.exe'))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
print("ERROR: CUDA binary not found in ./dist/")
|
||||||
|
print("Please provide the path as an argument:")
|
||||||
|
print(" python test_cuda_compression.py path/to/voicebox-server-cuda.exe")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cuda_binary = candidates[0]
|
||||||
|
|
||||||
|
if not cuda_binary.exists():
|
||||||
|
print(f"ERROR: File not found: {cuda_binary}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("CUDA Binary Compression Test")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Get original size
|
||||||
|
original_size = get_file_size(cuda_binary)
|
||||||
|
print(f"\nOriginal file: {cuda_binary.name}")
|
||||||
|
print(f"Original size: {format_size(original_size)} ({original_size:,} bytes)")
|
||||||
|
|
||||||
|
# Check if already over 2GB
|
||||||
|
github_limit = 2 * 1024 * 1024 * 1024 # 2GB in bytes
|
||||||
|
print(f"GitHub limit: {format_size(github_limit)} ({github_limit:,} bytes)")
|
||||||
|
|
||||||
|
if original_size > github_limit:
|
||||||
|
print(f"\n[WARNING] Original file exceeds GitHub limit by {format_size(original_size - github_limit)}")
|
||||||
|
else:
|
||||||
|
print(f"\n[OK] Original file is under GitHub limit")
|
||||||
|
|
||||||
|
# Compress
|
||||||
|
output_file = cuda_binary.parent / f"{cuda_binary.stem}.7z"
|
||||||
|
if output_file.exists():
|
||||||
|
print(f"\nRemoving existing compressed file: {output_file.name}")
|
||||||
|
output_file.unlink()
|
||||||
|
|
||||||
|
success = compress_with_7z(cuda_binary, output_file)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Check compressed size
|
||||||
|
compressed_size = get_file_size(output_file)
|
||||||
|
compression_ratio = (1 - compressed_size / original_size) * 100
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("Compression Results")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"\nCompressed file: {output_file.name}")
|
||||||
|
print(f"Compressed size: {format_size(compressed_size)} ({compressed_size:,} bytes)")
|
||||||
|
print(f"Compression ratio: {compression_ratio:.1f}%")
|
||||||
|
print(f"Space saved: {format_size(original_size - compressed_size)}")
|
||||||
|
|
||||||
|
if compressed_size <= github_limit:
|
||||||
|
print(f"\n[SUCCESS] Compressed file fits under GitHub's 2GB limit!")
|
||||||
|
print(f" Margin: {format_size(github_limit - compressed_size)} remaining")
|
||||||
|
else:
|
||||||
|
print(f"\n[FAILED] Compressed file still exceeds GitHub limit")
|
||||||
|
print(f" Over by: {format_size(compressed_size - github_limit)}")
|
||||||
|
print(f"\n Alternative: Host on external storage (S3, Azure Blob, etc.)")
|
||||||
|
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Test R2 upload locally before running in CI
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "============================================================"
|
||||||
|
echo "Cloudflare R2 Upload Test"
|
||||||
|
echo "============================================================"
|
||||||
|
|
||||||
|
# Check for required environment variables
|
||||||
|
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ] || [ -z "$R2_ENDPOINT" ]; then
|
||||||
|
echo "ERROR: Missing required environment variables"
|
||||||
|
echo ""
|
||||||
|
echo "Please set:"
|
||||||
|
echo " export AWS_ACCESS_KEY_ID='your-r2-access-key-id'"
|
||||||
|
echo " export AWS_SECRET_ACCESS_KEY='your-r2-secret-access-key'"
|
||||||
|
echo " export R2_ENDPOINT='https://your-account-id.r2.cloudflarestorage.com'"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for AWS CLI
|
||||||
|
if ! command -v aws &> /dev/null; then
|
||||||
|
echo "Installing AWS CLI..."
|
||||||
|
pip install awscli
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find CUDA binary
|
||||||
|
CUDA_BINARY=$(ls dist/voicebox-server-cuda*.exe 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -z "$CUDA_BINARY" ]; then
|
||||||
|
echo "ERROR: CUDA binary not found in dist/"
|
||||||
|
echo "Run: bash build_cuda.bat"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Found CUDA binary: $CUDA_BINARY"
|
||||||
|
echo "Size: $(du -h "$CUDA_BINARY" | cut -f1)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Test version
|
||||||
|
VERSION="v0.1.12-test"
|
||||||
|
PLATFORM="x86_64-pc-windows-msvc"
|
||||||
|
FILENAME="voicebox-server-cuda-${PLATFORM}.exe"
|
||||||
|
|
||||||
|
echo "Test upload configuration:"
|
||||||
|
echo " Version: $VERSION"
|
||||||
|
echo " Platform: $PLATFORM"
|
||||||
|
echo " Endpoint: $R2_ENDPOINT"
|
||||||
|
echo " Bucket: voicebox"
|
||||||
|
echo " Path: cuda/$VERSION/$FILENAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Proceed with upload? (y/n) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Uploading to R2..."
|
||||||
|
|
||||||
|
aws s3 cp "$CUDA_BINARY" \
|
||||||
|
"s3://voicebox/cuda/${VERSION}/${FILENAME}" \
|
||||||
|
--endpoint-url "$R2_ENDPOINT" \
|
||||||
|
--acl public-read
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "============================================================"
|
||||||
|
echo "Upload successful!"
|
||||||
|
echo "============================================================"
|
||||||
|
echo ""
|
||||||
|
echo "Download URL:"
|
||||||
|
echo "https://downloads.voicebox.sh/cuda/${VERSION}/${FILENAME}"
|
||||||
|
echo ""
|
||||||
|
echo "Test with:"
|
||||||
|
echo "curl -I https://downloads.voicebox.sh/cuda/${VERSION}/${FILENAME}"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Upload failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Backend Tests
|
||||||
|
|
||||||
|
Manual test scripts for debugging and validating backend functionality.
|
||||||
|
|
||||||
|
## Test Files
|
||||||
|
|
||||||
|
### `test_generation_progress.py`
|
||||||
|
Tests TTS generation with SSE progress monitoring to identify UX issues where users see download progress even when the model is already cached.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python tests/test_generation_progress.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Server must be running (`python main.py`)
|
||||||
|
- At least one voice profile must exist
|
||||||
|
|
||||||
|
### `test_real_download.py`
|
||||||
|
Tests real model download with SSE progress monitoring.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
# Delete cache first to force fresh download
|
||||||
|
rm -rf ~/.cache/huggingface/hub/models--openai--whisper-base
|
||||||
|
python tests/test_real_download.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Prerequisites:**
|
||||||
|
- Server must be running (`python main.py`)
|
||||||
|
|
||||||
|
### `test_progress.py`
|
||||||
|
Unit tests for ProgressManager and HFProgressTracker functionality.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python tests/test_progress.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### `test_check_progress_state.py`
|
||||||
|
Debugging script to inspect the internal state of ProgressManager and TaskManager.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python tests/test_check_progress_state.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
These are manual test scripts, not automated unit tests. They're designed for:
|
||||||
|
- Debugging progress tracking issues
|
||||||
|
- Validating SSE event streams
|
||||||
|
- Monitoring real-time download behavior
|
||||||
|
- Inspecting internal state during development
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""
|
||||||
|
Test suite for Voicebox backend.
|
||||||
|
|
||||||
|
This directory contains manual test scripts for debugging and validating
|
||||||
|
progress tracking, model downloads, and generation functionality.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""
|
||||||
|
Test TTS generation with SSE progress monitoring.
|
||||||
|
This test captures the exact SSE events triggered during generation
|
||||||
|
to identify UX issues where users see download progress even when
|
||||||
|
the model is already cached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_sse_stream(model_name: str, timeout: int = 120):
|
||||||
|
"""Monitor SSE stream for a model during generation."""
|
||||||
|
events: List[Dict] = []
|
||||||
|
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||||
|
|
||||||
|
print(f"[{_timestamp()}] Connecting to SSE endpoint: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("GET", url) as response:
|
||||||
|
print(f"[{_timestamp()}] SSE connected, status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"[{_timestamp()}] Error: SSE endpoint returned {response.status_code}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = _timestamp()
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
print(f"[{timestamp}] → SSE Event: {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||||
|
events.append({
|
||||||
|
**data,
|
||||||
|
"_timestamp": timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
# Stop if complete or error
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
print(f"[{timestamp}] → Model {data['status']}!")
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"[{timestamp}] Error parsing JSON: {e}")
|
||||||
|
print(f" Line was: {line}")
|
||||||
|
|
||||||
|
elif line.startswith(": heartbeat"):
|
||||||
|
print(f"[{timestamp}] ♥ heartbeat")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print(f"[{_timestamp()}] SSE monitoring timed out")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{_timestamp()}] SSE error: {e}")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_generation(profile_id: str, text: str, model_size: str = "1.7B"):
|
||||||
|
"""Trigger TTS generation via the API."""
|
||||||
|
url = "http://localhost:8000/generate"
|
||||||
|
|
||||||
|
print(f"\n[{_timestamp()}] Triggering generation...")
|
||||||
|
print(f" Profile: {profile_id}")
|
||||||
|
print(f" Text: {text[:50]}...")
|
||||||
|
print(f" Model: {model_size}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
response = await client.post(url, json={
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"text": text,
|
||||||
|
"language": "en",
|
||||||
|
"model_size": model_size,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"[{_timestamp()}] Response: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"[{_timestamp()}] ✓ Generation successful!")
|
||||||
|
print(f" Generation ID: {result.get('id')}")
|
||||||
|
print(f" Duration: {result.get('duration', 0):.2f}s")
|
||||||
|
return True, result
|
||||||
|
elif response.status_code == 202:
|
||||||
|
# Model is being downloaded
|
||||||
|
result = response.json()
|
||||||
|
print(f"[{_timestamp()}] → Model download in progress")
|
||||||
|
print(f" Detail: {result}")
|
||||||
|
return False, result
|
||||||
|
else:
|
||||||
|
print(f"[{_timestamp()}] ✗ Error: {response.text}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[{_timestamp()}] ✗ Exception: {e}")
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_first_profile():
|
||||||
|
"""Get the first available voice profile."""
|
||||||
|
url = "http://localhost:8000/profiles"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
profiles = response.json()
|
||||||
|
if profiles:
|
||||||
|
return profiles[0]["id"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting profiles: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server():
|
||||||
|
"""Check if the server is running."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get("http://localhost:8000/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Server not running: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp():
|
||||||
|
"""Get current timestamp for logging."""
|
||||||
|
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generation_with_cached_model():
|
||||||
|
"""
|
||||||
|
Test Case 1: Generation when model is already cached.
|
||||||
|
|
||||||
|
This should NOT show any download progress events.
|
||||||
|
If it does, that's the UX bug we're trying to fix.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("TEST CASE 1: Generation with Cached Model")
|
||||||
|
print("=" * 80)
|
||||||
|
print("Expected: No download progress events (or minimal/instant completion)")
|
||||||
|
print("Actual UX Issue: Users see 'started' and 'finished' events even for cached models")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
model_size = "1.7B"
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Get a profile
|
||||||
|
profile_id = await get_first_profile()
|
||||||
|
if not profile_id:
|
||||||
|
print("✗ No voice profiles found. Please create a profile first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\nUsing profile: {profile_id}")
|
||||||
|
|
||||||
|
# Start SSE monitor BEFORE triggering generation
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=30))
|
||||||
|
|
||||||
|
# Wait for SSE to connect
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger generation
|
||||||
|
test_text = "Hello, this is a test of the voice generation system."
|
||||||
|
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||||
|
|
||||||
|
if not success and result and result.get("downloading"):
|
||||||
|
print("\n⚠ Model is being downloaded. Waiting for download to complete...")
|
||||||
|
# Wait for SSE monitor to capture download events
|
||||||
|
events = await monitor_task
|
||||||
|
return events
|
||||||
|
|
||||||
|
# Wait a bit more to catch any progress events
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
# Cancel SSE monitor
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
events = await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
events = []
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def test_generation_with_fresh_download():
|
||||||
|
"""
|
||||||
|
Test Case 2: Generation when model needs to be downloaded.
|
||||||
|
|
||||||
|
This SHOULD show download progress events.
|
||||||
|
"""
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("TEST CASE 2: Generation with Model Download")
|
||||||
|
print("=" * 80)
|
||||||
|
print("Expected: Download progress events from 0% to 100%")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Use a different model size to force download
|
||||||
|
model_size = "0.6B" # Smaller model for faster testing
|
||||||
|
model_name = f"qwen-tts-{model_size}"
|
||||||
|
|
||||||
|
# Get a profile
|
||||||
|
profile_id = await get_first_profile()
|
||||||
|
if not profile_id:
|
||||||
|
print("✗ No voice profiles found. Please create a profile first.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"\nUsing profile: {profile_id}")
|
||||||
|
print("Note: This will download the model if not cached")
|
||||||
|
|
||||||
|
# Start SSE monitor BEFORE triggering generation
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=300))
|
||||||
|
|
||||||
|
# Wait for SSE to connect
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger generation
|
||||||
|
test_text = "This should trigger a model download if the model is not cached."
|
||||||
|
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||||
|
|
||||||
|
if not success and result and result.get("downloading"):
|
||||||
|
print("\n→ Model download initiated. Monitoring progress...")
|
||||||
|
# Wait for download to complete
|
||||||
|
events = await monitor_task
|
||||||
|
|
||||||
|
# Try generation again
|
||||||
|
print(f"\n[{_timestamp()}] Retrying generation after download...")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
success, result = await trigger_generation(profile_id, test_text, model_size)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("✓ Generation successful after download")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
# If model was already cached
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
events = await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
events = []
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 80)
|
||||||
|
print("TTS Generation Progress Test")
|
||||||
|
print("=" * 80)
|
||||||
|
print("Purpose: Capture exact SSE events during generation to identify UX issues")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
print(f"\n[{_timestamp()}] Checking if server is running...")
|
||||||
|
if not await check_server():
|
||||||
|
print("✗ Server is not running on http://localhost:8000")
|
||||||
|
print("\nPlease start the server first:")
|
||||||
|
print(" cd backend && python main.py")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("✓ Server is running")
|
||||||
|
|
||||||
|
# Test Case 1: Cached model
|
||||||
|
print("\n" + "🧪 " * 20)
|
||||||
|
events_cached = await test_generation_with_cached_model()
|
||||||
|
|
||||||
|
# Results for Test Case 1
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("TEST CASE 1 RESULTS: Generation with Cached Model")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
if not events_cached:
|
||||||
|
print("✓ GOOD: No SSE progress events received")
|
||||||
|
print(" This is the expected behavior for a cached model.")
|
||||||
|
else:
|
||||||
|
print(f"⚠ ISSUE FOUND: Received {len(events_cached)} SSE events:")
|
||||||
|
print("\nEvent Timeline:")
|
||||||
|
for i, event in enumerate(events_cached, 1):
|
||||||
|
timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||||
|
print(f" {i}. [{timestamp}] {event}")
|
||||||
|
|
||||||
|
print("\n⚠ This explains the UX issue!")
|
||||||
|
print(" Users see progress events even when the model is already cached,")
|
||||||
|
print(" making them think the model is downloading again.")
|
||||||
|
|
||||||
|
# Test Case 2: Fresh download (optional, commented out by default)
|
||||||
|
# Uncomment if you want to test download progress
|
||||||
|
# print("\n" + "🧪 " * 20)
|
||||||
|
# events_download = await test_generation_with_fresh_download()
|
||||||
|
#
|
||||||
|
# print("\n" + "=" * 80)
|
||||||
|
# print("TEST CASE 2 RESULTS: Generation with Model Download")
|
||||||
|
# print("=" * 80)
|
||||||
|
#
|
||||||
|
# if not events_download:
|
||||||
|
# print("ℹ Model was already cached, no download occurred")
|
||||||
|
# else:
|
||||||
|
# print(f"✓ Received {len(events_download)} download progress events")
|
||||||
|
# print("\nDownload Timeline:")
|
||||||
|
# for i, event in enumerate(events_download, 1):
|
||||||
|
# timestamp = event.pop("_timestamp", "??:??:??.???")
|
||||||
|
# print(f" {i}. [{timestamp}] {event}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("Test Complete!")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Test script to debug model download progress tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import List, Dict
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# Set up logging to see what's happening
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
from utils.progress import ProgressManager, get_progress_manager
|
||||||
|
from utils.hf_progress import HFProgressTracker, create_hf_progress_callback
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_manager_basic():
|
||||||
|
"""Test 1: Basic ProgressManager functionality."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 1: ProgressManager Basic Operations")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
pm = ProgressManager()
|
||||||
|
|
||||||
|
# Test update_progress
|
||||||
|
pm.update_progress(
|
||||||
|
model_name="test-model",
|
||||||
|
current=50,
|
||||||
|
total=100,
|
||||||
|
filename="test.bin",
|
||||||
|
status="downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test get_progress
|
||||||
|
progress = pm.get_progress("test-model")
|
||||||
|
print(f"✓ Progress stored: {progress}")
|
||||||
|
assert progress is not None
|
||||||
|
assert progress["progress"] == 50.0
|
||||||
|
assert progress["filename"] == "test.bin"
|
||||||
|
assert progress["status"] == "downloading"
|
||||||
|
|
||||||
|
# Test mark_complete
|
||||||
|
pm.mark_complete("test-model")
|
||||||
|
progress = pm.get_progress("test-model")
|
||||||
|
print(f"✓ Marked complete: {progress}")
|
||||||
|
assert progress["status"] == "complete"
|
||||||
|
assert progress["progress"] == 100.0
|
||||||
|
|
||||||
|
print("✓ Test 1 PASSED\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_progress_manager_sse():
|
||||||
|
"""Test 2: ProgressManager SSE streaming."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 2: ProgressManager SSE Streaming")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
pm = ProgressManager()
|
||||||
|
collected_events: List[Dict] = []
|
||||||
|
|
||||||
|
# Simulate SSE client
|
||||||
|
async def sse_client():
|
||||||
|
"""Simulates a frontend SSE connection."""
|
||||||
|
print(" SSE client: Subscribing to test-model-sse...")
|
||||||
|
async for event in pm.subscribe("test-model-sse"):
|
||||||
|
# Parse SSE event
|
||||||
|
if event.startswith("data: "):
|
||||||
|
data = json.loads(event[6:])
|
||||||
|
print(f" SSE client: Received event: {data['status']} - {data.get('progress', 0):.1f}%")
|
||||||
|
collected_events.append(data)
|
||||||
|
|
||||||
|
# Stop when complete
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
break
|
||||||
|
elif event.startswith(": heartbeat"):
|
||||||
|
print(" SSE client: Received heartbeat")
|
||||||
|
|
||||||
|
# Simulate download progress updates (from backend thread)
|
||||||
|
async def simulate_download():
|
||||||
|
"""Simulates backend sending progress updates."""
|
||||||
|
print(" Backend: Starting simulated download...")
|
||||||
|
await asyncio.sleep(0.2) # Let SSE client subscribe first
|
||||||
|
|
||||||
|
# Send progress updates
|
||||||
|
for i in range(0, 101, 20):
|
||||||
|
print(f" Backend: Updating progress to {i}%")
|
||||||
|
pm.update_progress(
|
||||||
|
model_name="test-model-sse",
|
||||||
|
current=i,
|
||||||
|
total=100,
|
||||||
|
filename=f"file_{i}.bin",
|
||||||
|
status="downloading" if i < 100 else "downloading"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Mark complete
|
||||||
|
print(" Backend: Marking download complete")
|
||||||
|
pm.mark_complete("test-model-sse")
|
||||||
|
|
||||||
|
# Run SSE client and download simulation concurrently
|
||||||
|
await asyncio.gather(
|
||||||
|
sse_client(),
|
||||||
|
simulate_download()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we got events
|
||||||
|
print(f"\n Collected {len(collected_events)} events")
|
||||||
|
assert len(collected_events) > 0, "Should have received at least one event"
|
||||||
|
assert collected_events[-1]["status"] == "complete", "Last event should be 'complete'"
|
||||||
|
|
||||||
|
print("✓ Test 2 PASSED\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hf_progress_tracker():
|
||||||
|
"""Test 3: HFProgressTracker tqdm patching."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 3: HFProgressTracker tqdm Patching")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
captured_progress: List[tuple] = []
|
||||||
|
|
||||||
|
def progress_callback(downloaded: int, total: int, filename: str):
|
||||||
|
"""Capture progress updates."""
|
||||||
|
captured_progress.append((downloaded, total, filename))
|
||||||
|
print(f" Progress callback: {downloaded}/{total} bytes ({filename})")
|
||||||
|
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Simulate a download with tqdm
|
||||||
|
with tracker.patch_download():
|
||||||
|
try:
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
# Simulate downloading a file
|
||||||
|
print(" Simulating download with tqdm...")
|
||||||
|
total_size = 1000
|
||||||
|
with tqdm(total=total_size, desc="model.bin", unit="B", unit_scale=True) as pbar:
|
||||||
|
for chunk in range(0, total_size, 100):
|
||||||
|
pbar.update(100)
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
print(f" Captured {len(captured_progress)} progress updates")
|
||||||
|
assert len(captured_progress) > 0, "Should have captured progress updates"
|
||||||
|
|
||||||
|
# Verify progress increases
|
||||||
|
last_downloaded = 0
|
||||||
|
for downloaded, total, filename in captured_progress:
|
||||||
|
assert downloaded >= last_downloaded, "Downloaded bytes should increase"
|
||||||
|
assert total == total_size, "Total should be consistent"
|
||||||
|
last_downloaded = downloaded
|
||||||
|
|
||||||
|
print("✓ Test 3 PASSED\n")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print("✗ tqdm not available, skipping test\n")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_integration():
|
||||||
|
"""Test 4: Full integration test."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test 4: Full Integration (ProgressManager + HFProgressTracker)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
pm = get_progress_manager()
|
||||||
|
collected_events: List[Dict] = []
|
||||||
|
|
||||||
|
# SSE client
|
||||||
|
async def sse_client():
|
||||||
|
print(" SSE client: Subscribing...")
|
||||||
|
async for event in pm.subscribe("integration-test"):
|
||||||
|
if event.startswith("data: "):
|
||||||
|
data = json.loads(event[6:])
|
||||||
|
print(f" SSE client: {data['status']} - {data.get('progress', 0):.1f}% - {data.get('filename', '')}")
|
||||||
|
collected_events.append(data)
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Simulate backend download with HFProgressTracker
|
||||||
|
async def simulate_real_download():
|
||||||
|
await asyncio.sleep(0.2) # Let SSE subscribe
|
||||||
|
|
||||||
|
print(" Backend: Starting download with HFProgressTracker...")
|
||||||
|
|
||||||
|
# Set up tracking (like the real backend does)
|
||||||
|
progress_callback = create_hf_progress_callback("integration-test", pm)
|
||||||
|
tracker = HFProgressTracker(progress_callback)
|
||||||
|
|
||||||
|
# Initialize progress
|
||||||
|
pm.update_progress(
|
||||||
|
model_name="integration-test",
|
||||||
|
current=0,
|
||||||
|
total=1,
|
||||||
|
filename="",
|
||||||
|
status="downloading"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate download with tqdm patching
|
||||||
|
with tracker.patch_download():
|
||||||
|
try:
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
# Simulate multi-file download (like HuggingFace does)
|
||||||
|
files = [
|
||||||
|
("model.safetensors", 5000),
|
||||||
|
("config.json", 1000),
|
||||||
|
("tokenizer.json", 500),
|
||||||
|
]
|
||||||
|
|
||||||
|
for filename, size in files:
|
||||||
|
print(f" Backend: Downloading {filename}...")
|
||||||
|
with tqdm(total=size, desc=filename, unit="B") as pbar:
|
||||||
|
for chunk in range(0, size, 500):
|
||||||
|
chunk_size = min(500, size - chunk)
|
||||||
|
pbar.update(chunk_size)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# Mark complete
|
||||||
|
print(" Backend: Download complete")
|
||||||
|
pm.mark_complete("integration-test")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
print(" ✗ tqdm not available")
|
||||||
|
pm.mark_error("integration-test", "tqdm not available")
|
||||||
|
|
||||||
|
# Run both
|
||||||
|
await asyncio.gather(
|
||||||
|
sse_client(),
|
||||||
|
simulate_real_download()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
print(f"\n Collected {len(collected_events)} events")
|
||||||
|
if len(collected_events) > 0:
|
||||||
|
print(f" First event: {collected_events[0]}")
|
||||||
|
print(f" Last event: {collected_events[-1]}")
|
||||||
|
assert collected_events[-1]["status"] == "complete", "Should end with 'complete'"
|
||||||
|
print("✓ Test 4 PASSED\n")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print("✗ Test 4 FAILED - No events received\n")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run all tests."""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Voicebox Progress Tracking Test Suite")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Test 1: Basic operations
|
||||||
|
try:
|
||||||
|
results.append(("Basic Operations", test_progress_manager_basic()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 1 FAILED: {e}\n")
|
||||||
|
results.append(("Basic Operations", False))
|
||||||
|
|
||||||
|
# Test 2: SSE streaming
|
||||||
|
try:
|
||||||
|
results.append(("SSE Streaming", await test_progress_manager_sse()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 2 FAILED: {e}\n")
|
||||||
|
results.append(("SSE Streaming", False))
|
||||||
|
|
||||||
|
# Test 3: tqdm patching
|
||||||
|
try:
|
||||||
|
results.append(("tqdm Patching", test_hf_progress_tracker()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 3 FAILED: {e}\n")
|
||||||
|
results.append(("tqdm Patching", False))
|
||||||
|
|
||||||
|
# Test 4: Full integration
|
||||||
|
try:
|
||||||
|
results.append(("Full Integration", await test_full_integration()))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Test 4 FAILED: {e}\n")
|
||||||
|
results.append(("Full Integration", False))
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test Results Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for name, result in results:
|
||||||
|
status = "✓ PASS" if result else ("⊘ SKIP" if result is None else "✗ FAIL")
|
||||||
|
print(f" {status:8} {name}")
|
||||||
|
|
||||||
|
passed = sum(1 for _, r in results if r is True)
|
||||||
|
failed = sum(1 for _, r in results if r is False)
|
||||||
|
skipped = sum(1 for _, r in results if r is None)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f" Total: {len(results)} tests")
|
||||||
|
print(f" Passed: {passed}")
|
||||||
|
print(f" Failed: {failed}")
|
||||||
|
print(f" Skipped: {skipped}")
|
||||||
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
|
return failed == 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = asyncio.run(main())
|
||||||
|
exit(0 if success else 1)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
Test Qwen TTS model download with SSE progress monitoring.
|
||||||
|
|
||||||
|
This specifically tests the MLX TTS backend download progress tracking,
|
||||||
|
which requires tqdm to be patched BEFORE mlx_audio is imported.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd backend && python -m tests.test_qwen_download
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
- Server must be running: cd backend && python main.py
|
||||||
|
- Delete model first for fresh download test:
|
||||||
|
curl -X DELETE http://localhost:8000/models/qwen-tts-0.6B
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import time
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
async def monitor_sse_stream(model_name: str, timeout: int = 600) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Monitor SSE stream for a model download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_name: Name of the model to monitor
|
||||||
|
timeout: Maximum time to wait for download (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SSE events received
|
||||||
|
"""
|
||||||
|
events: List[Dict] = []
|
||||||
|
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||||
|
last_progress = -1
|
||||||
|
|
||||||
|
print(f"\n📡 Connecting to SSE endpoint: {url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("GET", url) as response:
|
||||||
|
print(f" SSE connected, status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f" ❌ Error: SSE endpoint returned {response.status_code}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
events.append(data)
|
||||||
|
|
||||||
|
# Print progress (only when it changes significantly)
|
||||||
|
progress = data.get('progress', 0)
|
||||||
|
status = data.get('status', 'unknown')
|
||||||
|
filename = data.get('filename', '')
|
||||||
|
current = data.get('current', 0)
|
||||||
|
total = data.get('total', 0)
|
||||||
|
|
||||||
|
# Print every 5% change or status change
|
||||||
|
if abs(progress - last_progress) >= 5 or status in ('complete', 'error'):
|
||||||
|
current_mb = current / (1024 * 1024)
|
||||||
|
total_mb = total / (1024 * 1024)
|
||||||
|
print(f" 📊 {status:12} {progress:6.1f}% ({current_mb:.1f}MB / {total_mb:.1f}MB) {filename[:50]}")
|
||||||
|
last_progress = progress
|
||||||
|
|
||||||
|
# Stop if complete or error
|
||||||
|
if status in ("complete", "error"):
|
||||||
|
if status == "complete":
|
||||||
|
print(f" ✅ Download complete!")
|
||||||
|
else:
|
||||||
|
print(f" ❌ Download error: {data.get('error', 'unknown')}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" ⚠️ Error parsing JSON: {e}")
|
||||||
|
|
||||||
|
elif line.startswith(": heartbeat"):
|
||||||
|
# Heartbeat every 1 second, don't spam
|
||||||
|
pass
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print(" ⏹️ SSE monitor cancelled")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ SSE error: {e}")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_download(model_name: str) -> bool:
|
||||||
|
"""Trigger a model download via the API."""
|
||||||
|
url = "http://localhost:8000/models/download"
|
||||||
|
|
||||||
|
print(f"\n🚀 Triggering download for: {model_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(url, json={"model_name": model_name})
|
||||||
|
result = response.json()
|
||||||
|
print(f" Response: {response.status_code} - {result}")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error triggering download: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_model(model_name: str) -> bool:
|
||||||
|
"""Delete a model from cache."""
|
||||||
|
url = f"http://localhost:8000/models/{model_name}"
|
||||||
|
|
||||||
|
print(f"\n🗑️ Deleting model: {model_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.delete(url)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" ✅ Model deleted")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 404:
|
||||||
|
print(f" ℹ️ Model not found (already deleted)")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ Delete response: {response.status_code} - {response.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Error deleting model: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_model_status(model_name: str) -> Optional[Dict]:
|
||||||
|
"""Check the status of a model."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get("http://localhost:8000/models/status")
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
for model in data.get("models", []):
|
||||||
|
if model["model_name"] == model_name:
|
||||||
|
return model
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ⚠️ Error checking model status: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server() -> bool:
|
||||||
|
"""Check if the server is running."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get("http://localhost:8000/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 70)
|
||||||
|
print("🧪 Qwen TTS Model Download Progress Test")
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nThis test verifies that MLX TTS download progress tracking works.")
|
||||||
|
print("It specifically tests the tqdm patching for mlx_audio.tts imports.")
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
print("\n📡 Checking if server is running...")
|
||||||
|
if not await check_server():
|
||||||
|
print(" ❌ Server is not running on http://localhost:8000")
|
||||||
|
print("\n Please start the server first:")
|
||||||
|
print(" cd backend && python main.py")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(" ✅ Server is running")
|
||||||
|
|
||||||
|
# Test model
|
||||||
|
model_name = "qwen-tts-0.6B" # Note: 0.6B currently maps to 1.7B on MLX
|
||||||
|
|
||||||
|
# Check current status
|
||||||
|
print(f"\n📊 Checking status of {model_name}...")
|
||||||
|
status = await check_model_status(model_name)
|
||||||
|
if status:
|
||||||
|
print(f" Downloaded: {status.get('downloaded', False)}")
|
||||||
|
print(f" Downloading: {status.get('downloading', False)}")
|
||||||
|
print(f" Loaded: {status.get('loaded', False)}")
|
||||||
|
if status.get('size_mb'):
|
||||||
|
print(f" Size: {status['size_mb']:.1f} MB")
|
||||||
|
else:
|
||||||
|
print(" ⚠️ Could not get model status")
|
||||||
|
|
||||||
|
# Ask if user wants to delete first
|
||||||
|
print("\n" + "-" * 70)
|
||||||
|
if status and status.get('downloaded'):
|
||||||
|
print("⚠️ Model is already downloaded. Delete it for a fresh download test?")
|
||||||
|
print(" [y] Yes, delete and download fresh")
|
||||||
|
print(" [n] No, just test SSE connection")
|
||||||
|
print(" [q] Quit")
|
||||||
|
|
||||||
|
choice = input("\nChoice [y/n/q]: ").strip().lower()
|
||||||
|
|
||||||
|
if choice == 'q':
|
||||||
|
print("Exiting...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if choice == 'y':
|
||||||
|
if not await delete_model(model_name):
|
||||||
|
print("Failed to delete model. Continue anyway? [y/n]")
|
||||||
|
if input().strip().lower() != 'y':
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print("Model not downloaded. Will perform fresh download test.")
|
||||||
|
input("Press Enter to continue...")
|
||||||
|
|
||||||
|
# Run the test
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("🏃 Starting Download Test")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
async def run_test():
|
||||||
|
# Start SSE monitor in background FIRST
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name, timeout=600))
|
||||||
|
|
||||||
|
# Wait for SSE to connect
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger download
|
||||||
|
success = await trigger_download(model_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print(" ❌ Failed to trigger download")
|
||||||
|
monitor_task.cancel()
|
||||||
|
try:
|
||||||
|
await monitor_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Wait for SSE monitor to complete
|
||||||
|
print("\n⏳ Waiting for download to complete (this may take several minutes)...")
|
||||||
|
events = await monitor_task
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
events = await run_test()
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
|
# Results
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("📋 Test Results")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
print(f"\n⏱️ Elapsed time: {elapsed:.1f} seconds")
|
||||||
|
print(f"📨 Total SSE events received: {len(events)}")
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
print("\n❌ FAILED - No SSE events received!")
|
||||||
|
print("\nPossible causes:")
|
||||||
|
print(" 1. SSE endpoint not working")
|
||||||
|
print(" 2. tqdm not patched before mlx_audio import")
|
||||||
|
print(" 3. Progress callbacks not firing")
|
||||||
|
print(" 4. Model already fully downloaded")
|
||||||
|
print("\nDebug steps:")
|
||||||
|
print(" 1. Check server logs for [DEBUG] messages")
|
||||||
|
print(" 2. Look for 'tqdm patched' before 'mlx_audio.tts import'")
|
||||||
|
print(f" 3. Delete model: curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Analyze events
|
||||||
|
first_event = events[0]
|
||||||
|
last_event = events[-1]
|
||||||
|
|
||||||
|
print(f"\n📊 First event:")
|
||||||
|
print(f" Status: {first_event.get('status')}")
|
||||||
|
print(f" Progress: {first_event.get('progress', 0):.1f}%")
|
||||||
|
|
||||||
|
print(f"\n📊 Last event:")
|
||||||
|
print(f" Status: {last_event.get('status')}")
|
||||||
|
print(f" Progress: {last_event.get('progress', 0):.1f}%")
|
||||||
|
|
||||||
|
# Check for expected behaviors
|
||||||
|
has_progress_updates = len(events) > 2
|
||||||
|
has_increasing_progress = False
|
||||||
|
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||||
|
has_100_percent = any(e.get('progress', 0) >= 100 for e in events)
|
||||||
|
|
||||||
|
# Check if progress increased over time
|
||||||
|
if len(events) >= 2:
|
||||||
|
progress_values = [e.get('progress', 0) for e in events]
|
||||||
|
has_increasing_progress = progress_values[-1] > progress_values[0]
|
||||||
|
|
||||||
|
print("\n📋 Checks:")
|
||||||
|
print(f" {'✅' if has_progress_updates else '❌'} Multiple progress updates received ({len(events)} events)")
|
||||||
|
print(f" {'✅' if has_increasing_progress else '❌'} Progress increased over time")
|
||||||
|
print(f" {'✅' if has_100_percent else '❌'} Reached 100% progress")
|
||||||
|
print(f" {'✅' if has_complete else '❌'} Received 'complete' status")
|
||||||
|
|
||||||
|
# Overall result
|
||||||
|
success = has_progress_updates and has_complete
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("✅ TEST PASSED - Qwen TTS download progress tracking works!")
|
||||||
|
print("=" * 70)
|
||||||
|
else:
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print("❌ TEST FAILED - Progress tracking has issues")
|
||||||
|
print("=" * 70)
|
||||||
|
print("\nCheck the server logs for debug output.")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = asyncio.run(main())
|
||||||
|
exit(0 if result else 1)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Test real model download with SSE progress monitoring.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import httpx
|
||||||
|
import time
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
async def monitor_sse_stream(model_name: str, timeout: int = 300):
|
||||||
|
"""Monitor SSE stream for a model download."""
|
||||||
|
events: List[Dict] = []
|
||||||
|
url = f"http://localhost:8000/models/progress/{model_name}"
|
||||||
|
|
||||||
|
print(f"Connecting to SSE endpoint: {url}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
async with client.stream("GET", url) as response:
|
||||||
|
print(f"SSE connected, status: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error: SSE endpoint returned {response.status_code}")
|
||||||
|
return events
|
||||||
|
|
||||||
|
async for line in response.aiter_lines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f" Raw SSE: {line[:100]}...") # Print first 100 chars
|
||||||
|
|
||||||
|
if line.startswith("data: "):
|
||||||
|
try:
|
||||||
|
data = json.loads(line[6:])
|
||||||
|
print(f" → {data['status']:12} {data.get('progress', 0):6.1f}% {data.get('filename', '')}")
|
||||||
|
events.append(data)
|
||||||
|
|
||||||
|
# Stop if complete or error
|
||||||
|
if data.get("status") in ("complete", "error"):
|
||||||
|
print(f" Download {data['status']}!")
|
||||||
|
break
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" Error parsing JSON: {e}")
|
||||||
|
print(f" Line was: {line}")
|
||||||
|
|
||||||
|
elif line.startswith(": heartbeat"):
|
||||||
|
print(" ♥ heartbeat")
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
async def trigger_download(model_name: str):
|
||||||
|
"""Trigger a model download via the API."""
|
||||||
|
url = "http://localhost:8000/models/download"
|
||||||
|
|
||||||
|
print(f"\nTriggering download for: {model_name}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=300) as client:
|
||||||
|
response = await client.post(url, json={"model_name": model_name})
|
||||||
|
print(f"Response: {response.status_code} - {response.json()}")
|
||||||
|
return response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def check_server():
|
||||||
|
"""Check if the server is running."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5) as client:
|
||||||
|
response = await client.get("http://localhost:8000/health")
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Server not running: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("Real Model Download Progress Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Check if server is running
|
||||||
|
print("\nChecking if server is running...")
|
||||||
|
if not await check_server():
|
||||||
|
print("✗ Server is not running on http://localhost:8000")
|
||||||
|
print("\nPlease start the server first:")
|
||||||
|
print(" cd backend && python main.py")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("✓ Server is running")
|
||||||
|
|
||||||
|
# Choose a small model for testing
|
||||||
|
model_name = "whisper-base" # ~150MB, faster to download
|
||||||
|
print(f"\nUsing model: {model_name}")
|
||||||
|
|
||||||
|
# Option to delete model first if it exists
|
||||||
|
print("\nDo you want to delete the model first to force a fresh download? (y/n)")
|
||||||
|
# For automated testing, skip deletion prompt
|
||||||
|
# delete_first = input().strip().lower() == 'y'
|
||||||
|
delete_first = False
|
||||||
|
|
||||||
|
if delete_first:
|
||||||
|
print(f"Deleting {model_name}...")
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.delete(f"http://localhost:8000/models/{model_name}")
|
||||||
|
print(f"Delete response: {response.status_code}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Starting Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Start monitoring SSE stream BEFORE triggering download
|
||||||
|
async def run_test():
|
||||||
|
# Start SSE monitor in background
|
||||||
|
monitor_task = asyncio.create_task(monitor_sse_stream(model_name))
|
||||||
|
|
||||||
|
# Wait a bit to ensure SSE is connected
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
# Trigger download
|
||||||
|
success = await trigger_download(model_name)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
print("✗ Failed to trigger download")
|
||||||
|
monitor_task.cancel()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Wait for SSE monitor to complete
|
||||||
|
events = await monitor_task
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
events = await run_test()
|
||||||
|
|
||||||
|
# Results
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Test Results")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
print("✗ FAILED - No SSE events received!")
|
||||||
|
print("\nPossible causes:")
|
||||||
|
print(" 1. SSE endpoint not working")
|
||||||
|
print(" 2. Progress updates not being sent")
|
||||||
|
print(" 3. Model already downloaded (no progress to report)")
|
||||||
|
print("\nTry deleting the model first to force a fresh download:")
|
||||||
|
print(f" curl -X DELETE http://localhost:8000/models/{model_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print(f"✓ Received {len(events)} SSE events")
|
||||||
|
print(f"\nFirst event: {events[0]}")
|
||||||
|
print(f"Last event: {events[-1]}")
|
||||||
|
|
||||||
|
# Check if we got meaningful progress
|
||||||
|
has_progress = any(e.get('progress', 0) > 0 for e in events)
|
||||||
|
has_complete = any(e.get('status') == 'complete' for e in events)
|
||||||
|
|
||||||
|
if has_progress:
|
||||||
|
print("✓ Progress updates received")
|
||||||
|
else:
|
||||||
|
print("✗ No progress updates (might be already downloaded)")
|
||||||
|
|
||||||
|
if has_complete:
|
||||||
|
print("✓ Download completed successfully")
|
||||||
|
else:
|
||||||
|
print("✗ Download did not complete")
|
||||||
|
|
||||||
|
success = has_progress and has_complete
|
||||||
|
|
||||||
|
if success:
|
||||||
|
print("\n✓ TEST PASSED - Progress tracking works!")
|
||||||
|
else:
|
||||||
|
print("\n⊘ TEST INCONCLUSIVE - Try with a fresh download")
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
+145
-23
@@ -11,8 +11,9 @@ import sys
|
|||||||
class HFProgressTracker:
|
class HFProgressTracker:
|
||||||
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
"""Tracks HuggingFace Hub download progress by intercepting tqdm."""
|
||||||
|
|
||||||
def __init__(self, progress_callback: Optional[Callable] = None):
|
def __init__(self, progress_callback: Optional[Callable] = None, filter_non_downloads: bool = False):
|
||||||
self.progress_callback = progress_callback
|
self.progress_callback = progress_callback
|
||||||
|
self.filter_non_downloads = filter_non_downloads # Only filter if True
|
||||||
self._original_tqdm_class = None
|
self._original_tqdm_class = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._total_downloaded = 0
|
self._total_downloaded = 0
|
||||||
@@ -21,6 +22,7 @@ class HFProgressTracker:
|
|||||||
self._file_downloaded = {} # Track downloaded bytes per file
|
self._file_downloaded = {} # Track downloaded bytes per file
|
||||||
self._current_filename = ""
|
self._current_filename = ""
|
||||||
self._active_tqdms = {} # Track active tqdm instances
|
self._active_tqdms = {} # Track active tqdm instances
|
||||||
|
self._hf_tqdm_original_update = None # For monkey-patching hf's tqdm
|
||||||
|
|
||||||
def _create_tracked_tqdm_class(self):
|
def _create_tracked_tqdm_class(self):
|
||||||
"""Create a tqdm subclass that tracks progress."""
|
"""Create a tqdm subclass that tracks progress."""
|
||||||
@@ -31,7 +33,6 @@ class HFProgressTracker:
|
|||||||
"""A tqdm subclass that reports progress to our tracker."""
|
"""A tqdm subclass that reports progress to our tracker."""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
print(f"[DEBUG TrackedTqdm] __init__ called with desc: {kwargs.get('desc', '')}")
|
|
||||||
# Extract filename from desc before passing to parent
|
# Extract filename from desc before passing to parent
|
||||||
desc = kwargs.get("desc", "")
|
desc = kwargs.get("desc", "")
|
||||||
if not desc and args:
|
if not desc and args:
|
||||||
@@ -80,7 +81,6 @@ class HFProgressTracker:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def update(self, n=1):
|
def update(self, n=1):
|
||||||
print(f"[DEBUG TrackedTqdm] update called with n={n}")
|
|
||||||
result = super().update(n)
|
result = super().update(n)
|
||||||
|
|
||||||
# Report progress
|
# Report progress
|
||||||
@@ -91,6 +91,16 @@ class HFProgressTracker:
|
|||||||
total = getattr(self, "total", 0)
|
total = getattr(self, "total", 0)
|
||||||
|
|
||||||
if total and total > 0:
|
if total and total > 0:
|
||||||
|
# Always filter out non-byte progress bars (e.g., "Fetching 12 files")
|
||||||
|
# These cause crazy percentages because they're counting files, not bytes
|
||||||
|
if self._is_non_byte_progress(filename):
|
||||||
|
return result
|
||||||
|
|
||||||
|
# When model is cached, also filter out generation-related progress
|
||||||
|
if tracker.filter_non_downloads:
|
||||||
|
if not self._is_download_progress(filename):
|
||||||
|
return result
|
||||||
|
|
||||||
# Update per-file tracking
|
# Update per-file tracking
|
||||||
tracker._file_sizes[filename] = total
|
tracker._file_sizes[filename] = total
|
||||||
tracker._file_downloaded[filename] = current
|
tracker._file_downloaded[filename] = current
|
||||||
@@ -99,6 +109,13 @@ class HFProgressTracker:
|
|||||||
tracker._total_size = sum(tracker._file_sizes.values())
|
tracker._total_size = sum(tracker._file_sizes.values())
|
||||||
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
tracker._total_downloaded = sum(tracker._file_downloaded.values())
|
||||||
|
|
||||||
|
# Only report progress once we have a meaningful total (at least 1MB)
|
||||||
|
# This avoids the "100% at 0MB" issue when small config
|
||||||
|
# files are counted before the real model files
|
||||||
|
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||||
|
if tracker._total_size < MIN_TOTAL_BYTES:
|
||||||
|
return result
|
||||||
|
|
||||||
# Call progress callback
|
# Call progress callback
|
||||||
if tracker.progress_callback:
|
if tracker.progress_callback:
|
||||||
tracker.progress_callback(
|
tracker.progress_callback(
|
||||||
@@ -109,6 +126,50 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _is_non_byte_progress(self, filename: str) -> bool:
|
||||||
|
"""Check if this progress bar should be SKIPPED (returns True to skip).
|
||||||
|
|
||||||
|
We want to track byte-based progress bars. This method identifies
|
||||||
|
progress bars that count files/items instead of bytes, which would
|
||||||
|
cause crazy percentages if mixed with our byte counting.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True = SKIP this bar (it's not byte-based)
|
||||||
|
False = TRACK this bar (it counts bytes)
|
||||||
|
"""
|
||||||
|
if not filename:
|
||||||
|
return False
|
||||||
|
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
|
||||||
|
# Skip "Fetching X files" - it counts files (total=12), not bytes
|
||||||
|
# Don't skip "Downloading (incomplete total...)" - that IS byte-based
|
||||||
|
skip_patterns = [
|
||||||
|
'fetching', # "Fetching 12 files" has total=12 files, not bytes
|
||||||
|
]
|
||||||
|
return any(pattern in filename_lower for pattern in skip_patterns)
|
||||||
|
|
||||||
|
def _is_download_progress(self, filename: str) -> bool:
|
||||||
|
"""Check if this is a real file download progress bar vs internal processing."""
|
||||||
|
if not filename or filename == "unknown":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Real downloads have file extensions
|
||||||
|
download_extensions = [
|
||||||
|
'.safetensors', '.bin', '.pt', '.pth', # Model weights
|
||||||
|
'.json', '.txt', '.py', # Config files
|
||||||
|
'.msgpack', '.h5', # Other formats
|
||||||
|
]
|
||||||
|
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
has_extension = any(filename_lower.endswith(ext) for ext in download_extensions)
|
||||||
|
|
||||||
|
# Skip generation-related progress indicators
|
||||||
|
skip_patterns = ['segment', 'processing', 'generating', 'loading']
|
||||||
|
has_skip_pattern = any(pattern in filename_lower for pattern in skip_patterns)
|
||||||
|
|
||||||
|
return has_extension and not has_skip_pattern
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
with tracker._lock:
|
with tracker._lock:
|
||||||
if id(self) in tracker._active_tqdms:
|
if id(self) in tracker._active_tqdms:
|
||||||
@@ -120,13 +181,11 @@ class HFProgressTracker:
|
|||||||
@contextmanager
|
@contextmanager
|
||||||
def patch_download(self):
|
def patch_download(self):
|
||||||
"""Context manager to patch tqdm for progress tracking."""
|
"""Context manager to patch tqdm for progress tracking."""
|
||||||
print("[DEBUG HFProgressTracker] patch_download called")
|
|
||||||
try:
|
try:
|
||||||
import tqdm as tqdm_module
|
import tqdm as tqdm_module
|
||||||
|
|
||||||
# Store original tqdm class
|
# Store original tqdm class
|
||||||
self._original_tqdm_class = tqdm_module.tqdm
|
self._original_tqdm_class = tqdm_module.tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Original tqdm class: {self._original_tqdm_class}")
|
|
||||||
|
|
||||||
# Reset totals
|
# Reset totals
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -139,39 +198,89 @@ class HFProgressTracker:
|
|||||||
|
|
||||||
# Create our tracked tqdm class
|
# Create our tracked tqdm class
|
||||||
tracked_tqdm = self._create_tracked_tqdm_class()
|
tracked_tqdm = self._create_tracked_tqdm_class()
|
||||||
print(f"[DEBUG HFProgressTracker] Created TrackedTqdm class: {tracked_tqdm}")
|
|
||||||
|
|
||||||
# Patch tqdm.tqdm
|
# Patch tqdm.tqdm
|
||||||
tqdm_module.tqdm = tracked_tqdm
|
tqdm_module.tqdm = tracked_tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.tqdm")
|
|
||||||
|
|
||||||
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
# Also patch tqdm.auto.tqdm if it exists (used by huggingface_hub)
|
||||||
self._original_tqdm_auto = None
|
self._original_tqdm_auto = None
|
||||||
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
|
||||||
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
self._original_tqdm_auto = tqdm_module.auto.tqdm
|
||||||
tqdm_module.auto.tqdm = tracked_tqdm
|
tqdm_module.auto.tqdm = tracked_tqdm
|
||||||
print(f"[DEBUG HFProgressTracker] Patched tqdm.auto.tqdm")
|
|
||||||
|
|
||||||
# Patch in sys.modules to catch already-imported references
|
# Patch in sys.modules to catch already-imported references
|
||||||
|
# huggingface_hub uses: from tqdm.auto import tqdm as base_tqdm
|
||||||
|
# So we need to patch both 'tqdm' and 'base_tqdm' attributes
|
||||||
self._patched_modules = {}
|
self._patched_modules = {}
|
||||||
|
tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
|
||||||
|
|
||||||
patched_count = 0
|
patched_count = 0
|
||||||
for module_name in list(sys.modules.keys()):
|
for module_name in list(sys.modules.keys()):
|
||||||
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
if "huggingface" in module_name or module_name.startswith("tqdm"):
|
||||||
try:
|
try:
|
||||||
module = sys.modules[module_name]
|
module = sys.modules[module_name]
|
||||||
if hasattr(module, "tqdm"):
|
for attr_name in tqdm_attr_names:
|
||||||
attr = getattr(module, "tqdm")
|
if hasattr(module, attr_name):
|
||||||
# Only patch if it's the original tqdm class (not already patched)
|
attr = getattr(module, attr_name)
|
||||||
if attr is self._original_tqdm_class or (
|
# Only patch if it's a tqdm class (not already patched)
|
||||||
hasattr(attr, "__name__") and attr.__name__ == "tqdm"
|
is_tqdm_class = (
|
||||||
):
|
attr is self._original_tqdm_class or
|
||||||
self._patched_modules[module_name] = attr
|
(self._original_tqdm_auto and attr is self._original_tqdm_auto) or
|
||||||
setattr(module, "tqdm", tracked_tqdm)
|
(hasattr(attr, "__name__") and attr.__name__ == "tqdm" and
|
||||||
|
hasattr(attr, "update")) # tqdm classes have update method
|
||||||
|
)
|
||||||
|
if is_tqdm_class:
|
||||||
|
key = f"{module_name}.{attr_name}"
|
||||||
|
self._patched_modules[key] = (module, attr_name, attr)
|
||||||
|
setattr(module, attr_name, tracked_tqdm)
|
||||||
patched_count += 1
|
patched_count += 1
|
||||||
print(f"[DEBUG HFProgressTracker] Patched {module_name}.tqdm")
|
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
print(f"[DEBUG HFProgressTracker] Patched {patched_count} modules in sys.modules")
|
|
||||||
|
# ALSO monkey-patch the update method on huggingface_hub's tqdm class
|
||||||
|
# This is needed because the class was already defined at import time
|
||||||
|
self._hf_tqdm_original_update = None
|
||||||
|
try:
|
||||||
|
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||||
|
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||||
|
hf_tqdm_class = hf_tqdm_module.tqdm
|
||||||
|
self._hf_tqdm_original_update = hf_tqdm_class.update
|
||||||
|
|
||||||
|
# Create a wrapper that calls our tracking
|
||||||
|
tracker = self # Reference to HFProgressTracker instance
|
||||||
|
def patched_update(tqdm_self, n=1):
|
||||||
|
result = tracker._hf_tqdm_original_update(tqdm_self, n)
|
||||||
|
|
||||||
|
# Track this progress
|
||||||
|
with tracker._lock:
|
||||||
|
desc = getattr(tqdm_self, 'desc', '') or ''
|
||||||
|
current = getattr(tqdm_self, 'n', 0)
|
||||||
|
total = getattr(tqdm_self, 'total', 0) or 0
|
||||||
|
|
||||||
|
# Skip non-byte progress bars
|
||||||
|
if 'fetching' in desc.lower():
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Skip until we have a meaningful total (at least 1MB)
|
||||||
|
# This avoids the "100% at 0MB" issue when small config
|
||||||
|
# files are counted before the real model files
|
||||||
|
MIN_TOTAL_BYTES = 1_000_000 # 1MB
|
||||||
|
if total >= MIN_TOTAL_BYTES:
|
||||||
|
tracker._total_downloaded = current
|
||||||
|
tracker._total_size = total
|
||||||
|
|
||||||
|
if tracker.progress_callback:
|
||||||
|
tracker.progress_callback(current, total, desc)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
hf_tqdm_class.update = patched_update
|
||||||
|
patched_count += 1
|
||||||
|
print(f"[HFProgressTracker] Monkey-patched huggingface_hub.utils.tqdm.tqdm.update")
|
||||||
|
except (ImportError, AttributeError) as e:
|
||||||
|
print(f"[HFProgressTracker] Could not monkey-patch hf_tqdm: {e}")
|
||||||
|
|
||||||
|
print(f"[HFProgressTracker] Patched {patched_count} tqdm references")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
@@ -189,15 +298,24 @@ class HFProgressTracker:
|
|||||||
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
tqdm_module.auto.tqdm = self._original_tqdm_auto
|
||||||
|
|
||||||
# Restore patched modules
|
# Restore patched modules
|
||||||
for module_name, original in self._patched_modules.items():
|
for key, (module, attr_name, original) in self._patched_modules.items():
|
||||||
try:
|
try:
|
||||||
module = sys.modules.get(module_name)
|
|
||||||
if module and original:
|
if module and original:
|
||||||
setattr(module, "tqdm", original)
|
setattr(module, attr_name, original)
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
self._patched_modules = {}
|
self._patched_modules = {}
|
||||||
|
|
||||||
|
# Restore hf_tqdm's original update method
|
||||||
|
if self._hf_tqdm_original_update:
|
||||||
|
try:
|
||||||
|
from huggingface_hub.utils import tqdm as hf_tqdm_module
|
||||||
|
if hasattr(hf_tqdm_module, 'tqdm'):
|
||||||
|
hf_tqdm_module.tqdm.update = self._hf_tqdm_original_update
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
self._hf_tqdm_original_update = None
|
||||||
|
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -205,8 +323,12 @@ class HFProgressTracker:
|
|||||||
def create_hf_progress_callback(model_name: str, progress_manager):
|
def create_hf_progress_callback(model_name: str, progress_manager):
|
||||||
"""Create a progress callback for HuggingFace downloads."""
|
"""Create a progress callback for HuggingFace downloads."""
|
||||||
def callback(downloaded: int, total: int, filename: str = ""):
|
def callback(downloaded: int, total: int, filename: str = ""):
|
||||||
"""Progress callback."""
|
"""Progress callback.
|
||||||
if total > 0:
|
|
||||||
|
Note: We send updates even when total=0 (unknown) to provide feedback
|
||||||
|
during the "incomplete total" phase of huggingface_hub downloads.
|
||||||
|
The frontend handles total=0 gracefully.
|
||||||
|
"""
|
||||||
progress_manager.update_progress(
|
progress_manager.update_progress(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
current=downloaded,
|
current=downloaded,
|
||||||
|
|||||||
+42
-11
@@ -16,11 +16,17 @@ class ProgressManager:
|
|||||||
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
Thread-safe: can be called from background threads (e.g., via asyncio.to_thread).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Throttle settings to prevent overwhelming SSE clients
|
||||||
|
THROTTLE_INTERVAL_SECONDS = 0.5 # Minimum time between updates
|
||||||
|
THROTTLE_PROGRESS_DELTA = 1.0 # Minimum progress change (%) to force update
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._progress: Dict[str, Dict] = {}
|
self._progress: Dict[str, Dict] = {}
|
||||||
self._listeners: Dict[str, list] = {}
|
self._listeners: Dict[str, list] = {}
|
||||||
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
self._lock = threading.Lock() # Thread-safe lock for progress dict
|
||||||
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
self._main_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
self._last_notify_time: Dict[str, float] = {} # Last notification time per model
|
||||||
|
self._last_notify_progress: Dict[str, float] = {} # Last notified progress per model
|
||||||
|
|
||||||
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
def _set_main_loop(self, loop: asyncio.AbstractEventLoop):
|
||||||
"""Set the main event loop for thread-safe operations."""
|
"""Set the main event loop for thread-safe operations."""
|
||||||
@@ -68,6 +74,10 @@ class ProgressManager:
|
|||||||
|
|
||||||
Thread-safe: can be called from background threads.
|
Thread-safe: can be called from background threads.
|
||||||
|
|
||||||
|
Progress updates are throttled to prevent overwhelming SSE clients.
|
||||||
|
Updates are sent at most every THROTTLE_INTERVAL_SECONDS, or when
|
||||||
|
progress changes by at least THROTTLE_PROGRESS_DELTA percent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
|
||||||
current: Current bytes downloaded
|
current: Current bytes downloaded
|
||||||
@@ -76,9 +86,17 @@ class ProgressManager:
|
|||||||
status: Status string (downloading, extracting, complete, error)
|
status: Status string (downloading, extracting, complete, error)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
progress_pct = (current / total * 100) if total > 0 else 0
|
# Calculate progress percentage, clamped to 0-100 range
|
||||||
|
# This prevents crazy percentages from edge cases like:
|
||||||
|
# - current > total temporarily during aggregation
|
||||||
|
# - mixing file-count progress with byte-count progress
|
||||||
|
if total > 0:
|
||||||
|
progress_pct = min(100.0, max(0.0, (current / total * 100)))
|
||||||
|
else:
|
||||||
|
progress_pct = 0
|
||||||
|
|
||||||
progress_data = {
|
progress_data = {
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
@@ -90,25 +108,38 @@ class ProgressManager:
|
|||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"[DEBUG] update_progress called: {model_name}, {progress_pct:.1f}%")
|
# Thread-safe update of progress dict (always update internal state)
|
||||||
|
|
||||||
# Thread-safe update of progress dict
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._progress[model_name] = progress_data
|
self._progress[model_name] = progress_data
|
||||||
|
|
||||||
|
# Check if we should notify listeners (throttling)
|
||||||
|
current_time = time.time()
|
||||||
|
last_time = self._last_notify_time.get(model_name, 0)
|
||||||
|
last_progress = self._last_notify_progress.get(model_name, -100)
|
||||||
|
|
||||||
|
time_delta = current_time - last_time
|
||||||
|
progress_delta = abs(progress_pct - last_progress)
|
||||||
|
|
||||||
|
# Always notify for complete/error status, or if throttle conditions are met
|
||||||
|
should_notify = (
|
||||||
|
status in ("complete", "error") or
|
||||||
|
time_delta >= self.THROTTLE_INTERVAL_SECONDS or
|
||||||
|
progress_delta >= self.THROTTLE_PROGRESS_DELTA
|
||||||
|
)
|
||||||
|
|
||||||
|
if not should_notify:
|
||||||
|
return # Skip this update (throttled)
|
||||||
|
|
||||||
|
# Update throttle tracking
|
||||||
|
self._last_notify_time[model_name] = current_time
|
||||||
|
self._last_notify_progress[model_name] = progress_pct
|
||||||
|
|
||||||
# Notify all listeners (thread-safe)
|
# Notify all listeners (thread-safe)
|
||||||
listener_count = len(self._listeners.get(model_name, []))
|
listener_count = len(self._listeners.get(model_name, []))
|
||||||
print(f"[DEBUG] Listener count for {model_name}: {listener_count}")
|
|
||||||
print(f"[DEBUG] All listeners: {list(self._listeners.keys())}")
|
|
||||||
print(f"[DEBUG] Main loop set: {self._main_loop is not None}")
|
|
||||||
if self._main_loop:
|
|
||||||
print(f"[DEBUG] Main loop running: {self._main_loop.is_running()}")
|
|
||||||
|
|
||||||
if listener_count > 0:
|
if listener_count > 0:
|
||||||
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
logger.debug(f"Notifying {listener_count} listeners for {model_name}: {progress_pct:.1f}% ({filename})")
|
||||||
print(f"[DEBUG] About to notify listeners...")
|
|
||||||
self._notify_listeners_threadsafe(model_name, progress_data)
|
self._notify_listeners_threadsafe(model_name, progress_data)
|
||||||
print(f"[DEBUG] Notified listeners")
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
from PyInstaller.utils.hooks import collect_data_files
|
||||||
|
from PyInstaller.utils.hooks import collect_submodules
|
||||||
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
|
||||||
|
datas = []
|
||||||
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern']
|
||||||
|
datas += collect_data_files('qwen_tts')
|
||||||
|
datas += copy_metadata('qwen-tts')
|
||||||
|
hiddenimports += collect_submodules('qwen_tts')
|
||||||
|
hiddenimports += collect_submodules('jaraco')
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['server.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=datas,
|
||||||
|
hiddenimports=hiddenimports,
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='voicebox-server-cuda',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
@@ -4,15 +4,11 @@ from PyInstaller.utils.hooks import collect_submodules
|
|||||||
from PyInstaller.utils.hooks import copy_metadata
|
from PyInstaller.utils.hooks import copy_metadata
|
||||||
|
|
||||||
datas = []
|
datas = []
|
||||||
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern', 'backend.backends.mlx_backend', 'mlx', 'mlx.core', 'mlx.nn', 'mlx_audio', 'mlx_audio.tts', 'mlx_audio.stt']
|
hiddenimports = ['backend', 'backend.main', 'backend.config', 'backend.database', 'backend.models', 'backend.profiles', 'backend.history', 'backend.tts', 'backend.transcribe', 'backend.platform_detect', 'backend.backends', 'backend.backends.pytorch_backend', 'backend.utils.audio', 'backend.utils.cache', 'backend.utils.progress', 'backend.utils.hf_progress', 'backend.utils.validation', 'torch', 'transformers', 'fastapi', 'uvicorn', 'sqlalchemy', 'librosa', 'soundfile', 'qwen_tts', 'qwen_tts.inference', 'qwen_tts.inference.qwen3_tts_model', 'qwen_tts.inference.qwen3_tts_tokenizer', 'qwen_tts.core', 'qwen_tts.cli', 'pkg_resources.extern']
|
||||||
datas += collect_data_files('qwen_tts')
|
datas += collect_data_files('qwen_tts')
|
||||||
datas += collect_data_files('mlx')
|
|
||||||
datas += collect_data_files('mlx_audio')
|
|
||||||
datas += copy_metadata('qwen-tts')
|
datas += copy_metadata('qwen-tts')
|
||||||
hiddenimports += collect_submodules('qwen_tts')
|
hiddenimports += collect_submodules('qwen_tts')
|
||||||
hiddenimports += collect_submodules('jaraco')
|
hiddenimports += collect_submodules('jaraco')
|
||||||
hiddenimports += collect_submodules('mlx')
|
|
||||||
hiddenimports += collect_submodules('mlx_audio')
|
|
||||||
|
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"name": "@voicebox/app",
|
"name": "@voicebox/app",
|
||||||
"version": "0.1.9",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.9",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -93,10 +93,14 @@
|
|||||||
},
|
},
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"version": "0.1.9",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
@@ -112,7 +116,7 @@
|
|||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"version": "0.1.9",
|
"version": "0.1.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,620 @@
|
|||||||
|
# CUDA Distribution Problem - Complete Analysis
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
1. [Problem Overview](#problem-overview)
|
||||||
|
2. [Root Cause](#root-cause)
|
||||||
|
3. [Attempted Solutions](#attempted-solutions)
|
||||||
|
4. [Current Status](#current-status)
|
||||||
|
5. [Available Options](#available-options)
|
||||||
|
6. [Technical Details](#technical-details)
|
||||||
|
7. [Cost Analysis](#cost-analysis)
|
||||||
|
8. [Recommendations](#recommendations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Overview
|
||||||
|
|
||||||
|
### Timeline of Issues
|
||||||
|
|
||||||
|
**Original Problem (v0.1.0 - v0.1.11)**
|
||||||
|
- Single server binary with CUDA support
|
||||||
|
- Size: ~2.9GB
|
||||||
|
- Issue: MSI installer build fails in GitHub Actions CI
|
||||||
|
- Error: WiX Toolset cannot handle 3GB files efficiently
|
||||||
|
|
||||||
|
**First Solution: Dual Binary System (v0.1.12)**
|
||||||
|
- Split into CPU (295MB) and CUDA (2.37GB) binaries
|
||||||
|
- CPU ships with installer
|
||||||
|
- CUDA as optional download
|
||||||
|
- Issue: GitHub Release assets have 2GB limit
|
||||||
|
|
||||||
|
**Current Problem (Discovered during implementation)**
|
||||||
|
- GitHub Release Asset Limit: **2GB hard maximum**
|
||||||
|
- CUDA binary: **2.37GB** (370MB over limit)
|
||||||
|
- Cannot upload to GitHub Releases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
### Why Is The CUDA Binary So Large?
|
||||||
|
|
||||||
|
The size difference between CPU and CUDA builds:
|
||||||
|
|
||||||
|
| Component | CPU Build | CUDA Build | Difference |
|
||||||
|
|-----------|-----------|------------|------------|
|
||||||
|
| PyTorch Core | ~150MB | ~150MB | - |
|
||||||
|
| CPU Libraries (MKL/OpenBLAS) | ~100MB | - | -100MB |
|
||||||
|
| CUDA Runtime | - | ~500MB | +500MB |
|
||||||
|
| cuBLAS | - | ~350MB | +350MB |
|
||||||
|
| cuDNN | - | ~1.2GB | +1.2GB |
|
||||||
|
| NVRTC (CUDA Compiler) | - | ~90MB | +90MB |
|
||||||
|
| Other CUDA libs | - | ~100MB | +100MB |
|
||||||
|
| **Total** | **~295MB** | **~2.37GB** | **+2.07GB** |
|
||||||
|
|
||||||
|
### CUDA Dependencies Breakdown
|
||||||
|
|
||||||
|
```
|
||||||
|
torch/lib/ (CUDA build):
|
||||||
|
├── cudart64_12.dll (~0.5 MB) - CUDA Runtime
|
||||||
|
├── cublas64_12.dll (~100 MB) - Basic Linear Algebra
|
||||||
|
├── cublasLt64_12.dll (~200 MB) - Linear Algebra (optimized)
|
||||||
|
├── cudnn64_9.dll (~800 MB) - Deep Neural Networks
|
||||||
|
├── cudnn_*_infer64_9.dll (~400 MB) - DNN Inference ops
|
||||||
|
├── nvrtc64_*.dll (~50 MB) - Runtime Compiler
|
||||||
|
├── nvrtc-builtins64_*.dll (~40 MB) - Compiler builtins
|
||||||
|
├── torch_cuda.dll (~200 MB) - PyTorch CUDA bridge
|
||||||
|
└── c10_cuda.dll (~20 MB) - Core CUDA utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why These Are Required:**
|
||||||
|
- cuDNN is essential for neural network operations
|
||||||
|
- cuBLAS handles all matrix operations (core of ML)
|
||||||
|
- Cannot split or remove without breaking functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attempted Solutions
|
||||||
|
|
||||||
|
### Solution 1: Dual Binary System ✅ (Partially Successful)
|
||||||
|
|
||||||
|
**Goal**: Split CPU and CUDA into separate downloads
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```bash
|
||||||
|
# Build CPU-only (295MB)
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||||
|
python build_binary.py cpu
|
||||||
|
|
||||||
|
# Build CUDA (2.37GB)
|
||||||
|
pip install torch --index-url https://download.pytorch.org/whl/cu121
|
||||||
|
python build_binary.py cuda
|
||||||
|
```
|
||||||
|
|
||||||
|
**Results**:
|
||||||
|
- ✅ CPU binary: 295MB (fits in installer)
|
||||||
|
- ✅ CI builds successfully
|
||||||
|
- ✅ Installer size reduced from 3GB to ~500MB
|
||||||
|
- ❌ CUDA binary still too large for GitHub
|
||||||
|
|
||||||
|
**See**: `docs/dual-server-binaries.md`
|
||||||
|
|
||||||
|
### Solution 2: Compression Testing ❌ (Failed)
|
||||||
|
|
||||||
|
**Goal**: Compress CUDA binary to fit under 2GB
|
||||||
|
|
||||||
|
**Method**: 7z with maximum compression settings
|
||||||
|
```bash
|
||||||
|
7z a -t7z -m0=lzma2 -mx=9 -mfb=64 -md=32m -ms=on \
|
||||||
|
voicebox-server-cuda.7z voicebox-server-cuda.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Results**:
|
||||||
|
```
|
||||||
|
Original: 2.37 GB (2,545,086,396 bytes)
|
||||||
|
Compressed: 2.35 GB (2,519,381,264 bytes)
|
||||||
|
Compression: 1.0% (only 24.5MB saved)
|
||||||
|
GitHub Limit: 2.00 GB (2,147,483,648 bytes)
|
||||||
|
Over by: 354.67 MB
|
||||||
|
|
||||||
|
Status: FAILED - Still exceeds limit by 354MB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Compression Failed**:
|
||||||
|
- CUDA binaries are already optimized machine code
|
||||||
|
- No redundant data to compress
|
||||||
|
- Neural network kernels are highly compact
|
||||||
|
- Libraries are already stripped of debug symbols
|
||||||
|
|
||||||
|
**Conclusion**: Compression is not viable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
### What Works
|
||||||
|
- ✅ CPU binary builds successfully (295MB)
|
||||||
|
- ✅ CUDA binary builds successfully (2.37GB)
|
||||||
|
- ✅ Build scripts for both variants
|
||||||
|
- ✅ CI workflow updated for dual binaries
|
||||||
|
- ✅ Installer can be created with CPU binary
|
||||||
|
|
||||||
|
### What Doesn't Work
|
||||||
|
- ❌ Cannot upload CUDA binary to GitHub Releases (exceeds 2GB limit)
|
||||||
|
- ❌ Compression doesn't reduce size enough
|
||||||
|
- ❌ No automated distribution path for CUDA binary
|
||||||
|
|
||||||
|
### Branch Status
|
||||||
|
- Branch: `feat/dual-server-binaries`
|
||||||
|
- Commits: Implementation complete
|
||||||
|
- Testing: Local builds successful
|
||||||
|
- Blocker: CUDA distribution path
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Options
|
||||||
|
|
||||||
|
### Option 1: AWS S3 Hosting (Recommended)
|
||||||
|
|
||||||
|
**Description**: Host CUDA binary in Amazon S3 bucket
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ No file size limits (can handle multi-GB files)
|
||||||
|
- ✅ Fast global CDN (CloudFront)
|
||||||
|
- ✅ Reliable (99.99% uptime)
|
||||||
|
- ✅ Pay only for usage
|
||||||
|
- ✅ Easy CI integration
|
||||||
|
- ✅ Version control (keep multiple releases)
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Requires AWS account
|
||||||
|
- ❌ Monthly costs (~$1-5/month)
|
||||||
|
- ❌ Additional infrastructure to manage
|
||||||
|
|
||||||
|
**Cost Estimate**:
|
||||||
|
```
|
||||||
|
Storage: 2.37 GB × $0.023/GB = $0.05/month
|
||||||
|
Transfer: 100 downloads × 2.37GB × $0.09/GB = $21.33/month
|
||||||
|
Total: ~$21-25/month for 100 downloads
|
||||||
|
~$2-5/month for 10-20 downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/release.yml
|
||||||
|
- name: Upload CUDA to S3
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
run: |
|
||||||
|
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
|
||||||
|
s3://voicebox-releases/cuda/${{ github.ref_name }}/ \
|
||||||
|
--acl public-read
|
||||||
|
|
||||||
|
# Generate download URL
|
||||||
|
echo "CUDA_URL=https://voicebox-releases.s3.amazonaws.com/cuda/${{ github.ref_name }}/voicebox-server-cuda-x86_64-pc-windows-msvc.exe" >> release_notes.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**User Experience**:
|
||||||
|
1. Install app normally (500MB installer)
|
||||||
|
2. App detects NVIDIA GPU
|
||||||
|
3. Shows: "Download CUDA support? (2.4GB)"
|
||||||
|
4. Downloads from S3: `https://voicebox-releases.s3.amazonaws.com/cuda/v0.1.12/voicebox-server-cuda.exe`
|
||||||
|
5. Saves to `%APPDATA%/voicebox/binaries/`
|
||||||
|
6. App restarts with CUDA server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 2: Azure Blob Storage
|
||||||
|
|
||||||
|
**Description**: Microsoft Azure alternative to S3
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ Similar to S3 (no size limits, CDN, reliable)
|
||||||
|
- ✅ Good if already using Azure
|
||||||
|
- ✅ Competitive pricing
|
||||||
|
- ✅ Global CDN with Azure CDN
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Requires Azure account
|
||||||
|
- ❌ Similar monthly costs
|
||||||
|
- ❌ Less common in open source projects
|
||||||
|
|
||||||
|
**Cost Estimate**:
|
||||||
|
```
|
||||||
|
Storage: $0.018/GB = $0.04/month
|
||||||
|
Transfer: ~$20-25/month for 100 downloads
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```yaml
|
||||||
|
- name: Upload to Azure Blob
|
||||||
|
env:
|
||||||
|
AZURE_STORAGE_CONNECTION_STRING: ${{ secrets.AZURE_STORAGE }}
|
||||||
|
run: |
|
||||||
|
az storage blob upload \
|
||||||
|
--account-name voiceboxreleases \
|
||||||
|
--container-name cuda-binaries \
|
||||||
|
--name v${{ github.ref_name }}/voicebox-server-cuda.exe \
|
||||||
|
--file backend/cuda-release/voicebox-server-cuda-*.exe \
|
||||||
|
--tier Hot
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 3: Cloudflare R2
|
||||||
|
|
||||||
|
**Description**: Cloudflare's S3-compatible object storage
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ S3-compatible API
|
||||||
|
- ✅ **FREE egress (no bandwidth charges!)**
|
||||||
|
- ✅ Cheaper than S3/Azure
|
||||||
|
- ✅ Cloudflare CDN included
|
||||||
|
- ✅ Good for open source projects
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Requires Cloudflare account
|
||||||
|
- ❌ Newer service (less mature than S3)
|
||||||
|
|
||||||
|
**Cost Estimate**:
|
||||||
|
```
|
||||||
|
Storage: $0.015/GB = $0.04/month
|
||||||
|
Egress: $0.00 (FREE!)
|
||||||
|
Class A ops: Negligible
|
||||||
|
Total: ~$0.04/month (essentially free!)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why This Is Attractive**:
|
||||||
|
- Zero bandwidth costs (huge savings)
|
||||||
|
- Perfect for open source distribution
|
||||||
|
- S3-compatible (easy migration if needed)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
Same as S3 (R2 is S3-compatible):
|
||||||
|
```yaml
|
||||||
|
- name: Upload to R2
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_ENDPOINT_URL: https://<account-id>.r2.cloudflarestorage.com
|
||||||
|
run: |
|
||||||
|
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
|
||||||
|
s3://voicebox-releases/cuda/${{ github.ref_name }}/ \
|
||||||
|
--endpoint-url=$AWS_ENDPOINT_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 4: GitHub Packages (Container Registry)
|
||||||
|
|
||||||
|
**Description**: Package CUDA binary as OCI/Docker artifact
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ Stays in GitHub ecosystem
|
||||||
|
- ✅ No additional accounts needed
|
||||||
|
- ✅ Free for public repos
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Complex for desktop app distribution
|
||||||
|
- ❌ Users need to extract from container
|
||||||
|
- ❌ Awkward UX (not designed for binary distribution)
|
||||||
|
- ❌ Requires Docker understanding
|
||||||
|
|
||||||
|
**Not Recommended**: Containers aren't designed for desktop app binaries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 5: Self-Hosted Server
|
||||||
|
|
||||||
|
**Description**: Host on your own VPS/server
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ Full control
|
||||||
|
- ✅ No cloud provider dependency
|
||||||
|
- ✅ Predictable costs
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Requires server maintenance
|
||||||
|
- ❌ Bandwidth costs can be high
|
||||||
|
- ❌ Uptime responsibility
|
||||||
|
- ❌ Scaling challenges
|
||||||
|
|
||||||
|
**Cost Estimate**:
|
||||||
|
```
|
||||||
|
VPS: $5-20/month (DigitalOcean, Linode)
|
||||||
|
Bandwidth: $0.01-0.02/GB
|
||||||
|
Total: $10-50/month depending on traffic
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 6: Manual Distribution
|
||||||
|
|
||||||
|
**Description**: Don't automate - provide manual download instructions
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ Zero cost
|
||||||
|
- ✅ Zero infrastructure
|
||||||
|
- ✅ Simple
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Poor user experience
|
||||||
|
- ❌ Manual upload to file host each release
|
||||||
|
- ❌ Users must manually download and install
|
||||||
|
- ❌ No automatic updates for CUDA binary
|
||||||
|
- ❌ Increases support burden
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```
|
||||||
|
Release notes:
|
||||||
|
"Windows users with NVIDIA GPUs can download CUDA support:
|
||||||
|
1. Download voicebox-server-cuda.exe from [Google Drive/Mega/etc]
|
||||||
|
2. Place in C:\Users\<YourName>\AppData\Roaming\voicebox\binaries\
|
||||||
|
3. Restart the app"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Not Recommended**: Creates friction, support issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option 7: Split CUDA Binary
|
||||||
|
|
||||||
|
**Description**: Break CUDA binary into multiple <2GB chunks
|
||||||
|
|
||||||
|
**Technical Approach**:
|
||||||
|
```python
|
||||||
|
# Split binary
|
||||||
|
split -b 2000M voicebox-server-cuda.exe cuda_part_
|
||||||
|
|
||||||
|
# Upload parts to GitHub (each <2GB)
|
||||||
|
cuda_part_aa (2.0 GB)
|
||||||
|
cuda_part_ab (0.37 GB)
|
||||||
|
|
||||||
|
# App downloads and reassembles
|
||||||
|
cat cuda_part_* > voicebox-server-cuda.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**:
|
||||||
|
- ✅ Stays on GitHub
|
||||||
|
- ✅ No external hosting
|
||||||
|
|
||||||
|
**Cons**:
|
||||||
|
- ❌ Complex download logic (multiple files)
|
||||||
|
- ❌ Integrity checking required
|
||||||
|
- ❌ More points of failure
|
||||||
|
- ❌ Users must wait for multiple downloads
|
||||||
|
- ❌ Still hacky solution
|
||||||
|
|
||||||
|
**Complexity**: Medium-High
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Current Build Output
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/dist/
|
||||||
|
├── voicebox-server.exe 295 MB (CPU-only)
|
||||||
|
└── voicebox-server-cuda.exe 2.37 GB (CUDA)
|
||||||
|
|
||||||
|
# After compression test:
|
||||||
|
backend/dist/
|
||||||
|
└── voicebox-server-cuda.7z 2.35 GB (not viable)
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI Workflow Changes Required
|
||||||
|
|
||||||
|
For external hosting (S3/R2/Azure):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Current workflow (fails)
|
||||||
|
- name: Upload CUDA server binary (Windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: backend/cuda-release/voicebox-server-cuda-*.exe # ❌ Fails: >2GB
|
||||||
|
draft: true
|
||||||
|
|
||||||
|
# New workflow (S3 example)
|
||||||
|
- name: Upload CUDA to S3 (Windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||||
|
run: |
|
||||||
|
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
|
||||||
|
s3://voicebox-releases/cuda/${{ github.ref_name }}/ \
|
||||||
|
--acl public-read
|
||||||
|
|
||||||
|
# Generate release notes with download URL
|
||||||
|
cat >> release_notes.md <<EOF
|
||||||
|
|
||||||
|
### GPU Acceleration (Windows)
|
||||||
|
Download CUDA support for NVIDIA GPUs:
|
||||||
|
[voicebox-server-cuda.exe](https://voicebox-releases.s3.amazonaws.com/cuda/${{ github.ref_name }}/voicebox-server-cuda-x86_64-pc-windows-msvc.exe)
|
||||||
|
Size: 2.37 GB
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### App Changes Required
|
||||||
|
|
||||||
|
**Frontend (Tauri)**: Download manager
|
||||||
|
```typescript
|
||||||
|
// src/lib/cuda-downloader.ts
|
||||||
|
const CUDA_DOWNLOAD_URL =
|
||||||
|
"https://voicebox-releases.s3.amazonaws.com/cuda/v{VERSION}/voicebox-server-cuda.exe";
|
||||||
|
|
||||||
|
async function downloadCudaBinary(version: string) {
|
||||||
|
const url = CUDA_DOWNLOAD_URL.replace("{VERSION}", version);
|
||||||
|
const savePath = path.join(app.getPath("userData"), "binaries", "voicebox-server-cuda.exe");
|
||||||
|
|
||||||
|
// Download with progress
|
||||||
|
await downloadFile(url, savePath, (progress) => {
|
||||||
|
// Update UI: "Downloading CUDA support: 45% (1.2GB / 2.4GB)"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify checksum
|
||||||
|
const checksum = await calculateChecksum(savePath);
|
||||||
|
if (checksum !== EXPECTED_CHECKSUM) {
|
||||||
|
throw new Error("Download corrupted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend**: Already supports both binaries (no changes needed)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cost Analysis
|
||||||
|
|
||||||
|
### Monthly Cost Comparison (100 downloads/month)
|
||||||
|
|
||||||
|
| Option | Storage | Bandwidth | Total/Month | Notes |
|
||||||
|
|--------|---------|-----------|-------------|-------|
|
||||||
|
| **Cloudflare R2** | $0.04 | $0.00 | **$0.04** | Best for open source |
|
||||||
|
| AWS S3 | $0.05 | $21.33 | $21.38 | Good reliability |
|
||||||
|
| Azure Blob | $0.04 | $20.00 | $20.04 | Azure ecosystem |
|
||||||
|
| Self-hosted VPS | $10.00 | $2.37 | $12.37 | Maintenance overhead |
|
||||||
|
| Manual | $0.00 | $0.00 | $0.00 | Poor UX |
|
||||||
|
|
||||||
|
### Annual Cost Comparison
|
||||||
|
|
||||||
|
| Option | Year 1 | Year 2+ | Notes |
|
||||||
|
|--------|--------|---------|-------|
|
||||||
|
| **Cloudflare R2** | **$0.50** | **$0.50** | Essentially free |
|
||||||
|
| AWS S3 | $256 | $256 | Predictable |
|
||||||
|
| Self-hosted | $144 | $144 | Time cost |
|
||||||
|
|
||||||
|
**Recommendation**: Cloudflare R2 (free egress = huge savings)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Recommended Solution: Cloudflare R2
|
||||||
|
|
||||||
|
**Why**:
|
||||||
|
1. **Cost**: Essentially free (~$0.04/month)
|
||||||
|
2. **Bandwidth**: Zero egress charges (unlimited downloads)
|
||||||
|
3. **CDN**: Cloudflare's global network included
|
||||||
|
4. **Compatibility**: S3-compatible API (easy to use)
|
||||||
|
5. **Perfect for open source**: No surprise bandwidth bills
|
||||||
|
|
||||||
|
### Implementation Priority
|
||||||
|
|
||||||
|
**Phase 1: Setup (1-2 hours)**
|
||||||
|
1. Create Cloudflare R2 account
|
||||||
|
2. Create bucket: `voicebox-releases`
|
||||||
|
3. Generate API credentials
|
||||||
|
4. Add to GitHub Secrets
|
||||||
|
|
||||||
|
**Phase 2: CI Integration (1-2 hours)**
|
||||||
|
1. Update `.github/workflows/release.yml`
|
||||||
|
2. Add R2 upload step
|
||||||
|
3. Generate release notes with download URL
|
||||||
|
4. Test with draft release
|
||||||
|
|
||||||
|
**Phase 3: App Integration (4-6 hours)**
|
||||||
|
1. Add GPU detection on startup
|
||||||
|
2. Implement download manager UI
|
||||||
|
3. Add progress indicators
|
||||||
|
4. Implement checksum verification
|
||||||
|
5. Server restart logic
|
||||||
|
|
||||||
|
**Phase 4: Documentation (1 hour)**
|
||||||
|
1. Update README with GPU instructions
|
||||||
|
2. Add troubleshooting guide
|
||||||
|
3. Document manual download process
|
||||||
|
|
||||||
|
**Total Time**: ~8-12 hours of development
|
||||||
|
|
||||||
|
### Alternative: AWS S3 (If Already Using AWS)
|
||||||
|
|
||||||
|
If you're already using AWS for other infrastructure, S3 is also a solid choice:
|
||||||
|
- More mature than R2
|
||||||
|
- Extensive documentation
|
||||||
|
- Familiar tooling
|
||||||
|
- ~$20/month for moderate usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Expected Download Volume**: How many CUDA downloads per month?
|
||||||
|
- Affects cost calculations
|
||||||
|
- Determines if R2's free egress is significant
|
||||||
|
|
||||||
|
2. **Update Strategy**: How to handle CUDA updates?
|
||||||
|
- Option A: Version in URL path (keep all versions)
|
||||||
|
- Option B: Overwrite latest (save space)
|
||||||
|
|
||||||
|
3. **Fallback Strategy**: What if cloud provider is down?
|
||||||
|
- Mirror on multiple providers?
|
||||||
|
- Graceful degradation to CPU?
|
||||||
|
|
||||||
|
4. **Telemetry**: Track CUDA download stats?
|
||||||
|
- Helps with cost forecasting
|
||||||
|
- User behavior insights
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Research Phase** (You are here)
|
||||||
|
- Evaluate cloud providers
|
||||||
|
- Check terms of service
|
||||||
|
- Test account creation
|
||||||
|
|
||||||
|
2. **Decision Phase**
|
||||||
|
- Choose provider (Cloudflare R2 recommended)
|
||||||
|
- Set up account
|
||||||
|
- Configure billing alerts
|
||||||
|
|
||||||
|
3. **Implementation Phase**
|
||||||
|
- Update CI workflow
|
||||||
|
- Implement download manager
|
||||||
|
- Test end-to-end flow
|
||||||
|
|
||||||
|
4. **Launch Phase**
|
||||||
|
- Deploy to production
|
||||||
|
- Monitor downloads
|
||||||
|
- Gather user feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **GitHub Release Limits**: https://docs.github.com/en/repositories/releasing-projects-on-github/about-releases
|
||||||
|
- **Cloudflare R2 Pricing**: https://developers.cloudflare.com/r2/pricing/
|
||||||
|
- **AWS S3 Pricing**: https://aws.amazon.com/s3/pricing/
|
||||||
|
- **Compression Test Results**: `backend/test_cuda_compression.py`
|
||||||
|
- **Dual Binary Implementation**: `docs/dual-server-binaries.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Alternative Approaches Considered
|
||||||
|
|
||||||
|
### A. Dynamic CUDA Loading
|
||||||
|
**Idea**: Load CUDA DLLs dynamically at runtime
|
||||||
|
**Why Not**: PyTorch requires CUDA DLLs at import time, can't lazy-load
|
||||||
|
|
||||||
|
### B. CUDA as Separate Package
|
||||||
|
**Idea**: Python package with just CUDA libs
|
||||||
|
**Why Not**: Still 2GB+, same problem
|
||||||
|
|
||||||
|
### C. Model Quantization
|
||||||
|
**Idea**: Use smaller quantized models
|
||||||
|
**Why Not**: Doesn't reduce CUDA runtime size
|
||||||
|
|
||||||
|
### D. Docker Distribution
|
||||||
|
**Idea**: Distribute as Docker container
|
||||||
|
**Why Not**: Poor fit for desktop app, requires Docker installed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version**: 1.0
|
||||||
|
**Last Updated**: 2026-01-31
|
||||||
|
**Status**: Research Phase
|
||||||
|
**Next Review**: After cloud provider decision
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Dual Server Binary System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Voicebox now uses a dual-binary approach to manage the size difference between CPU-only and CUDA-enabled builds:
|
||||||
|
|
||||||
|
- **CPU Binary** (~500MB): Ships with the installer by default
|
||||||
|
- **CUDA Binary** (~3GB): Downloaded on-demand for GPU users
|
||||||
|
|
||||||
|
## Problem Solved
|
||||||
|
|
||||||
|
Previously, bundling PyTorch with CUDA support created a 3GB server binary, which:
|
||||||
|
- Made the installer too large (failed CI builds with WiX)
|
||||||
|
- Forced all users to download CUDA libraries even without NVIDIA GPUs
|
||||||
|
- Created poor user experience
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Build Process
|
||||||
|
|
||||||
|
**Two separate binaries are built:**
|
||||||
|
|
||||||
|
1. **voicebox-server.exe** (CPU)
|
||||||
|
- Built with: `pip install torch --index-url https://download.pytorch.org/whl/cpu`
|
||||||
|
- Size: ~500MB
|
||||||
|
- Works on all Windows machines
|
||||||
|
- Included in the installer by default
|
||||||
|
|
||||||
|
2. **voicebox-server-cuda.exe** (CUDA)
|
||||||
|
- Built with: `pip install torch --index-url https://download.pytorch.org/whl/cu121`
|
||||||
|
- Size: ~3GB
|
||||||
|
- Requires NVIDIA GPU + drivers
|
||||||
|
- Uploaded as separate GitHub Release asset
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
|
||||||
|
**First Launch:**
|
||||||
|
1. User installs app (~500MB download)
|
||||||
|
2. App starts with CPU server
|
||||||
|
3. If NVIDIA GPU detected:
|
||||||
|
- Show notification: "Download CUDA support for 4-5x faster inference?"
|
||||||
|
- User clicks "Download"
|
||||||
|
- Download voicebox-server-cuda.exe from GitHub (~3GB)
|
||||||
|
- Save to `%APPDATA%/voicebox/binaries/`
|
||||||
|
- Restart server with CUDA version
|
||||||
|
|
||||||
|
**Settings Panel:**
|
||||||
|
- Toggle between CPU/CUDA modes
|
||||||
|
- Download CUDA if not already installed
|
||||||
|
- Show current inference backend
|
||||||
|
|
||||||
|
### Build Scripts
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Build CPU only
|
||||||
|
build_cpu.bat
|
||||||
|
|
||||||
|
# Build CUDA only
|
||||||
|
build_cuda.bat
|
||||||
|
|
||||||
|
# Build both
|
||||||
|
build_both.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unix (macOS/Linux):**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Build CPU only
|
||||||
|
./build_cpu.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD Workflow
|
||||||
|
|
||||||
|
**GitHub Actions (.github/workflows/release.yml):**
|
||||||
|
|
||||||
|
1. Install CPU PyTorch
|
||||||
|
2. Build CPU server → Copy to Tauri binaries
|
||||||
|
3. Install CUDA PyTorch
|
||||||
|
4. Build CUDA server → Save for upload
|
||||||
|
5. Build Tauri app (bundles CPU server)
|
||||||
|
6. Upload CUDA server as separate release asset
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Release Assets:
|
||||||
|
├── Voicebox_0.1.12_x64_en-US.msi (~500MB - includes CPU server)
|
||||||
|
├── voicebox-server-cuda-x86_64-pc-windows-msvc.exe (~3GB - optional download)
|
||||||
|
└── latest.json (updater manifest)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
1. **backend/build_binary.py**
|
||||||
|
- Added `variant` parameter ('cpu' or 'cuda')
|
||||||
|
- Outputs different binary names based on variant
|
||||||
|
|
||||||
|
2. **backend/build_cpu.bat** (new)
|
||||||
|
- Installs CPU PyTorch
|
||||||
|
- Builds CPU binary
|
||||||
|
- Restores CUDA PyTorch for dev
|
||||||
|
|
||||||
|
3. **backend/build_cuda.bat** (new)
|
||||||
|
- Ensures CUDA PyTorch is installed
|
||||||
|
- Builds CUDA binary
|
||||||
|
|
||||||
|
4. **.github/workflows/release.yml**
|
||||||
|
- Build CPU binary first (for installer)
|
||||||
|
- Build CUDA binary second (for upload)
|
||||||
|
- Upload CUDA binary as additional release asset
|
||||||
|
- Updated release notes to explain GPU acceleration
|
||||||
|
|
||||||
|
### Future Frontend Work
|
||||||
|
|
||||||
|
**TODO: Implement CUDA download in the app**
|
||||||
|
|
||||||
|
Location: `tauri/src/`
|
||||||
|
|
||||||
|
Features needed:
|
||||||
|
1. GPU detection on startup
|
||||||
|
2. Download manager for CUDA binary
|
||||||
|
3. Server binary path switcher
|
||||||
|
4. Settings UI for CPU/CUDA toggle
|
||||||
|
5. Progress indicator for 3GB download
|
||||||
|
|
||||||
|
API endpoints needed (already exist):
|
||||||
|
- `/health` - Shows GPU availability
|
||||||
|
- Server restart mechanism
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
✓ **Smaller installer**: ~500MB instead of 3GB
|
||||||
|
✓ **Faster CI builds**: WiX can handle 500MB easily
|
||||||
|
✓ **User choice**: CPU users don't download unnecessary files
|
||||||
|
✓ **Better UX**: Optional performance upgrade for GPU users
|
||||||
|
✓ **Cost savings**: Reduced bandwidth for users without GPUs
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Test CPU build:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python build_binary.py cpu
|
||||||
|
./dist/voicebox-server.exe --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test CUDA build:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python build_binary.py cuda
|
||||||
|
./dist/voicebox-server-cuda.exe --version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify size:**
|
||||||
|
```bash
|
||||||
|
ls -lh backend/dist/
|
||||||
|
# Should see:
|
||||||
|
# voicebox-server.exe ~500MB
|
||||||
|
# voicebox-server-cuda.exe ~3GB
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test server startup:**
|
||||||
|
```bash
|
||||||
|
# CPU version
|
||||||
|
./backend/dist/voicebox-server.exe
|
||||||
|
# Check logs: Should show CPU inference
|
||||||
|
|
||||||
|
# CUDA version (requires NVIDIA GPU)
|
||||||
|
./backend/dist/voicebox-server-cuda.exe
|
||||||
|
# Check logs: Should show CUDA inference
|
||||||
|
```
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# GitHub 2GB Release Asset Limit Issue
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The CUDA server binary upload fails in CI with:
|
||||||
|
```
|
||||||
|
Error: File size (2543828017) is greater than 2 GiB
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub release assets have a hard limit of 2GB per file. Our CUDA binary is ~2.5GB, which exceeds this limit.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
The dual-server binary system (see `dual-server-binaries.md`) creates two binaries:
|
||||||
|
- **CPU binary**: ~500MB ✅ Works fine
|
||||||
|
- **CUDA binary**: ~2.5GB ❌ Exceeds GitHub limit
|
||||||
|
|
||||||
|
## Attempted Solution: Compression
|
||||||
|
|
||||||
|
We're testing 7z compression with maximum settings to see if we can squeeze the CUDA binary under 2GB.
|
||||||
|
|
||||||
|
### Test Script
|
||||||
|
|
||||||
|
Run `backend/test_cuda_compression.py` to test compression locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python test_cuda_compression.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
1. Find the CUDA binary in `dist/`
|
||||||
|
2. Compress it with 7z (maximum compression)
|
||||||
|
3. Report if the compressed size fits under 2GB
|
||||||
|
|
||||||
|
### Expected Compression
|
||||||
|
|
||||||
|
PyTorch CUDA binaries typically compress well since they contain:
|
||||||
|
- Repeated patterns in neural network weights
|
||||||
|
- Debug symbols and metadata
|
||||||
|
- Redundant CUDA libraries
|
||||||
|
|
||||||
|
Estimated compression: 30-40% reduction
|
||||||
|
- Original: ~2.5GB
|
||||||
|
- Target: <2GB
|
||||||
|
- Required compression: >20%
|
||||||
|
|
||||||
|
## Fallback: External Hosting
|
||||||
|
|
||||||
|
If compression doesn't work, we'll need to host the CUDA binary externally:
|
||||||
|
|
||||||
|
### Option 1: AWS S3
|
||||||
|
```yaml
|
||||||
|
- name: Upload CUDA binary to S3
|
||||||
|
run: |
|
||||||
|
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
|
||||||
|
s3://voicebox-releases/cuda-binaries/${{ github.ref_name }}/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Azure Blob Storage
|
||||||
|
```yaml
|
||||||
|
- name: Upload to Azure Blob
|
||||||
|
run: |
|
||||||
|
az storage blob upload \
|
||||||
|
--account-name voiceboxreleases \
|
||||||
|
--container-name cuda-binaries \
|
||||||
|
--file backend/cuda-release/voicebox-server-cuda-*.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: GitHub Packages (Container Registry)
|
||||||
|
Package as a container image, though this adds complexity for desktop app distribution.
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
1. **Test compression locally** ← Current step
|
||||||
|
2. **If compression works (<2GB)**:
|
||||||
|
- Update CI to compress before upload
|
||||||
|
- Update app to handle .7z downloads
|
||||||
|
- Add extraction step in download manager
|
||||||
|
|
||||||
|
3. **If compression fails (≥2GB)**:
|
||||||
|
- Set up external storage (likely S3)
|
||||||
|
- Update CI to upload to S3
|
||||||
|
- Provide download URL in release notes
|
||||||
|
- Update app download manager to fetch from S3
|
||||||
|
|
||||||
|
## CI Workflow Changes (if compression works)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Compress CUDA binary (Windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd backend/cuda-release
|
||||||
|
7z a -t7z -m0=lzma2 -mx=9 -mfb=64 -md=32m -ms=on \
|
||||||
|
voicebox-server-cuda-x86_64-pc-windows-msvc.7z \
|
||||||
|
voicebox-server-cuda-*.exe
|
||||||
|
|
||||||
|
- name: Upload compressed CUDA server (Windows only)
|
||||||
|
if: matrix.platform == 'windows-latest'
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: backend/cuda-release/*.7z
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Experience Impact
|
||||||
|
|
||||||
|
### With Compression
|
||||||
|
- Download: `voicebox-server-cuda-*.7z` (~1.5-1.8GB)
|
||||||
|
- App extracts automatically
|
||||||
|
- One extra step but manageable
|
||||||
|
|
||||||
|
### With External Hosting
|
||||||
|
- Download from S3/Azure URL
|
||||||
|
- No GitHub release asset dependency
|
||||||
|
- Potentially faster download speeds (CDN)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
🔄 **Testing compression locally to determine viability**
|
||||||
|
|
||||||
|
Results pending from local test run.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# Cloudflare R2 Setup Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The CUDA binary (2.4GB) is hosted on Cloudflare R2 at `downloads.voicebox.sh` instead of GitHub Releases (which has a 2GB limit).
|
||||||
|
|
||||||
|
## R2 Bucket Configuration
|
||||||
|
|
||||||
|
✅ **Completed:**
|
||||||
|
- Bucket created: `voicebox`
|
||||||
|
- Custom domain configured: `downloads.voicebox.sh`
|
||||||
|
|
||||||
|
## GitHub Secrets Required
|
||||||
|
|
||||||
|
Add these secrets to your GitHub repository:
|
||||||
|
|
||||||
|
### 1. R2_ACCESS_KEY_ID
|
||||||
|
|
||||||
|
Your Cloudflare R2 API Access Key ID
|
||||||
|
|
||||||
|
**How to get it:**
|
||||||
|
1. Go to Cloudflare Dashboard → R2
|
||||||
|
2. Click "Manage R2 API Tokens"
|
||||||
|
3. Create API Token with "Object Read & Write" permissions
|
||||||
|
4. Copy the "Access Key ID"
|
||||||
|
|
||||||
|
**Add to GitHub:**
|
||||||
|
```
|
||||||
|
Repository Settings → Secrets and variables → Actions → New repository secret
|
||||||
|
Name: R2_ACCESS_KEY_ID
|
||||||
|
Value: <your-access-key-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. R2_SECRET_ACCESS_KEY
|
||||||
|
|
||||||
|
Your Cloudflare R2 Secret Access Key
|
||||||
|
|
||||||
|
**How to get it:**
|
||||||
|
- Same process as above
|
||||||
|
- Copy the "Secret Access Key" (shown only once!)
|
||||||
|
- Store it securely
|
||||||
|
|
||||||
|
**Add to GitHub:**
|
||||||
|
```
|
||||||
|
Name: R2_SECRET_ACCESS_KEY
|
||||||
|
Value: <your-secret-access-key>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. R2_ENDPOINT
|
||||||
|
|
||||||
|
Your Cloudflare R2 endpoint URL
|
||||||
|
|
||||||
|
**Format:**
|
||||||
|
```
|
||||||
|
https://<account-id>.r2.cloudflarestorage.com
|
||||||
|
```
|
||||||
|
|
||||||
|
**How to find your account ID:**
|
||||||
|
- Cloudflare Dashboard → R2
|
||||||
|
- Look at the URL or bucket settings
|
||||||
|
- Should be a string of letters/numbers
|
||||||
|
|
||||||
|
**Add to GitHub:**
|
||||||
|
```
|
||||||
|
Name: R2_ENDPOINT
|
||||||
|
Value: https://<your-account-id>.r2.cloudflarestorage.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bucket Structure
|
||||||
|
|
||||||
|
After CI uploads, the bucket will have this structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
voicebox/
|
||||||
|
└── cuda/
|
||||||
|
├── v0.1.12/
|
||||||
|
│ └── voicebox-server-cuda-x86_64-pc-windows-msvc.exe
|
||||||
|
├── v0.1.13/
|
||||||
|
│ └── voicebox-server-cuda-x86_64-pc-windows-msvc.exe
|
||||||
|
└── v0.2.0/
|
||||||
|
└── voicebox-server-cuda-x86_64-pc-windows-msvc.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Public Access
|
||||||
|
|
||||||
|
Files are uploaded with `--acl public-read`, making them accessible at:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://downloads.voicebox.sh/cuda/v{VERSION}/voicebox-server-cuda-x86_64-pc-windows-msvc.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
https://downloads.voicebox.sh/cuda/v0.1.12/voicebox-server-cuda-x86_64-pc-windows-msvc.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing the Setup
|
||||||
|
|
||||||
|
### Local Test Upload
|
||||||
|
|
||||||
|
Before running the CI, test uploading locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set environment variables
|
||||||
|
export AWS_ACCESS_KEY_ID="your-r2-access-key-id"
|
||||||
|
export AWS_SECRET_ACCESS_KEY="your-r2-secret-access-key"
|
||||||
|
export R2_ENDPOINT="https://your-account-id.r2.cloudflarestorage.com"
|
||||||
|
|
||||||
|
# Install AWS CLI
|
||||||
|
pip install awscli
|
||||||
|
|
||||||
|
# Test upload (use a small test file first)
|
||||||
|
echo "test" > test.txt
|
||||||
|
aws s3 cp test.txt \
|
||||||
|
s3://voicebox/test/test.txt \
|
||||||
|
--endpoint-url $R2_ENDPOINT \
|
||||||
|
--acl public-read
|
||||||
|
|
||||||
|
# Verify it's accessible
|
||||||
|
curl https://downloads.voicebox.sh/test/test.txt
|
||||||
|
|
||||||
|
# If successful, try the actual CUDA binary
|
||||||
|
aws s3 cp backend/dist/voicebox-server-cuda.exe \
|
||||||
|
s3://voicebox/cuda/v0.1.12-test/voicebox-server-cuda-x86_64-pc-windows-msvc.exe \
|
||||||
|
--endpoint-url $R2_ENDPOINT \
|
||||||
|
--acl public-read
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verify Upload
|
||||||
|
|
||||||
|
Check if the file is accessible:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://downloads.voicebox.sh/cuda/v0.1.12-test/voicebox-server-cuda-x86_64-pc-windows-msvc.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return:
|
||||||
|
```
|
||||||
|
HTTP/2 200
|
||||||
|
content-length: 2545086396
|
||||||
|
content-type: application/x-msdownload
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI Workflow
|
||||||
|
|
||||||
|
The workflow now:
|
||||||
|
|
||||||
|
1. **Builds CPU binary** → Includes in installer
|
||||||
|
2. **Builds CUDA binary** → Uploads to R2
|
||||||
|
3. **Release notes** → Include R2 download link
|
||||||
|
|
||||||
|
### CI Steps (Windows)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Build CUDA Python server (Windows only)
|
||||||
|
# Builds the CUDA binary
|
||||||
|
|
||||||
|
- name: Upload CUDA server to Cloudflare R2 (Windows only)
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
run: |
|
||||||
|
aws s3 cp backend/cuda-release/voicebox-server-cuda-*.exe \
|
||||||
|
s3://voicebox/cuda/${VERSION}/... \
|
||||||
|
--endpoint-url $R2_ENDPOINT \
|
||||||
|
--acl public-read
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cost Tracking
|
||||||
|
|
||||||
|
Monitor your R2 usage:
|
||||||
|
|
||||||
|
**Cloudflare Dashboard → R2 → voicebox → Metrics**
|
||||||
|
|
||||||
|
Expected costs (per month):
|
||||||
|
- Storage: 2.4GB × $0.015/GB = **$0.036**
|
||||||
|
- Egress: **$0.00** (free!)
|
||||||
|
- Class A ops: ~100 × $4.50/million = **$0.00**
|
||||||
|
|
||||||
|
**Total: ~$0.04/month** (essentially free!)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Upload fails: "Access Denied"
|
||||||
|
|
||||||
|
**Solution:** Check API token permissions
|
||||||
|
- Must have "Object Read & Write" on the bucket
|
||||||
|
- Regenerate token if needed
|
||||||
|
|
||||||
|
### File not accessible at downloads.voicebox.sh
|
||||||
|
|
||||||
|
**Solution:** Check custom domain configuration
|
||||||
|
- R2 Dashboard → Bucket → Settings → Custom Domains
|
||||||
|
- Ensure `downloads.voicebox.sh` is properly configured
|
||||||
|
- DNS may take time to propagate
|
||||||
|
|
||||||
|
### "endpoint-url" not recognized
|
||||||
|
|
||||||
|
**Solution:** Make sure AWS CLI is updated
|
||||||
|
```bash
|
||||||
|
pip install --upgrade awscli
|
||||||
|
```
|
||||||
|
|
||||||
|
### File uploaded but wrong permissions
|
||||||
|
|
||||||
|
**Solution:** Re-upload with `--acl public-read`
|
||||||
|
```bash
|
||||||
|
aws s3 cp ... --acl public-read
|
||||||
|
```
|
||||||
|
|
||||||
|
Or set bucket default permissions in R2 Dashboard.
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### API Token Permissions
|
||||||
|
|
||||||
|
✅ **Recommended:**
|
||||||
|
- Object Read & Write only
|
||||||
|
- No admin permissions needed
|
||||||
|
- Scoped to `voicebox` bucket only
|
||||||
|
|
||||||
|
❌ **Avoid:**
|
||||||
|
- Account-wide permissions
|
||||||
|
- Account admin access
|
||||||
|
- Worker edit permissions
|
||||||
|
|
||||||
|
### Secret Rotation
|
||||||
|
|
||||||
|
Rotate API tokens every 6-12 months:
|
||||||
|
1. Create new API token
|
||||||
|
2. Update GitHub secrets
|
||||||
|
3. Verify CI still works
|
||||||
|
4. Delete old token
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Cleaning Old Versions
|
||||||
|
|
||||||
|
Optional: Delete old CUDA binaries to save storage costs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all versions
|
||||||
|
aws s3 ls s3://voicebox/cuda/ \
|
||||||
|
--endpoint-url $R2_ENDPOINT
|
||||||
|
|
||||||
|
# Delete old version
|
||||||
|
aws s3 rm s3://voicebox/cuda/v0.1.0/ \
|
||||||
|
--recursive \
|
||||||
|
--endpoint-url $R2_ENDPOINT
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Set up Cloudflare notifications:
|
||||||
|
- Storage approaching limits
|
||||||
|
- Unusual traffic patterns
|
||||||
|
- High operation counts
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. ✅ Bucket configured
|
||||||
|
2. ⏳ Add GitHub secrets (R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT)
|
||||||
|
3. ⏳ Test local upload
|
||||||
|
4. ⏳ Push branch and create test release
|
||||||
|
5. ⏳ Verify CUDA binary accessible from downloads.voicebox.sh
|
||||||
|
6. ⏳ Implement frontend download manager
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Status**: Ready for testing
|
||||||
|
**Cost**: ~$0.04/month
|
||||||
|
**Bandwidth**: Free (unlimited)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/landing",
|
"name": "@voicebox/landing",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"description": "Landing page for voicebox.sh",
|
"description": "Landing page for voicebox.sh",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --bun next dev --turbo",
|
"dev": "bun --bun next dev --turbo",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "voicebox",
|
"name": "voicebox",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"app",
|
"app",
|
||||||
|
|||||||
+6
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/tauri",
|
"name": "@voicebox/tauri",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.0.0"
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-process": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-updater": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
Generated
+1
-1
@@ -5041,7 +5041,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "voicebox"
|
name = "voicebox"
|
||||||
version = "0.1.11"
|
version = "0.1.12"
|
||||||
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
description = "A production-quality desktop app for Qwen3-TTS voice cloning and generation"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
|
|||||||
Binary file not shown.
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Voicebox",
|
"productName": "Voicebox",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"identifier": "sh.voicebox.app",
|
"identifier": "sh.voicebox.app",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
@@ -35,5 +35,15 @@ export default defineConfig({
|
|||||||
minify: !process.env.TAURI_DEBUG,
|
minify: !process.env.TAURI_DEBUG,
|
||||||
sourcemap: !!process.env.TAURI_DEBUG,
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'@tauri-apps/api',
|
||||||
|
'@tauri-apps/plugin-dialog',
|
||||||
|
'@tauri-apps/plugin-fs',
|
||||||
|
'@tauri-apps/plugin-process',
|
||||||
|
'@tauri-apps/plugin-shell',
|
||||||
|
'@tauri-apps/plugin-updater',
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Test CUDA detection in voicebox backend"""
|
||||||
|
import sys
|
||||||
|
import torch
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("PyTorch CUDA Detection Test")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Basic torch info
|
||||||
|
print(f"\nPyTorch version: {torch.__version__}")
|
||||||
|
print(f"CUDA available: {torch.cuda.is_available()}")
|
||||||
|
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
print(f"CUDA version: {torch.version.cuda}")
|
||||||
|
print(f"GPU count: {torch.cuda.device_count()}")
|
||||||
|
print(f"Current GPU: {torch.cuda.current_device()}")
|
||||||
|
print(f"GPU name: {torch.cuda.get_device_name(0)}")
|
||||||
|
print(f"GPU memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.2f} GB")
|
||||||
|
else:
|
||||||
|
print("\nNo CUDA available - would run on CPU")
|
||||||
|
|
||||||
|
# Test backend device selection
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Backend Device Selection")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Simulate the _get_device method from pytorch_backend.py
|
||||||
|
def _get_device() -> str:
|
||||||
|
"""Get the best available device."""
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
return "cuda"
|
||||||
|
elif hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
|
||||||
|
# MPS can have issues, use CPU for stability
|
||||||
|
return "cpu"
|
||||||
|
return "cpu"
|
||||||
|
|
||||||
|
selected_device = _get_device()
|
||||||
|
print(f"\nSelected device: {selected_device}")
|
||||||
|
print(f"Would use dtype: {'torch.bfloat16' if selected_device != 'cpu' else 'torch.float32'}")
|
||||||
|
|
||||||
|
# Test actual tensor creation on device
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Testing Tensor Creation on Device")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
test_tensor = torch.randn(1000, 1000).to(selected_device)
|
||||||
|
print(f"\n[OK] Successfully created tensor on {selected_device}")
|
||||||
|
print(f" Tensor device: {test_tensor.device}")
|
||||||
|
print(f" Tensor dtype: {test_tensor.dtype}")
|
||||||
|
|
||||||
|
# Test computation
|
||||||
|
result = test_tensor @ test_tensor.T
|
||||||
|
print(f"[OK] Successfully performed computation on {selected_device}")
|
||||||
|
|
||||||
|
if selected_device == "cuda":
|
||||||
|
print(f"\nCUDA memory allocated: {torch.cuda.memory_allocated() / 1024**2:.2f} MB")
|
||||||
|
print(f"CUDA memory reserved: {torch.cuda.memory_reserved() / 1024**2:.2f} MB")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n[ERROR] {e}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("Summary")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if selected_device == "cuda":
|
||||||
|
print("\n[SUCCESS] CUDA IS WORKING!")
|
||||||
|
print(" The backend will use your NVIDIA GPU for inference")
|
||||||
|
print(f" GPU: {torch.cuda.get_device_name(0)}")
|
||||||
|
print(f" This will be significantly faster than CPU")
|
||||||
|
else:
|
||||||
|
print("\n[FAIL] CUDA is not available")
|
||||||
|
print(" The backend will use CPU for inference")
|
||||||
|
print(" This will be slower than GPU")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@voicebox/web",
|
"name": "@voicebox/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in New Issue
Block a user