mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
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:
+6
-5
@@ -70,11 +70,12 @@ SUPPORTED_INPUT_FORMATS = [
|
||||
"vtt",
|
||||
]
|
||||
|
||||
# Supported languages 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
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [Language.EN_US, Language.EN_GB]
|
||||
# Supported languages for subtitle generation.
|
||||
# All languages are supported: only English emits per-word timestamped tokens
|
||||
# in the Kokoro pipeline, but other languages fall back to segment-level fake
|
||||
# tokens (see abogen.domain.tokens.FakeToken), so subtitles are still
|
||||
# generated at segment granularity.
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(Language)
|
||||
|
||||
# Voice and sample text mapping
|
||||
SAMPLE_VOICE_TEXTS = {
|
||||
|
||||
@@ -112,12 +112,10 @@ def spacy_pre_tts_segmentation(
|
||||
|
||||
|
||||
def _to_language_enum(lang_code: Any) -> Language:
|
||||
"""Convert lang_code to Language enum."""
|
||||
if isinstance(lang_code, Language):
|
||||
return lang_code
|
||||
"""Convert lang_code to Language enum (ISO code or Language enum)."""
|
||||
try:
|
||||
return Language.from_str(str(lang_code))
|
||||
except (ValueError, AttributeError):
|
||||
except ValueError:
|
||||
return Language.EN_US
|
||||
|
||||
|
||||
|
||||
@@ -212,8 +212,12 @@ class Language(str, Enum):
|
||||
|
||||
@property
|
||||
def supports_subtitle_tokens(self) -> bool:
|
||||
"""True if this language generates timestamped tokens for subtitles."""
|
||||
return self in (self.EN_US, self.EN_GB)
|
||||
"""True if this language supports subtitle generation.
|
||||
|
||||
All languages are supported: languages without per-word timestamped
|
||||
tokens fall back to segment-level fake tokens in the pipeline.
|
||||
"""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str) -> Language:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Args:
|
||||
language: Language enum value.
|
||||
language: Language enum value, ISO code, or kokoro letter code.
|
||||
subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -22,6 +22,7 @@ from abogen.constants import (
|
||||
)
|
||||
from abogen.infrastructure.subtitle_writer import make_subtitle_writer, resolve_subtitle_format
|
||||
from abogen.domain.split_pattern import get_split_pattern
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.domain.subtitle_processor import (
|
||||
parse_subtitle_file,
|
||||
process_subtitle_entries,
|
||||
@@ -872,12 +873,12 @@ class ConversionThread(QThread):
|
||||
)
|
||||
spacy_sentences = None
|
||||
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
|
||||
if (
|
||||
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"]
|
||||
):
|
||||
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
|
||||
self.log_updated.emit(
|
||||
("\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,
|
||||
subtitle_mode=self.subtitle_mode,
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
+41
-39
@@ -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.book_handler import HandlerDialog
|
||||
from abogen.domain.enums import Language
|
||||
from abogen.constants import (
|
||||
PROGRAM_NAME,
|
||||
VERSION,
|
||||
@@ -88,8 +89,9 @@ from abogen.constants import (
|
||||
from abogen.tts_plugin.utils import get_voices
|
||||
import threading
|
||||
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 plugins.kokoro.engine import language_for_code, language_for_voice_id
|
||||
|
||||
# Module-level default cache for use outside __init__
|
||||
_DEFAULTS = all_settings_defaults()
|
||||
@@ -397,11 +399,7 @@ class InputBox(QLabel):
|
||||
# Re-enable subtitle and replace newlines controls when cleared
|
||||
window = self.window()
|
||||
if hasattr(window, "subtitle_combo"):
|
||||
# Only enable if language supports it
|
||||
current_lang = getattr(window, "selected_lang", "a")
|
||||
window.subtitle_combo.setEnabled(
|
||||
current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
)
|
||||
window.subtitle_combo.setEnabled(True)
|
||||
if hasattr(window, "replace_newlines_combo"):
|
||||
window.replace_newlines_combo.setEnabled(True)
|
||||
|
||||
@@ -941,7 +939,7 @@ class abogen(QWidget):
|
||||
self.selected_lang = None
|
||||
else:
|
||||
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.subtitle_mode = self.config.get("subtitle_mode", _d["subtitle_mode"])
|
||||
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, {})
|
||||
if isinstance(entry, dict):
|
||||
self.mixed_voice_state = entry.get("voices", [])
|
||||
self.selected_lang = entry.get("language")
|
||||
self.selected_lang = resolve_profile_language(entry)
|
||||
else:
|
||||
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:
|
||||
self.save_path_label.setText(self.selected_output_folder)
|
||||
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 + Highlighting: Subtitles with word-by-word karaoke highlighting.\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"
|
||||
+ "\n".join(
|
||||
f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}'
|
||||
@@ -1759,8 +1760,9 @@ class abogen(QWidget):
|
||||
|
||||
def update_subtitle_options_availability(self):
|
||||
"""
|
||||
Update the enabled state of subtitle options based on the selected language.
|
||||
For non-English languages, only sentence-based and line-based modes are supported.
|
||||
Update the enabled state of subtitle options based on the selected
|
||||
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
|
||||
is_subtitle_input = False
|
||||
@@ -1769,16 +1771,14 @@ class abogen(QWidget):
|
||||
):
|
||||
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
|
||||
self.subtitle_combo.setEnabled(not is_subtitle_input)
|
||||
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
|
||||
allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"]
|
||||
@@ -1793,10 +1793,7 @@ class abogen(QWidget):
|
||||
if is_english:
|
||||
item.setEnabled(True)
|
||||
else:
|
||||
if text in allowed_modes:
|
||||
item.setEnabled(True)
|
||||
else:
|
||||
item.setEnabled(False)
|
||||
item.setEnabled(text in allowed_modes)
|
||||
|
||||
# If current selection is disabled, switch to a valid one
|
||||
current_text = self.subtitle_combo.currentText()
|
||||
@@ -1815,7 +1812,7 @@ class abogen(QWidget):
|
||||
|
||||
def on_voice_changed(self, 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
|
||||
save_config(self.config)
|
||||
# Enable/disable subtitle options based on language
|
||||
@@ -1832,10 +1829,12 @@ class abogen(QWidget):
|
||||
# set mixed voices and language
|
||||
if isinstance(entry, dict):
|
||||
self.mixed_voice_state = entry.get("voices", [])
|
||||
self.selected_lang = entry.get("language")
|
||||
self.selected_lang = resolve_profile_language(entry)
|
||||
else:
|
||||
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.config["selected_profile_name"] = pname
|
||||
self.config.pop("selected_voice", None)
|
||||
@@ -1845,7 +1844,7 @@ class abogen(QWidget):
|
||||
else:
|
||||
self.mixed_voice_state = 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
|
||||
if "selected_profile_name" in self.config:
|
||||
del self.config["selected_profile_name"]
|
||||
@@ -1856,8 +1855,9 @@ class abogen(QWidget):
|
||||
from abogen.voice_profiles import load_profiles
|
||||
|
||||
entry = load_profiles().get(profile_name, {})
|
||||
lang = entry.get("language") if isinstance(entry, dict) else None
|
||||
enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
enable = (
|
||||
resolve_profile_language(entry) in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
)
|
||||
self.subtitle_combo.setEnabled(enable)
|
||||
self.subtitle_format_combo.setEnabled(enable)
|
||||
|
||||
@@ -2239,18 +2239,18 @@ class abogen(QWidget):
|
||||
else:
|
||||
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:
|
||||
from abogen.voice_profiles import load_profiles
|
||||
|
||||
entry = load_profiles().get(self.selected_profile_name, {})
|
||||
selected_lang = entry.get("language")
|
||||
selected_lang = resolve_profile_language(entry)
|
||||
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
|
||||
if not selected_lang:
|
||||
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
|
||||
|
||||
def get_actual_subtitle_mode(self) -> str:
|
||||
@@ -2426,7 +2426,7 @@ class abogen(QWidget):
|
||||
self.update_log((gpu_msg, gpu_ok))
|
||||
self.update_log("Loading modules...")
|
||||
|
||||
lang_code = self.selected_lang or "a"
|
||||
lang_code = self.selected_lang or Language.EN_US
|
||||
load_thread = LoadPipelineThread(
|
||||
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
|
||||
|
||||
entry = load_profiles().get(self.selected_profile_name, {})
|
||||
lang_to_cache = entry.get("language")
|
||||
lang_to_cache = resolve_profile_language(entry)
|
||||
else:
|
||||
lang_to_cache = self.selected_lang
|
||||
if not lang_to_cache and self.mixed_voice_state:
|
||||
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]
|
||||
else None
|
||||
)
|
||||
@@ -2866,7 +2866,7 @@ class abogen(QWidget):
|
||||
)
|
||||
self.loading_movie.start()
|
||||
|
||||
lang = self.selected_lang or "a"
|
||||
lang = self.selected_lang or Language.EN_US
|
||||
load_thread = LoadPipelineThread(
|
||||
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
|
||||
|
||||
entry = load_profiles().get(self.selected_profile_name, {})
|
||||
lang = entry.get("language")
|
||||
lang = resolve_profile_language(entry)
|
||||
else:
|
||||
lang = self.selected_lang
|
||||
if not lang and self.mixed_voice_state:
|
||||
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]
|
||||
else None
|
||||
)
|
||||
else:
|
||||
lang = self.selected_voice[0]
|
||||
lang = language_for_voice_id(self.selected_voice)
|
||||
voice = self.selected_voice
|
||||
|
||||
# use same gpu/cpu logic as in conversion
|
||||
@@ -3954,7 +3954,9 @@ Categories=AudioVideo;Audio;Utility;
|
||||
initial_state = entry.get("voices", [])
|
||||
else:
|
||||
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(
|
||||
self, initial_state=initial_state, selected_profile=selected_profile
|
||||
)
|
||||
|
||||
@@ -28,7 +28,6 @@ from PyQt6.QtWidgets import (
|
||||
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||||
from PyQt6.QtGui import QPixmap, QIcon, QAction
|
||||
from abogen.constants import (
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
COLORS,
|
||||
)
|
||||
@@ -949,7 +948,9 @@ class VoiceFormulaDialog(QDialog):
|
||||
lang = state.get("language") if isinstance(state, dict) else None
|
||||
# apply language selection
|
||||
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:
|
||||
self.language_combo.blockSignals(True)
|
||||
self.language_combo.setCurrentIndex(i)
|
||||
@@ -1571,9 +1572,10 @@ class VoiceFormulaDialog(QDialog):
|
||||
parent.selected_profile_name = None
|
||||
lang = self.language_combo.currentData()
|
||||
parent.selected_lang = lang
|
||||
parent.subtitle_combo.setEnabled(
|
||||
lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
)
|
||||
if hasattr(parent, "update_subtitle_options_availability"):
|
||||
parent.update_subtitle_options_availability()
|
||||
else:
|
||||
parent.subtitle_combo.setEnabled(True)
|
||||
# Reset start flag and trigger preview
|
||||
self._started = False
|
||||
parent.preview_voice()
|
||||
|
||||
@@ -57,7 +57,9 @@ def get_spacy_model(language: Language, log_callback=None):
|
||||
print(msg)
|
||||
|
||||
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:
|
||||
return _nlp_cache[language]
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import os
|
||||
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.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")
|
||||
|
||||
if not language:
|
||||
language = "a"
|
||||
language = Language.EN_US
|
||||
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"provider": "kokoro", "language": language, "voices": normalized}
|
||||
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:
|
||||
delete_profile(name)
|
||||
|
||||
|
||||
@@ -73,17 +73,27 @@ def engine_language(lang: Language) -> str:
|
||||
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.
|
||||
|
||||
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.
|
||||
Callers pass a voice ID string; the engine returns a Language enum.
|
||||
"""
|
||||
prefix = str(voice_id or "").strip()[:1].lower()
|
||||
if prefix in _CODE_TO_LANGUAGE:
|
||||
return _CODE_TO_LANGUAGE[prefix]
|
||||
return Language.EN_US
|
||||
return language_for_code(voice_id)
|
||||
|
||||
|
||||
class KokoroSession:
|
||||
|
||||
+6
-1
@@ -119,7 +119,7 @@ class TestLanguage:
|
||||
def test_supports_subtitle_tokens(self):
|
||||
assert Language.EN_US.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):
|
||||
assert Language.from_str("EN-US") == Language.EN_US
|
||||
@@ -129,3 +129,8 @@ class TestLanguage:
|
||||
def test_from_str_invalid(self):
|
||||
with pytest.raises(ValueError, match="Invalid Language"):
|
||||
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")
|
||||
|
||||
@@ -196,3 +196,42 @@ class TestKokoroVoiceLister:
|
||||
assert isinstance(voice.tags, tuple)
|
||||
assert len(voice.tags) > 0
|
||||
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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user