diff --git a/abogen/constants.py b/abogen/constants.py index b45d4da..95b28eb 100644 --- a/abogen/constants.py +++ b/abogen/constants.py @@ -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 = { diff --git a/abogen/domain/conversion_pipeline.py b/abogen/domain/conversion_pipeline.py index 9afbf45..ad4e56f 100644 --- a/abogen/domain/conversion_pipeline.py +++ b/abogen/domain/conversion_pipeline.py @@ -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 diff --git a/abogen/domain/enums.py b/abogen/domain/enums.py index bdf0abf..40f304d 100644 --- a/abogen/domain/enums.py +++ b/abogen/domain/enums.py @@ -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: diff --git a/abogen/domain/split_pattern.py b/abogen/domain/split_pattern.py index 01daf9d..616d918 100644 --- a/abogen/domain/split_pattern.py +++ b/abogen/domain/split_pattern.py @@ -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: diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index 3787c0a..ff3a829 100644 --- a/abogen/pyqt/conversion.py +++ b/abogen/pyqt/conversion.py @@ -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), ) diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index 6f33d2d..9ed5bc7 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -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 ) diff --git a/abogen/pyqt/voice_formula_gui.py b/abogen/pyqt/voice_formula_gui.py index 0621fbf..80e1ef9 100644 --- a/abogen/pyqt/voice_formula_gui.py +++ b/abogen/pyqt/voice_formula_gui.py @@ -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() diff --git a/abogen/spacy_utils.py b/abogen/spacy_utils.py index dc09f11..1901bfe 100644 --- a/abogen/spacy_utils.py +++ b/abogen/spacy_utils.py @@ -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] diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index 1841348..9df4c7d 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -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) diff --git a/plugins/kokoro/engine.py b/plugins/kokoro/engine.py index 2440691..e1d0b6e 100644 --- a/plugins/kokoro/engine.py +++ b/plugins/kokoro/engine.py @@ -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: diff --git a/tests/test_enums.py b/tests/test_enums.py index c5cc422..a31ba66 100644 --- a/tests/test_enums.py +++ b/tests/test_enums.py @@ -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") diff --git a/tests/test_kokoro_plugin.py b/tests/test_kokoro_plugin.py index a73f800..6ce1f2b 100644 --- a/tests/test_kokoro_plugin.py +++ b/tests/test_kokoro_plugin.py @@ -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 diff --git a/tests/test_voice_profiles.py b/tests/test_voice_profiles.py new file mode 100644 index 0000000..0edcb97 --- /dev/null +++ b/tests/test_voice_profiles.py @@ -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 \ No newline at end of file