@@ -255,20 +289,21 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
Loaded
)}
- {model.downloaded && !model.loaded && (
+ {/* Only show Downloaded if actually downloaded AND not downloading */}
+ {model.downloaded && !model.loaded && !showDownloading && (
Downloaded
)}
- {model.downloaded && model.size_mb && (
+ {model.downloaded && model.size_mb && !showDownloading && (
Size: {formatSize(model.size_mb)}
)}
- {model.downloaded ? (
+ {model.downloaded && !showDownloading ? (
Ready
@@ -283,19 +318,15 @@ function ModelItem({ model, onDownload, onDelete, isDownloading, formatSize }: M
+ ) : showDownloading ? (
+
) : (
-
diff --git a/app/src/components/ServerSettings/ModelProgress.tsx b/app/src/components/ServerSettings/ModelProgress.tsx
index f222ed10..76aa99f1 100644
--- a/app/src/components/ServerSettings/ModelProgress.tsx
+++ b/app/src/components/ServerSettings/ModelProgress.tsx
@@ -8,14 +8,23 @@ import { useServerStore } from '@/stores/serverStore';
interface ModelProgressProps {
modelName: 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
(null);
const serverUrl = useServerStore((state) => state.serverUrl);
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
const eventSource = new EventSource(`${serverUrl}/models/progress/${modelName}`);
@@ -27,6 +36,7 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
// Close connection if complete or error
if (data.status === 'complete' || data.status === 'error') {
+ console.log(`[ModelProgress] Download ${data.status} for ${modelName}, closing SSE`);
eventSource.close();
}
} catch (error) {
@@ -35,14 +45,15 @@ export function ModelProgress({ modelName, displayName }: ModelProgressProps) {
};
eventSource.onerror = (error) => {
- console.error('SSE error:', error);
+ console.error(`[ModelProgress] SSE error for ${modelName}:`, error);
eventSource.close();
};
return () => {
+ console.log(`[ModelProgress] Cleanup - closing SSE for ${modelName}`);
eventSource.close();
};
- }, [serverUrl, modelName]);
+ }, [serverUrl, modelName, isDownloading]);
// Don't render if no progress or if complete/error and some time has passed
if (
diff --git a/app/src/components/ServerSettings/UpdateStatus.tsx b/app/src/components/ServerSettings/UpdateStatus.tsx
index f0a2e9fc..a3d832aa 100644
--- a/app/src/components/ServerSettings/UpdateStatus.tsx
+++ b/app/src/components/ServerSettings/UpdateStatus.tsx
@@ -13,9 +13,10 @@ export function UpdateStatus() {
const [currentVersion, setCurrentVersion] = useState('');
useEffect(() => {
- platform.metadata.getVersion()
+ platform.metadata
+ .getVersion()
.then(setCurrentVersion)
- .catch(() => setCurrentVersion('0.1.0'));
+ .catch(() => setCurrentVersion('Unknown'));
}, [platform]);
return (
diff --git a/app/src/hooks/useAutoUpdater.ts b/app/src/hooks/useAutoUpdater.ts
index 44940349..7a9f169a 100644
--- a/app/src/hooks/useAutoUpdater.ts
+++ b/app/src/hooks/useAutoUpdater.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { usePlatform } from '@/platform/PlatformContext';
import type { UpdateStatus } from '@/platform/types';
@@ -7,9 +7,8 @@ export type { UpdateStatus };
export function useAutoUpdater(checkOnMount = false) {
const platform = usePlatform();
- const [status, setStatus] = useState(
- platform.updater.getStatus(),
- );
+ const [status, setStatus] = useState(platform.updater.getStatus());
+ const hasCheckedRef = useRef(false);
// Subscribe to updater status changes
useEffect(() => {
@@ -17,25 +16,32 @@ export function useAutoUpdater(checkOnMount = false) {
setStatus(newStatus);
});
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 () => {
await platform.updater.checkForUpdates();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.checkForUpdates]);
const downloadAndInstall = useCallback(async () => {
await platform.updater.downloadAndInstall();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.downloadAndInstall]);
const restartAndInstall = useCallback(async () => {
await platform.updater.restartAndInstall();
- }, [platform]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.updater.restartAndInstall]);
useEffect(() => {
- if (checkOnMount && platform.metadata.isTauri) {
+ if (checkOnMount && platform.metadata.isTauri && !hasCheckedRef.current) {
+ hasCheckedRef.current = true;
checkForUpdates();
}
- }, [checkOnMount, checkForUpdates, platform.metadata.isTauri]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [platform.metadata.isTauricheckOnMountcheckForUpdates]);
return {
status,
diff --git a/app/src/hooks/useAutoUpdater.tsx b/app/src/hooks/useAutoUpdater.tsx
new file mode 100644
index 00000000..8a6351f6
--- /dev/null
+++ b/app/src/hooks/useAutoUpdater.tsx
@@ -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(platform.updater.getStatus());
+ const hasCheckedRef = useRef(false);
+ const toastIdRef = useRef(null);
+ const toastUpdateRef = useRef<
+ | ((props: {
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ duration?: number;
+ variant?: 'default' | 'destructive';
+ open?: boolean;
+ action?: React.ReactElement;
+ }) => 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: (
+
+ Update Now
+
+ ),
+ });
+
+ 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: (
+
+
+ Downloading Update
+
+ ),
+ description: (
+
+
Version {status.version}
+ {progressPercent > 0 && (
+ <>
+
+ {progressText &&
{progressText}
}
+ >
+ )}
+
+ ),
+ 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: (
+
+
+ Restart Now
+
+ ),
+ });
+ }, [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,
+ };
+}
diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts
index cd87ab89..c5b079b2 100644
--- a/app/src/lib/api/client.ts
+++ b/app/src/lib/api/client.ts
@@ -310,10 +310,13 @@ class ApiClient {
}
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',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
});
+ console.log('[API] triggerModelDownload response:', result);
+ return result;
}
async deleteModel(modelName: string): Promise<{ message: string }> {
diff --git a/app/src/lib/api/models/ModelStatus.ts b/app/src/lib/api/models/ModelStatus.ts
index e8b806b3..0d744893 100644
--- a/app/src/lib/api/models/ModelStatus.ts
+++ b/app/src/lib/api/models/ModelStatus.ts
@@ -9,6 +9,7 @@ export type ModelStatus = {
model_name: string;
display_name: string;
downloaded: boolean;
+ downloading?: boolean; // True if download is in progress
size_mb?: number | null;
loaded?: boolean;
};
diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts
index 6da3161a..131c1be5 100644
--- a/app/src/lib/api/types.ts
+++ b/app/src/lib/api/types.ts
@@ -96,6 +96,7 @@ export interface ModelStatus {
model_name: string;
display_name: string;
downloaded: boolean;
+ downloading: boolean; // True if download is in progress
size_mb?: number;
loaded: boolean;
}
diff --git a/app/src/lib/hooks/useModelDownloadToast.tsx b/app/src/lib/hooks/useModelDownloadToast.tsx
index d1865bb4..2df221e1 100644
--- a/app/src/lib/hooks/useModelDownloadToast.tsx
+++ b/app/src/lib/hooks/useModelDownloadToast.tsx
@@ -1,14 +1,16 @@
-import { useEffect, useRef } from 'react';
-import { useToast } from '@/components/ui/use-toast';
-import { useServerStore } from '@/stores/serverStore';
+import { CheckCircle2, Loader2, XCircle } from 'lucide-react';
+import { useCallback, useEffect, useRef } from 'react';
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 { useServerStore } from '@/stores/serverStore';
interface UseModelDownloadToastOptions {
modelName: string;
displayName: string;
enabled?: boolean;
+ onComplete?: () => void;
+ onError?: () => void;
}
/**
@@ -19,47 +21,64 @@ export function useModelDownloadToast({
modelName,
displayName,
enabled = false,
+ onComplete,
+ onError,
}: UseModelDownloadToastOptions) {
const { toast } = useToast();
const serverUrl = useServerStore((state) => state.serverUrl);
const toastIdRef = useRef(null);
- const toastUpdateRef = useRef<
- ((props: {
- title?: React.ReactNode;
- description?: React.ReactNode;
- duration?: number;
- variant?: 'default' | 'destructive';
- open?: boolean;
- }) => void) | null
- >(null);
+ // biome-ignore lint: Using any for toast update ref to handle complex toast types
+ const toastUpdateRef = useRef(null);
const eventSourceRef = useRef(null);
- const formatBytes = (bytes: number): string => {
+ const formatBytes = useCallback((bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
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(() => {
+ console.log('[useModelDownloadToast] useEffect triggered', {
+ enabled,
+ serverUrl,
+ modelName,
+ displayName,
+ });
+
if (!enabled || !serverUrl || !modelName) {
+ console.log('[useModelDownloadToast] Not enabled, skipping');
return;
}
+ console.log('[useModelDownloadToast] Creating toast and EventSource for:', modelName);
+
// Create initial toast
const toastResult = toast({
title: displayName,
- description: 'Starting download...',
+ description: (
+
+
+ Connecting to download...
+
+ ),
duration: Infinity, // Don't auto-dismiss, we'll handle it manually
});
toastIdRef.current = toastResult.id;
toastUpdateRef.current = toastResult.update;
// 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) => {
+ console.log('[useModelDownloadToast] Received SSE message:', event.data);
try {
const progress = JSON.parse(event.data) as ModelProgress;
@@ -86,7 +105,7 @@ export function useModelDownloadToast({
break;
case 'downloading':
statusIcon = ;
- statusText = progress.filename ? `Downloading ${progress.filename}...` : 'Downloading...';
+ statusText = progress.filename || 'Downloading...';
break;
case 'extracting':
statusIcon = ;
@@ -117,21 +136,40 @@ export function useModelDownloadToast({
});
// 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();
eventSourceRef.current = null;
- // Auto-dismiss on completion after delay
- if (progress.status === 'complete') {
- setTimeout(() => {
- if (toastIdRef.current && toastUpdateRef.current) {
- toastUpdateRef.current({
- open: false,
- });
- toastIdRef.current = null;
- toastUpdateRef.current = null;
- }
- }, 5000);
+ // Update toast to show completion state before callbacks
+ if (isComplete && toastUpdateRef.current) {
+ toastUpdateRef.current({
+ title: (
+
+
+ {displayName}
+
+ ),
+ description: 'Download complete',
+ duration: 3000,
+ });
+ }
+
+ // 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) => {
- console.error('SSE error:', error);
+ console.error('[useModelDownloadToast] SSE error for:', modelName, error);
+ console.log('[useModelDownloadToast] EventSource readyState:', eventSource.readyState);
eventSource.close();
eventSourceRef.current = null;
@@ -162,15 +201,16 @@ export function useModelDownloadToast({
// Cleanup on unmount or when disabled
return () => {
+ console.log('[useModelDownloadToast] Cleanup - closing EventSource for:', modelName);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
// 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 {
isTracking: enabled && eventSourceRef.current !== null,
};
-}
\ No newline at end of file
+}
diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py
index a019f418..c4ecc090 100644
--- a/backend/backends/mlx_backend.py
+++ b/backend/backends/mlx_backend.py
@@ -52,6 +52,47 @@ class MLXTTSBackend:
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):
"""
Lazy load the MLX TTS model.
@@ -79,46 +120,63 @@ class MLXTTSBackend:
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
try:
- from mlx_audio.tts import load
-
- # Get model path
+ # Get model path BEFORE importing mlx_audio
model_path = self._get_model_path(model_size)
# Set up progress tracking
progress_manager = get_progress_manager()
+ task_manager = get_task_manager()
model_name = f"qwen-tts-{model_size}"
- # Start tracking download task
- task_manager = get_task_manager()
- task_manager.start_download(model_name)
+ # Check if model is already cached
+ is_cached = self._is_model_cached(model_size)
+
+ # 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}...")
- # Initialize progress state
- progress_manager.update_progress(
- model_name=model_name,
- current=0,
- total=1,
- filename="",
- status="downloading",
- )
+ # 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(
+ model_name=model_name,
+ current=0,
+ total=0, # Will be updated once actual total is known
+ filename="Connecting to HuggingFace...",
+ status="downloading",
+ )
- # Set up progress callback
- progress_callback = create_hf_progress_callback(model_name, progress_manager)
- tracker = HFProgressTracker(progress_callback)
+ # IMPORTANT: Patch tqdm BEFORE importing mlx_audio
+ # Otherwise mlx_audio caches reference to original tqdm
+ tracker_context = tracker.patch_download()
+ tracker_context.__enter__()
- # Use progress tracker during download
- with tracker.patch_download():
- # Load MLX model (downloads automatically)
+ # Import mlx_audio AFTER patching tqdm
+ from mlx_audio.tts import load
+
+ # Load MLX model (downloads automatically)
+ try:
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.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")
except ImportError as e:
@@ -332,6 +390,47 @@ class MLXSTTBackend:
"""Check if model is loaded."""
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):
"""
Lazy load the MLX Whisper model.
@@ -354,55 +453,58 @@ class MLXSTTBackend:
def _load_model_sync(self, model_size: str):
"""Synchronous model loading."""
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()
+ task_manager = get_task_manager()
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
+ # 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)
- tracker = HFProgressTracker(progress_callback)
+ tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
# 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.__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
# MLX Whisper uses the standard OpenAI models
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}...")
- # Initialize progress state
- progress_manager.update_progress(
- model_name=progress_model_name,
- current=0,
- total=1,
- filename="",
- status="downloading",
- )
+ # 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(
+ model_name=progress_model_name,
+ current=0,
+ total=0,
+ filename="Connecting to HuggingFace...",
+ status="downloading",
+ )
- # Load the model (tqdm is already patched from above)
+ # Load the model (tqdm is patched, but filters out non-download progress)
try:
self.model = load(model_name)
finally:
# Exit the patch context
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:
+ progress_manager.mark_complete(progress_model_name)
+ task_manager.complete_download(progress_model_name)
- # Mark as complete
- progress_manager.mark_complete(progress_model_name)
- task_manager.complete_download(progress_model_name)
+ self.model_size = model_size
print(f"MLX Whisper model {model_size} loaded successfully")
diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py
index cd1257cb..26f38726 100644
--- a/backend/backends/pytorch_backend.py
+++ b/backend/backends/pytorch_backend.py
@@ -58,6 +58,46 @@ class PyTorchTTSBackend:
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):
"""
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):
"""Synchronous model loading."""
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()
+ task_manager = get_task_manager()
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
+ # 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)
- tracker = HFProgressTracker(progress_callback)
+ tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
# Patch tqdm BEFORE importing qwen_tts
tracker_context = tracker.patch_download()
tracker_context.__enter__()
- # NOW import qwen_tts - it will use our patched tqdm
+ # Import qwen_tts
from qwen_tts import Qwen3TTSModel
# Get model path (local or HuggingFace Hub ID)
@@ -106,20 +150,21 @@ class PyTorchTTSBackend:
print(f"Loading TTS model {model_size} on {self.device}...")
- # Start tracking download task
- task_manager = get_task_manager()
- task_manager.start_download(model_name)
+ # 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 to show download has started
- progress_manager.update_progress(
- model_name=model_name,
- current=0,
- total=1, # Set to 1 initially, will be updated by callback
- filename="",
- status="downloading",
- )
+ # Initialize progress state so SSE endpoint has initial data to send
+ progress_manager.update_progress(
+ model_name=model_name,
+ current=0,
+ total=0, # Will be updated once actual total is known
+ filename="Connecting to HuggingFace...",
+ status="downloading",
+ )
- # Load the model (tqdm is already patched from above)
+ # Load the model (tqdm is patched, but filters out non-download progress)
try:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
@@ -130,9 +175,10 @@ class PyTorchTTSBackend:
# Exit the patch context
tracker_context.__exit__(None, None, None)
- # Mark as complete
- progress_manager.mark_complete(model_name)
- task_manager.complete_download(model_name)
+ # 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.model_size = model_size
@@ -321,6 +367,46 @@ class PyTorchSTTBackend:
"""Check if model is loaded."""
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):
"""
Lazy load the Whisper model.
@@ -349,14 +435,18 @@ class PyTorchSTTBackend:
"""Synchronous model loading."""
print(f"[DEBUG] _load_model_sync called for Whisper {model_size}")
try:
- # IMPORTANT: Set up progress tracking BEFORE importing transformers
- # This ensures tqdm is patched before any HuggingFace Hub imports
progress_manager = get_progress_manager()
+ task_manager = get_task_manager()
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
+ # 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)
- tracker = HFProgressTracker(progress_callback)
+ tracker = HFProgressTracker(progress_callback, filter_non_downloads=is_cached)
# Patch tqdm BEFORE importing transformers
print("[DEBUG] Starting tqdm patch BEFORE transformers import")
@@ -364,31 +454,29 @@ class PyTorchSTTBackend:
tracker_context.__enter__()
print("[DEBUG] tqdm patched, now importing transformers")
- # NOW import transformers - it will use our patched tqdm
+ # Import transformers
from transformers import WhisperProcessor, WhisperForConditionalGeneration
model_name = f"openai/whisper-{model_size}"
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}...")
- # Initialize progress state to show download has started
- print(f"[DEBUG] Calling update_progress...")
- progress_manager.update_progress(
- model_name=progress_model_name,
- current=0,
- total=1, # Set to 1 initially, will be updated by callback
- filename="",
- status="downloading",
- )
- print(f"[DEBUG] update_progress called, listeners: {len(progress_manager._listeners.get(progress_model_name, []))}")
+ # Only track download progress if model is NOT cached
+ if not is_cached:
+ # Start tracking download task
+ task_manager.start_download(progress_model_name)
- # Load models (tqdm is already patched from above)
+ # Initialize progress state so SSE endpoint has initial data to send
+ progress_manager.update_progress(
+ model_name=progress_model_name,
+ current=0,
+ total=0, # Will be updated once actual total is known
+ filename="Connecting to HuggingFace...",
+ status="downloading",
+ )
+
+ # Load models (tqdm is patched, but filters out non-download progress)
try:
self.processor = WhisperProcessor.from_pretrained(model_name)
self.model = WhisperForConditionalGeneration.from_pretrained(model_name)
@@ -396,13 +484,14 @@ class PyTorchSTTBackend:
# 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(progress_model_name)
+ task_manager.complete_download(progress_model_name)
+
self.model.to(self.device)
self.model_size = model_size
- # Mark as complete
- progress_manager.mark_complete(progress_model_name)
- task_manager.complete_download(progress_model_name)
-
print(f"Whisper model {model_size} loaded successfully")
except Exception as e:
diff --git a/backend/main.py b/backend/main.py
index 83a44bfe..59fb9e18 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1156,11 +1156,14 @@ async def get_model_progress(model_name: str):
@app.get("/models/status", response_model=models.ModelStatusListResponse)
async def get_model_status():
"""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
- import os
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:
@@ -1189,10 +1192,11 @@ async def get_model_status():
if backend_type == "mlx":
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
- whisper_base_id = "mlx-community/whisper-base"
- whisper_small_id = "mlx-community/whisper-small"
- whisper_medium_id = "mlx-community/whisper-medium"
- whisper_large_id = "mlx-community/whisper-large"
+ # MLX backend uses openai/whisper-* models, not mlx-community
+ whisper_base_id = "openai/whisper-base"
+ whisper_small_id = "openai/whisper-small"
+ whisper_medium_id = "openai/whisper-medium"
+ whisper_large_id = "openai/whisper-large"
else:
tts_1_7b_id = "Qwen/Qwen3-TTS-12Hz-1.7B-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)
cache_info = None
if use_scan_cache:
@@ -1268,13 +1279,37 @@ async def get_model_status():
repo_id = config["hf_repo_id"]
for repo in cache_info.repos:
if repo.repo_id == repo_id:
- downloaded = True
- # Calculate size from cache info
+ # 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:
- total_size = sum(revision.size_on_disk for revision in repo.revisions)
- size_mb = total_size / (1024 * 1024)
+ 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
+ # Calculate size from cache info
+ try:
+ total_size = sum(revision.size_on_disk for revision in repo.revisions)
+ size_mb = total_size / (1024 * 1024)
+ except Exception:
+ pass
break
# Method 2: Fallback to checking cache directory directly (using HuggingFace's OS-specific cache location)
@@ -1284,42 +1319,40 @@ async def get_model_status():
repo_cache = Path(cache_dir) / ("models--" + config["hf_repo_id"].replace("/", "--"))
if repo_cache.exists():
- # Check for model files (bin, safetensors, or other common model files)
- # MLX models may use .npz or .safetensors
- has_model_files = (
- any(repo_cache.rglob("*.bin")) or
- any(repo_cache.rglob("*.safetensors")) or
- any(repo_cache.rglob("*.pt")) or
- any(repo_cache.rglob("*.pth")) or
- any(repo_cache.rglob("*.npz")) or
- any(repo_cache.rglob("model.safetensors.index.json")) or
- any(repo_cache.rglob("pytorch_model.bin.index.json"))
- )
+ # Check for .incomplete files - if any exist, download is still in progress
+ blobs_dir = repo_cache / "blobs"
+ has_incomplete = blobs_dir.exists() and any(blobs_dir.glob("*.incomplete"))
- if has_model_files:
- downloaded = True
- # Calculate size
- try:
- total_size = sum(f.stat().st_size for f in repo_cache.rglob("*") if f.is_file())
- size_mb = total_size / (1024 * 1024)
- except Exception:
- pass
+ 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 = (
+ any(snapshots_dir.rglob("*.bin")) or
+ any(snapshots_dir.rglob("*.safetensors")) or
+ any(snapshots_dir.rglob("*.pt")) or
+ any(snapshots_dir.rglob("*.pth")) or
+ any(snapshots_dir.rglob("*.npz"))
+ )
+
+ if has_model_files:
+ downloaded = True
+ # Calculate size (exclude .incomplete files)
+ try:
+ 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)
+ except Exception:
+ pass
except Exception:
pass
- # Method 3: Try to check if model can be loaded locally (last resort)
- if not downloaded:
- 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
+ # Method 3 removed - checking for config.json is too lenient
+ # Methods 1 and 2 properly verify that model weight files exist
# Check if loaded in memory
try:
@@ -1327,10 +1360,19 @@ async def get_model_status():
except Exception:
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(
model_name=config["model_name"],
display_name=config["display_name"],
downloaded=downloaded,
+ downloading=is_downloading,
size_mb=size_mb,
loaded=loaded,
))
@@ -1341,10 +1383,14 @@ async def get_model_status():
except Exception:
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(
model_name=config["model_name"],
display_name=config["display_name"],
downloaded=False, # Assume not downloaded if check failed
+ downloading=is_downloading,
size_mb=None,
loaded=loaded,
))
@@ -1358,6 +1404,7 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
import asyncio
task_manager = get_task_manager()
+ progress_manager = get_progress_manager()
model_configs = {
"qwen-tts-1.7B": {
@@ -1405,6 +1452,18 @@ async def trigger_model_download(request: models.ModelDownloadRequest):
# Start tracking download
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)
asyncio.create_task(download_in_background())
diff --git a/backend/models.py b/backend/models.py
index 7e72cd13..59e45405 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -134,6 +134,7 @@ class ModelStatus(BaseModel):
model_name: str
display_name: str
downloaded: bool
+ downloading: bool = False # True if download is in progress
size_mb: Optional[float] = None
loaded: bool = False
diff --git a/backend/tests/README.md b/backend/tests/README.md
new file mode 100644
index 00000000..6f92b52b
--- /dev/null
+++ b/backend/tests/README.md
@@ -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
diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py
new file mode 100644
index 00000000..34ddf976
--- /dev/null
+++ b/backend/tests/__init__.py
@@ -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.
+"""
diff --git a/backend/tests/test_generation_download.py b/backend/tests/test_generation_download.py
new file mode 100644
index 00000000..5cbe3fdf
--- /dev/null
+++ b/backend/tests/test_generation_download.py
@@ -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())
diff --git a/backend/tests/test_progress.py b/backend/tests/test_progress.py
new file mode 100644
index 00000000..a66ba079
--- /dev/null
+++ b/backend/tests/test_progress.py
@@ -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)
diff --git a/backend/tests/test_qwen_download.py b/backend/tests/test_qwen_download.py
new file mode 100644
index 00000000..b6c42e17
--- /dev/null
+++ b/backend/tests/test_qwen_download.py
@@ -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)
diff --git a/backend/tests/test_whisper_download.py b/backend/tests/test_whisper_download.py
new file mode 100644
index 00000000..9bebca85
--- /dev/null
+++ b/backend/tests/test_whisper_download.py
@@ -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())
diff --git a/backend/utils/hf_progress.py b/backend/utils/hf_progress.py
index 3b88fe4b..7fc88edd 100644
--- a/backend/utils/hf_progress.py
+++ b/backend/utils/hf_progress.py
@@ -11,8 +11,9 @@ import sys
class HFProgressTracker:
"""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.filter_non_downloads = filter_non_downloads # Only filter if True
self._original_tqdm_class = None
self._lock = threading.Lock()
self._total_downloaded = 0
@@ -21,6 +22,7 @@ class HFProgressTracker:
self._file_downloaded = {} # Track downloaded bytes per file
self._current_filename = ""
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):
"""Create a tqdm subclass that tracks progress."""
@@ -31,7 +33,6 @@ class HFProgressTracker:
"""A tqdm subclass that reports progress to our tracker."""
def __init__(self, *args, **kwargs):
- print(f"[DEBUG TrackedTqdm] __init__ called with desc: {kwargs.get('desc', '')}")
# Extract filename from desc before passing to parent
desc = kwargs.get("desc", "")
if not desc and args:
@@ -80,7 +81,6 @@ class HFProgressTracker:
}
def update(self, n=1):
- print(f"[DEBUG TrackedTqdm] update called with n={n}")
result = super().update(n)
# Report progress
@@ -91,6 +91,16 @@ class HFProgressTracker:
total = getattr(self, "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
tracker._file_sizes[filename] = total
tracker._file_downloaded[filename] = current
@@ -99,6 +109,13 @@ class HFProgressTracker:
tracker._total_size = sum(tracker._file_sizes.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
if tracker.progress_callback:
tracker.progress_callback(
@@ -109,6 +126,50 @@ class HFProgressTracker:
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):
with tracker._lock:
if id(self) in tracker._active_tqdms:
@@ -120,13 +181,11 @@ class HFProgressTracker:
@contextmanager
def patch_download(self):
"""Context manager to patch tqdm for progress tracking."""
- print("[DEBUG HFProgressTracker] patch_download called")
try:
import tqdm as tqdm_module
# Store original tqdm class
self._original_tqdm_class = tqdm_module.tqdm
- print(f"[DEBUG HFProgressTracker] Original tqdm class: {self._original_tqdm_class}")
# Reset totals
with self._lock:
@@ -139,39 +198,89 @@ class HFProgressTracker:
# Create our tracked tqdm class
tracked_tqdm = self._create_tracked_tqdm_class()
- print(f"[DEBUG HFProgressTracker] Created TrackedTqdm class: {tracked_tqdm}")
# Patch tqdm.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)
self._original_tqdm_auto = None
if hasattr(tqdm_module, "auto") and hasattr(tqdm_module.auto, "tqdm"):
self._original_tqdm_auto = tqdm_module.auto.tqdm
tqdm_module.auto.tqdm = tracked_tqdm
- print(f"[DEBUG HFProgressTracker] Patched tqdm.auto.tqdm")
# 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 = {}
+ tqdm_attr_names = ['tqdm', 'base_tqdm', 'old_tqdm'] # Various names used
+
patched_count = 0
for module_name in list(sys.modules.keys()):
if "huggingface" in module_name or module_name.startswith("tqdm"):
try:
module = sys.modules[module_name]
- if hasattr(module, "tqdm"):
- attr = getattr(module, "tqdm")
- # Only patch if it's the original tqdm class (not already patched)
- if attr is self._original_tqdm_class or (
- hasattr(attr, "__name__") and attr.__name__ == "tqdm"
- ):
- self._patched_modules[module_name] = attr
- setattr(module, "tqdm", tracked_tqdm)
- patched_count += 1
- print(f"[DEBUG HFProgressTracker] Patched {module_name}.tqdm")
+ for attr_name in tqdm_attr_names:
+ if hasattr(module, attr_name):
+ attr = getattr(module, attr_name)
+ # Only patch if it's a tqdm class (not already patched)
+ is_tqdm_class = (
+ attr is self._original_tqdm_class or
+ (self._original_tqdm_auto and attr is self._original_tqdm_auto) or
+ (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
except (AttributeError, TypeError):
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
@@ -189,15 +298,24 @@ class HFProgressTracker:
tqdm_module.auto.tqdm = self._original_tqdm_auto
# Restore patched modules
- for module_name, original in self._patched_modules.items():
+ for key, (module, attr_name, original) in self._patched_modules.items():
try:
- module = sys.modules.get(module_name)
if module and original:
- setattr(module, "tqdm", original)
+ setattr(module, attr_name, original)
except (AttributeError, TypeError):
pass
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):
pass
@@ -205,13 +323,17 @@ class HFProgressTracker:
def create_hf_progress_callback(model_name: str, progress_manager):
"""Create a progress callback for HuggingFace downloads."""
def callback(downloaded: int, total: int, filename: str = ""):
- """Progress callback."""
- if total > 0:
- progress_manager.update_progress(
- model_name=model_name,
- current=downloaded,
- total=total,
- filename=filename or "",
- status="downloading",
- )
+ """Progress callback.
+
+ 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(
+ model_name=model_name,
+ current=downloaded,
+ total=total,
+ filename=filename or "",
+ status="downloading",
+ )
return callback
diff --git a/backend/utils/progress.py b/backend/utils/progress.py
index 6535c958..418a88c7 100644
--- a/backend/utils/progress.py
+++ b/backend/utils/progress.py
@@ -16,11 +16,17 @@ class ProgressManager:
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):
self._progress: Dict[str, Dict] = {}
self._listeners: Dict[str, list] = {}
self._lock = threading.Lock() # Thread-safe lock for progress dict
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):
"""Set the main event loop for thread-safe operations."""
@@ -67,6 +73,10 @@ class ProgressManager:
Update progress for a model download.
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:
model_name: Name of the model (e.g., "qwen-tts-1.7B", "whisper-base")
@@ -76,9 +86,17 @@ class ProgressManager:
status: Status string (downloading, extracting, complete, error)
"""
import logging
+ import time
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 = {
"model_name": model_name,
@@ -90,25 +108,38 @@ class ProgressManager:
"timestamp": datetime.now().isoformat(),
}
- print(f"[DEBUG] update_progress called: {model_name}, {progress_pct:.1f}%")
-
- # Thread-safe update of progress dict
+ # Thread-safe update of progress dict (always update internal state)
with self._lock:
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)
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:
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)
- print(f"[DEBUG] Notified listeners")
else:
logger.debug(f"No listeners for {model_name}, progress update stored: {progress_pct:.1f}%")
diff --git a/bun.lock b/bun.lock
index bd8425fe..9e08a825 100644
--- a/bun.lock
+++ b/bun.lock
@@ -13,7 +13,7 @@
},
"app": {
"name": "@voicebox/app",
- "version": "0.1.9",
+ "version": "0.1.11",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -68,7 +68,7 @@
},
"landing": {
"name": "@voicebox/landing",
- "version": "0.1.9",
+ "version": "0.1.11",
"dependencies": {
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
@@ -93,10 +93,14 @@
},
"tauri": {
"name": "@voicebox/tauri",
- "version": "0.1.9",
+ "version": "0.1.11",
"dependencies": {
"@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-updater": "^2.0.0",
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
@@ -112,7 +116,7 @@
},
"web": {
"name": "@voicebox/web",
- "version": "0.1.9",
+ "version": "0.1.11",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"react": "^18.3.0",
diff --git a/tauri/package.json b/tauri/package.json
index 0e71a085..44794d97 100644
--- a/tauri/package.json
+++ b/tauri/package.json
@@ -10,7 +10,11 @@
},
"dependencies": {
"@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": {
"@tailwindcss/vite": "^4.1.18",
diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car
index e867da32..de0e9a08 100644
Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ