mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -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 { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { HistoryResponse } from '@/lib/api/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -33,18 +34,21 @@ import { usePlayerStore } from '@/stores/playerStore';
|
|||||||
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
// OLD TABLE-BASED COMPONENT - REMOVED (can be found in git history)
|
||||||
// This is the new alternate history view with fixed height rows
|
// 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() {
|
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 [isScrolled, setIsScrolled] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: historyData, isLoading } = useHistory({
|
const { data: historyData, isLoading, isFetching } = useHistory({
|
||||||
limit,
|
limit,
|
||||||
offset: page * limit,
|
offset: page * limit,
|
||||||
});
|
});
|
||||||
@@ -60,6 +64,56 @@ export function HistoryTable() {
|
|||||||
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
const audioUrl = usePlayerStore((state) => state.audioUrl);
|
||||||
const isPlayerVisible = !!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(() => {
|
useEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
if (!scrollEl) return;
|
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 = () => {
|
const handleImportConfirm = () => {
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
importGeneration.mutate(selectedFile, {
|
importGeneration.mutate(selectedFile, {
|
||||||
@@ -159,13 +192,16 @@ export function HistoryTable() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading && page === 0) {
|
||||||
return null;
|
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 history = allHistory;
|
||||||
const total = historyData?.total || 0;
|
const hasMore = allHistory.length < total;
|
||||||
const _hasMore = history.length === limit && (page + 1) * limit < total;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 relative">
|
<div className="flex flex-col h-full min-h-0 relative">
|
||||||
@@ -284,6 +320,20 @@ export function HistoryTable() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { Plus, Trash2, Play, Edit, Check, X, Volume2, Pause } from 'lucide-react
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CircleButton } from '@/components/ui/circle-button';
|
import { CircleButton } from '@/components/ui/circle-button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useDeleteSample, useProfileSamples, useUpdateSample } from '@/lib/hooks/useProfiles';
|
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">
|
<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" />
|
<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-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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -206,7 +208,7 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
key={sample.id}
|
key={sample.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative rounded-lg border bg-card transition-all duration-200',
|
'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 ? (
|
{isEditing ? (
|
||||||
@@ -287,11 +289,22 @@ export function SampleList({ profileId }: SampleListProps) {
|
|||||||
</div>
|
</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" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Sample
|
Add Sample
|
||||||
</Button>
|
</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} />
|
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -175,7 +175,13 @@ class MLXTTSBackend:
|
|||||||
if cached_prompt is not None:
|
if cached_prompt is not None:
|
||||||
# Return cached prompt (should be dict format)
|
# Return cached prompt (should be dict format)
|
||||||
if isinstance(cached_prompt, dict):
|
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
|
# MLX voice prompt format - store audio path and text
|
||||||
# The model will process this during generation
|
# 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_audio = voice_prompt.get("ref_audio") or voice_prompt.get("ref_audio_path")
|
||||||
ref_text = voice_prompt.get("ref_text", "")
|
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
|
# Check if model supports voice cloning via generate method
|
||||||
# MLX API may support ref_audio parameter directly
|
# MLX API may support ref_audio parameter directly
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ class PyTorchTTSBackend:
|
|||||||
# Cache stores as torch.Tensor but actual prompt is dict
|
# Cache stores as torch.Tensor but actual prompt is dict
|
||||||
# Convert if needed
|
# Convert if needed
|
||||||
if isinstance(cached_prompt, dict):
|
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
|
return cached_prompt, True
|
||||||
elif isinstance(cached_prompt, torch.Tensor):
|
elif isinstance(cached_prompt, torch.Tensor):
|
||||||
# Legacy cache format - convert to dict
|
# 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 .database import get_db, Generation as DBGeneration, VoiceProfile as DBVoiceProfile
|
||||||
from .utils.progress import get_progress_manager
|
from .utils.progress import get_progress_manager
|
||||||
from .utils.tasks import get_task_manager
|
from .utils.tasks import get_task_manager
|
||||||
|
from .utils.cache import clear_voice_prompt_cache
|
||||||
from .platform_detect import get_backend_type
|
from .platform_detect import get_backend_type
|
||||||
|
|
||||||
app = FastAPI(
|
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)}")
|
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
|
# TASK MANAGEMENT
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
+21
-16
@@ -22,6 +22,7 @@ from .database import (
|
|||||||
)
|
)
|
||||||
from .utils.audio import validate_reference_audio, load_audio, save_audio
|
from .utils.audio import validate_reference_audio, load_audio, save_audio
|
||||||
from .utils.images import validate_image, process_avatar
|
from .utils.images import validate_image, process_avatar
|
||||||
|
from .utils.cache import _get_cache_dir
|
||||||
from .tts import get_tts_model
|
from .tts import get_tts_model
|
||||||
from . import config
|
from . import config
|
||||||
|
|
||||||
@@ -345,23 +346,27 @@ async def create_voice_prompt_for_profile(
|
|||||||
reference_texts,
|
reference_texts,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save combined audio temporarily
|
# Save combined audio to cache directory (persistent)
|
||||||
import tempfile
|
# Create a hash of sample IDs to identify this specific combination
|
||||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
import hashlib
|
||||||
save_audio(combined_audio, tmp.name, 24000)
|
sample_ids_str = "-".join(sorted([s.id for s in samples]))
|
||||||
tmp_path = tmp.name
|
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
|
||||||
# Create prompt from combined audio
|
voice_prompt, _ = await tts_model.create_voice_prompt(
|
||||||
voice_prompt, _ = await tts_model.create_voice_prompt(
|
str(combined_path),
|
||||||
tmp_path,
|
combined_text,
|
||||||
combined_text,
|
use_cache=use_cache,
|
||||||
use_cache=use_cache,
|
)
|
||||||
)
|
return voice_prompt
|
||||||
return voice_prompt
|
|
||||||
finally:
|
|
||||||
# Clean up temp file
|
|
||||||
Path(tmp_path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
|
|||||||
@@ -88,3 +88,28 @@ def cache_voice_prompt(
|
|||||||
# Store on disk (torch.save can handle both dicts and tensors)
|
# Store on disk (torch.save can handle both dicts and tensors)
|
||||||
cache_file = _get_cache_dir() / f"{cache_key}.prompt"
|
cache_file = _get_cache_dir() / f"{cache_key}.prompt"
|
||||||
torch.save(voice_prompt, cache_file)
|
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