mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
Enhance HistoryTable Component with Infinite Scroll and Cache Management
- Updated HistoryTable to implement infinite scrolling for loading history items dynamically. - Introduced state management for accumulated history and total item count. - Added Intersection Observer for triggering additional data fetches when scrolling. - Implemented cache clearing functionality in the backend to manage voice prompt caches effectively. - Improved loading indicators and user feedback for data fetching states. - Refactored code for better readability and maintainability.
This commit is contained in:
@@ -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<HistoryResponse[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col h-full min-h-0 relative">
|
||||
@@ -284,6 +320,20 @@ export function HistoryTable() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Load more trigger element */}
|
||||
{hasMore && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{isFetching && <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of list indicator */}
|
||||
{!hasMore && history.length > 0 && (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">
|
||||
You've reached the end
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
|
||||
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No samples yet</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">Add your first audio sample to get started</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
Add your first audio sample to get started
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Sample
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center px-2">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ============================================
|
||||
|
||||
+21
-16
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user