fix(pyqt): migrate GUI to Language enums, fix subtitle mode gating

Complete the Language enum unification (commit 0dc491e) for the PyQt
GUI, which was left resolving languages from kokoro letter codes
(voice[0]) while domain and WebUI already used Language enums.

- selected_lang is now always a Language enum: voices resolve via
  language_for_voice_id(), profiles via resolve_profile_language()
- kokoro letter->Language mapping stays in the engine (new public
  language_for_code()); the Language.from_code() domain shim is removed
- legacy profile files with letter codes ("a", "e", ...) are tolerated
  only at the profile read boundary; new profiles save ISO codes
- conversion.py letter comparisons replaced with enum comparisons, and
  SynthParams lang_code= -> language= (would TypeError at runtime)
- subtitle dropdown enabled for all languages; word-count and
  highlighting modes restricted to English with auto-switch to Sentence
- tests: engine mapping + profile language resolution added; 1566 pass
This commit is contained in:
Deniz Şafak
2026-08-20 21:28:30 +03:00
parent f340b976db
commit 11274ad6bf
13 changed files with 183 additions and 68 deletions
+6 -5
View File
@@ -70,11 +70,12 @@ SUPPORTED_INPUT_FORMATS = [
"vtt", "vtt",
] ]
# Supported languages for subtitle generation # Supported languages for subtitle generation.
# Currently, only English (EN_US, EN_GB) are supported for subtitle generation. # All languages are supported: only English emits per-word timestamped tokens
# This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline. # in the Kokoro pipeline, but other languages fall back to segment-level fake
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py # tokens (see abogen.domain.tokens.FakeToken), so subtitles are still
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [Language.EN_US, Language.EN_GB] # generated at segment granularity.
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(Language)
# Voice and sample text mapping # Voice and sample text mapping
SAMPLE_VOICE_TEXTS = { SAMPLE_VOICE_TEXTS = {
+2 -4
View File
@@ -112,12 +112,10 @@ def spacy_pre_tts_segmentation(
def _to_language_enum(lang_code: Any) -> Language: def _to_language_enum(lang_code: Any) -> Language:
"""Convert lang_code to Language enum.""" """Convert lang_code to Language enum (ISO code or Language enum)."""
if isinstance(lang_code, Language):
return lang_code
try: try:
return Language.from_str(str(lang_code)) return Language.from_str(str(lang_code))
except (ValueError, AttributeError): except ValueError:
return Language.EN_US return Language.EN_US
+6 -2
View File
@@ -212,8 +212,12 @@ class Language(str, Enum):
@property @property
def supports_subtitle_tokens(self) -> bool: def supports_subtitle_tokens(self) -> bool:
"""True if this language generates timestamped tokens for subtitles.""" """True if this language supports subtitle generation.
return self in (self.EN_US, self.EN_GB)
All languages are supported: languages without per-word timestamped
tokens fall back to segment-level fake tokens in the pipeline.
"""
return True
@classmethod @classmethod
def from_str(cls, value: str) -> Language: def from_str(cls, value: str) -> Language:
+1 -1
View File
@@ -16,7 +16,7 @@ def get_split_pattern(language: Language, subtitle_mode: str) -> str:
"""Get the appropriate split pattern based on language and subtitle mode. """Get the appropriate split pattern based on language and subtitle mode.
Args: Args:
language: Language enum value. language: Language enum value, ISO code, or kokoro letter code.
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.) subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
Returns: Returns:
+5 -4
View File
@@ -22,6 +22,7 @@ from abogen.constants import (
) )
from abogen.infrastructure.subtitle_writer import make_subtitle_writer, resolve_subtitle_format from abogen.infrastructure.subtitle_writer import make_subtitle_writer, resolve_subtitle_format
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.enums import Language
from abogen.domain.subtitle_processor import ( from abogen.domain.subtitle_processor import (
parse_subtitle_file, parse_subtitle_file,
process_subtitle_entries, process_subtitle_entries,
@@ -872,12 +873,12 @@ class ConversionThread(QThread):
) )
spacy_sentences = None spacy_sentences = None
active_split_pattern = self.split_pattern active_split_pattern = self.split_pattern
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+" spacing_pattern = r"\s*" if self.lang_code in (Language.JA, Language.ZH) else r"\s+"
# Pre-load spaCy model for English if it will be needed for subtitle generation # Pre-load spaCy model for English if it will be needed for subtitle generation
if ( if (
use_spacy use_spacy
and self.lang_code in ["a", "b"] and self.lang_code in (Language.EN_US, Language.EN_GB)
and self.subtitle_mode in ["Sentence", "Sentence + Comma"] and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
): ):
from abogen.spacy_utils import get_spacy_model from abogen.spacy_utils import get_spacy_model
@@ -894,7 +895,7 @@ class ConversionThread(QThread):
) )
) )
if use_spacy and self.lang_code not in ["a", "b"]: if use_spacy and self.lang_code not in (Language.EN_US, Language.EN_GB):
# Non-English: use spaCy for pre-TTS segmentation # Non-English: use spaCy for pre-TTS segmentation
self.log_updated.emit( self.log_updated.emit(
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
@@ -1011,7 +1012,7 @@ class ConversionThread(QThread):
audio_sink=merged_sink if merge_chapters_at_end else None, audio_sink=merged_sink if merge_chapters_at_end else None,
subtitle_mode=self.subtitle_mode, subtitle_mode=self.subtitle_mode,
max_subtitle_words=self.max_subtitle_words, max_subtitle_words=self.max_subtitle_words,
lang_code=self.lang_code, language=self.lang_code,
use_spacy_segmentation=getattr(self, "use_spacy_segmentation", False), use_spacy_segmentation=getattr(self, "use_spacy_segmentation", False),
) )
+41 -39
View File
@@ -75,6 +75,7 @@ from abogen.domain.text_utils import calculate_text_length
from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog
from abogen.pyqt.book_handler import HandlerDialog from abogen.pyqt.book_handler import HandlerDialog
from abogen.domain.enums import Language
from abogen.constants import ( from abogen.constants import (
PROGRAM_NAME, PROGRAM_NAME,
VERSION, VERSION,
@@ -88,8 +89,9 @@ from abogen.constants import (
from abogen.tts_plugin.utils import get_voices from abogen.tts_plugin.utils import get_voices
import threading import threading
from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles, resolve_profile_language
from abogen.domain.settings_core import all_settings_defaults from abogen.domain.settings_core import all_settings_defaults
from plugins.kokoro.engine import language_for_code, language_for_voice_id
# Module-level default cache for use outside __init__ # Module-level default cache for use outside __init__
_DEFAULTS = all_settings_defaults() _DEFAULTS = all_settings_defaults()
@@ -397,11 +399,7 @@ class InputBox(QLabel):
# Re-enable subtitle and replace newlines controls when cleared # Re-enable subtitle and replace newlines controls when cleared
window = self.window() window = self.window()
if hasattr(window, "subtitle_combo"): if hasattr(window, "subtitle_combo"):
# Only enable if language supports it window.subtitle_combo.setEnabled(True)
current_lang = getattr(window, "selected_lang", "a")
window.subtitle_combo.setEnabled(
current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
if hasattr(window, "replace_newlines_combo"): if hasattr(window, "replace_newlines_combo"):
window.replace_newlines_combo.setEnabled(True) window.replace_newlines_combo.setEnabled(True)
@@ -941,7 +939,7 @@ class abogen(QWidget):
self.selected_lang = None self.selected_lang = None
else: else:
self.selected_voice = self.config.get("selected_voice", _d["selected_voice"]) self.selected_voice = self.config.get("selected_voice", _d["selected_voice"])
self.selected_lang = self.selected_voice[0] if self.selected_voice else None self.selected_lang = language_for_voice_id(self.selected_voice)
self.is_converting = False self.is_converting = False
self.subtitle_mode = self.config.get("subtitle_mode", _d["subtitle_mode"]) self.subtitle_mode = self.config.get("subtitle_mode", _d["subtitle_mode"])
self.max_subtitle_words = self.config.get("max_subtitle_words", _d["max_subtitle_words"]) self.max_subtitle_words = self.config.get("max_subtitle_words", _d["max_subtitle_words"])
@@ -1012,10 +1010,12 @@ class abogen(QWidget):
entry = load_profiles().get(self.selected_profile_name, {}) entry = load_profiles().get(self.selected_profile_name, {})
if isinstance(entry, dict): if isinstance(entry, dict):
self.mixed_voice_state = entry.get("voices", []) self.mixed_voice_state = entry.get("voices", [])
self.selected_lang = entry.get("language") self.selected_lang = resolve_profile_language(entry)
else: else:
self.mixed_voice_state = entry self.mixed_voice_state = entry
self.selected_lang = entry[0][0] if entry and entry[0] else None self.selected_lang = (
language_for_voice_id(entry[0]) if entry and entry[0] else Language.EN_US
)
if self.save_option == "Choose output folder" and self.selected_output_folder: if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder) self.save_path_label.setText(self.selected_output_folder)
self.save_path_row_widget.show() self.save_path_row_widget.show()
@@ -1181,6 +1181,7 @@ class abogen(QWidget):
"Sentence + Comma: Subtitles will be generated for each sentence and comma.\n" "Sentence + Comma: Subtitles will be generated for each sentence and comma.\n"
"Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n" "Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n"
"1+ word: Subtitles will be generated for each word(s).\n\n" "1+ word: Subtitles will be generated for each word(s).\n\n"
"Word-count and highlighting modes are only available for English.\n"
"Supported languages for subtitle generation:\n" "Supported languages for subtitle generation:\n"
+ "\n".join( + "\n".join(
f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}' f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}'
@@ -1759,8 +1760,9 @@ class abogen(QWidget):
def update_subtitle_options_availability(self): def update_subtitle_options_availability(self):
""" """
Update the enabled state of subtitle options based on the selected language. Update the enabled state of subtitle options based on the selected
For non-English languages, only sentence-based and line-based modes are supported. language and input type. Subtitle generation works for every language,
but word-count and highlighting modes are only available for English.
""" """
# Check if current file is a subtitle file # Check if current file is a subtitle file
is_subtitle_input = False is_subtitle_input = False
@@ -1769,16 +1771,14 @@ class abogen(QWidget):
): ):
is_subtitle_input = True is_subtitle_input = True
if self.selected_lang not in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
self.subtitle_combo.setEnabled(False)
self.subtitle_format_combo.setEnabled(False)
return
# Only enable subtitle_combo if it's NOT a subtitle input # Only enable subtitle_combo if it's NOT a subtitle input
self.subtitle_combo.setEnabled(not is_subtitle_input) self.subtitle_combo.setEnabled(not is_subtitle_input)
self.subtitle_format_combo.setEnabled(True) self.subtitle_format_combo.setEnabled(True)
is_english = self.selected_lang in ["a", "b"] is_english = self.selected_lang in (
Language.EN_US,
Language.EN_GB,
)
# Items to keep enabled for non-English # Items to keep enabled for non-English
allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"] allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"]
@@ -1793,10 +1793,7 @@ class abogen(QWidget):
if is_english: if is_english:
item.setEnabled(True) item.setEnabled(True)
else: else:
if text in allowed_modes: item.setEnabled(text in allowed_modes)
item.setEnabled(True)
else:
item.setEnabled(False)
# If current selection is disabled, switch to a valid one # If current selection is disabled, switch to a valid one
current_text = self.subtitle_combo.currentText() current_text = self.subtitle_combo.currentText()
@@ -1815,7 +1812,7 @@ class abogen(QWidget):
def on_voice_changed(self, index): def on_voice_changed(self, index):
voice = self.voice_combo.itemData(index) voice = self.voice_combo.itemData(index)
self.selected_voice, self.selected_lang = voice, voice[0] self.selected_voice, self.selected_lang = voice, language_for_voice_id(voice)
self.config["selected_voice"] = voice self.config["selected_voice"] = voice
save_config(self.config) save_config(self.config)
# Enable/disable subtitle options based on language # Enable/disable subtitle options based on language
@@ -1832,10 +1829,12 @@ class abogen(QWidget):
# set mixed voices and language # set mixed voices and language
if isinstance(entry, dict): if isinstance(entry, dict):
self.mixed_voice_state = entry.get("voices", []) self.mixed_voice_state = entry.get("voices", [])
self.selected_lang = entry.get("language") self.selected_lang = resolve_profile_language(entry)
else: else:
self.mixed_voice_state = entry self.mixed_voice_state = entry
self.selected_lang = entry[0][0] if entry and entry[0] else None self.selected_lang = (
language_for_voice_id(entry[0]) if entry and entry[0] else Language.EN_US
)
self.selected_voice = None self.selected_voice = None
self.config["selected_profile_name"] = pname self.config["selected_profile_name"] = pname
self.config.pop("selected_voice", None) self.config.pop("selected_voice", None)
@@ -1845,7 +1844,7 @@ class abogen(QWidget):
else: else:
self.mixed_voice_state = None self.mixed_voice_state = None
self.selected_profile_name = None self.selected_profile_name = None
self.selected_voice, self.selected_lang = data, data[0] self.selected_voice, self.selected_lang = data, language_for_voice_id(data)
self.config["selected_voice"] = data self.config["selected_voice"] = data
if "selected_profile_name" in self.config: if "selected_profile_name" in self.config:
del self.config["selected_profile_name"] del self.config["selected_profile_name"]
@@ -1856,8 +1855,9 @@ class abogen(QWidget):
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
entry = load_profiles().get(profile_name, {}) entry = load_profiles().get(profile_name, {})
lang = entry.get("language") if isinstance(entry, dict) else None enable = (
enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION resolve_profile_language(entry) in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
self.subtitle_combo.setEnabled(enable) self.subtitle_combo.setEnabled(enable)
self.subtitle_format_combo.setEnabled(enable) self.subtitle_format_combo.setEnabled(enable)
@@ -2239,18 +2239,18 @@ class abogen(QWidget):
else: else:
return self.selected_voice return self.selected_voice
def get_selected_lang(self, voice_formula) -> str: def get_selected_lang(self, voice_formula) -> Language:
if self.selected_profile_name: if self.selected_profile_name:
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {}) entry = load_profiles().get(self.selected_profile_name, {})
selected_lang = entry.get("language") selected_lang = resolve_profile_language(entry)
else: else:
selected_lang = self.selected_voice[0] if self.selected_voice else None selected_lang = language_for_voice_id(self.selected_voice)
# fallback: extract from formula if missing # fallback: extract from formula if missing
if not selected_lang: if not selected_lang:
m = re.search(r"\b([a-z])", voice_formula) m = re.search(r"\b([a-z])", voice_formula)
selected_lang = m.group(1) if m else None selected_lang = language_for_code(m.group(1)) if m else Language.EN_US
return selected_lang return selected_lang
def get_actual_subtitle_mode(self) -> str: def get_actual_subtitle_mode(self) -> str:
@@ -2426,7 +2426,7 @@ class abogen(QWidget):
self.update_log((gpu_msg, gpu_ok)) self.update_log((gpu_msg, gpu_ok))
self.update_log("Loading modules...") self.update_log("Loading modules...")
lang_code = self.selected_lang or "a" lang_code = self.selected_lang or Language.EN_US
load_thread = LoadPipelineThread( load_thread = LoadPipelineThread(
pipeline_loaded_callback, lang_code=lang_code, use_gpu=gpu_ok pipeline_loaded_callback, lang_code=lang_code, use_gpu=gpu_ok
) )
@@ -2757,12 +2757,12 @@ class abogen(QWidget):
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {}) entry = load_profiles().get(self.selected_profile_name, {})
lang_to_cache = entry.get("language") lang_to_cache = resolve_profile_language(entry)
else: else:
lang_to_cache = self.selected_lang lang_to_cache = self.selected_lang
if not lang_to_cache and self.mixed_voice_state: if not lang_to_cache and self.mixed_voice_state:
lang_to_cache = ( lang_to_cache = (
self.mixed_voice_state[0][0][0] language_for_voice_id(self.mixed_voice_state[0][0])
if self.mixed_voice_state and self.mixed_voice_state[0][0] if self.mixed_voice_state and self.mixed_voice_state[0][0]
else None else None
) )
@@ -2866,7 +2866,7 @@ class abogen(QWidget):
) )
self.loading_movie.start() self.loading_movie.start()
lang = self.selected_lang or "a" lang = self.selected_lang or Language.EN_US
load_thread = LoadPipelineThread( load_thread = LoadPipelineThread(
self._on_pipeline_loaded_for_preview, lang_code=lang, use_gpu=self.gpu_ok self._on_pipeline_loaded_for_preview, lang_code=lang, use_gpu=self.gpu_ok
) )
@@ -2898,17 +2898,17 @@ class abogen(QWidget):
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {}) entry = load_profiles().get(self.selected_profile_name, {})
lang = entry.get("language") lang = resolve_profile_language(entry)
else: else:
lang = self.selected_lang lang = self.selected_lang
if not lang and self.mixed_voice_state: if not lang and self.mixed_voice_state:
lang = ( lang = (
self.mixed_voice_state[0][0][0] language_for_voice_id(self.mixed_voice_state[0][0])
if self.mixed_voice_state and self.mixed_voice_state[0][0] if self.mixed_voice_state and self.mixed_voice_state[0][0]
else None else None
) )
else: else:
lang = self.selected_voice[0] lang = language_for_voice_id(self.selected_voice)
voice = self.selected_voice voice = self.selected_voice
# use same gpu/cpu logic as in conversion # use same gpu/cpu logic as in conversion
@@ -3954,7 +3954,9 @@ Categories=AudioVideo;Audio;Utility;
initial_state = entry.get("voices", []) initial_state = entry.get("voices", [])
else: else:
initial_state = entry initial_state = entry
self.selected_lang = entry[0][0] if entry and entry[0] else None self.selected_lang = (
language_for_voice_id(entry[0]) if entry and entry[0] else Language.EN_US
)
dialog = VoiceFormulaDialog( dialog = VoiceFormulaDialog(
self, initial_state=initial_state, selected_profile=selected_profile self, initial_state=initial_state, selected_profile=selected_profile
) )
+7 -5
View File
@@ -28,7 +28,6 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt6.QtGui import QPixmap, QIcon, QAction from PyQt6.QtGui import QPixmap, QIcon, QAction
from abogen.constants import ( from abogen.constants import (
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
COLORS, COLORS,
) )
@@ -949,7 +948,9 @@ class VoiceFormulaDialog(QDialog):
lang = state.get("language") if isinstance(state, dict) else None lang = state.get("language") if isinstance(state, dict) else None
# apply language selection # apply language selection
if lang: if lang:
i = self.language_combo.findData(lang) from abogen.voice_profiles import resolve_profile_language
i = self.language_combo.findData(resolve_profile_language(state))
if i >= 0: if i >= 0:
self.language_combo.blockSignals(True) self.language_combo.blockSignals(True)
self.language_combo.setCurrentIndex(i) self.language_combo.setCurrentIndex(i)
@@ -1571,9 +1572,10 @@ class VoiceFormulaDialog(QDialog):
parent.selected_profile_name = None parent.selected_profile_name = None
lang = self.language_combo.currentData() lang = self.language_combo.currentData()
parent.selected_lang = lang parent.selected_lang = lang
parent.subtitle_combo.setEnabled( if hasattr(parent, "update_subtitle_options_availability"):
lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION parent.update_subtitle_options_availability()
) else:
parent.subtitle_combo.setEnabled(True)
# Reset start flag and trigger preview # Reset start flag and trigger preview
self._started = False self._started = False
parent.preview_voice() parent.preview_voice()
+3 -1
View File
@@ -57,7 +57,9 @@ def get_spacy_model(language: Language, log_callback=None):
print(msg) print(msg)
if not isinstance(language, Language): if not isinstance(language, Language):
raise TypeError(f"language must be Language enum, got {type(language).__name__}: {language!r}") raise TypeError(
f"language must be Language enum, got {type(language).__name__}: {language!r}"
)
if language in _nlp_cache: if language in _nlp_cache:
return _nlp_cache[language] return _nlp_cache[language]
+24 -1
View File
@@ -2,6 +2,7 @@ import json
import os import os
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from abogen.domain.enums import Language
from abogen.tts_plugin.utils import get_voices, is_plugin_registered from abogen.tts_plugin.utils import get_voices, is_plugin_registered
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
@@ -176,13 +177,35 @@ def save_profile(name: str, *, language: str, voices: Iterable) -> None:
raise ValueError("At least one voice with a weight above zero is required") raise ValueError("At least one voice with a weight above zero is required")
if not language: if not language:
language = "a" language = Language.EN_US
profiles = load_profiles() profiles = load_profiles()
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized} profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
save_profiles(profiles) save_profiles(profiles)
def resolve_profile_language(entry: Any) -> Language:
"""Resolve a profile's stored language to a Language enum.
New profiles store ISO codes (Language enum values); legacy profiles may
store kokoro letter codes ("a", "b", ...). Unparseable values fall back
to EN_US.
"""
raw = entry.get("language") if isinstance(entry, dict) else None
if isinstance(raw, Language):
return raw
text = str(raw or "").strip()
if not text:
return Language.EN_US
try:
return Language.from_str(text)
except ValueError:
from plugins.kokoro.engine import language_for_code
return language_for_code(text)
def remove_profile(name: str) -> None: def remove_profile(name: str) -> None:
delete_profile(name) delete_profile(name)
+15 -5
View File
@@ -73,17 +73,27 @@ def engine_language(lang: Language) -> str:
return _KOKORO_LANG_MAP.get(lang, "a") return _KOKORO_LANG_MAP.get(lang, "a")
def language_for_voice_id(voice_id: str) -> Language: def language_for_code(code: str | None) -> Language:
"""Map a kokoro engine language code (single letter) to a Language enum.
Used to resolve legacy data such as old profile files that stored
kokoro letter codes. This is kokoro-specific knowledge that stays
inside the engine. Unparseable values fall back to EN_US.
"""
letter = str(code or "").strip()[:1].lower()
if letter in _CODE_TO_LANGUAGE:
return _CODE_TO_LANGUAGE[letter]
return Language.EN_US
def language_for_voice_id(voice_id: str | None) -> Language:
"""Determine which Language a voice belongs to from its voice ID. """Determine which Language a voice belongs to from its voice ID.
Kokoro voice IDs encode language as a prefix (e.g. "af_heart" "a" EN_US). Kokoro voice IDs encode language as a prefix (e.g. "af_heart" "a" EN_US).
This is kokoro-specific knowledge that stays inside the engine. This is kokoro-specific knowledge that stays inside the engine.
Callers pass a voice ID string; the engine returns a Language enum. Callers pass a voice ID string; the engine returns a Language enum.
""" """
prefix = str(voice_id or "").strip()[:1].lower() return language_for_code(voice_id)
if prefix in _CODE_TO_LANGUAGE:
return _CODE_TO_LANGUAGE[prefix]
return Language.EN_US
class KokoroSession: class KokoroSession:
+6 -1
View File
@@ -119,7 +119,7 @@ class TestLanguage:
def test_supports_subtitle_tokens(self): def test_supports_subtitle_tokens(self):
assert Language.EN_US.supports_subtitle_tokens is True assert Language.EN_US.supports_subtitle_tokens is True
assert Language.EN_GB.supports_subtitle_tokens is True assert Language.EN_GB.supports_subtitle_tokens is True
assert Language.ZH.supports_subtitle_tokens is False assert Language.ZH.supports_subtitle_tokens is True
def test_from_str_case_insensitive(self): def test_from_str_case_insensitive(self):
assert Language.from_str("EN-US") == Language.EN_US assert Language.from_str("EN-US") == Language.EN_US
@@ -129,3 +129,8 @@ class TestLanguage:
def test_from_str_invalid(self): def test_from_str_invalid(self):
with pytest.raises(ValueError, match="Invalid Language"): with pytest.raises(ValueError, match="Invalid Language"):
Language.from_str("en") Language.from_str("en")
def test_kokoro_letter_codes_not_accepted_by_domain(self):
# Letter codes are kokoro-engine internals, not domain API.
with pytest.raises(ValueError):
Language.from_str("a")
+39
View File
@@ -196,3 +196,42 @@ class TestKokoroVoiceLister:
assert isinstance(voice.tags, tuple) assert isinstance(voice.tags, tuple)
assert len(voice.tags) > 0 assert len(voice.tags) > 0
engine.dispose() engine.dispose()
# ──────────────────────────────────────────────────────────────
# Language mapping helpers
# ──────────────────────────────────────────────────────────────
class TestKokoroLanguageMapping:
"""Language resolution helpers: the letter codes live in the engine only."""
def test_language_for_code(self) -> None:
from abogen.domain.enums import Language
from plugins.kokoro.engine import language_for_code
assert language_for_code("a") == Language.EN_US
assert language_for_code("b") == Language.EN_GB
assert language_for_code("e") == Language.ES
assert language_for_code("f") == Language.FR
assert language_for_code("h") == Language.HI
assert language_for_code("i") == Language.IT
assert language_for_code("j") == Language.JA
assert language_for_code("p") == Language.PT_BR
assert language_for_code("z") == Language.ZH
def test_language_for_code_fallbacks(self) -> None:
from abogen.domain.enums import Language
from plugins.kokoro.engine import language_for_code
assert language_for_code("x") == Language.EN_US
assert language_for_code("") == Language.EN_US
def test_language_for_voice_id(self) -> None:
from abogen.domain.enums import Language
from plugins.kokoro.engine import language_for_voice_id
assert language_for_voice_id("af_heart") == Language.EN_US
assert language_for_voice_id("bf_emma") == Language.EN_GB
assert language_for_voice_id("ef_dora") == Language.ES
assert language_for_voice_id("zf_xiaobei") == Language.ZH
assert language_for_voice_id("") == Language.EN_US
+28
View File
@@ -0,0 +1,28 @@
"""Tests for voice profile language resolution."""
from __future__ import annotations
from abogen.domain.enums import Language
from abogen.voice_profiles import resolve_profile_language
class TestResolveProfileLanguage:
def test_iso_code(self) -> None:
assert resolve_profile_language({"language": "en-US"}) == Language.EN_US
assert resolve_profile_language({"language": "es"}) == Language.ES
def test_enum_value(self) -> None:
assert resolve_profile_language({"language": Language.ZH}) == Language.ZH
def test_legacy_kokoro_letter(self) -> None:
assert resolve_profile_language({"language": "a"}) == Language.EN_US
assert resolve_profile_language({"language": "e"}) == Language.ES
assert resolve_profile_language({"language": "z"}) == Language.ZH
def test_missing_or_unparseable_falls_back(self) -> None:
assert resolve_profile_language({}) == Language.EN_US
assert resolve_profile_language({"language": ""}) == Language.EN_US
assert resolve_profile_language({"language": "xx"}) == Language.EN_US
assert resolve_profile_language(None) == Language.EN_US
assert resolve_profile_language([]) == Language.EN_US