Compare commits

...
Author SHA1 Message Date
Deniz ŞafakandGitHub cbc05ead42 Merge pull request #173 from k0sm0naft/refactor/tts-backend-interface
refactor: introduce TTS backend abstraction
2026-07-03 21:04:47 +03:00
Artem Akymenko 50b4d6872a feat: Add minimal TTSBackend interface for future extensibility
- Create TTSBackend abstract base class with minimal contract
- Implement KokoroTTSBackend that maintains existing behavior
- Update conversion_runner.py to use new interface
- No behavioral changes, GUI unchanged, no new features
2026-07-03 01:25:41 +03:00
2 changed files with 134 additions and 20 deletions
+117
View File
@@ -0,0 +1,117 @@
"""
Minimal TTS Backend Interface
This module defines a minimal interface for TTS backends to enable future
extensibility while maintaining backward compatibility with existing Kokoro
implementation.
"""
from abc import ABC, abstractmethod
from typing import Any, Iterator, Optional, Union
class TTSBackend(ABC):
"""
Minimal interface for TTS backends.
This interface is designed to be minimal and focused on the essential
operations needed for text-to-speech conversion.
"""
@abstractmethod
def __call__(
self,
text: str,
voice: Union[str, Any],
speed: float = 1.0,
**kwargs: Any
) -> Iterator[Any]:
"""
Generate speech segments from text.
Args:
text: Text to convert to speech
voice: Voice specification or object
speed: Speed multiplier for speech
**kwargs: Additional backend-specific parameters
Yields:
Speech segments (audio data, timing info, etc.)
"""
pass
class KokoroTTSBackend(TTSBackend):
"""
Implementation of TTSBackend using Kokoro.
This class provides the concrete implementation that maintains
the existing behavior while conforming to the TTSBackend interface.
"""
def __init__(self, lang_code: str, repo_id: str = "hexgrad/Kokoro-82M", device: str = "cpu"):
"""
Initialize Kokoro backend.
Args:
lang_code: Language code for the model
repo_id: Repository ID for the Kokoro model
device: Device to run the model on (cpu, cuda, etc.)
"""
self.lang_code = lang_code
self.repo_id = repo_id
self.device = device
self._pipeline = None
def _get_pipeline(self):
"""Lazy initialization of the Kokoro pipeline."""
if self._pipeline is None:
from abogen.utils import load_numpy_kpipeline
_, KPipeline = load_numpy_kpipeline()
try:
self._pipeline = KPipeline(
lang_code=self.lang_code,
repo_id=self.repo_id,
device=self.device
)
except RuntimeError as e:
if "CUDA" in str(e) and self.device != "cpu":
# Fall back to CPU if CUDA fails
self._pipeline = KPipeline(
lang_code=self.lang_code,
repo_id=self.repo_id,
device="cpu"
)
else:
raise
return self._pipeline
def __call__(
self,
text: str,
voice: Union[str, Any],
speed: float = 1.0,
split_pattern: str = r"\n+",
**kwargs: Any
) -> Iterator[Any]:
"""
Generate speech segments from text using Kokoro.
Args:
text: Text to convert to speech
voice: Voice specification or object
speed: Speed multiplier for speech
split_pattern: Pattern to split text into segments
**kwargs: Additional parameters passed to the pipeline
Yields:
Speech segments
"""
pipeline = self._get_pipeline()
return pipeline(
text,
voice=voice,
speed=speed,
split_pattern=split_pattern,
**kwargs
)
+17 -20
View File
@@ -41,6 +41,7 @@ from abogen.utils import (
load_config, load_config,
load_numpy_kpipeline, load_numpy_kpipeline,
) )
from abogen.tts_backend import KokoroTTSBackend
from abogen.voice_cache import ensure_voice_assets from abogen.voice_cache import ensure_voice_assets
from abogen.voice_formulas import extract_voice_ids, get_new_voice from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.voice_profiles import load_profiles, normalize_profile_entry from abogen.voice_profiles import load_profiles, normalize_profile_entry
@@ -1594,16 +1595,12 @@ def run_conversion_job(job: Job) -> None:
device = "cpu" device = "cpu"
if not disable_gpu: if not disable_gpu:
device = _select_device() device = _select_device()
_np, KPipeline = load_numpy_kpipeline() # Create KokoroTTSBackend instance instead of directly instantiating KPipeline
# Try to initialize with the selected device; fall back to CPU if CUDA fails pipelines[provider_norm] = KokoroTTSBackend(
try: lang_code=job.language,
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device) repo_id="hexgrad/Kokoro-82M",
except RuntimeError as e: device=device
if "CUDA" in str(e) and device != "cpu": )
job.add_log(f"CUDA initialization failed, falling back to CPU: {e}", level="warning")
pipelines[provider_norm] = KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device="cpu")
else:
raise
if not kokoro_cache_ready: if not kokoro_cache_ready:
_initialize_voice_cache(job) _initialize_voice_cache(job)
kokoro_cache_ready = True kokoro_cache_ready = True
@@ -1644,8 +1641,8 @@ def run_conversion_job(job: Job) -> None:
return provider, resolved, cached, speed, steps return provider, resolved, cached, speed, steps
if provider == "kokoro": if provider == "kokoro":
kokoro_pipeline = get_pipeline("kokoro") kokoro_backend = get_pipeline("kokoro")
choice = _resolve_voice(kokoro_pipeline, resolved, job.use_gpu) choice = _resolve_voice(kokoro_backend, resolved, job.use_gpu)
else: else:
choice = resolved choice = resolved
@@ -1774,8 +1771,8 @@ def run_conversion_job(job: Job) -> None:
voice_cache: Dict[str, Any] = {} voice_cache: Dict[str, Any] = {}
base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec) base_provider, base_voice_resolved, _, _ = resolve_voice_target(base_voice_spec)
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_pipeline = get_pipeline("kokoro") kokoro_backend = get_pipeline("kokoro")
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_pipeline, base_voice_resolved, job.use_gpu) voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu)
processed_chars = 0 processed_chars = 0
subtitle_index = 1 subtitle_index = 1
current_time = 0.0 current_time = 0.0
@@ -1860,8 +1857,8 @@ def run_conversion_job(job: Job) -> None:
total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)), total_steps=int(supertonic_steps_override if supertonic_steps_override is not None else getattr(job, "supertonic_total_steps", 5)),
) )
else: else:
kokoro_pipeline = get_pipeline("kokoro") kokoro_backend = get_pipeline("kokoro")
segment_iter = kokoro_pipeline( segment_iter = kokoro_backend(
normalized, normalized,
voice=voice_choice, voice=voice_choice,
speed=float(speed_override if speed_override is not None else job.speed), speed=float(speed_override if speed_override is not None else job.speed),
@@ -1950,8 +1947,8 @@ def run_conversion_job(job: Job) -> None:
if chapter_provider == "kokoro": if chapter_provider == "kokoro":
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_pipeline = get_pipeline("kokoro") kokoro_backend = get_pipeline("kokoro")
voice_choice = _resolve_voice(kokoro_pipeline, chapter_voice_resolved, job.use_gpu) voice_choice = _resolve_voice(kokoro_backend, chapter_voice_resolved, job.use_gpu)
voice_cache[chapter_cache_key] = voice_choice voice_cache[chapter_cache_key] = voice_choice
else: else:
voice_choice = chapter_voice_resolved voice_choice = chapter_voice_resolved
@@ -2095,9 +2092,9 @@ def run_conversion_job(job: Job) -> None:
if chunk_provider == "kokoro": if chunk_provider == "kokoro":
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_pipeline = get_pipeline("kokoro") kokoro_backend = get_pipeline("kokoro")
chunk_voice_choice = _resolve_voice( chunk_voice_choice = _resolve_voice(
kokoro_pipeline, kokoro_backend,
chunk_voice_resolved, chunk_voice_resolved,
job.use_gpu, job.use_gpu,
) )