mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
refactor: unify Language enum across all layers
- EngineConfig.language: Language (was lang_code: str = 'a') - Engine owns _KOKORO_LANG_MAP, engine_language(), supported_languages() - Engine provides language_for_voice_id() for voice catalog - Plugins/kokoro/__init__.py calls engine_language() internally - create_pipeline(plugin_id, language=Language) — no kokoro codes - pipeline_factory.py clean of kokoro-specific code - Domain functions raise TypeError if non-enum passed - WebUI api.py: _parse_language() helper at API boundary - Voice catalog returns ISO codes (lang.value) - Constants: LANGUAGE_DESCRIPTIONS keyed by Language enum - All tests updated for Language enum - 1414 tests pass
This commit is contained in:
@@ -254,7 +254,7 @@ def execute_conversion(
|
||||
audio_sink=audio_sink,
|
||||
subtitle_mode=effective_subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
lang_code=request.language,
|
||||
language=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
)
|
||||
|
||||
@@ -470,7 +470,7 @@ def execute_conversion(
|
||||
subtitle_writer,
|
||||
subtitle_mode=request.subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
lang_code=request.language,
|
||||
language=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
fallback_end_time=stats.current_time,
|
||||
)
|
||||
@@ -480,7 +480,7 @@ def execute_conversion(
|
||||
chapter_subtitle_writer,
|
||||
subtitle_mode=request.subtitle_mode,
|
||||
max_subtitle_words=request.max_subtitle_words,
|
||||
lang_code=request.language,
|
||||
language=request.language,
|
||||
use_spacy_segmentation=use_spacy,
|
||||
fallback_end_time=stats.current_time,
|
||||
)
|
||||
|
||||
+27
-14
@@ -1,4 +1,5 @@
|
||||
from abogen.utils import get_version
|
||||
from abogen.domain.enums import Language
|
||||
|
||||
# Program Information
|
||||
PROGRAM_NAME = "abogen"
|
||||
@@ -16,8 +17,22 @@ SUBTITLE_FORMATS = [
|
||||
("ass_centered_narrow", "ASS (centered narrow)"),
|
||||
]
|
||||
|
||||
# Language description mapping
|
||||
# Language description mapping (Language enum → human-readable label).
|
||||
LANGUAGE_DESCRIPTIONS = {
|
||||
Language.EN_US: "American English",
|
||||
Language.EN_GB: "British English",
|
||||
Language.ES: "Spanish",
|
||||
Language.FR: "French",
|
||||
Language.HI: "Hindi",
|
||||
Language.IT: "Italian",
|
||||
Language.JA: "Japanese",
|
||||
Language.PT_BR: "Brazilian Portuguese",
|
||||
Language.ZH: "Mandarin Chinese",
|
||||
}
|
||||
|
||||
# Display-only mapping for kokoro codes → labels.
|
||||
# Used by voice catalog and PyQt (legacy) where kokoro codes are still present.
|
||||
KOKORO_CODE_LABELS = {
|
||||
"a": "American English",
|
||||
"b": "British English",
|
||||
"e": "Spanish",
|
||||
@@ -56,24 +71,22 @@ SUPPORTED_INPUT_FORMATS = [
|
||||
]
|
||||
|
||||
# Supported languages for subtitle generation
|
||||
# Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation.
|
||||
# Currently, only English (EN_US, EN_GB) are supported for subtitle generation.
|
||||
# This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline.
|
||||
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
||||
# 383 English processing (unchanged)
|
||||
# 384 if self.lang_code in 'ab':
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [Language.EN_US, Language.EN_GB]
|
||||
|
||||
# Voice and sample text mapping
|
||||
SAMPLE_VOICE_TEXTS = {
|
||||
"a": "This is a sample of the selected voice.",
|
||||
"b": "This is a sample of the selected voice.",
|
||||
"e": "Este es una muestra de la voz seleccionada.",
|
||||
"f": "Ceci est un exemple de la voix sélectionnée.",
|
||||
"h": "यह चयनित आवाज़ का एक नमूना है।",
|
||||
"i": "Questo è un esempio della voce selezionata.",
|
||||
"j": "これは選択した声のサンプルです。",
|
||||
"p": "Este é um exemplo da voz selecionada.",
|
||||
"z": "这是所选语音的示例。",
|
||||
Language.EN_US: "This is a sample of the selected voice.",
|
||||
Language.EN_GB: "This is a sample of the selected voice.",
|
||||
Language.ES: "Este es una muestra de la voz seleccionada.",
|
||||
Language.FR: "Ceci est un exemple de la voix sélectionnée.",
|
||||
Language.HI: "यह चयनित आवाज़ का एक नमूना है।",
|
||||
Language.IT: "Questo è un esempio della voce selezionata.",
|
||||
Language.JA: "これは選択した声のサンプルです。",
|
||||
Language.PT_BR: "Este é um exemplo da voz selecionada.",
|
||||
Language.ZH: "这是所选语音的示例。",
|
||||
}
|
||||
|
||||
COLORS = {
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, Iterable, Mapping
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.pronunciation_store import increment_usage
|
||||
|
||||
|
||||
@@ -44,7 +45,7 @@ def record_override_usage(
|
||||
if not usage_counter:
|
||||
return
|
||||
|
||||
language = getattr(job, "language", "") or "a"
|
||||
language = getattr(job, "language", Language.EN_US) or Language.EN_US
|
||||
for normalized, amount in usage_counter.items():
|
||||
if amount <= 0:
|
||||
continue
|
||||
|
||||
@@ -23,7 +23,7 @@ from typing import Any, Callable, Optional, Protocol
|
||||
|
||||
from abogen.domain.audio_sink import AudioSink
|
||||
from abogen.domain.conversion_pipeline import tts_segments
|
||||
from abogen.domain.enums import SubtitleMode
|
||||
from abogen.domain.enums import Language, SubtitleMode
|
||||
from abogen.domain.normalization import TTSContext
|
||||
from abogen.domain.progress import calc_etr_str
|
||||
from abogen.domain.subtitle_generation import process_subtitle_tokens
|
||||
@@ -153,7 +153,7 @@ def process_and_write_subtitles(
|
||||
*,
|
||||
subtitle_mode: str,
|
||||
max_subtitle_words: int,
|
||||
lang_code: str,
|
||||
language: Language,
|
||||
use_spacy_segmentation: bool,
|
||||
fallback_end_time: float,
|
||||
) -> None:
|
||||
@@ -169,7 +169,7 @@ def process_and_write_subtitles(
|
||||
new_entries,
|
||||
max_subtitle_words,
|
||||
subtitle_mode,
|
||||
lang_code,
|
||||
language,
|
||||
use_spacy_segmentation=use_spacy_segmentation,
|
||||
fallback_end_time=fallback_end_time,
|
||||
)
|
||||
@@ -191,7 +191,7 @@ class SynthParams:
|
||||
audio_sink: Optional[AudioSink] = None
|
||||
subtitle_mode: str = "Disabled"
|
||||
max_subtitle_words: int = 50
|
||||
lang_code: str = "a"
|
||||
language: Language = Language.EN_US
|
||||
use_spacy_segmentation: bool = False
|
||||
|
||||
|
||||
|
||||
@@ -282,7 +282,7 @@ def emit_text_to_sinks(
|
||||
# subtitle
|
||||
subtitle_writer: Any = None,
|
||||
subtitle_mode: str = "Disabled",
|
||||
subtitle_lang: str = "a",
|
||||
subtitle_lang: Language = Language.EN_US,
|
||||
max_subtitle_words: int = 50,
|
||||
use_spacy_segmentation: bool = True,
|
||||
# normalization
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.kokoro_text_normalization import (
|
||||
ApostropheConfig,
|
||||
normalize_for_pipeline as _normalize_for_pipeline,
|
||||
@@ -127,7 +128,7 @@ def prepare_text_for_tts(
|
||||
|
||||
def build_tts_context(
|
||||
*,
|
||||
language: str = "a",
|
||||
language: Language,
|
||||
subtitle_mode: str = "Disabled",
|
||||
pronunciation_overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
manual_overrides: Optional[List[Dict[str, Any]]] = None,
|
||||
@@ -143,7 +144,7 @@ def build_tts_context(
|
||||
merges pronunciation overrides, and compiles all rules.
|
||||
|
||||
Args:
|
||||
language: Language code (a, b, e, f, etc.).
|
||||
language: Language enum value.
|
||||
subtitle_mode: Subtitle mode string.
|
||||
pronunciation_overrides: List of pronunciation override dicts.
|
||||
manual_overrides: List of manual override dicts.
|
||||
@@ -199,15 +200,13 @@ def build_tts_context(
|
||||
)
|
||||
|
||||
# Compute split pattern
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = Language.EN_US
|
||||
if not isinstance(language, Language):
|
||||
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
|
||||
try:
|
||||
mode = SubtitleMode.from_str(subtitle_mode) if not isinstance(subtitle_mode, SubtitleMode) else subtitle_mode
|
||||
except ValueError:
|
||||
mode = SubtitleMode.DISABLED
|
||||
split_pattern = get_split_pattern(lang, mode)
|
||||
split_pattern = get_split_pattern(language, mode)
|
||||
|
||||
# Merge pronunciation overrides (accepts dict or object)
|
||||
source = {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
Provides a unified interface for creating and managing TTS pipelines
|
||||
across all UI layers (WebUI, PyQt, CLI).
|
||||
|
||||
Language handling: the engine owns the mapping between Language enum
|
||||
and its internal format. Callers pass Language enum; the engine
|
||||
converts internally. No engine-specific codes leak outside the engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,19 +17,6 @@ from abogen.domain.enums import Language
|
||||
from abogen.domain.voice_resolution import initialize_voice_cache
|
||||
from abogen.tts_plugin.utils import create_pipeline, is_plugin_registered
|
||||
|
||||
# Kokoro-specific language mapping (engine's responsibility)
|
||||
_KOKORO_LANG_MAP = {
|
||||
Language.EN_US: "a",
|
||||
Language.EN_GB: "b",
|
||||
Language.ES: "e",
|
||||
Language.FR: "f",
|
||||
Language.HI: "h",
|
||||
Language.IT: "i",
|
||||
Language.JA: "j",
|
||||
Language.PT_BR: "p",
|
||||
Language.ZH: "z",
|
||||
}
|
||||
|
||||
|
||||
def resolve_device(use_gpu: bool) -> str:
|
||||
"""Determine compute device from job and global config flags."""
|
||||
@@ -39,29 +30,25 @@ def resolve_device(use_gpu: bool) -> str:
|
||||
|
||||
def create_pipeline_for_job(
|
||||
provider: str,
|
||||
language: str,
|
||||
language: Language,
|
||||
use_gpu: bool,
|
||||
) -> Any:
|
||||
"""Create a TTS pipeline with proper device selection.
|
||||
|
||||
Handles provider validation, GPU decision, and plugin checks.
|
||||
Args:
|
||||
provider: TTS provider name ("kokoro" or "supertonic").
|
||||
language: Language enum (app-layer type, not engine-specific).
|
||||
use_gpu: Whether GPU acceleration is requested.
|
||||
"""
|
||||
provider = str(provider or "kokoro").strip().lower() or "kokoro"
|
||||
if not is_plugin_registered(provider):
|
||||
provider = "kokoro"
|
||||
|
||||
# Convert Language enum to Kokoro single-letter code
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = Language.EN_US # fallback for unknown languages
|
||||
kokoro_code = _KOKORO_LANG_MAP.get(lang, "a")
|
||||
|
||||
if provider == "supertonic":
|
||||
return create_pipeline("supertonic")
|
||||
|
||||
device = resolve_device(use_gpu)
|
||||
return create_pipeline("kokoro", lang_code=kokoro_code, device=device)
|
||||
return create_pipeline("kokoro", language=language, device=device)
|
||||
|
||||
|
||||
def dispose_pipelines(pipelines: Dict[str, Any]) -> None:
|
||||
@@ -80,7 +67,7 @@ class PipelinePool:
|
||||
Usage::
|
||||
|
||||
pool = PipelinePool()
|
||||
backend = pool.get("kokoro", "en", use_gpu=True)
|
||||
backend = pool.get("kokoro", Language.EN_US, use_gpu=True)
|
||||
# ... use backend ...
|
||||
pool.dispose_all()
|
||||
"""
|
||||
@@ -92,7 +79,7 @@ class PipelinePool:
|
||||
def get(
|
||||
self,
|
||||
provider: str,
|
||||
language: str,
|
||||
language: Language,
|
||||
use_gpu: bool,
|
||||
*,
|
||||
request: Any = None,
|
||||
@@ -102,7 +89,7 @@ class PipelinePool:
|
||||
|
||||
Args:
|
||||
provider: TTS provider name ("kokoro" or "supertonic").
|
||||
language: Language code (for kokoro).
|
||||
language: Language enum (app-layer type).
|
||||
use_gpu: Whether GPU acceleration is requested.
|
||||
request: ConversionRequest for voice cache initialization.
|
||||
events: ConversionEvents for logging during cache init.
|
||||
|
||||
@@ -13,7 +13,7 @@ from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Mapping
|
||||
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
KOKORO_CODE_LABELS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
)
|
||||
@@ -135,10 +135,10 @@ def _norm_speaker_spec(value: Any, default: str) -> str:
|
||||
|
||||
def _norm_language_list(value: Any, default: list) -> list:
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
|
||||
return [code for code in value if isinstance(code, str) and code in KOKORO_CODE_LABELS]
|
||||
if isinstance(value, str):
|
||||
parts = [item.strip().lower() for item in value.split(",") if item.strip()]
|
||||
return [code for code in parts if code in LANGUAGE_DESCRIPTIONS]
|
||||
return [code for code in parts if code in KOKORO_CODE_LABELS]
|
||||
return default
|
||||
|
||||
|
||||
|
||||
@@ -12,35 +12,31 @@ PUNCTUATION_SENTENCE_COMMA = r".!?,?。!?،,、।"
|
||||
PUNCTUATION_COMMAS = ",,、"
|
||||
|
||||
|
||||
def get_split_pattern(language: str, subtitle_mode: str) -> str:
|
||||
def get_split_pattern(language: Language, subtitle_mode: str) -> str:
|
||||
"""Get the appropriate split pattern based on language and subtitle mode.
|
||||
|
||||
Args:
|
||||
language: Language code (a, b, e, f, etc.)
|
||||
language: Language enum value.
|
||||
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
|
||||
|
||||
Returns:
|
||||
Split pattern string
|
||||
"""
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = None # unknown language — treat as non-English, non-CJK
|
||||
try:
|
||||
mode = SubtitleMode.from_str(subtitle_mode) if not isinstance(subtitle_mode, SubtitleMode) else subtitle_mode
|
||||
except ValueError:
|
||||
mode = SubtitleMode.DISABLED
|
||||
|
||||
# For English, always use newline splitting only
|
||||
if lang in (Language.EN_US, Language.EN_GB):
|
||||
if language in (Language.EN_US, Language.EN_GB):
|
||||
return "\n"
|
||||
|
||||
# Determine spacing pattern based on language
|
||||
spacing = r"\s*" if lang and lang.is_cjk else r"\s+"
|
||||
spacing = r"\s*" if language.is_cjk else r"\s+"
|
||||
|
||||
# For CJK languages, when subtitle mode is Disabled or Line, prefer
|
||||
# punctuation-based splitting instead of plain newline splitting.
|
||||
if mode in (SubtitleMode.DISABLED, SubtitleMode.LINE) and lang and lang.is_cjk:
|
||||
if mode in (SubtitleMode.DISABLED, SubtitleMode.LINE) and language.is_cjk:
|
||||
return rf"(?<=[{PUNCTUATION_SENTENCE}]){spacing}|\n+"
|
||||
|
||||
if mode == SubtitleMode.LINE:
|
||||
|
||||
@@ -19,7 +19,7 @@ def process_subtitle_tokens(
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
lang_code: str,
|
||||
language: Language,
|
||||
use_spacy_segmentation: bool = False,
|
||||
fallback_end_time: Optional[float] = None,
|
||||
) -> None:
|
||||
@@ -35,7 +35,7 @@ def process_subtitle_tokens(
|
||||
max_subtitle_words: Maximum number of words per subtitle entry.
|
||||
subtitle_mode: One of "Disabled", "Line", "Sentence", "Sentence + Comma",
|
||||
"Sentence + Highlighting", or a string like "5" for word-count mode.
|
||||
lang_code: Language code for spaCy processing (e.g., "a" for English).
|
||||
language: Language enum value for spaCy processing.
|
||||
use_spacy_segmentation: Whether to use spaCy for sentence boundary detection.
|
||||
fallback_end_time: Fallback end time for the last entry if none is available.
|
||||
"""
|
||||
@@ -49,7 +49,7 @@ def process_subtitle_tokens(
|
||||
use_spacy_for_english = (
|
||||
use_spacy_segmentation
|
||||
and subtitle_mode not in [SubtitleMode.DISABLED, SubtitleMode.LINE]
|
||||
and lang_code in [Language.EN_US, Language.EN_GB]
|
||||
and language in [Language.EN_US, Language.EN_GB]
|
||||
and subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA]
|
||||
)
|
||||
|
||||
@@ -61,7 +61,7 @@ def process_subtitle_tokens(
|
||||
if use_spacy_for_english and subtitle_mode != SubtitleMode.LINE:
|
||||
_process_spacy_sentences(
|
||||
processed_tokens, subtitle_entries, max_subtitle_words,
|
||||
subtitle_mode, lang_code, fallback_end_time
|
||||
subtitle_mode, language, fallback_end_time
|
||||
)
|
||||
else:
|
||||
_process_regex_sentences(
|
||||
@@ -141,7 +141,7 @@ def _process_spacy_sentences(
|
||||
subtitle_entries: List[Tuple[float, float, str]],
|
||||
max_subtitle_words: int,
|
||||
subtitle_mode: str,
|
||||
lang_code: str,
|
||||
language: Language,
|
||||
fallback_end_time: Optional[float],
|
||||
) -> None:
|
||||
"""Process tokens using spaCy for sentence boundary detection."""
|
||||
@@ -155,7 +155,7 @@ def _process_spacy_sentences(
|
||||
)
|
||||
return
|
||||
|
||||
nlp = get_spacy_model(lang_code)
|
||||
nlp = get_spacy_model(language)
|
||||
if not nlp:
|
||||
_process_regex_sentences(
|
||||
tokens, subtitle_entries, max_subtitle_words,
|
||||
|
||||
+14
-40
@@ -21,20 +21,6 @@ SPACY_MODELS = {
|
||||
Language.HI: "xx_sent_ud_sm",
|
||||
}
|
||||
|
||||
# Kokoro single-letter codes -> Language enum (inverse of pipeline_factory._KOKORO_LANG_MAP)
|
||||
_KOKORO_TO_LANGUAGE = {
|
||||
"a": Language.EN_US,
|
||||
"b": Language.EN_GB,
|
||||
"e": Language.ES,
|
||||
"f": Language.FR,
|
||||
"h": Language.HI,
|
||||
"i": Language.IT,
|
||||
"j": Language.JA,
|
||||
"p": Language.PT_BR,
|
||||
"z": Language.ZH,
|
||||
}
|
||||
|
||||
|
||||
def _load_spacy():
|
||||
"""Lazy load spaCy module."""
|
||||
global _spacy
|
||||
@@ -48,12 +34,12 @@ def _load_spacy():
|
||||
return _spacy
|
||||
|
||||
|
||||
def get_spacy_model(lang_code, log_callback=None):
|
||||
def get_spacy_model(language: Language, log_callback=None):
|
||||
"""
|
||||
Get or load a spaCy model for the given language code.
|
||||
Get or load a spaCy model for the given language.
|
||||
|
||||
Args:
|
||||
lang_code: Language code or Language enum (e.g., "a", "en-US", Language.EN_US)
|
||||
language: Language enum value.
|
||||
log_callback: Optional function to log messages
|
||||
|
||||
Returns:
|
||||
@@ -61,36 +47,24 @@ def get_spacy_model(lang_code, log_callback=None):
|
||||
"""
|
||||
|
||||
def log(msg, is_error=False):
|
||||
# Prefer GUI log callback when provided to avoid spamming stdout.
|
||||
if log_callback:
|
||||
color = "red" if is_error else "grey"
|
||||
try:
|
||||
log_callback((msg, color))
|
||||
except Exception:
|
||||
# Fallback to printing if callback misbehaves
|
||||
print(msg)
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
# Normalize to Language enum
|
||||
if not isinstance(lang_code, Language):
|
||||
if isinstance(lang_code, str) and lang_code in _KOKORO_TO_LANGUAGE:
|
||||
lang_code = _KOKORO_TO_LANGUAGE[lang_code]
|
||||
else:
|
||||
try:
|
||||
lang_code = Language.from_str(lang_code)
|
||||
except ValueError:
|
||||
log(f"\nspaCy: Unknown language '{lang_code}'...")
|
||||
return None
|
||||
if not isinstance(language, Language):
|
||||
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}")
|
||||
|
||||
# Check if model is cached
|
||||
if lang_code in _nlp_cache:
|
||||
return _nlp_cache[lang_code]
|
||||
if language in _nlp_cache:
|
||||
return _nlp_cache[language]
|
||||
|
||||
# Check if language is supported
|
||||
model_name = SPACY_MODELS.get(lang_code)
|
||||
model_name = SPACY_MODELS.get(language)
|
||||
if not model_name:
|
||||
log(f"\nspaCy: No model mapping for language '{lang_code}'...")
|
||||
log(f"\nspaCy: No model mapping for language '{language}'...")
|
||||
return None
|
||||
|
||||
# Lazy load spaCy
|
||||
@@ -114,7 +88,7 @@ def get_spacy_model(lang_code, log_callback=None):
|
||||
if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names:
|
||||
nlp.add_pipe("sentencizer")
|
||||
|
||||
_nlp_cache[lang_code] = nlp
|
||||
_nlp_cache[language] = nlp
|
||||
return nlp
|
||||
except OSError:
|
||||
# Model not found, attempt download
|
||||
@@ -131,7 +105,7 @@ def get_spacy_model(lang_code, log_callback=None):
|
||||
if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names:
|
||||
nlp.add_pipe("sentencizer")
|
||||
|
||||
_nlp_cache[lang_code] = nlp
|
||||
_nlp_cache[language] = nlp
|
||||
log(f"spaCy model '{model_name}' downloaded and loaded")
|
||||
return nlp
|
||||
except Exception as e:
|
||||
@@ -145,19 +119,19 @@ def get_spacy_model(lang_code, log_callback=None):
|
||||
return None
|
||||
|
||||
|
||||
def segment_sentences(text, lang_code, log_callback=None):
|
||||
def segment_sentences(text, language: Language, log_callback=None):
|
||||
"""
|
||||
Segment text into sentences using spaCy.
|
||||
|
||||
Args:
|
||||
text: Text to segment
|
||||
lang_code: Language code
|
||||
language: Language enum value
|
||||
log_callback: Optional function to log messages
|
||||
|
||||
Returns:
|
||||
List of sentence strings, or None if spaCy unavailable
|
||||
"""
|
||||
nlp = get_spacy_model(lang_code, log_callback)
|
||||
nlp = get_spacy_model(language, log_callback)
|
||||
if nlp is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from abogen.constants import LANGUAGE_DESCRIPTIONS
|
||||
from abogen.constants import KOKORO_CODE_LABELS
|
||||
from abogen.utils import get_user_config_path
|
||||
|
||||
_CONFIG_WRAPPER_KEY = "abogen_speaker_configs"
|
||||
@@ -163,4 +163,4 @@ def list_configs() -> List[Dict[str, Any]]:
|
||||
|
||||
def describe_language(code: str) -> str:
|
||||
code = (code or "a").lower()
|
||||
return LANGUAGE_DESCRIPTIONS.get(code, code.upper())
|
||||
return KOKORO_CODE_LABELS.get(code, code.upper())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
from abogen.utils import detect_encoding, load_config
|
||||
from abogen.constants import SAMPLE_VOICE_TEXTS
|
||||
from abogen.domain.enums import Language
|
||||
|
||||
# Pre-compile frequently used regex patterns for better performance
|
||||
_METADATA_TAG_PATTERN = re.compile(r"<<METADATA_[^:]+:[^>]*>>")
|
||||
@@ -388,8 +389,18 @@ def parse_ass_file(file_path):
|
||||
return subtitles
|
||||
|
||||
|
||||
def get_sample_voice_text(lang_code):
|
||||
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
|
||||
def get_sample_voice_text(language):
|
||||
"""Get sample voice text for a language.
|
||||
|
||||
Args:
|
||||
language: Language enum value or string (for backward compatibility).
|
||||
"""
|
||||
if isinstance(language, str):
|
||||
try:
|
||||
language = Language.from_str(language)
|
||||
except (ValueError, AttributeError):
|
||||
language = Language.EN_US
|
||||
return SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS[Language.EN_US])
|
||||
|
||||
|
||||
# Backward-compatible re-exports — canonical location is domain/output_paths.py
|
||||
|
||||
@@ -8,7 +8,7 @@ Usage:
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
engine = manager.create_engine("kokoro", lang_code="a", device="cpu")
|
||||
engine = manager.create_engine("kokoro", language=Language.EN_US, device="cpu")
|
||||
session = engine.create_session()
|
||||
try:
|
||||
result = session.synthesize("Hello world")
|
||||
|
||||
@@ -9,6 +9,8 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AudioFormat:
|
||||
@@ -103,9 +105,9 @@ class EngineConfig:
|
||||
|
||||
Attributes:
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
lang_code: Language code for the engine (e.g., "a" for Kokoro English).
|
||||
Plugins that do not require a language code ignore this field.
|
||||
language: Language enum value. The engine converts to its internal
|
||||
format internally — callers never see engine-specific codes.
|
||||
"""
|
||||
|
||||
device: str = "cpu"
|
||||
lang_code: str = "a"
|
||||
language: Language = Language.EN_US
|
||||
|
||||
@@ -10,6 +10,7 @@ from typing import Any, Iterator
|
||||
|
||||
import numpy as np
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.tts_plugin.plugin_manager import get_plugin_manager
|
||||
|
||||
|
||||
@@ -123,7 +124,7 @@ class Pipeline:
|
||||
|
||||
Presents the same interface that old callers expect::
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code="a", device="cpu")
|
||||
pipeline = create_pipeline("kokoro", language=Language.EN_US, device="cpu")
|
||||
for segment in pipeline(text, voice="af_nova", speed=1.0):
|
||||
audio = segment.audio
|
||||
"""
|
||||
@@ -200,7 +201,7 @@ class Pipeline:
|
||||
def create_pipeline(
|
||||
plugin_id: str,
|
||||
*,
|
||||
lang_code: str = "a",
|
||||
language: Language = Language.EN_US,
|
||||
device: str = "cpu",
|
||||
) -> Pipeline:
|
||||
"""Create a callable TTS pipeline via the Plugin Architecture.
|
||||
@@ -211,7 +212,7 @@ def create_pipeline(
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier (e.g., "kokoro", "supertonic").
|
||||
lang_code: Language code for the engine.
|
||||
language: Language enum value (app-layer type, not engine-specific).
|
||||
device: Device to use (e.g., "cpu", "cuda:0").
|
||||
|
||||
Returns:
|
||||
@@ -235,7 +236,7 @@ def create_pipeline(
|
||||
})(),
|
||||
)
|
||||
|
||||
config = EngineConfig(device=device, lang_code=lang_code)
|
||||
config = EngineConfig(device=device, language=language)
|
||||
|
||||
engine = manager.create_engine(plugin_id, context=ctx, model_path=None, config=config)
|
||||
return Pipeline(engine)
|
||||
|
||||
@@ -14,9 +14,12 @@ from abogen.kokoro_text_normalization import normalize_for_pipeline
|
||||
from abogen.normalization_settings import build_apostrophe_config
|
||||
from abogen.text_extractor import extract_from_path
|
||||
from abogen.voice_cache import ensure_voice_assets
|
||||
from abogen.webui.conversion_runner import SAMPLE_RATE, _select_device, _to_float32, _spec_to_voice_ids
|
||||
from abogen.domain.device import select_device as _select_device
|
||||
from abogen.domain.audio_helpers import to_float32 as _to_float32, SAMPLE_RATE
|
||||
from abogen.domain.voice_resolution import spec_to_voice_ids as _spec_to_voice_ids
|
||||
from abogen.domain.voice_loader import resolve_voice
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
|
||||
@@ -43,11 +46,11 @@ def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str
|
||||
return resolve_voice_setting(value)
|
||||
|
||||
|
||||
def _load_pipeline(language: str, use_gpu: bool) -> Any:
|
||||
def _load_pipeline(language: Language, use_gpu: bool) -> Any:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
device = _select_device()
|
||||
return create_pipeline("kokoro", lang_code=language, device=device)
|
||||
return create_pipeline("kokoro", language=language, device=device)
|
||||
|
||||
|
||||
def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
|
||||
@@ -127,32 +130,14 @@ def run_debug_tts_wavs(
|
||||
if missing:
|
||||
raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}")
|
||||
|
||||
language = str(settings.get("language") or "a").strip() or "a"
|
||||
# Kokoro's KPipeline expects short language codes like "a" (American English),
|
||||
# but older settings may store ISO-like values such as "en".
|
||||
language_aliases = {
|
||||
"en": "a",
|
||||
"en-us": "a",
|
||||
"en_us": "a",
|
||||
"en-gb": "b",
|
||||
"en_gb": "b",
|
||||
"es": "e",
|
||||
"es-es": "e",
|
||||
"fr": "f",
|
||||
"fr-fr": "f",
|
||||
"hi": "h",
|
||||
"it": "i",
|
||||
"pt": "p",
|
||||
"pt-br": "p",
|
||||
"ja": "j",
|
||||
"jp": "j",
|
||||
"zh": "z",
|
||||
"zh-cn": "z",
|
||||
}
|
||||
language = language_aliases.get(language.lower(), language)
|
||||
raw_language = str(settings.get("language") or "en-US").strip() or "en-US"
|
||||
try:
|
||||
language = Language.from_str(raw_language)
|
||||
except ValueError:
|
||||
language = Language.EN_US
|
||||
voice_spec = str(settings.get("default_voice") or "").strip()
|
||||
use_gpu = bool(settings.get("use_gpu", False))
|
||||
speed = float(settings.get("default_speed", 1.0) or 1.0)
|
||||
speed = float(settings.get("default_speed") or 1.0)
|
||||
|
||||
# Settings may store "profile:<name>" which is not a Kokoro voice ID.
|
||||
# Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
|
||||
@@ -162,7 +147,10 @@ def run_debug_tts_wavs(
|
||||
if resolved_voice:
|
||||
voice_spec = resolved_voice
|
||||
if profile_language:
|
||||
language = str(profile_language).strip() or language
|
||||
try:
|
||||
language = Language.from_str(str(profile_language).strip()) or language
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
except Exception:
|
||||
# Voice profile resolution is best-effort; fall back to raw voice_spec.
|
||||
pass
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from flask import Blueprint, request, jsonify, send_file, url_for, current_app
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.webui.routes.utils.settings import (
|
||||
load_settings,
|
||||
load_integration_settings,
|
||||
@@ -47,6 +48,21 @@ from werkzeug.utils import secure_filename
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
def _parse_language(value: Any) -> Language:
|
||||
"""Parse a frontend language value to Language enum.
|
||||
|
||||
This is the API boundary — frontend sends strings, backend parses
|
||||
to Language enum. No engine-specific codes leak outside the engine.
|
||||
"""
|
||||
if isinstance(value, Language):
|
||||
return value
|
||||
try:
|
||||
return Language.from_str(str(value or "").strip())
|
||||
except (ValueError, AttributeError):
|
||||
return Language.EN_US
|
||||
|
||||
|
||||
# --- Voice Profile Routes ---
|
||||
|
||||
@api_bp.get("/voice-profiles")
|
||||
@@ -152,7 +168,7 @@ def api_export_voice_profiles() -> ResponseReturnValue:
|
||||
def api_voice_profiles_preview() -> ResponseReturnValue:
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
text = str(payload.get("text") or "").strip() or "Hello world"
|
||||
language = str(payload.get("language") or "a").strip().lower() or "a"
|
||||
language = _parse_language(payload.get("language"))
|
||||
speed = coerce_float(payload.get("speed"), 1.0)
|
||||
max_seconds = coerce_float(payload.get("max_seconds"), 8.0)
|
||||
|
||||
@@ -186,7 +202,7 @@ def api_voice_profiles_preview() -> ResponseReturnValue:
|
||||
speed = float(normalized_entry.get("speed") or speed)
|
||||
else:
|
||||
voice_spec = formula_from_profile(normalized_entry) or ""
|
||||
language = str(normalized_entry.get("language") or language)
|
||||
language = _parse_language(normalized_entry.get("language") or language)
|
||||
elif formula:
|
||||
voice_spec = formula
|
||||
resolved_provider = "kokoro"
|
||||
@@ -221,7 +237,7 @@ def api_speaker_preview() -> ResponseReturnValue:
|
||||
pending_id = str(payload.get("pending_id") or "").strip()
|
||||
text = payload.get("text", "Hello world")
|
||||
voice = payload.get("voice", "af_heart")
|
||||
language = payload.get("language", "a")
|
||||
language = _parse_language(payload.get("language"))
|
||||
speed_value = payload.get("speed")
|
||||
speed = coerce_float(speed_value, 1.0)
|
||||
tts_provider = str(payload.get("tts_provider") or "").strip().lower()
|
||||
@@ -576,7 +592,7 @@ def api_entity_pronunciation_preview() -> ResponseReturnValue:
|
||||
token = payload.get("token", "").strip()
|
||||
pronunciation = payload.get("pronunciation", "").strip()
|
||||
voice = payload.get("voice", "").strip()
|
||||
language = payload.get("language", "a").strip()
|
||||
language = _parse_language(payload.get("language"))
|
||||
|
||||
if not token and not pronunciation:
|
||||
return jsonify({"error": "Token or pronunciation required"}), 400
|
||||
|
||||
@@ -15,19 +15,6 @@ from abogen.domain.pronunciation import (
|
||||
apply_pronunciation_rules,
|
||||
)
|
||||
|
||||
# Kokoro-specific language mapping (engine's responsibility)
|
||||
_KOKORO_LANG_MAP = {
|
||||
Language.EN_US: "a",
|
||||
Language.EN_GB: "b",
|
||||
Language.ES: "e",
|
||||
Language.FR: "f",
|
||||
Language.HI: "h",
|
||||
Language.IT: "i",
|
||||
Language.JA: "j",
|
||||
Language.PT_BR: "p",
|
||||
Language.ZH: "z",
|
||||
}
|
||||
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
@@ -46,7 +33,7 @@ def clear_preview_pipelines() -> None:
|
||||
_preview_pipelines.clear()
|
||||
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
def _resolve_pipeline(language: Language, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
@@ -63,29 +50,22 @@ def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def get_preview_pipeline(language: str, device: str) -> Any:
|
||||
# Convert Language enum to Kokoro single-letter code
|
||||
try:
|
||||
lang = Language.from_str(language) if not isinstance(language, Language) else language
|
||||
except ValueError:
|
||||
lang = Language.EN_US
|
||||
kokoro_code = _KOKORO_LANG_MAP.get(lang, "a")
|
||||
|
||||
key = (kokoro_code, device)
|
||||
def get_preview_pipeline(language: Language, device: str) -> Any:
|
||||
key = (language, device)
|
||||
with _preview_pipeline_lock:
|
||||
pipeline = _preview_pipelines.get(key)
|
||||
if pipeline is not None:
|
||||
return pipeline
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
|
||||
pipeline = create_pipeline("kokoro", lang_code=kokoro_code, device=device)
|
||||
pipeline = create_pipeline("kokoro", language=lang, device=device)
|
||||
_preview_pipelines[key] = pipeline
|
||||
return pipeline
|
||||
|
||||
def generate_preview_audio(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
language: Language,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
@@ -131,7 +111,7 @@ def generate_preview_audio(
|
||||
current_app.logger.exception("Preview normalization failed; using raw text")
|
||||
normalized_text = source_text
|
||||
|
||||
preview_split = get_split_pattern(str(language or "a"), "Disabled")
|
||||
preview_split = get_split_pattern(language, "Disabled")
|
||||
|
||||
if provider == "supertonic":
|
||||
from abogen.tts_plugin.utils import create_pipeline
|
||||
@@ -194,7 +174,7 @@ def generate_preview_audio(
|
||||
def synthesize_preview(
|
||||
text: str,
|
||||
voice_spec: str,
|
||||
language: str,
|
||||
language: Language,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
tts_provider: str = "kokoro",
|
||||
|
||||
@@ -277,17 +277,19 @@ def filter_voice_catalog(
|
||||
|
||||
|
||||
def build_voice_catalog() -> List[Dict[str, str]]:
|
||||
from plugins.kokoro.engine import language_for_voice_id
|
||||
|
||||
catalog: List[Dict[str, str]] = []
|
||||
gender_map = {"f": "Female", "m": "Male"}
|
||||
for voice_id in get_voices("kokoro"):
|
||||
prefix, _, rest = voice_id.partition("_")
|
||||
language_code = prefix[0] if prefix else "a"
|
||||
gender_code = prefix[1] if len(prefix) > 1 else ""
|
||||
lang = language_for_voice_id(voice_id)
|
||||
catalog.append(
|
||||
{
|
||||
"id": voice_id,
|
||||
"language": language_code,
|
||||
"language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
|
||||
"language": lang.value,
|
||||
"language_label": LANGUAGE_DESCRIPTIONS.get(lang, lang.value.upper()),
|
||||
"gender": gender_map.get(gender_code, "Unknown"),
|
||||
"gender_code": gender_code,
|
||||
"display_name": rest.replace("_", " ").title() if rest else voice_id,
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Any, Dict, List, Optional
|
||||
from flask import Blueprint, render_template, request, jsonify, abort, flash, redirect, url_for
|
||||
from flask.typing import ResponseReturnValue
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.webui.routes.utils.voice import (
|
||||
template_options,
|
||||
resolve_voice_setting,
|
||||
@@ -39,7 +40,7 @@ def test_voice() -> ResponseReturnValue:
|
||||
return synthesize_preview(
|
||||
text=text,
|
||||
voice_spec=voice,
|
||||
language="a", # Default language
|
||||
language=Language.EN_US,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
)
|
||||
|
||||
+14
-5
@@ -14,6 +14,7 @@ from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping
|
||||
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config
|
||||
from abogen.voice_cache import bootstrap_voice_cache
|
||||
from abogen.integrations.audiobookshelf import (
|
||||
@@ -105,7 +106,7 @@ class Job:
|
||||
id: str
|
||||
original_filename: str
|
||||
stored_path: Path
|
||||
language: str
|
||||
language: Language
|
||||
voice: str
|
||||
speed: float
|
||||
use_gpu: bool
|
||||
@@ -296,7 +297,7 @@ class PendingJob:
|
||||
id: str
|
||||
original_filename: str
|
||||
stored_path: Path
|
||||
language: str
|
||||
language: Language
|
||||
voice: str
|
||||
speed: float
|
||||
use_gpu: bool
|
||||
@@ -384,7 +385,7 @@ class ConversionService:
|
||||
*,
|
||||
original_filename: str,
|
||||
stored_path: Path,
|
||||
language: str,
|
||||
language: Language,
|
||||
voice: str,
|
||||
speed: float,
|
||||
tts_provider: str = "kokoro",
|
||||
@@ -910,7 +911,7 @@ class ConversionService:
|
||||
"id": job.id,
|
||||
"original_filename": job.original_filename,
|
||||
"stored_path": str(job.stored_path),
|
||||
"language": job.language,
|
||||
"language": job.language.value if isinstance(job.language, Language) else str(job.language),
|
||||
"tts_provider": getattr(job, "tts_provider", "kokoro"),
|
||||
"voice": job.voice,
|
||||
"speed": job.speed,
|
||||
@@ -1026,11 +1027,19 @@ class ConversionService:
|
||||
stored_path = Path(payload["stored_path"])
|
||||
output_folder_raw = payload.get("output_folder")
|
||||
output_folder = Path(output_folder_raw) if output_folder_raw else None
|
||||
raw_lang = payload.get("language", "")
|
||||
if isinstance(raw_lang, Language):
|
||||
language = raw_lang
|
||||
else:
|
||||
try:
|
||||
language = Language.from_str(str(raw_lang or "").strip())
|
||||
except (ValueError, AttributeError):
|
||||
language = Language.EN_US
|
||||
job = Job(
|
||||
id=payload["id"],
|
||||
original_filename=payload["original_filename"],
|
||||
stored_path=stored_path,
|
||||
language=payload.get("language", "a"),
|
||||
language=language,
|
||||
tts_provider=str(payload.get("tts_provider") or "kokoro"),
|
||||
voice=payload.get("voice", ""),
|
||||
speed=float(payload.get("speed", 1.0)),
|
||||
|
||||
Reference in New Issue
Block a user