Refine Character Voices waveform and discovery Implementation details: - Add normalized cached waveform rendering to the trim timeline - Add bounded playback progress with a smooth playhead - Anchor the trim transcription warning outside layout flow - Stop node schema queries from forcing voice discovery rescans - Deduplicate background discovery refreshes across instances Follow-up to #318
189 lines
6.8 KiB
Python
189 lines
6.8 KiB
Python
"""
|
|
Voice Discovery Cache Manager
|
|
Persistent caching system for voice character discovery to speed up initialization.
|
|
Cache is stored in .cache/ folder (gitignored) with background refresh.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import threading
|
|
import socket
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple, Set, Callable
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
_PROCESS_REFRESH_LOCK = threading.Lock()
|
|
_PROCESS_REFRESH_STATE: Dict[str, str] = {}
|
|
|
|
|
|
class VoiceDiscoveryCacheManager:
|
|
"""Manage persistent cache for voice discovery results with background refresh."""
|
|
|
|
CACHE_VERSION = "1.0"
|
|
|
|
def __init__(self, node_root_dir: Optional[str] = None):
|
|
"""
|
|
Initialize cache manager.
|
|
|
|
Args:
|
|
node_root_dir: Root directory of the TTS Audio Suite node (auto-detected if None)
|
|
"""
|
|
self.node_root_dir = node_root_dir or self._get_node_root()
|
|
self.cache_dir = os.path.join(self.node_root_dir, ".cache")
|
|
self.cache_file = os.path.join(self.cache_dir, "voice_discovery.json")
|
|
self.metadata_file = os.path.join(self.cache_dir, "cache_metadata.json")
|
|
|
|
# Background refresh tracking
|
|
self._refresh_thread = None
|
|
self._refresh_callback = None
|
|
self._stop_refresh = False
|
|
self._refresh_lock = threading.Lock()
|
|
|
|
# Ensure cache directory exists
|
|
os.makedirs(self.cache_dir, exist_ok=True)
|
|
|
|
def _get_node_root(self) -> str:
|
|
"""Get the root directory of the TTS Audio Suite node."""
|
|
# This file is at utils/voice/cache_manager.py, so go up 3 levels to get node root
|
|
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
|
|
|
def is_cache_valid(self) -> bool:
|
|
"""
|
|
Check if cache file exists and is readable.
|
|
No expiration check - cache lives until ComfyUI refresh or manually invalidated.
|
|
|
|
Returns:
|
|
True if cache file exists and can be loaded
|
|
"""
|
|
if not os.path.exists(self.cache_file):
|
|
return False
|
|
|
|
try:
|
|
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
|
json.load(f) # Validate JSON is readable
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def load_cache(self) -> Optional[Dict]:
|
|
"""
|
|
Load voice discovery cache from disk.
|
|
|
|
Returns:
|
|
Cached data or None if cache doesn't exist/is invalid
|
|
"""
|
|
if not os.path.exists(self.cache_file):
|
|
return None
|
|
|
|
try:
|
|
with open(self.cache_file, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
# Silently fail - cache is just an optimization
|
|
return None
|
|
|
|
def save_cache(self, cache_data: Dict):
|
|
"""
|
|
Save voice discovery cache to disk.
|
|
|
|
Args:
|
|
cache_data: Voice discovery data to cache
|
|
"""
|
|
try:
|
|
with open(self.cache_file, 'w', encoding='utf-8') as f:
|
|
json.dump(cache_data, f, indent=2)
|
|
except Exception:
|
|
# Silently fail - cache is just an optimization
|
|
pass
|
|
|
|
def invalidate_cache(self):
|
|
"""Manually invalidate the cache."""
|
|
try:
|
|
if os.path.exists(self.cache_file):
|
|
os.remove(self.cache_file)
|
|
if os.path.exists(self.metadata_file):
|
|
os.remove(self.metadata_file)
|
|
except Exception:
|
|
pass
|
|
|
|
def start_background_refresh(self, refresh_callback: Callable[[], Tuple[Dict, bool]]):
|
|
"""
|
|
Start background refresh thread. Scans directories once after ComfyUI loads.
|
|
|
|
Args:
|
|
refresh_callback: Callable that returns (cache_data, was_updated) tuple
|
|
"""
|
|
if self._refresh_thread is not None and self._refresh_thread.is_alive():
|
|
return # Already running
|
|
|
|
refresh_key = os.path.normcase(os.path.abspath(self.cache_file))
|
|
with _PROCESS_REFRESH_LOCK:
|
|
if _PROCESS_REFRESH_STATE.get(refresh_key) in {"running", "complete"}:
|
|
return
|
|
_PROCESS_REFRESH_STATE[refresh_key] = "running"
|
|
|
|
self._refresh_callback = refresh_callback
|
|
self._stop_refresh = False
|
|
self._refresh_thread = threading.Thread(target=self._background_refresh_worker, daemon=True)
|
|
try:
|
|
self._refresh_thread.start()
|
|
except Exception:
|
|
with _PROCESS_REFRESH_LOCK:
|
|
if _PROCESS_REFRESH_STATE.get(refresh_key) == "running":
|
|
_PROCESS_REFRESH_STATE.pop(refresh_key, None)
|
|
raise
|
|
|
|
def _background_refresh_worker(self):
|
|
"""Background thread worker - performs one cache update after startup."""
|
|
refresh_key = os.path.normcase(os.path.abspath(self.cache_file))
|
|
refresh_completed = False
|
|
try:
|
|
# Give ComfyUI time to fully load before starting scan
|
|
time.sleep(2)
|
|
|
|
if self._stop_refresh:
|
|
return
|
|
|
|
# Call the refresh callback to get updated data
|
|
if self._refresh_callback:
|
|
with self._refresh_lock:
|
|
try:
|
|
result = self._refresh_callback()
|
|
if isinstance(result, tuple):
|
|
updated_data, was_updated = result
|
|
else:
|
|
updated_data = result
|
|
was_updated = False
|
|
|
|
refresh_completed = True
|
|
|
|
if updated_data:
|
|
self.save_cache(updated_data)
|
|
# Return whether cache was actually updated
|
|
return was_updated
|
|
except Exception:
|
|
pass # Silently fail - background refresh is optional
|
|
except Exception:
|
|
pass # Handle any outer exceptions silently
|
|
finally:
|
|
with _PROCESS_REFRESH_LOCK:
|
|
if refresh_completed:
|
|
_PROCESS_REFRESH_STATE[refresh_key] = "complete"
|
|
elif _PROCESS_REFRESH_STATE.get(refresh_key) == "running":
|
|
_PROCESS_REFRESH_STATE.pop(refresh_key, None)
|
|
|
|
# Properly close any lingering sockets on Windows before thread exit
|
|
try:
|
|
import gc
|
|
gc.collect() # Force garbage collection to clean up resources
|
|
except Exception:
|
|
pass
|
|
|
|
def stop_background_refresh(self):
|
|
"""Stop the background refresh thread."""
|
|
self._stop_refresh = True
|
|
if self._refresh_thread and self._refresh_thread.is_alive():
|
|
self._refresh_thread.join(timeout=1)
|