diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index a6510f0..e25402f 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ # These are supported funding model platforms -github: [jborza, jeremiahsb, mohangk] +github: [jborza, jeremiahsb, mohangk, k0sm0naft] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username diff --git a/abogen/domain/subtitle_generation.py b/abogen/domain/subtitle_generation.py index 37ed0c6..7f6b9e7 100644 --- a/abogen/domain/subtitle_generation.py +++ b/abogen/domain/subtitle_generation.py @@ -274,16 +274,28 @@ def _process_regex_sentences( current_sentence = [] word_count = 0 - # Add any remaining tokens as a sentence + # Add any remaining tokens as a sentence (split multi-sentence FakeToken) if current_sentence: start_time = current_sentence[0]["start"] end_time = current_sentence[-1]["end"] - # Simplified text joining logic sentence_text = "" for t in current_sentence: sentence_text += t["text"] + (t.get("whitespace") or "") - subtitle_entries.append((start_time, end_time, sentence_text.strip())) + sentence_text = sentence_text.strip() + + if len(current_sentence) == 1: + parts = re.split(rf"(?<={separator})\s+", sentence_text) + if len(parts) > 1: + d = end_time - start_time + for i, p in enumerate(parts): + e = end_time if i == len(parts) - 1 else start_time + d * len(p) / len(sentence_text) + subtitle_entries.append((start_time, e, p.strip())) + start_time = e + current_sentence = [] + + if current_sentence: + subtitle_entries.append((start_time, end_time, sentence_text)) # Fallback for last entry _apply_fallback_end_time(subtitle_entries, fallback_end_time) diff --git a/abogen/hf_tracker.py b/abogen/hf_tracker.py index 84c6350..73ae968 100644 --- a/abogen/hf_tracker.py +++ b/abogen/hf_tracker.py @@ -19,7 +19,7 @@ def tracked_hf_hub_download(*args, **kwargs): try: local_kwargs = dict(kwargs) local_kwargs["local_files_only"] = True - hf_hub_download(*args, **local_kwargs) + return hf_hub_download(*args, **local_kwargs) except Exception: repo_id = kwargs.get("repo_id", "") filename = kwargs.get("filename", "") diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index a3ed659..3e480cf 100644 --- a/abogen/pyqt/conversion.py +++ b/abogen/pyqt/conversion.py @@ -1,6 +1,7 @@ import os import time import hashlib # For generating unique cache filenames +from pathlib import Path from platformdirs import user_desktop_dir from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox @@ -32,7 +33,7 @@ from abogen.domain.output_paths import ( ) from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32 from abogen.domain.audio_sink import open_audio_sink -from abogen.domain.conversion_engine import synthesize_text, SynthParams, SegmentStats, SegmentInfo +from abogen.domain.conversion_engine import run_tts_segment_loop, synthesize_text, SynthParams, SegmentStats, SegmentInfo from abogen.domain.intro_outro import resolve_intro, resolve_outro from abogen.domain.audio_buffer import ( create_silence, diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index 6120a34..fca8c0f 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -842,7 +842,7 @@ class WordSubstitutionsDialog(QDialog): self, ) instructions.setStyleSheet( - "padding: 10px; background-color: #f0f0f0; border-radius: 5px;" + f"padding: 10px; background-color: {COLORS['GREY_BACKGROUND']}; border-radius: 5px;" ) instructions.setWordWrap(True) layout.addWidget(instructions) diff --git a/abogen/pyqt/main.py b/abogen/pyqt/main.py index 9e11c0c..437068a 100644 --- a/abogen/pyqt/main.py +++ b/abogen/pyqt/main.py @@ -46,6 +46,8 @@ except ImportError: print("PyQt6 not installed.") +from abogen.utils import get_resource_path + # Pre-load "libxcb-cursor" on Linux (fixes #101) if platform.system() == "Linux": arch = platform.machine().lower() @@ -118,6 +120,8 @@ def qt_message_handler(mode, context, message): return # Suppress this specific message if "setGrabPopup called with a parent, QtWaylandClient" in message: return + if "Failed to register with host portal" in message: + return if mode == QtMsgType.QtWarningMsg: print(f"Qt Warning: {message}") diff --git a/abogen/spacy_utils.py b/abogen/spacy_utils.py index d03e859..d2f331a 100644 --- a/abogen/spacy_utils.py +++ b/abogen/spacy_utils.py @@ -21,6 +21,19 @@ 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.""" @@ -61,11 +74,14 @@ def get_spacy_model(lang_code, log_callback=None): # Normalize to Language enum if not isinstance(lang_code, Language): - try: - lang_code = Language.from_str(lang_code) - except ValueError: - log(f"\nspaCy: Unknown language '{lang_code}'...") - return None + 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 # Check if model is cached if lang_code in _nlp_cache: diff --git a/abogen/tts_plugin/loader.py b/abogen/tts_plugin/loader.py index fa600ab..7c8baf1 100644 --- a/abogen/tts_plugin/loader.py +++ b/abogen/tts_plugin/loader.py @@ -12,7 +12,7 @@ The loader does NOT: from __future__ import annotations -import importlib +import importlib.util import re import sys import types diff --git a/abogen/tts_plugin/plugin_manager.py b/abogen/tts_plugin/plugin_manager.py index 1589d08..6789620 100644 --- a/abogen/tts_plugin/plugin_manager.py +++ b/abogen/tts_plugin/plugin_manager.py @@ -42,8 +42,11 @@ class PluginManager: plugins_path = Path(plugins_dir) if not plugins_path.exists(): - self._loaded = True - return + if plugins_dir == "plugins": + plugins_path = Path(__file__).resolve().parent.parent.parent / "plugins" + if not plugins_path.exists(): + self._loaded = True + return for entry in plugins_path.iterdir(): if entry.is_dir() and (entry / "__init__.py").exists(): diff --git a/abogen/tts_plugin/utils.py b/abogen/tts_plugin/utils.py index 0bbdb71..dd92431 100644 --- a/abogen/tts_plugin/utils.py +++ b/abogen/tts_plugin/utils.py @@ -179,6 +179,12 @@ class Pipeline: yield Segment(graphemes=text, audio=audio_array) + def load_single_voice(self, voice_name: str) -> Any: + engine_pipeline = getattr(self._engine, '_pipeline', None) + if engine_pipeline is not None and hasattr(engine_pipeline, 'load_single_voice'): + return engine_pipeline.load_single_voice(voice_name) + raise AttributeError(f"load_single_voice not available on {type(self._engine).__name__}") + def dispose(self) -> None: if self._session is not None: try: diff --git a/abogen/webui/routes/settings.py b/abogen/webui/routes/settings.py index ba4ea07..975cad4 100644 --- a/abogen/webui/routes/settings.py +++ b/abogen/webui/routes/settings.py @@ -7,6 +7,7 @@ from flask import Blueprint, current_app, render_template, request, redirect, ur from flask.typing import ResponseReturnValue from abogen.webui.routes.utils.settings import ( + load_integration_settings, load_settings, save_settings, SAVE_MODE_LABELS,