unify voice caching: VoiceCache class used by both WebUI and PyQt

- domain/voice_loader.py: VoiceCache class now used by both UIs;
  resolve_voice() and load_voice_cached() accept VoiceCache or plain dict;
  added hasattr(pipeline, 'load_single_voice') safety check from WebUI
- conversion_runner.py: replaced local _resolve_voice() with domain's
  resolve_voice(); voice_cache changed from Dict to VoiceCache instance;
  all cache access uses VoiceCache.get()/set() API
- pyqt/conversion.py: self.voice_cache changed from Dict to VoiceCache
- debug_tts_runner.py: imports resolve_voice from domain instead of
  removed _resolve_voice from conversion_runner
This commit is contained in:
Artem Akymenko
2026-07-20 08:02:34 +00:00
parent 804517f5b2
commit f38700025a
4 changed files with 35 additions and 34 deletions
+21 -13
View File
@@ -62,10 +62,10 @@ def resolve_voice(
# Check cache first # Check cache first
if cache and cache.contains(voice_spec): if cache and cache.contains(voice_spec):
return cache.get(voice_spec) return cache.get(voice_spec)
# Load voice # Load voice
if "*" in voice_spec: if "*" in voice_spec:
if pipeline is None: if pipeline is None or not hasattr(pipeline, "load_single_voice"):
return voice_spec return voice_spec
loaded_voice = get_new_voice(pipeline, voice_spec, use_gpu) loaded_voice = get_new_voice(pipeline, voice_spec, use_gpu)
else: else:
@@ -82,35 +82,43 @@ def load_voice_cached(
voice_name: str, voice_name: str,
pipeline: Any, pipeline: Any,
use_gpu: bool, use_gpu: bool,
cache: Optional[Dict[str, Any]] = None, cache: Any = None,
) -> Any: ) -> Any:
"""Load voice with caching (compatibility wrapper for PyQt). """Load voice with caching (compatibility wrapper for PyQt).
This function maintains backward compatibility with the PyQt interface This function maintains backward compatibility with the PyQt interface
while using the unified voice loading logic. while using the unified voice loading logic.
Args: Args:
voice_name: Voice name or formula string. voice_name: Voice name or formula string.
pipeline: TTS pipeline instance. pipeline: TTS pipeline instance.
use_gpu: Whether to use GPU. use_gpu: Whether to use GPU.
cache: Optional dict to use as cache (instead of VoiceCache). cache: Optional VoiceCache or dict to use as cache.
Returns: Returns:
Loaded voice tensor or voice name string. Loaded voice tensor or voice name string.
""" """
# Use dict cache if provided (for backward compatibility) # Check cache (supports both VoiceCache and plain dict)
if cache is not None: if cache is not None:
if voice_name in cache: if isinstance(cache, VoiceCache):
if cache.contains(voice_name):
return cache.get(voice_name)
elif voice_name in cache:
return cache[voice_name] return cache[voice_name]
# Load voice # Load voice
if "*" in voice_name: if "*" in voice_name:
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
return voice_name
loaded_voice = get_new_voice(pipeline, voice_name, use_gpu) loaded_voice = get_new_voice(pipeline, voice_name, use_gpu)
else: else:
loaded_voice = voice_name loaded_voice = voice_name
# Cache it # Cache it
if cache is not None: if cache is not None:
cache[voice_name] = loaded_voice if isinstance(cache, VoiceCache):
cache.set(voice_name, loaded_voice)
else:
cache[voice_name] = loaded_voice
return loaded_voice return loaded_voice
+2 -2
View File
@@ -44,7 +44,7 @@ from abogen.domain.audio_buffer import (
SAMPLE_RATE, SAMPLE_RATE,
) )
from abogen.domain.subtitle_generation import process_subtitle_tokens from abogen.domain.subtitle_generation import process_subtitle_tokens
from abogen.domain.voice_loader import load_voice_cached, resolve_voice from abogen.domain.voice_loader import VoiceCache, load_voice_cached, resolve_voice
from abogen.domain.progress import calc_etr_str from abogen.domain.progress import calc_etr_str
from abogen.domain.normalization import prepare_text_for_tts from abogen.domain.normalization import prepare_text_for_tts
from abogen.domain.pronunciation import ( from abogen.domain.pronunciation import (
@@ -295,7 +295,7 @@ class ConversionThread(QThread):
self.use_spacy_segmentation = True # Default, will be overridden from GUI self.use_spacy_segmentation = True # Default, will be overridden from GUI
# Set split pattern based on language and subtitle mode # Set split pattern based on language and subtitle mode
self.split_pattern = get_split_pattern(lang_code, subtitle_mode) self.split_pattern = get_split_pattern(lang_code, subtitle_mode)
self.voice_cache = {} # Cache for loaded voices self.voice_cache = VoiceCache() # Cache for loaded voices
def load_voice_cached(self, voice_name, tts): def load_voice_cached(self, voice_name, tts):
"""Load voice with caching to avoid reloading same voice. """Load voice with caching to avoid reloading same voice.
+9 -17
View File
@@ -119,6 +119,7 @@ from abogen.domain.audio_buffer import (
from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.audio_sink import AudioSink, open_audio_sink
from abogen.domain.pipeline_factory import PipelinePool from abogen.domain.pipeline_factory import PipelinePool
from abogen.domain.conversion_engine import run_tts_segment_loop, process_and_write_subtitles, SegmentStats from abogen.domain.conversion_engine import run_tts_segment_loop, process_and_write_subtitles, SegmentStats
from abogen.domain.voice_loader import VoiceCache, resolve_voice
from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target from abogen.domain.voice_utils import resolve_voice_target as _resolve_voice_target
@@ -216,11 +217,11 @@ def run_conversion_job(job: Job) -> None:
if provider == "kokoro": if provider == "kokoro":
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
choice = _resolve_voice(kokoro_backend, resolved, job.use_gpu) choice = resolve_voice(resolved, kokoro_backend, job.use_gpu, cache=voice_cache)
else: else:
choice = resolved choice = resolved
voice_cache[cache_key] = choice voice_cache.set(cache_key, choice)
return provider, resolved, choice, speed, steps return provider, resolved, choice, speed, steps
extraction = extract_from_path(job.stored_path) extraction = extract_from_path(job.stored_path)
@@ -360,7 +361,7 @@ def run_conversion_job(job: Job) -> None:
chapter_dir.mkdir(parents=True, exist_ok=True) chapter_dir.mkdir(parents=True, exist_ok=True)
base_voice_spec = _job_voice_fallback(job) base_voice_spec = _job_voice_fallback(job)
voice_cache: Dict[str, Any] = {} voice_cache = VoiceCache()
base_provider, base_voice_resolved, _, _ = _resolve_voice_target( base_provider, base_voice_resolved, _, _ = _resolve_voice_target(
base_voice_spec, normalized_profiles, base_voice_spec, normalized_profiles,
job_voice=getattr(job, "voice", "M1"), job_voice=getattr(job, "voice", "M1"),
@@ -368,7 +369,7 @@ def run_conversion_job(job: Job) -> None:
) )
if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved: if base_provider == "kokoro" and base_voice_resolved and "*" not in base_voice_resolved:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu) voice_cache.set(f"kokoro:{base_voice_resolved}", resolve_voice(base_voice_resolved, kokoro_backend, job.use_gpu))
processed_chars = 0 processed_chars = 0
current_time = 0.0 current_time = 0.0
etr_start_time = time.time() etr_start_time = time.time()
@@ -550,8 +551,7 @@ def run_conversion_job(job: Job) -> None:
voice_choice = voice_cache.get(chapter_cache_key) voice_choice = voice_cache.get(chapter_cache_key)
if voice_choice is None: if voice_choice is None:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
voice_choice = _resolve_voice(kokoro_backend, chapter_voice_resolved, job.use_gpu) voice_choice = resolve_voice(chapter_voice_resolved, kokoro_backend, job.use_gpu, cache=voice_cache)
voice_cache[chapter_cache_key] = voice_choice
else: else:
voice_choice = chapter_voice_resolved voice_choice = chapter_voice_resolved
@@ -694,12 +694,12 @@ def run_conversion_job(job: Job) -> None:
chunk_voice_choice = voice_cache.get(chunk_cache_key) chunk_voice_choice = voice_cache.get(chunk_cache_key)
if chunk_voice_choice is None: if chunk_voice_choice is None:
kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job) kokoro_backend = pipeline_pool.get("kokoro", job.language, job.use_gpu, job=job)
chunk_voice_choice = _resolve_voice( chunk_voice_choice = resolve_voice(
kokoro_backend,
chunk_voice_resolved, chunk_voice_resolved,
kokoro_backend,
job.use_gpu, job.use_gpu,
cache=voice_cache,
) )
voice_cache[chunk_cache_key] = chunk_voice_choice
else: else:
chunk_voice_choice = chunk_voice_resolved chunk_voice_choice = chunk_voice_resolved
@@ -1066,14 +1066,6 @@ def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path,
def _resolve_voice(pipeline, voice_spec: str, use_gpu: bool):
if "*" in voice_spec:
if pipeline is None or not hasattr(pipeline, "load_single_voice"):
return voice_spec
return get_new_voice(pipeline, voice_spec, use_gpu)
return voice_spec
def _make_canceller(job: Job) -> Callable[[], None]: def _make_canceller(job: Job) -> Callable[[], None]:
def _cancel() -> None: def _cancel() -> None:
+3 -2
View File
@@ -14,7 +14,8 @@ from abogen.kokoro_text_normalization import normalize_for_pipeline
from abogen.normalization_settings import build_apostrophe_config from abogen.normalization_settings import build_apostrophe_config
from abogen.text_extractor import extract_from_path from abogen.text_extractor import extract_from_path
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.webui.conversion_runner import SAMPLE_RATE, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids from abogen.webui.conversion_runner import SAMPLE_RATE, _select_device, _to_float32, _spec_to_voice_ids
from abogen.domain.voice_loader import resolve_voice
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
from abogen.tts_plugin.utils import create_pipeline from abogen.tts_plugin.utils import create_pipeline
@@ -176,7 +177,7 @@ def run_debug_tts_wavs(
pass pass
pipeline = _load_pipeline(language, use_gpu) pipeline = _load_pipeline(language, use_gpu)
voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu) voice_choice = resolve_voice(voice_spec, pipeline, use_gpu)
apostrophe_config = build_apostrophe_config(settings=settings) apostrophe_config = build_apostrophe_config(settings=settings)
normalization_settings = dict(settings) normalization_settings = dict(settings)