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:
Artem Akymenko
2026-07-27 07:36:36 +00:00
parent 713abdfd73
commit 0dc491e420
35 changed files with 339 additions and 291 deletions
+3 -3
View File
@@ -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
View File
@@ -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 = {
+2 -1
View File
@@ -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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+6 -7
View File
@@ -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 = {
+13 -26
View File
@@ -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.
+3 -3
View File
@@ -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
+5 -9
View File
@@ -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:
+6 -6
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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())
+13 -2
View File
@@ -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
+1 -1
View File
@@ -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")
+5 -3
View File
@@ -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
+5 -4
View File
@@ -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)
+16 -28
View File
@@ -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
+20 -4
View File
@@ -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
+7 -27
View File
@@ -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",
+5 -3
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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)),