diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index bfb27edd..67abe80d 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -1,5 +1,6 @@ -import { AudioWaveform, Download, FileArchive, MoreHorizontal, Play, Trash2 } from 'lucide-react'; +import { AudioWaveform, Download, FileArchive, Loader2, MoreHorizontal, Play, Trash2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import type { HistoryResponse } from '@/lib/api/types'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -33,18 +34,21 @@ import { usePlayerStore } from '@/stores/playerStore'; // OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history) // This is the new alternate history view with fixed height rows -// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS +// NEW ALTERNATE HISTORY VIEW - FIXED HEIGHT ROWS WITH INFINITE SCROLL export function HistoryTable() { - const [page, _setPage] = useState(0); + const [page, setPage] = useState(0); + const [allHistory, setAllHistory] = useState([]); + const [total, setTotal] = useState(0); const [isScrolled, setIsScrolled] = useState(false); const scrollRef = useRef(null); + const loadMoreRef = useRef(null); const fileInputRef = useRef(null); const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); const limit = 20; const { toast } = useToast(); - const { data: historyData, isLoading } = useHistory({ + const { data: historyData, isLoading, isFetching } = useHistory({ limit, offset: page * limit, }); @@ -60,6 +64,56 @@ export function HistoryTable() { const audioUrl = usePlayerStore((state) => state.audioUrl); const isPlayerVisible = !!audioUrl; + // Update accumulated history when new data arrives + useEffect(() => { + if (historyData?.items) { + setTotal(historyData.total); + if (page === 0) { + // Reset to first page + setAllHistory(historyData.items); + } else { + // Append new items, avoiding duplicates + setAllHistory((prev) => { + const existingIds = new Set(prev.map((item) => item.id)); + const newItems = historyData.items.filter((item) => !existingIds.has(item.id)); + return [...prev, ...newItems]; + }); + } + } + }, [historyData, page]); + + // Reset to page 0 when deletions or imports occur + useEffect(() => { + if (deleteGeneration.isSuccess || importGeneration.isSuccess) { + setPage(0); + setAllHistory([]); + } + }, [deleteGeneration.isSuccess, importGeneration.isSuccess]); + + // Intersection Observer for infinite scroll + useEffect(() => { + const loadMoreEl = loadMoreRef.current; + if (!loadMoreEl) return; + + const observer = new IntersectionObserver( + (entries) => { + const target = entries[0]; + if (target.isIntersecting && !isFetching && allHistory.length < total) { + setPage((prev) => prev + 1); + } + }, + { + root: scrollRef.current, + rootMargin: '100px', + threshold: 0.1, + }, + ); + + observer.observe(loadMoreEl); + return () => observer.disconnect(); + }, [isFetching, allHistory.length, total]); + + // Track scroll position for gradient effect useEffect(() => { const scrollEl = scrollRef.current; if (!scrollEl) return; @@ -113,27 +167,6 @@ export function HistoryTable() { ); }; - const _handleImportClick = () => { - file_handleImportClickk.click(); - }; - - const _handleFileChange = (_e: React.ChangeEvent) => { - cons_handleFileChangeet.files?.[0]; - if (file) { - // Validate file extension - if (!file.name.endsWith('.voicebox.zip')) { - toast({ - title: 'Invalid file type', - description: 'Please select a valid .voicebox.zip file', - variant: 'destructive', - }); - return; - } - setSelectedFile(file); - setImportDialogOpen(true); - } - }; - const handleImportConfirm = () => { if (selectedFile) { importGeneration.mutate(selectedFile, { @@ -159,13 +192,16 @@ export function HistoryTable() { } }; - if (isLoading) { - return null; + if (isLoading && page === 0) { + return ( +
+ +
+ ); } - const history = historyData?.items || []; - const total = historyData?.total || 0; - const _hasMore = history.length === limit && (page + 1) * limit < total; + const history = allHistory; + const hasMore = allHistory.length < total; return (
@@ -284,6 +320,20 @@ export function HistoryTable() {
); })} + + {/* Load more trigger element */} + {hasMore && ( +
+ {isFetching && } +
+ )} + + {/* End of list indicator */} + {!hasMore && history.length > 0 && ( +
+ You've reached the end +
+ )} )} diff --git a/app/src/components/VoiceProfiles/SampleList.tsx b/app/src/components/VoiceProfiles/SampleList.tsx index de848737..b63dfe07 100644 --- a/app/src/components/VoiceProfiles/SampleList.tsx +++ b/app/src/components/VoiceProfiles/SampleList.tsx @@ -2,8 +2,8 @@ import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react import { useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { CircleButton } from '@/components/ui/circle-button'; -import { Textarea } from '@/components/ui/textarea'; import { Slider } from '@/components/ui/slider'; +import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles'; @@ -194,7 +194,9 @@ export function SampleList({ profileId }: SampleListProps) {

No samples yet

-

Add your first audio sample to get started

+

+ Add your first audio sample to get started +

) : (
@@ -206,7 +208,7 @@ export function SampleList({ profileId }: SampleListProps) { key={sample.id} className={cn( 'group relative rounded-lg border bg-card transition-all duration-200', - isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30' + isEditing ? 'ring-2 ring-primary/20' : 'hover:border-primary/30', )} > {isEditing ? ( @@ -287,11 +289,22 @@ export function SampleList({ profileId }: SampleListProps) {
)} - +

+ Note: A single 30-second sample is the sweet spot. Quality may decrease with multiple + samples. In a future update samples might be interchangable and tagged for varying styles of + the same voice. +

+ ); diff --git a/backend/backends/mlx_backend.py b/backend/backends/mlx_backend.py index 7585c656..a019f418 100644 --- a/backend/backends/mlx_backend.py +++ b/backend/backends/mlx_backend.py @@ -175,7 +175,13 @@ class MLXTTSBackend: if cached_prompt is not None: # Return cached prompt (should be dict format) if isinstance(cached_prompt, dict): - return cached_prompt, True + # Validate that the cached audio file still exists + cached_audio_path = cached_prompt.get("ref_audio") or cached_prompt.get("ref_audio_path") + if cached_audio_path and Path(cached_audio_path).exists(): + return cached_prompt, True + else: + # Cached file no longer exists, invalidate cache + print(f"Cached audio file not found: {cached_audio_path}, regenerating prompt") # MLX voice prompt format - store audio path and text # The model will process this during generation @@ -263,6 +269,13 @@ class MLXTTSBackend: ref_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path") ref_text = voice_prompt.get("ref_text", "") + # Validate that the audio file exists + if ref_audio and not Path(ref_audio).exists(): + print(f"Warning: Audio file not found: {ref_audio}") + print("This may be due to a cached voice prompt referencing a deleted temp file.") + print("Regenerating without voice prompt.") + ref_audio = None + # Check if model supports voice cloning via generate method # MLX API may support ref_audio parameter directly try: diff --git a/backend/backends/pytorch_backend.py b/backend/backends/pytorch_backend.py index f03af3e0..cd1257cb 100644 --- a/backend/backends/pytorch_backend.py +++ b/backend/backends/pytorch_backend.py @@ -196,6 +196,8 @@ class PyTorchTTSBackend: # Cache stores as torch.Tensor but actual prompt is dict # Convert if needed if isinstance(cached_prompt, dict): + # For PyTorch backend, the dict should contain tensors, not file paths + # So we can safely return it return cached_prompt, True elif isinstance(cached_prompt, torch.Tensor): # Legacy cache format - convert to dict diff --git a/backend/main.py b/backend/main.py index a8a85a87..83a44bfe 100644 --- a/backend/main.py +++ b/backend/main.py @@ -27,6 +27,7 @@ from . import database, models, profiles, history, tts, transcribe, config, expo from .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile from .utils.progress import get_progress_manager from .utils.tasks import get_task_manager +from .utils.cache import clear_voice_prompt_cache from .platform_detect import get_backend_type app = FastAPI( @@ -1495,6 +1496,19 @@ async def delete_model(model_name: str): raise HTTPException(status_code=500, detail=f"Failed to delete model: {str(e)}") +@app.post("/cache/clear") +async def clear_cache(): + """Clear all voice prompt caches (memory and disk).""" + try: + deleted_count = clear_voice_prompt_cache() + return { + "message": f"Voice prompt cache cleared successfully", + "files_deleted": deleted_count, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to clear cache: {str(e)}") + + # ============================================ # TASK MANAGEMENT # ============================================ diff --git a/backend/profiles.py b/backend/profiles.py index 81c12876..42f2144b 100644 --- a/backend/profiles.py +++ b/backend/profiles.py @@ -22,6 +22,7 @@ from .database import ( ) from .utils.audio import validate_reference_audio, load_audio, save_audio from .utils.images import validate_image, process_avatar +from .utils.cache import _get_cache_dir from .tts import get_tts_model from . import config @@ -345,23 +346,27 @@ async def create_voice_prompt_for_profile( reference_texts, ) - # Save combined audio temporarily - import tempfile - with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: - save_audio(combined_audio, tmp.name, 24000) - tmp_path = tmp.name + # Save combined audio to cache directory (persistent) + # Create a hash of sample IDs to identify this specific combination + import hashlib + sample_ids_str = "-".join(sorted([s.id for s in samples])) + combination_hash = hashlib.md5(sample_ids_str.encode()).hexdigest()[:12] + + # Store in cache directory + cache_dir = _get_cache_dir() + cache_dir.mkdir(parents=True, exist_ok=True) + combined_path = cache_dir / f"combined_{profile_id}_{combination_hash}.wav" + + # Save combined audio + save_audio(combined_audio, str(combined_path), 24000) - try: - # Create prompt from combined audio - voice_prompt, _ = await tts_model.create_voice_prompt( - tmp_path, - combined_text, - use_cache=use_cache, - ) - return voice_prompt - finally: - # Clean up temp file - Path(tmp_path).unlink(missing_ok=True) + # Create prompt from combined audio + voice_prompt, _ = await tts_model.create_voice_prompt( + str(combined_path), + combined_text, + use_cache=use_cache, + ) + return voice_prompt async def upload_avatar( diff --git a/backend/utils/cache.py b/backend/utils/cache.py index a070781f..fddc7b16 100644 --- a/backend/utils/cache.py +++ b/backend/utils/cache.py @@ -88,3 +88,28 @@ def cache_voice_prompt( # Store on disk (torch.save can handle both dicts and tensors) cache_file = _get_cache_dir() / f"{cache_key}.prompt" torch.save(voice_prompt, cache_file) + + +def clear_voice_prompt_cache() -> int: + """ + Clear all voice prompt caches (memory and disk). + + Returns: + Number of cache files deleted + """ + # Clear memory cache + _memory_cache.clear() + + # Clear disk cache + cache_dir = _get_cache_dir() + deleted_count = 0 + + if cache_dir.exists(): + for cache_file in cache_dir.glob("*.prompt"): + try: + cache_file.unlink() + deleted_count += 1 + except Exception as e: + print(f"Failed to delete cache file {cache_file}: {e}") + + return deleted_count diff --git a/tauri/src-tauri/gen/Assets.car b/tauri/src-tauri/gen/Assets.car index 7d0523ee..e867da32 100644 Binary files a/tauri/src-tauri/gen/Assets.car and b/tauri/src-tauri/gen/Assets.car differ