{PROGRAM_DESCRIPTION}
" - "Visit the GitHub repository for updates, documentation, and to report issues.
" - ) - desc_label.setTextFormat(Qt.TextFormat.RichText) - desc_label.setWordWrap(True) - layout.addWidget(desc_label) - - # GitHub link - github_btn = QPushButton("Visit GitHub Repository") - github_btn.setIcon(QIcon(get_resource_path("abogen.assets", "github.png"))) - github_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(GITHUB_URL))) - github_btn.setFixedHeight(32) - layout.addWidget(github_btn) - - # Check for updates button - update_btn = QPushButton("Check for updates") - update_btn.clicked.connect(self.manual_check_for_updates) - update_btn.setFixedHeight(32) - layout.addWidget(update_btn) - - # Close button - close_btn = QPushButton("Close") - close_btn.clicked.connect(dialog.accept) - close_btn.setFixedHeight(32) - layout.addWidget(close_btn) - - dialog.exec() - - def manual_check_for_updates(self): - """Manually check for updates and always show result""" - # Set a flag to always show the result message - self._show_update_check_result = True - self.check_for_updates_startup() - - def check_for_updates_startup(self): - import urllib.request - - def show_update_message(remote_version, local_version): - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Information) - msg_box.setWindowTitle("Update Available") - msg_box.setText( - f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})" - ) - msg_box.setInformativeText( - f"If you installed via pip, update by running:\n" - f"pip install --upgrade {PROGRAM_NAME}\n\n" - f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n" - "Alternatively, visit the GitHub repository for more information. " - "Would you like to view the changelog?" - ) - msg_box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) - if msg_box.exec() == QMessageBox.StandardButton.Yes: - try: - QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest")) - except Exception: - pass - - # Reset flag to track if we should show "no updates" message - show_result = ( - hasattr(self, "_show_update_check_result") - and self._show_update_check_result - ) - self._show_update_check_result = False - - try: - update_url = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION" - with urllib.request.urlopen(update_url) as response: - remote_raw = response.read().decode().strip() - local_raw = VERSION - - # Parse version numbers - remote_version = remote_raw - local_version = local_raw - - try: - remote_num = int("".join(remote_version.split("."))) - local_num = int("".join(local_version.split("."))) - except ValueError as ve: - return - - if remote_num > local_num: - # Use QTimer to ensure UI is ready, then show update message. - QTimer.singleShot( - 1000, lambda: show_update_message(remote_version, local_version) - ) - elif show_result: - # Show "no updates" message if manually checking - QMessageBox.information( - self, - "Up to Date", - f"You are running the latest version of {PROGRAM_NAME} ({local_version}).", - ) - except Exception as e: - if show_result: - QMessageBox.warning( - self, - "Update Check Failed", - f"Could not check for updates:\n{str(e)}", - ) - pass - - def clear_cache_files(self): - """Clear cache files created by the program.""" - import glob - - try: - # Get the abogen cache directory - cache_dir = get_user_cache_path() - - # Find all .txt files and cover images in the abogen cache directory - cache_files = glob.glob(os.path.join(cache_dir, "*.txt")) - cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg"))) - - # Count the files - file_count = len(cache_files) - - # Check for preview cache files - preview_cache_dir = os.path.join(cache_dir, "preview_cache") - preview_files = [] - if os.path.exists(preview_cache_dir): - preview_pattern = os.path.join(preview_cache_dir, "*.wav") - preview_files = glob.glob(preview_pattern) - - preview_count = len(preview_files) - - if file_count == 0 and preview_count == 0: - QMessageBox.information( - self, "No Cache Files", "No cache files were found." - ) - return - - # Create a custom message box with checkbox - msg_box = QMessageBox(self) - msg_box.setIcon(QMessageBox.Icon.Question) - msg_box.setWindowTitle("Clear Cache Files") - - msg_text = f"Found {file_count} cache file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} cache folder." - if preview_count > 0: - msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}." - - msg_box.setText(msg_text + "\nDo you want to delete them?") - - # Add checkbox for preview cache - preview_cache_checkbox = QCheckBox("Also clean preview cache", msg_box) - preview_cache_checkbox.setChecked(False) - # Only enable checkbox if preview files exist - preview_cache_checkbox.setEnabled(preview_count > 0) - - # Add the checkbox to the layout - msg_box.setCheckBox(preview_cache_checkbox) - - # Add buttons - msg_box.setStandardButtons( - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No - ) - msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) - - if msg_box.exec() != QMessageBox.StandardButton.Yes: - return - - # Delete the text files - deleted_count = 0 - for file_path in cache_files: - try: - os.remove(file_path) - deleted_count += 1 - except Exception as e: - print(f"Error deleting {file_path}: {e}") - - # Delete preview cache files if checkbox is checked - deleted_preview_count = 0 - if preview_cache_checkbox.isChecked() and preview_count > 0: - for file_path in preview_files: - try: - os.remove(file_path) - deleted_preview_count += 1 - except Exception as e: - print(f"Error deleting preview cache {file_path}: {e}") - - # Build result message - result_msg = f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}." - if preview_cache_checkbox.isChecked() and deleted_preview_count > 0: - result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}." - - # Show results - QMessageBox.information(self, "Cache Files Cleared", result_msg) - - # If currently selected file is in the cache directory, clear the UI - if ( - self.selected_file - and os.path.dirname(self.selected_file) == cache_dir - and self.selected_file.endswith(".txt") - ): - self.input_box.clear_input() - - except Exception as e: - QMessageBox.critical( - self, "Error", f"An error occurred while clearing temporary files:\n{e}" - ) - - def set_max_log_lines(self): - """Open a dialog to set the maximum lines in the log window.""" - from PyQt6.QtWidgets import QInputDialog - - value, ok = QInputDialog.getInt( - self, - "Max Lines in Log Window", - "Enter the maximum number of lines to display in the log window:", - self.log_window_max_lines, - 10, # min value - 999999999, # max value - 1, # step - ) - if ok: - self.log_window_max_lines = value - self.config["log_window_max_lines"] = value - save_config(self.config) - QMessageBox.information( - self, - "Setting Saved", - f"Maximum lines in log window set to {value}.", - ) - - def set_max_subtitle_words(self): - """Open a dialog to set the maximum words per subtitle""" - from PyQt6.QtWidgets import QInputDialog - - current_value = self.config.get("max_subtitle_words", 50) - - value, ok = QInputDialog.getInt( - self, - "Max Words Per Subtitle", - "Enter the maximum number of words per\nsubtitle (before splitting the subtitle):", - current_value, - 1, # min value - 200, # max value - 1, # step - ) - - if ok: - # Save the new value - self.max_subtitle_words = value - self.config["max_subtitle_words"] = value - save_config(self.config) - - # Show confirmation - QMessageBox.information( - self, - "Setting Saved", - f"Maximum words per subtitle set to {value}.", - ) - - def set_silence_between_chapters(self): - """Open a dialog to set the silence duration between chapters""" - - current_value = self.config.get("silence_duration", 2.0) - - dlg = QInputDialog(self) - dlg.setWindowTitle("Silence Duration (seconds)") - dlg.setLabelText( - "Enter the duration of silence\nbetween chapters (in seconds):" - ) - dlg.setInputMode(QInputDialog.InputMode.DoubleInput) - dlg.setDoubleDecimals(1) - dlg.setDoubleMinimum(0.0) - dlg.setDoubleMaximum(60.0) - dlg.setDoubleValue(current_value) - dlg.setDoubleStep(0.1) # <-- set step to 0.1 - - if dlg.exec() == QDialog.DialogCode.Accepted: - value = dlg.doubleValue() - # Round to one decimal to avoid floating-point representation noise - value = round(value, 1) - - # Save the new value - self.silence_duration = value - self.config["silence_duration"] = value - save_config(self.config) - - # Show confirmation (format with one decimal) - QMessageBox.information( - self, - "Setting Saved", - f"Silence duration between chapters set to {value:.1f} seconds.", - ) - - def set_separate_chapters_format(self, fmt): - """Set the format for separate chapters audio files.""" - self.separate_chapters_format = fmt - self.config["separate_chapters_format"] = fmt - save_config(self.config) - - def set_subtitle_format(self, fmt): - """Set the subtitle format.""" - self.config["subtitle_format"] = fmt - save_config(self.config) - - def show_model_download_warning(self, title, message): - QMessageBox.information(self, title, message) +import os +import time +import sys +import tempfile +import platform +import base64 +import re +from abogen.pyqt.queue_manager_gui import QueueManager +from abogen.pyqt.queued_item import QueuedItem +import abogen.hf_tracker as hf_tracker +import hashlib # Added for cache path generation +from PyQt6.QtWidgets import ( + QApplication, + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QComboBox, + QTextEdit, + QLabel, + QSlider, + QMessageBox, + QFileDialog, + QProgressBar, + QFrame, + QStyleFactory, + QInputDialog, + QFileIconProvider, + QSizePolicy, + QDialog, + QCheckBox, + QMenu, +) +from PyQt6.QtGui import QAction, QActionGroup +from PyQt6.QtCore import ( + Qt, + QUrl, + QPoint, + QFileInfo, + QThread, + pyqtSignal, + QObject, + QBuffer, + QIODevice, + QSize, + QTimer, + QEvent, + QProcess, +) +from PyQt6.QtGui import ( + QTextCursor, + QDesktopServices, + QIcon, + QPixmap, + QPainter, + QPolygon, + QColor, + QMovie, + QPalette, +) +from abogen.utils import ( + load_config, + save_config, + get_gpu_acceleration, + prevent_sleep_start, + prevent_sleep_end, + get_resource_path, + get_user_cache_path, + LoadPipelineThread, +) + +from abogen.subtitle_utils import ( + clean_text, + calculate_text_length, +) + +from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog +from abogen.pyqt.book_handler import HandlerDialog +from abogen.constants import ( + PROGRAM_NAME, + VERSION, + GITHUB_URL, + PROGRAM_DESCRIPTION, + LANGUAGE_DESCRIPTIONS, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + COLORS, + SUBTITLE_FORMATS, +) +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 + +# Import ctypes for Windows-specific taskbar icon +if platform.system() == "Windows": + import ctypes + + +class DarkTitleBarEventFilter(QObject): + def __init__(self, is_windows, get_dark_mode_func, set_title_bar_dark_mode_func): + super().__init__() + self.is_windows = is_windows + self.get_dark_mode = get_dark_mode_func + self.set_title_bar_dark_mode = set_title_bar_dark_mode_func + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.Show: + # Only apply to QWidget windows + if isinstance(obj, QWidget) and obj.isWindow(): + if self.is_windows and self.get_dark_mode(): + self.set_title_bar_dark_mode(obj, True) + return super().eventFilter(obj, event) + + +class ShowWarningSignalEmitter(QObject): # New class to handle signal emission + show_warning_signal = pyqtSignal(str, str) + + def emit(self, title, message): + self.show_warning_signal.emit(title, message) + + +class ThreadSafeLogSignal(QObject): + log_signal = pyqtSignal(object) + + def __init__(self, parent=None): + super().__init__(parent) + + def emit_log(self, message): + self.log_signal.emit(message) + + +class IconProvider(QFileIconProvider): + def icon(self, fileInfo): + return super().icon(fileInfo) + + +LOG_COLOR_MAP = { + True: COLORS["GREEN"], + False: COLORS["RED"], + "red": COLORS["RED"], + "green": COLORS["GREEN"], + "orange": COLORS["ORANGE"], + "blue": COLORS["BLUE"], + "grey": COLORS["LIGHT_DISABLED"], + None: COLORS["LIGHT_DISABLED"], +} + + +class InputBox(QLabel): + # Define CSS styles as class constants + STYLE_DEFAULT = f"border:2px dashed #aaa; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG']}; min-height:100px;" + STYLE_DEFAULT_HOVER = f"background:{COLORS['BLUE_BG_HOVER']}; border-color:{COLORS['BLUE_BORDER_HOVER']};" + + STYLE_ACTIVE = f"border:2px dashed {COLORS['GREEN']}; border-radius:5px; padding:20px; background:{COLORS['GREEN_BG']}; min-height:100px;" + STYLE_ACTIVE_HOVER = ( + f"background:{COLORS['GREEN_BG_HOVER']}; border-color:{COLORS['GREEN_BORDER']};" + ) + + STYLE_ERROR = f"border:2px dashed {COLORS['RED']}; border-radius:5px; padding:20px; background:{COLORS['RED_BG']}; min-height:100px; color:{COLORS['RED']};" + STYLE_ERROR_HOVER = ( + f"background:{COLORS['RED_BG_HOVER']}; border-color:{COLORS['RED']};" + ) + + def __init__(self, parent=None): + super().__init__(parent) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.setAcceptDrops(True) + self.setText( + "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)" + ) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setCursor(Qt.CursorShape.PointingHandCursor) + + # Add clear button + self.clear_btn = QPushButton("✕", self) + self.clear_btn.setFixedSize(28, 28) + self.clear_btn.hide() + self.clear_btn.clicked.connect(self.clear_input) + + # Add Chapters button + self.chapters_btn = QPushButton("Chapters", self) + self.chapters_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.chapters_btn.hide() + self.chapters_btn.clicked.connect(self.on_chapters_clicked) + + # Add Textbox button with no padding + self.textbox_btn = QPushButton("Textbox", self) + self.textbox_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.textbox_btn.setToolTip("Input text directly instead of using a file") + self.textbox_btn.clicked.connect(self.on_textbox_clicked) + + # Add Edit button matching the textbox button + self.edit_btn = QPushButton("Edit", self) + self.edit_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.edit_btn.setToolTip("Edit the current text file") + self.edit_btn.clicked.connect(self.on_edit_clicked) + self.edit_btn.hide() + + # Add Go to folder button + self.go_to_folder_btn = QPushButton("Go to folder", self) + self.go_to_folder_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") + self.go_to_folder_btn.setToolTip( + "Open the folder that contains the converted file" + ) + self.go_to_folder_btn.clicked.connect(self.on_go_to_folder_clicked) + self.go_to_folder_btn.hide() + + def resizeEvent(self, event): + super().resizeEvent(event) + margin = 12 + self.clear_btn.move(self.width() - self.clear_btn.width() - margin, margin) + self.chapters_btn.move( + margin, self.height() - self.chapters_btn.height() - margin + ) + # Position textbox button at top left + self.textbox_btn.move(margin, margin) + self.edit_btn.move(margin, margin) + # Position go to folder button at bottom right with correct margins + self.go_to_folder_btn.move( + self.width() - self.go_to_folder_btn.width() - margin, + self.height() - self.go_to_folder_btn.height() - margin, + ) + + def set_file_info(self, file_path): + # get icon without resizing using custom provider + provider = IconProvider() + qicon = provider.icon(QFileInfo(file_path)) + size = QSize(32, 32) + pixmap = qicon.pixmap(size) + # convert to base64 PNG + buffer = QBuffer() + buffer.open(QIODevice.OpenModeFlag.WriteOnly) + pixmap.save(buffer, "PNG") + img_data = base64.b64encode(buffer.data()).decode() + + size_str = self._human_readable_size(os.path.getsize(file_path)) + name = os.path.basename(file_path) + char_count = 0 + window = self.window() + cache = getattr(window, "_char_count_cache", None) + + def parse_size(size_str): + # Use regex to extract the numeric part + match = re.match(r"([\d.]+)", size_str) + if match: + return float(match.group(1)) + raise ValueError(f"Invalid size format: {size_str}") + + # Format numbers with commas + def format_num(n): + try: + if isinstance(n, str): + size = int(parse_size(n)) + return f"{size:,}" + else: + return f"{n:,}" + except Exception: + return str(n) + + doc_extensions = (".epub", ".pdf", ".md", ".markdown", ".srt", ".ass", ".vtt") + char_source_path = file_path + cached_char_count = None + + if file_path.lower().endswith(doc_extensions): + selected_file_path = getattr(window, "selected_file", None) + if selected_file_path and os.path.exists(selected_file_path): + char_source_path = selected_file_path + else: + char_source_path = None + + if cache is not None: + cached_char_count = cache.get(file_path) + if ( + cached_char_count is None + and char_source_path + and char_source_path != file_path + ): + cached_char_count = cache.get(char_source_path) + + if cached_char_count is not None: + char_count = cached_char_count + elif char_source_path: + try: + with open( + char_source_path, "r", encoding="utf-8", errors="ignore" + ) as f: + text = f.read() + cleaned_text = clean_text(text) + char_count = calculate_text_length(cleaned_text) + except Exception: + char_count = "N/A" + else: + char_count = "N/A" + + if cache is not None and isinstance(char_count, int): + cache[file_path] = char_count + if char_source_path and char_source_path != file_path: + cache[char_source_path] = char_count + + # Store numeric char_count on window + try: + window.char_count = int(char_count) + except Exception: + window.char_count = 0 + # embed icon at native size with word-wrap for the filename + self.setText( + f'{PROGRAM_DESCRIPTION}
" + "Visit the GitHub repository for updates, documentation, and to report issues.
" + ) + desc_label.setTextFormat(Qt.TextFormat.RichText) + desc_label.setWordWrap(True) + layout.addWidget(desc_label) + + # GitHub link + github_btn = QPushButton("Visit GitHub Repository") + github_btn.setIcon(QIcon(get_resource_path("abogen.assets", "github.png"))) + github_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(GITHUB_URL))) + github_btn.setFixedHeight(32) + layout.addWidget(github_btn) + + # Check for updates button + update_btn = QPushButton("Check for updates") + update_btn.clicked.connect(self.manual_check_for_updates) + update_btn.setFixedHeight(32) + layout.addWidget(update_btn) + + # Close button + close_btn = QPushButton("Close") + close_btn.clicked.connect(dialog.accept) + close_btn.setFixedHeight(32) + layout.addWidget(close_btn) + + dialog.exec() + + def manual_check_for_updates(self): + """Manually check for updates and always show result""" + # Set a flag to always show the result message + self._show_update_check_result = True + self.check_for_updates_startup() + + def check_for_updates_startup(self): + import urllib.request + + def show_update_message(remote_version, local_version): + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Icon.Information) + msg_box.setWindowTitle("Update Available") + msg_box.setText( + f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})" + ) + msg_box.setInformativeText( + f"If you installed via pip, update by running:\n" + f"pip install --upgrade {PROGRAM_NAME}\n\n" + f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n" + "Alternatively, visit the GitHub repository for more information. " + "Would you like to view the changelog?" + ) + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) + if msg_box.exec() == QMessageBox.StandardButton.Yes: + try: + QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest")) + except Exception: + pass + + # Reset flag to track if we should show "no updates" message + show_result = ( + hasattr(self, "_show_update_check_result") + and self._show_update_check_result + ) + self._show_update_check_result = False + + try: + update_url = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION" + with urllib.request.urlopen(update_url) as response: + remote_raw = response.read().decode().strip() + local_raw = VERSION + + # Parse version numbers + remote_version = remote_raw + local_version = local_raw + + try: + remote_num = int("".join(remote_version.split("."))) + local_num = int("".join(local_version.split("."))) + except ValueError as ve: + return + + if remote_num > local_num: + # Use QTimer to ensure UI is ready, then show update message. + QTimer.singleShot( + 1000, lambda: show_update_message(remote_version, local_version) + ) + elif show_result: + # Show "no updates" message if manually checking + QMessageBox.information( + self, + "Up to Date", + f"You are running the latest version of {PROGRAM_NAME} ({local_version}).", + ) + except Exception as e: + if show_result: + QMessageBox.warning( + self, + "Update Check Failed", + f"Could not check for updates:\n{str(e)}", + ) + pass + + def clear_cache_files(self): + """Clear cache files created by the program.""" + import glob + + try: + # Get the abogen cache directory + cache_dir = get_user_cache_path() + + # Find all .txt files and cover images in the abogen cache directory + cache_files = glob.glob(os.path.join(cache_dir, "*.txt")) + cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg"))) + + # Count the files + file_count = len(cache_files) + + # Check for preview cache files + preview_cache_dir = os.path.join(cache_dir, "preview_cache") + preview_files = [] + if os.path.exists(preview_cache_dir): + preview_pattern = os.path.join(preview_cache_dir, "*.wav") + preview_files = glob.glob(preview_pattern) + + preview_count = len(preview_files) + + if file_count == 0 and preview_count == 0: + QMessageBox.information( + self, "No Cache Files", "No cache files were found." + ) + return + + # Create a custom message box with checkbox + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Icon.Question) + msg_box.setWindowTitle("Clear Cache Files") + + msg_text = f"Found {file_count} cache file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} cache folder." + if preview_count > 0: + msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}." + + msg_box.setText(msg_text + "\nDo you want to delete them?") + + # Add checkbox for preview cache + preview_cache_checkbox = QCheckBox("Also clean preview cache", msg_box) + preview_cache_checkbox.setChecked(False) + # Only enable checkbox if preview files exist + preview_cache_checkbox.setEnabled(preview_count > 0) + + # Add the checkbox to the layout + msg_box.setCheckBox(preview_cache_checkbox) + + # Add buttons + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.Yes) + + if msg_box.exec() != QMessageBox.StandardButton.Yes: + return + + # Delete the text files + deleted_count = 0 + for file_path in cache_files: + try: + os.remove(file_path) + deleted_count += 1 + except Exception as e: + print(f"Error deleting {file_path}: {e}") + + # Delete preview cache files if checkbox is checked + deleted_preview_count = 0 + if preview_cache_checkbox.isChecked() and preview_count > 0: + for file_path in preview_files: + try: + os.remove(file_path) + deleted_preview_count += 1 + except Exception as e: + print(f"Error deleting preview cache {file_path}: {e}") + + # Build result message + result_msg = f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}." + if preview_cache_checkbox.isChecked() and deleted_preview_count > 0: + result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}." + + # Show results + QMessageBox.information(self, "Cache Files Cleared", result_msg) + + # If currently selected file is in the cache directory, clear the UI + if ( + self.selected_file + and os.path.dirname(self.selected_file) == cache_dir + and self.selected_file.endswith(".txt") + ): + self.input_box.clear_input() + + except Exception as e: + QMessageBox.critical( + self, "Error", f"An error occurred while clearing temporary files:\n{e}" + ) + + def set_max_log_lines(self): + """Open a dialog to set the maximum lines in the log window.""" + from PyQt6.QtWidgets import QInputDialog + + value, ok = QInputDialog.getInt( + self, + "Max Lines in Log Window", + "Enter the maximum number of lines to display in the log window:", + self.log_window_max_lines, + 10, # min value + 999999999, # max value + 1, # step + ) + if ok: + self.log_window_max_lines = value + self.config["log_window_max_lines"] = value + save_config(self.config) + QMessageBox.information( + self, + "Setting Saved", + f"Maximum lines in log window set to {value}.", + ) + + def set_max_subtitle_words(self): + """Open a dialog to set the maximum words per subtitle""" + from PyQt6.QtWidgets import QInputDialog + + current_value = self.config.get("max_subtitle_words", 50) + + value, ok = QInputDialog.getInt( + self, + "Max Words Per Subtitle", + "Enter the maximum number of words per\nsubtitle (before splitting the subtitle):", + current_value, + 1, # min value + 200, # max value + 1, # step + ) + + if ok: + # Save the new value + self.max_subtitle_words = value + self.config["max_subtitle_words"] = value + save_config(self.config) + + # Show confirmation + QMessageBox.information( + self, + "Setting Saved", + f"Maximum words per subtitle set to {value}.", + ) + + def set_silence_between_chapters(self): + """Open a dialog to set the silence duration between chapters""" + + current_value = self.config.get("silence_duration", 2.0) + + dlg = QInputDialog(self) + dlg.setWindowTitle("Silence Duration (seconds)") + dlg.setLabelText( + "Enter the duration of silence\nbetween chapters (in seconds):" + ) + dlg.setInputMode(QInputDialog.InputMode.DoubleInput) + dlg.setDoubleDecimals(1) + dlg.setDoubleMinimum(0.0) + dlg.setDoubleMaximum(60.0) + dlg.setDoubleValue(current_value) + dlg.setDoubleStep(0.1) # <-- set step to 0.1 + + if dlg.exec() == QDialog.DialogCode.Accepted: + value = dlg.doubleValue() + # Round to one decimal to avoid floating-point representation noise + value = round(value, 1) + + # Save the new value + self.silence_duration = value + self.config["silence_duration"] = value + save_config(self.config) + + # Show confirmation (format with one decimal) + QMessageBox.information( + self, + "Setting Saved", + f"Silence duration between chapters set to {value:.1f} seconds.", + ) + + def set_separate_chapters_format(self, fmt): + """Set the format for separate chapters audio files.""" + self.separate_chapters_format = fmt + self.config["separate_chapters_format"] = fmt + save_config(self.config) + + def set_subtitle_format(self, fmt): + """Set the subtitle format.""" + self.config["subtitle_format"] = fmt + save_config(self.config) + + def show_model_download_warning(self, title, message): + QMessageBox.information(self, title, message) diff --git a/abogen/pyqt/predownload_gui.py b/abogen/pyqt/predownload_gui.py index fb084ea..9116928 100644 --- a/abogen/pyqt/predownload_gui.py +++ b/abogen/pyqt/predownload_gui.py @@ -1,591 +1,591 @@ -""" -Pre-download dialog and worker for Abogen - -This module consolidates pre-download logic for Kokoro voices and model -and spaCy language models. The code favors clarity, avoids duplication, -and handles optional dependencies gracefully. -""" - -from typing import List, Optional, Tuple -import importlib -import importlib.util - -from PyQt6.QtWidgets import ( - QDialog, - QVBoxLayout, - QHBoxLayout, - QLabel, - QPushButton, - QSpacerItem, - QSizePolicy, -) -from PyQt6.QtCore import QThread, pyqtSignal - -from abogen.constants import COLORS -from abogen.tts_plugin.utils import get_voices -from abogen.spacy_utils import SPACY_MODELS -import abogen.hf_tracker - - -# Helpers -def _unique_sorted_models() -> List[str]: - """Return a sorted list of unique spaCy model package names.""" - return sorted(set(SPACY_MODELS.values())) - - -def _is_package_installed(pkg_name: str) -> bool: - """Return True if a package with the given name can be imported (site-packages).""" - try: - return importlib.util.find_spec(pkg_name) is not None - except Exception: - return False - - -# NOTE: explicit HF cache helper removed; we use try_to_load_from_cache in-scope where needed - - -class PreDownloadWorker(QThread): - """Worker thread to download required models/voices. - - Emits human-readable messages via `progress`. Uses `category_done` to indicate - a category (voices/model/spacy) finished successfully. Emits `error` on exception - and `finished` after all work completes. - """ - - # Emit (category, status, message) - progress = pyqtSignal(str, str, str) - category_done = pyqtSignal(str) - finished = pyqtSignal() - error = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self._cancelled = False - # repo and filenames used for Kokoro model - self._repo_id = "hexgrad/Kokoro-82M" - self._model_files = ["kokoro-v1_0.pth", "config.json"] - # Track download success per category - self._voices_success = False - self._model_success = False - self._spacy_success = False - # Suppress HF tracker warnings during downloads - self._original_emitter = abogen.hf_tracker.show_warning_signal_emitter - - def cancel(self) -> None: - self._cancelled = True - - def run(self) -> None: - # Suppress HF tracker warnings during downloads - abogen.hf_tracker.show_warning_signal_emitter = None - try: - self._download_kokoro_voices() - if self._cancelled: - return - if self._voices_success: - self.category_done.emit("voices") - - self._download_kokoro_model() - if self._cancelled: - return - if self._model_success: - self.category_done.emit("model") - - self._download_spacy_models() - if self._cancelled: - return - if self._spacy_success: - self.category_done.emit("spacy") - - self.finished.emit() - except Exception as exc: # pragma: no cover - best-effort reporting - self.error.emit(str(exc)) - finally: - # Restore original emitter - abogen.hf_tracker.show_warning_signal_emitter = self._original_emitter - - # Kokoro voices - def _download_kokoro_voices(self) -> None: - self._voices_success = True - try: - from huggingface_hub import hf_hub_download, try_to_load_from_cache - except Exception: - self.progress.emit( - "voice", "warning", "huggingface_hub not installed, skipping voices..." - ) - self._voices_success = False - return - - voice_list = get_voices("kokoro") - for idx, voice in enumerate(voice_list, start=1): - if self._cancelled: - self._voices_success = False - return - filename = f"voices/{voice}.pt" - if try_to_load_from_cache(repo_id=self._repo_id, filename=filename): - self.progress.emit( - "voice", - "installed", - f"{idx}/{len(voice_list)}: {voice} already present", - ) - continue - self.progress.emit( - "voice", "downloading", f"{idx}/{len(voice_list)}: {voice}..." - ) - try: - hf_hub_download(repo_id=self._repo_id, filename=filename) - self.progress.emit("voice", "downloaded", f"{voice} downloaded") - except Exception as exc: - self.progress.emit( - "voice", "warning", f"could not download {voice}: {exc}" - ) - self._voices_success = False - - # Kokoro model - def _download_kokoro_model(self) -> None: - self._model_success = True - try: - from huggingface_hub import hf_hub_download, try_to_load_from_cache - except Exception: - self.progress.emit( - "model", "warning", "huggingface_hub not installed, skipping model..." - ) - self._model_success = False - return - for fname in self._model_files: - if self._cancelled: - self._model_success = False - return - category = "config" if fname == "config.json" else "model" - if try_to_load_from_cache(repo_id=self._repo_id, filename=fname): - self.progress.emit( - category, "installed", f"file {fname} already present" - ) - continue - self.progress.emit(category, "downloading", f"file {fname}...") - try: - hf_hub_download(repo_id=self._repo_id, filename=fname) - self.progress.emit(category, "downloaded", f"file {fname} downloaded") - except Exception as exc: - self.progress.emit( - category, "warning", f"could not download file {fname}: {exc}" - ) - self._model_success = False - - # spaCy models - def _download_spacy_models(self) -> None: - """Download spaCy models. Prefer missing models provided by parent. - - Parent dialog will populate _spacy_models_missing during checking. - """ - self._spacy_success = True - # Determine which models to process: prefer parent-provided missing list to avoid - # re-checking everything; otherwise use the full unique list. - parent = self.parent() - models_to_process: List[str] = _unique_sorted_models() - try: - if ( - parent is not None - and hasattr(parent, "_spacy_models_missing") - and parent._spacy_models_missing - ): - models_to_process = list(dict.fromkeys(parent._spacy_models_missing)) - except Exception: - pass - - # If spaCy is not available to run the CLI, skip gracefully - try: - import spacy.cli as _spacy_cli - except Exception: - self.progress.emit( - "spacy", "warning", "spaCy not available, skipping spaCy models..." - ) - self._spacy_success = False - return - - for idx, model_name in enumerate(models_to_process, start=1): - if self._cancelled: - self._spacy_success = False - return - if _is_package_installed(model_name): - self.progress.emit( - "spacy", - "installed", - f"{idx}/{len(models_to_process)}: {model_name} already installed", - ) - continue - self.progress.emit( - "spacy", - "downloading", - f"{idx}/{len(models_to_process)}: {model_name}...", - ) - try: - _spacy_cli.download(model_name) - self.progress.emit("spacy", "downloaded", f"{model_name} downloaded") - except Exception as exc: - self.progress.emit( - "spacy", "warning", f"could not download {model_name}: {exc}" - ) - self._spacy_success = False - - -class PreDownloadDialog(QDialog): - """Dialog to show and control pre-download process.""" - - VOICE_PREFIX = "Kokoro voices: " - MODEL_PREFIX = "Kokoro model: " - CONFIG_PREFIX = "Kokoro config: " - SPACY_PREFIX = "spaCy models: " - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Pre-download Models and Voices") - self.setMinimumWidth(500) - self.worker: Optional[PreDownloadWorker] = None - self.has_missing = False - self._spacy_models_checked: List[tuple] = [] - self._spacy_models_missing: List[str] = [] - self._status_worker = None - - # Map keywords to (label, prefix) - labels filled after UI creation - self.status_map = { - "voice": (None, self.VOICE_PREFIX), - "spacy": (None, self.SPACY_PREFIX), - "model": (None, self.MODEL_PREFIX), - "config": (None, self.CONFIG_PREFIX), - } - - self.category_map = { - "voices": ["voice"], - "model": ["model", "config"], - "spacy": ["spacy"], - } - - self._setup_ui() - self._start_status_check() - - def _setup_ui(self) -> None: - layout = QVBoxLayout(self) - layout.setSpacing(0) - layout.setContentsMargins(15, 0, 15, 15) - - desc = QLabel( - "You can pre-download all required models and voices for offline use.\n" - "This includes Kokoro voices, Kokoro model (and config), and spaCy models." - ) - desc.setWordWrap(True) - layout.addWidget(desc) - - # Status rows - status_layout = QVBoxLayout() - status_title = QLabel("Current Status:") - status_layout.addWidget(status_title) - - self.voices_status = QLabel(self.VOICE_PREFIX + "⏳ Checking...") - row = QHBoxLayout() - row.addWidget(self.voices_status) - row.addStretch() - status_layout.addLayout(row) - - self.model_status = QLabel(self.MODEL_PREFIX + "⏳ Checking...") - row = QHBoxLayout() - row.addWidget(self.model_status) - row.addStretch() - status_layout.addLayout(row) - - self.config_status = QLabel(self.CONFIG_PREFIX + "⏳ Checking...") - row = QHBoxLayout() - row.addWidget(self.config_status) - row.addStretch() - status_layout.addLayout(row) - - self.spacy_status = QLabel(self.SPACY_PREFIX + "⏳ Checking...") - row = QHBoxLayout() - row.addWidget(self.spacy_status) - row.addStretch() - status_layout.addLayout(row) - - # register labels - self.status_map["voice"] = (self.voices_status, self.VOICE_PREFIX) - self.status_map["model"] = (self.model_status, self.MODEL_PREFIX) - self.status_map["config"] = (self.config_status, self.CONFIG_PREFIX) - self.status_map["spacy"] = (self.spacy_status, self.SPACY_PREFIX) - - layout.addLayout(status_layout) - - layout.addItem( - QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - ) - - # Buttons - button_row = QHBoxLayout() - button_row.setSpacing(10) - self.download_btn = QPushButton("Download all") - self.download_btn.setMinimumWidth(100) - self.download_btn.setMinimumHeight(35) - self.download_btn.setEnabled(False) - self.download_btn.clicked.connect(self._start_download) - button_row.addWidget(self.download_btn) - - self.close_btn = QPushButton("Close") - self.close_btn.setMinimumWidth(100) - self.close_btn.setMinimumHeight(35) - self.close_btn.clicked.connect(self._handle_close) - button_row.addWidget(self.close_btn) - - layout.addLayout(button_row) - self.adjustSize() - - # Status checking worker - class StatusCheckWorker(QThread): - voices_checked = pyqtSignal(bool, list) - model_checked = pyqtSignal(bool) - config_checked = pyqtSignal(bool) - spacy_model_checking = pyqtSignal(str) - spacy_model_result = pyqtSignal(str, bool) - spacy_checked = pyqtSignal(bool, list) - - def run(self): - parent = self.parent() - if parent is None: - return - - voices_ok, missing_voices = parent._check_kokoro_voices() - self.voices_checked.emit(voices_ok, missing_voices) - - model_ok = parent._check_kokoro_model() - self.model_checked.emit(model_ok) - - config_ok = parent._check_kokoro_config() - self.config_checked.emit(config_ok) - - # Check spaCy models by package name to detect site-package installs - unique = _unique_sorted_models() - missing: List[str] = [] - for name in unique: - self.spacy_model_checking.emit(name) - ok = _is_package_installed(name) - self.spacy_model_result.emit(name, ok) - if not ok: - missing.append(name) - parent._spacy_models_missing = missing - self.spacy_checked.emit(len(missing) == 0, missing) - - def _start_status_check(self) -> None: - self._status_worker = self.StatusCheckWorker(self) - self._status_worker.voices_checked.connect(self._update_voices_status) - self._status_worker.model_checked.connect(self._update_model_status) - self._status_worker.config_checked.connect(self._update_config_status) - self._status_worker.spacy_model_checking.connect(self._spacy_model_checking) - self._status_worker.spacy_model_result.connect(self._spacy_model_result) - self._status_worker.spacy_checked.connect(self._update_spacy_status) - - # These are initialized in __init__ to keep consistent object state - - # Set checking visual state - for lbl in ( - self.voices_status, - self.model_status, - self.config_status, - self.spacy_status, - ): - lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") - - self.spacy_status.setText(self.SPACY_PREFIX + "⏳ Checking...") - self._status_worker.start() - - # UI update callbacks - def _spacy_model_checking(self, name: str) -> None: - self.spacy_status.setText(f"{self.SPACY_PREFIX}Checking {name}...") - - def _spacy_model_result(self, name: str, ok: bool) -> None: - self._spacy_models_checked.append((name, ok)) - if not ok and name not in self._spacy_models_missing: - self._spacy_models_missing.append(name) - checked = len(self._spacy_models_checked) - missing_count = len(self._spacy_models_missing) - if missing_count: - self.spacy_status.setText( - f"{self.SPACY_PREFIX}{checked} checked, {missing_count} missing..." - ) - else: - self.spacy_status.setText(f"{self.SPACY_PREFIX}{checked} checked...") - - def _update_voices_status(self, ok: bool, missing: List[str]) -> None: - if ok: - self._set_status("voice", "✓ Downloaded", COLORS["GREEN"]) - else: - self.has_missing = True - if missing: - self._set_status( - "voice", f"✗ Missing {len(missing)} voices", COLORS["RED"] - ) - else: - self._set_status("voice", "✗ Not downloaded", COLORS["RED"]) - - def _update_model_status(self, ok: bool) -> None: - if ok: - self._set_status("model", "✓ Downloaded", COLORS["GREEN"]) - else: - self.has_missing = True - self._set_status("model", "✗ Not downloaded", COLORS["RED"]) - - def _update_config_status(self, ok: bool) -> None: - if ok: - self._set_status("config", "✓ Downloaded", COLORS["GREEN"]) - else: - self.has_missing = True - self._set_status("config", "✗ Not downloaded", COLORS["RED"]) - - def _update_spacy_status(self, ok: bool, missing: List[str]) -> None: - if ok: - self._set_status("spacy", "✓ Downloaded", COLORS["GREEN"]) - else: - self.has_missing = True - if missing: - self._set_status( - "spacy", f"✗ Missing {len(missing)} model(s)", COLORS["RED"] - ) - else: - self._set_status("spacy", "✗ Not downloaded", COLORS["RED"]) - self.download_btn.setEnabled(self.has_missing) - - def _set_status(self, key: str, text: str, color: str) -> None: - lbl, prefix = self.status_map.get(key, (None, "")) - if not lbl: - return - lbl.setText(prefix + text) - lbl.setStyleSheet(f"color: {color};") - - # Helper checks - def _check_kokoro_voices(self) -> Tuple[bool, List[str]]: - """Return (ok, missing_list) for Kokoro voices check.""" - missing = [] - try: - from huggingface_hub import try_to_load_from_cache - - for voice in get_voices("kokoro"): - if not try_to_load_from_cache( - repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" - ): - missing.append(voice) - except Exception: - # If HF missing, report all as missing - return False, list(get_voices("kokoro")) - return (len(missing) == 0), missing - - def _check_kokoro_model(self) -> bool: - try: - from huggingface_hub import try_to_load_from_cache - - return ( - try_to_load_from_cache( - repo_id="hexgrad/Kokoro-82M", filename="kokoro-v1_0.pth" - ) - is not None - ) - except Exception: - return False - - def _check_kokoro_config(self) -> bool: - try: - from huggingface_hub import try_to_load_from_cache - - return ( - try_to_load_from_cache( - repo_id="hexgrad/Kokoro-82M", filename="config.json" - ) - is not None - ) - except Exception: - return False - - def _check_spacy_models(self) -> bool: - unique = _unique_sorted_models() - missing = [m for m in unique if not _is_package_installed(m)] - self._spacy_models_missing = missing - return len(missing) == 0 - - # Download control - def _start_download(self) -> None: - self.download_btn.setEnabled(False) - self.download_btn.setText("Downloading...") - # mark the start of downloads; this triggers the labels - self._on_progress("system", "starting", "Processing, please wait...") - self.worker = PreDownloadWorker(self) - self.worker.progress.connect(self._on_progress) - self.worker.category_done.connect(self._on_category_done) - self.worker.finished.connect(self._on_download_finished) - self.worker.error.connect(self._on_download_error) - self.worker.start() - - def _on_progress(self, category: str, status: str, message: str) -> None: - """Map worker (category, status, message) to UI label updates. - - Status is one of: 'downloading', 'installed', 'downloaded', 'warning', 'starting'. - Category is one of: 'voice', 'model', 'spacy', 'config', or 'system'. - """ - try: - # If the category targets a specific label, update directly - if category in self.status_map: - lbl, prefix = self.status_map[category] - if not lbl: - return - # Compose message and set color based on status token - full_text = prefix + message - if len(full_text) > 60: - display_text = full_text[:57] + "..." - lbl.setText(display_text) - lbl.setToolTip(full_text) - else: - lbl.setText(full_text) - lbl.setToolTip("") # Clear tooltip if not needed - if status == "downloading": - lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") - elif status in ("installed", "downloaded"): - lbl.setStyleSheet(f"color: {COLORS['GREEN']};") - elif status == "warning": - lbl.setStyleSheet(f"color: {COLORS['RED']};") - elif status == "error": - lbl.setStyleSheet(f"color: {COLORS['RED']};") - return - - # System-level messages - if category == "system": - if status == "starting": - for k in self.status_map: - lbl, prefix = self.status_map[k] - if lbl: - lbl.setText(prefix + "Processing, please wait...") - lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") - # other system statuses don't require action - return - except Exception: - # Do not let UI thread crash on unexpected worker message - pass - - def _on_category_done(self, category: str) -> None: - for key in self.category_map.get(category, []): - self._set_status(key, "✓ Downloaded", COLORS["GREEN"]) - - def _on_download_finished(self) -> None: - self.has_missing = False - self.download_btn.setText("Download all") - self.download_btn.setEnabled(False) - - def _on_download_error(self, error_msg: str) -> None: - self.download_btn.setText("Download all") - self.download_btn.setEnabled(True) - for key in self.status_map: - self._set_status(key, f"✗ Error - {error_msg}", COLORS["RED"]) - - def _handle_close(self) -> None: - if self.worker and self.worker.isRunning(): - self.worker.cancel() - self.worker.wait(2000) - self.accept() - - def closeEvent(self, event) -> None: - if self.worker and self.worker.isRunning(): - self.worker.cancel() - self.worker.wait(2000) - super().closeEvent(event) +""" +Pre-download dialog and worker for Abogen + +This module consolidates pre-download logic for Kokoro voices and model +and spaCy language models. The code favors clarity, avoids duplication, +and handles optional dependencies gracefully. +""" + +from typing import List, Optional, Tuple +import importlib +import importlib.util + +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPushButton, + QSpacerItem, + QSizePolicy, +) +from PyQt6.QtCore import QThread, pyqtSignal + +from abogen.constants import COLORS +from abogen.tts_plugin.utils import get_voices +from abogen.spacy_utils import SPACY_MODELS +import abogen.hf_tracker + + +# Helpers +def _unique_sorted_models() -> List[str]: + """Return a sorted list of unique spaCy model package names.""" + return sorted(set(SPACY_MODELS.values())) + + +def _is_package_installed(pkg_name: str) -> bool: + """Return True if a package with the given name can be imported (site-packages).""" + try: + return importlib.util.find_spec(pkg_name) is not None + except Exception: + return False + + +# NOTE: explicit HF cache helper removed; we use try_to_load_from_cache in-scope where needed + + +class PreDownloadWorker(QThread): + """Worker thread to download required models/voices. + + Emits human-readable messages via `progress`. Uses `category_done` to indicate + a category (voices/model/spacy) finished successfully. Emits `error` on exception + and `finished` after all work completes. + """ + + # Emit (category, status, message) + progress = pyqtSignal(str, str, str) + category_done = pyqtSignal(str) + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._cancelled = False + # repo and filenames used for Kokoro model + self._repo_id = "hexgrad/Kokoro-82M" + self._model_files = ["kokoro-v1_0.pth", "config.json"] + # Track download success per category + self._voices_success = False + self._model_success = False + self._spacy_success = False + # Suppress HF tracker warnings during downloads + self._original_emitter = abogen.hf_tracker.show_warning_signal_emitter + + def cancel(self) -> None: + self._cancelled = True + + def run(self) -> None: + # Suppress HF tracker warnings during downloads + abogen.hf_tracker.show_warning_signal_emitter = None + try: + self._download_kokoro_voices() + if self._cancelled: + return + if self._voices_success: + self.category_done.emit("voices") + + self._download_kokoro_model() + if self._cancelled: + return + if self._model_success: + self.category_done.emit("model") + + self._download_spacy_models() + if self._cancelled: + return + if self._spacy_success: + self.category_done.emit("spacy") + + self.finished.emit() + except Exception as exc: # pragma: no cover - best-effort reporting + self.error.emit(str(exc)) + finally: + # Restore original emitter + abogen.hf_tracker.show_warning_signal_emitter = self._original_emitter + + # Kokoro voices + def _download_kokoro_voices(self) -> None: + self._voices_success = True + try: + from huggingface_hub import hf_hub_download, try_to_load_from_cache + except Exception: + self.progress.emit( + "voice", "warning", "huggingface_hub not installed, skipping voices..." + ) + self._voices_success = False + return + + voice_list = get_voices("kokoro") + for idx, voice in enumerate(voice_list, start=1): + if self._cancelled: + self._voices_success = False + return + filename = f"voices/{voice}.pt" + if try_to_load_from_cache(repo_id=self._repo_id, filename=filename): + self.progress.emit( + "voice", + "installed", + f"{idx}/{len(voice_list)}: {voice} already present", + ) + continue + self.progress.emit( + "voice", "downloading", f"{idx}/{len(voice_list)}: {voice}..." + ) + try: + hf_hub_download(repo_id=self._repo_id, filename=filename) + self.progress.emit("voice", "downloaded", f"{voice} downloaded") + except Exception as exc: + self.progress.emit( + "voice", "warning", f"could not download {voice}: {exc}" + ) + self._voices_success = False + + # Kokoro model + def _download_kokoro_model(self) -> None: + self._model_success = True + try: + from huggingface_hub import hf_hub_download, try_to_load_from_cache + except Exception: + self.progress.emit( + "model", "warning", "huggingface_hub not installed, skipping model..." + ) + self._model_success = False + return + for fname in self._model_files: + if self._cancelled: + self._model_success = False + return + category = "config" if fname == "config.json" else "model" + if try_to_load_from_cache(repo_id=self._repo_id, filename=fname): + self.progress.emit( + category, "installed", f"file {fname} already present" + ) + continue + self.progress.emit(category, "downloading", f"file {fname}...") + try: + hf_hub_download(repo_id=self._repo_id, filename=fname) + self.progress.emit(category, "downloaded", f"file {fname} downloaded") + except Exception as exc: + self.progress.emit( + category, "warning", f"could not download file {fname}: {exc}" + ) + self._model_success = False + + # spaCy models + def _download_spacy_models(self) -> None: + """Download spaCy models. Prefer missing models provided by parent. + + Parent dialog will populate _spacy_models_missing during checking. + """ + self._spacy_success = True + # Determine which models to process: prefer parent-provided missing list to avoid + # re-checking everything; otherwise use the full unique list. + parent = self.parent() + models_to_process: List[str] = _unique_sorted_models() + try: + if ( + parent is not None + and hasattr(parent, "_spacy_models_missing") + and parent._spacy_models_missing + ): + models_to_process = list(dict.fromkeys(parent._spacy_models_missing)) + except Exception: + pass + + # If spaCy is not available to run the CLI, skip gracefully + try: + import spacy.cli as _spacy_cli + except Exception: + self.progress.emit( + "spacy", "warning", "spaCy not available, skipping spaCy models..." + ) + self._spacy_success = False + return + + for idx, model_name in enumerate(models_to_process, start=1): + if self._cancelled: + self._spacy_success = False + return + if _is_package_installed(model_name): + self.progress.emit( + "spacy", + "installed", + f"{idx}/{len(models_to_process)}: {model_name} already installed", + ) + continue + self.progress.emit( + "spacy", + "downloading", + f"{idx}/{len(models_to_process)}: {model_name}...", + ) + try: + _spacy_cli.download(model_name) + self.progress.emit("spacy", "downloaded", f"{model_name} downloaded") + except Exception as exc: + self.progress.emit( + "spacy", "warning", f"could not download {model_name}: {exc}" + ) + self._spacy_success = False + + +class PreDownloadDialog(QDialog): + """Dialog to show and control pre-download process.""" + + VOICE_PREFIX = "Kokoro voices: " + MODEL_PREFIX = "Kokoro model: " + CONFIG_PREFIX = "Kokoro config: " + SPACY_PREFIX = "spaCy models: " + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Pre-download Models and Voices") + self.setMinimumWidth(500) + self.worker: Optional[PreDownloadWorker] = None + self.has_missing = False + self._spacy_models_checked: List[tuple] = [] + self._spacy_models_missing: List[str] = [] + self._status_worker = None + + # Map keywords to (label, prefix) - labels filled after UI creation + self.status_map = { + "voice": (None, self.VOICE_PREFIX), + "spacy": (None, self.SPACY_PREFIX), + "model": (None, self.MODEL_PREFIX), + "config": (None, self.CONFIG_PREFIX), + } + + self.category_map = { + "voices": ["voice"], + "model": ["model", "config"], + "spacy": ["spacy"], + } + + self._setup_ui() + self._start_status_check() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(15, 0, 15, 15) + + desc = QLabel( + "You can pre-download all required models and voices for offline use.\n" + "This includes Kokoro voices, Kokoro model (and config), and spaCy models." + ) + desc.setWordWrap(True) + layout.addWidget(desc) + + # Status rows + status_layout = QVBoxLayout() + status_title = QLabel("Current Status:") + status_layout.addWidget(status_title) + + self.voices_status = QLabel(self.VOICE_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.voices_status) + row.addStretch() + status_layout.addLayout(row) + + self.model_status = QLabel(self.MODEL_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.model_status) + row.addStretch() + status_layout.addLayout(row) + + self.config_status = QLabel(self.CONFIG_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.config_status) + row.addStretch() + status_layout.addLayout(row) + + self.spacy_status = QLabel(self.SPACY_PREFIX + "⏳ Checking...") + row = QHBoxLayout() + row.addWidget(self.spacy_status) + row.addStretch() + status_layout.addLayout(row) + + # register labels + self.status_map["voice"] = (self.voices_status, self.VOICE_PREFIX) + self.status_map["model"] = (self.model_status, self.MODEL_PREFIX) + self.status_map["config"] = (self.config_status, self.CONFIG_PREFIX) + self.status_map["spacy"] = (self.spacy_status, self.SPACY_PREFIX) + + layout.addLayout(status_layout) + + layout.addItem( + QSpacerItem(0, 20, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + ) + + # Buttons + button_row = QHBoxLayout() + button_row.setSpacing(10) + self.download_btn = QPushButton("Download all") + self.download_btn.setMinimumWidth(100) + self.download_btn.setMinimumHeight(35) + self.download_btn.setEnabled(False) + self.download_btn.clicked.connect(self._start_download) + button_row.addWidget(self.download_btn) + + self.close_btn = QPushButton("Close") + self.close_btn.setMinimumWidth(100) + self.close_btn.setMinimumHeight(35) + self.close_btn.clicked.connect(self._handle_close) + button_row.addWidget(self.close_btn) + + layout.addLayout(button_row) + self.adjustSize() + + # Status checking worker + class StatusCheckWorker(QThread): + voices_checked = pyqtSignal(bool, list) + model_checked = pyqtSignal(bool) + config_checked = pyqtSignal(bool) + spacy_model_checking = pyqtSignal(str) + spacy_model_result = pyqtSignal(str, bool) + spacy_checked = pyqtSignal(bool, list) + + def run(self): + parent = self.parent() + if parent is None: + return + + voices_ok, missing_voices = parent._check_kokoro_voices() + self.voices_checked.emit(voices_ok, missing_voices) + + model_ok = parent._check_kokoro_model() + self.model_checked.emit(model_ok) + + config_ok = parent._check_kokoro_config() + self.config_checked.emit(config_ok) + + # Check spaCy models by package name to detect site-package installs + unique = _unique_sorted_models() + missing: List[str] = [] + for name in unique: + self.spacy_model_checking.emit(name) + ok = _is_package_installed(name) + self.spacy_model_result.emit(name, ok) + if not ok: + missing.append(name) + parent._spacy_models_missing = missing + self.spacy_checked.emit(len(missing) == 0, missing) + + def _start_status_check(self) -> None: + self._status_worker = self.StatusCheckWorker(self) + self._status_worker.voices_checked.connect(self._update_voices_status) + self._status_worker.model_checked.connect(self._update_model_status) + self._status_worker.config_checked.connect(self._update_config_status) + self._status_worker.spacy_model_checking.connect(self._spacy_model_checking) + self._status_worker.spacy_model_result.connect(self._spacy_model_result) + self._status_worker.spacy_checked.connect(self._update_spacy_status) + + # These are initialized in __init__ to keep consistent object state + + # Set checking visual state + for lbl in ( + self.voices_status, + self.model_status, + self.config_status, + self.spacy_status, + ): + lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") + + self.spacy_status.setText(self.SPACY_PREFIX + "⏳ Checking...") + self._status_worker.start() + + # UI update callbacks + def _spacy_model_checking(self, name: str) -> None: + self.spacy_status.setText(f"{self.SPACY_PREFIX}Checking {name}...") + + def _spacy_model_result(self, name: str, ok: bool) -> None: + self._spacy_models_checked.append((name, ok)) + if not ok and name not in self._spacy_models_missing: + self._spacy_models_missing.append(name) + checked = len(self._spacy_models_checked) + missing_count = len(self._spacy_models_missing) + if missing_count: + self.spacy_status.setText( + f"{self.SPACY_PREFIX}{checked} checked, {missing_count} missing..." + ) + else: + self.spacy_status.setText(f"{self.SPACY_PREFIX}{checked} checked...") + + def _update_voices_status(self, ok: bool, missing: List[str]) -> None: + if ok: + self._set_status("voice", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + if missing: + self._set_status( + "voice", f"✗ Missing {len(missing)} voices", COLORS["RED"] + ) + else: + self._set_status("voice", "✗ Not downloaded", COLORS["RED"]) + + def _update_model_status(self, ok: bool) -> None: + if ok: + self._set_status("model", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + self._set_status("model", "✗ Not downloaded", COLORS["RED"]) + + def _update_config_status(self, ok: bool) -> None: + if ok: + self._set_status("config", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + self._set_status("config", "✗ Not downloaded", COLORS["RED"]) + + def _update_spacy_status(self, ok: bool, missing: List[str]) -> None: + if ok: + self._set_status("spacy", "✓ Downloaded", COLORS["GREEN"]) + else: + self.has_missing = True + if missing: + self._set_status( + "spacy", f"✗ Missing {len(missing)} model(s)", COLORS["RED"] + ) + else: + self._set_status("spacy", "✗ Not downloaded", COLORS["RED"]) + self.download_btn.setEnabled(self.has_missing) + + def _set_status(self, key: str, text: str, color: str) -> None: + lbl, prefix = self.status_map.get(key, (None, "")) + if not lbl: + return + lbl.setText(prefix + text) + lbl.setStyleSheet(f"color: {color};") + + # Helper checks + def _check_kokoro_voices(self) -> Tuple[bool, List[str]]: + """Return (ok, missing_list) for Kokoro voices check.""" + missing = [] + try: + from huggingface_hub import try_to_load_from_cache + + for voice in get_voices("kokoro"): + if not try_to_load_from_cache( + repo_id="hexgrad/Kokoro-82M", filename=f"voices/{voice}.pt" + ): + missing.append(voice) + except Exception: + # If HF missing, report all as missing + return False, list(get_voices("kokoro")) + return (len(missing) == 0), missing + + def _check_kokoro_model(self) -> bool: + try: + from huggingface_hub import try_to_load_from_cache + + return ( + try_to_load_from_cache( + repo_id="hexgrad/Kokoro-82M", filename="kokoro-v1_0.pth" + ) + is not None + ) + except Exception: + return False + + def _check_kokoro_config(self) -> bool: + try: + from huggingface_hub import try_to_load_from_cache + + return ( + try_to_load_from_cache( + repo_id="hexgrad/Kokoro-82M", filename="config.json" + ) + is not None + ) + except Exception: + return False + + def _check_spacy_models(self) -> bool: + unique = _unique_sorted_models() + missing = [m for m in unique if not _is_package_installed(m)] + self._spacy_models_missing = missing + return len(missing) == 0 + + # Download control + def _start_download(self) -> None: + self.download_btn.setEnabled(False) + self.download_btn.setText("Downloading...") + # mark the start of downloads; this triggers the labels + self._on_progress("system", "starting", "Processing, please wait...") + self.worker = PreDownloadWorker(self) + self.worker.progress.connect(self._on_progress) + self.worker.category_done.connect(self._on_category_done) + self.worker.finished.connect(self._on_download_finished) + self.worker.error.connect(self._on_download_error) + self.worker.start() + + def _on_progress(self, category: str, status: str, message: str) -> None: + """Map worker (category, status, message) to UI label updates. + + Status is one of: 'downloading', 'installed', 'downloaded', 'warning', 'starting'. + Category is one of: 'voice', 'model', 'spacy', 'config', or 'system'. + """ + try: + # If the category targets a specific label, update directly + if category in self.status_map: + lbl, prefix = self.status_map[category] + if not lbl: + return + # Compose message and set color based on status token + full_text = prefix + message + if len(full_text) > 60: + display_text = full_text[:57] + "..." + lbl.setText(display_text) + lbl.setToolTip(full_text) + else: + lbl.setText(full_text) + lbl.setToolTip("") # Clear tooltip if not needed + if status == "downloading": + lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") + elif status in ("installed", "downloaded"): + lbl.setStyleSheet(f"color: {COLORS['GREEN']};") + elif status == "warning": + lbl.setStyleSheet(f"color: {COLORS['RED']};") + elif status == "error": + lbl.setStyleSheet(f"color: {COLORS['RED']};") + return + + # System-level messages + if category == "system": + if status == "starting": + for k in self.status_map: + lbl, prefix = self.status_map[k] + if lbl: + lbl.setText(prefix + "Processing, please wait...") + lbl.setStyleSheet(f"color: {COLORS['ORANGE']};") + # other system statuses don't require action + return + except Exception: + # Do not let UI thread crash on unexpected worker message + pass + + def _on_category_done(self, category: str) -> None: + for key in self.category_map.get(category, []): + self._set_status(key, "✓ Downloaded", COLORS["GREEN"]) + + def _on_download_finished(self) -> None: + self.has_missing = False + self.download_btn.setText("Download all") + self.download_btn.setEnabled(False) + + def _on_download_error(self, error_msg: str) -> None: + self.download_btn.setText("Download all") + self.download_btn.setEnabled(True) + for key in self.status_map: + self._set_status(key, f"✗ Error - {error_msg}", COLORS["RED"]) + + def _handle_close(self) -> None: + if self.worker and self.worker.isRunning(): + self.worker.cancel() + self.worker.wait(2000) + self.accept() + + def closeEvent(self, event) -> None: + if self.worker and self.worker.isRunning(): + self.worker.cancel() + self.worker.wait(2000) + super().closeEvent(event) diff --git a/abogen/pyqt/voice_formula_gui.py b/abogen/pyqt/voice_formula_gui.py index 0621fbf..1ab4b35 100644 --- a/abogen/pyqt/voice_formula_gui.py +++ b/abogen/pyqt/voice_formula_gui.py @@ -1,1598 +1,1598 @@ -import json -import os -from PyQt6.QtWidgets import ( - QDialog, - QVBoxLayout, - QCheckBox, - QLabel, - QHBoxLayout, - QDoubleSpinBox, - QSlider, - QScrollArea, - QWidget, - QPushButton, - QSizePolicy, - QMessageBox, - QFrame, - QLayout, - QStyle, - QListWidget, - QListWidgetItem, - QInputDialog, - QFileDialog, - QSplitter, - QMenu, - QApplication, - QComboBox, -) -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, -) -from abogen.tts_plugin.utils import get_voices -import re -import platform -from abogen.utils import get_resource_path -from abogen.voice_profiles import ( - load_profiles, - save_profiles, - delete_profile, - duplicate_profile, - export_profiles, -) - - -# Constants -VOICE_MIXER_WIDTH = 100 -SLIDER_WIDTH = 32 -MIN_WINDOW_WIDTH = 600 -MIN_WINDOW_HEIGHT = 400 -INITIAL_WINDOW_WIDTH = 1200 -INITIAL_WINDOW_HEIGHT = 500 - -# Language options for the language selector loaded from constants -LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items()) - - -class SaveButtonWidget(QWidget): - def __init__(self, parent, profile_name, save_callback): - super().__init__(parent) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - self.save_btn = QPushButton("Save", self) - self.save_btn.setFixedWidth(48) - self.save_btn.clicked.connect(lambda: save_callback(profile_name)) - layout.addStretch() - layout.addWidget(self.save_btn) - self.setLayout(layout) - - -class FlowLayout(QLayout): - def __init__(self, parent=None, margin=0, spacing=-1): - super().__init__(parent) - if parent: - self.setContentsMargins(margin, margin, margin, margin) - self.setSpacing(spacing) - self._item_list = [] - - def __del__(self): - item = self.takeAt(0) - while item: - item = self.takeAt(0) - - def addItem(self, item): - self._item_list.append(item) - - def count(self): - return len(self._item_list) - - def expandingDirections(self): - return Qt.Orientation(0) - - def hasHeightForWidth(self): - return True - - def sizeHint(self): - return self.minimumSize() - - def itemAt(self, index): - if 0 <= index < len(self._item_list): - return self._item_list[index] - return None - - def takeAt(self, index): - if 0 <= index < len(self._item_list): - return self._item_list.pop(index) - return None - - def heightForWidth(self, width): - return self._do_layout(QRect(0, 0, width, 0), True) - - def setGeometry(self, rect): - super().setGeometry(rect) - self._do_layout(rect, False) - - def minimumSize(self): - size = QSize() - for item in self._item_list: - size = size.expandedTo(item.minimumSize()) - margin, _, _, _ = self.getContentsMargins() - size += QSize(2 * margin, 2 * margin) - return size - - def _do_layout(self, rect, test_only): - x, y = rect.x(), rect.y() - line_height = 0 - spacing = self.spacing() - - for item in self._item_list: - style = self.parentWidget().style() if self.parentWidget() else QStyle() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Horizontal, - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Vertical, - ) - space_x = spacing if spacing >= 0 else layout_spacing_x - space_y = spacing if spacing >= 0 else layout_spacing_y - - next_x = x + item.sizeHint().width() + space_x - if next_x - space_x > rect.right() and line_height > 0: - x = rect.x() - y = y + line_height + space_y - next_x = x + item.sizeHint().width() + space_x - line_height = 0 - - if not test_only: - item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) - - x = next_x - line_height = max(line_height, item.sizeHint().height()) - - return y + line_height - rect.y() - - -class VoiceMixer(QWidget): - def __init__( - self, voice_name, language_code, initial_status=False, initial_weight=0.0 - ): - super().__init__() - self.voice_name = voice_name - self.setFixedWidth(VOICE_MIXER_WIDTH) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - - # TODO Set CSS for rounded corners - # self.setObjectName("VoiceMixer") - # self.setStyleSheet(self.ROUNDED_CSS) - - layout = QVBoxLayout() - - # Name label at the top - name = voice_name - layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) - - # Voice name label with gender icon - is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f" - - # Icons layout (flag and gender) - icons_layout = QHBoxLayout() - icons_layout.setSpacing(3) - icons_layout.setAlignment( - Qt.AlignmentFlag.AlignCenter - ) # Center the icons horizontally - - # Flag icon - flag_icon_path = get_resource_path( - "abogen.assets.flags", f"{language_code}.png" - ) - gender_icon_path = get_resource_path( - "abogen.assets", "female.png" if is_female else "male.png" - ) - flag_label = QLabel() - gender_label = QLabel() - flag_pixmap = QPixmap(flag_icon_path) - flag_label.setPixmap( - flag_pixmap.scaled( - 16, - 16, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - gender_pixmap = QPixmap(gender_icon_path) - gender_label.setPixmap( - gender_pixmap.scaled( - 16, - 16, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation, - ) - ) - icons_layout.addWidget(flag_label) - icons_layout.addWidget(gender_label) - - # Add icons layout - layout.addLayout(icons_layout) - - # Checkbox (now below icons) - self.checkbox = QCheckBox() - self.checkbox.setChecked(initial_status) - self.checkbox.stateChanged.connect(self.toggle_inputs) - layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter) - - # Spinbox and slider - self.spin_box = QDoubleSpinBox() - self.spin_box.setRange(0, 1) - self.spin_box.setSingleStep(0.01) - self.spin_box.setDecimals(2) - self.spin_box.setValue(initial_weight) - - self.slider = QSlider(Qt.Orientation.Vertical) - self.slider.setRange(0, 100) - self.slider.setValue(int(initial_weight * 100)) - self.slider.setSizePolicy( - QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding - ) - self.slider.setFixedWidth(SLIDER_WIDTH) - - # Apply slider styling after widget is added to window (see showEvent) - self._slider_style_applied = False - - # Connect controls with internal sync only (no external updates) - self.slider.valueChanged.connect(self._on_slider_changed) - self.spin_box.valueChanged.connect(self._on_spinbox_changed) - - # Flag to prevent recursive updates - self._syncing = False - - # Layout for slider and labels - slider_layout = QVBoxLayout() - slider_layout.addWidget(self.spin_box) - slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) - - slider_center_layout = QHBoxLayout() - slider_center_layout.addWidget( - self.slider, alignment=Qt.AlignmentFlag.AlignHCenter - ) - slider_center_layout.setContentsMargins(0, 0, 0, 0) - - slider_center_widget = QWidget() - slider_center_widget.setLayout(slider_center_layout) - - slider_layout.addWidget(slider_center_widget, stretch=1) - slider_layout.addWidget(QLabel("0", alignment=Qt.AlignmentFlag.AlignCenter)) - slider_layout.setStretch(2, 1) - - layout.addLayout(slider_layout, stretch=1) - self.setLayout(layout) - self.toggle_inputs() - - def showEvent(self, event): - super().showEvent(event) - # Apply slider styling once when widget is shown and has access to parent - if not self._slider_style_applied: - self._slider_style_applied = True - - # Fix slider in Windows - if platform.system() == "Windows": - appstyle = QApplication.instance().style().objectName().lower() - if appstyle != "windowsvista": - # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] - self.slider.setStyleSheet( - f""" - QSlider::groove:vertical:disabled {{ - background: {COLORS.get("GREY_BACKGROUND")}; - width: 4px; - border-radius: 4px; - }} - """ - ) - else: - # Apply same fix for Light theme on non-Windows systems - # Get theme from parent window's config - parent_window = self.window() - theme = "system" - while parent_window: - if hasattr(parent_window, "config"): - theme = parent_window.config.get("theme", "system") - break - parent_window = parent_window.parent() - - if theme == "light": - self.slider.setStyleSheet( - f""" - QSlider::groove:vertical:disabled {{ - background: {COLORS.get("GREY_BACKGROUND")}; - width: 4px; - border-radius: 4px; - }} - """ - ) - - def toggle_inputs(self): - is_enabled = self.checkbox.isChecked() - self.spin_box.setEnabled(is_enabled) - self.slider.setEnabled(is_enabled) - - def _on_slider_changed(self, val): - """Handle slider value change - sync to spinbox without triggering external updates.""" - if self._syncing: - return - self._syncing = True - self.spin_box.setValue(val / 100) - self._syncing = False - - def _on_spinbox_changed(self, val): - """Handle spinbox value change - sync to slider without triggering external updates.""" - if self._syncing: - return - self._syncing = True - self.slider.setValue(int(val * 100)) - self._syncing = False - - def get_voice_weight(self): - if self.checkbox.isChecked(): - return self.voice_name, self.spin_box.value() - return None - - -class HoverLabel(QLabel): - def __init__(self, text, voice_name, parent=None): - super().__init__(text, parent) - self.voice_name = voice_name - self.setMouseTracking(True) - self.setStyleSheet( - "background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;" - ) - - # Create delete button - self.delete_button = QPushButton("×", self) - self.delete_button.setFixedSize(16, 16) - self.delete_button.setStyleSheet( - f""" - QPushButton {{ - background-color: {COLORS.get("RED")}; - color: white; - border-radius: 7px; - font-weight: bold; - font-size: 12px; - border: none; - padding: 0px; - margin: 0px; - }} - QPushButton:hover {{ - background-color: red; - }} - """ - ) - # Make sure the entire button is clickable, not just the text - self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.delete_button.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents, False - ) - self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.delete_button.hide() - - def resizeEvent(self, event): - super().resizeEvent(event) - # Position the button in the top-right corner with a small margin - self.delete_button.move(self.width() - 16, +0) - - def enterEvent(self, event): - self.delete_button.show() - - def leaveEvent(self, event): - self.delete_button.hide() - - -class VoiceFormulaDialog(QDialog): - def __init__(self, parent=None, initial_state=None, selected_profile=None): - super().__init__(parent) - # Store original profile/mix state for restoration on cancel - self._original_profile_name = None - self._original_mixed_voice_state = None - if parent is not None: - self._original_profile_name = getattr(parent, "selected_profile_name", None) - self._original_mixed_voice_state = getattr( - parent, "mixed_voice_state", None - ) - profiles = load_profiles() - self._virtual_new_profile = False - if not profiles: - # No profiles: show 'New profile' in the list, unsaved, not in JSON - self.current_profile = "New profile" - self._profile_dirty = {"New profile": True} - self._virtual_new_profile = True - profiles = {} # Do not add to JSON yet - else: - self.current_profile = ( - selected_profile - if selected_profile in profiles - else list(profiles.keys())[0] - ) - self._profile_dirty = {name: False for name in profiles} - # Track unsaved states per profile - self._profile_states = {} - # Cache for loaded profiles to avoid repeated disk reads - self._cached_profiles = profiles.copy() - - # Debounce timer for slider updates (prevents lag during rapid slider movement) - self._update_timer = QTimer(self) - self._update_timer.setSingleShot(True) - self._update_timer.setInterval(30) # 30ms debounce - self._update_timer.timeout.connect(self._do_debounced_update) - self._pending_weighted_update = False - self._pending_profile_modified = False - - # Cache for voice weight labels to enable in-place updates - self._voice_labels = {} # voice_name -> HoverLabel widget - - # Add subtitle_combo reference if parent has it - self.subtitle_combo = None - if parent is not None and hasattr(parent, "subtitle_combo"): - self.subtitle_combo = parent.subtitle_combo - # Create main container layout with profile section and mixer section - splitter = QSplitter(Qt.Orientation.Horizontal) - # Profile section - profile_widget = QWidget() - profile_layout = QVBoxLayout(profile_widget) - profile_layout.setContentsMargins(0, 0, 0, 0) - # Profile header and save/new buttons - header_layout = QHBoxLayout() - header_layout.addWidget(QLabel("Profiles:")) - header_layout.addStretch() - self.btn_new_profile = QPushButton("New profile") - header_layout.addWidget(self.btn_new_profile) - profile_layout.addLayout(header_layout) - # Profile list - self.profile_list = QListWidget() - self.profile_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) - self.profile_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows) - self.profile_list.setStyleSheet( - "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" - ) - icon = QIcon(get_resource_path("abogen.assets", "profile.png")) - if self._virtual_new_profile: - item = QListWidgetItem(icon, "New profile") - self.profile_list.addItem(item) - self.profile_list.setCurrentRow(0) - else: - for name in profiles: - item = QListWidgetItem(icon, name) - self.profile_list.addItem(item) - idx = list(profiles.keys()).index(self.current_profile) - self.profile_list.setCurrentRow(idx) - profile_layout.addWidget(self.profile_list) - self.profile_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.profile_list.customContextMenuRequested.connect( - self.show_profile_context_menu - ) - self.profile_list.setItemWidget = ( - self.profile_list.setItemWidget - ) # for type hints - # Save and management buttons - mgmt_layout = QVBoxLayout() - self.btn_import_profiles = QPushButton("Import profile(s)") - mgmt_layout.addWidget(self.btn_import_profiles) - self.btn_export_profiles = QPushButton("Export profiles") - mgmt_layout.addWidget(self.btn_export_profiles) - profile_layout.addLayout(mgmt_layout) - # prepare mixer widget - mixer_widget = QWidget() - mixer_layout = QVBoxLayout(mixer_widget) - mixer_layout.setContentsMargins(5, 0, 0, 0) - - self.setWindowTitle("Voice Mixer") - self.setWindowFlags( - Qt.WindowType.Window - | Qt.WindowType.WindowCloseButtonHint - | Qt.WindowType.WindowMaximizeButtonHint - ) - self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) - self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) - self.voice_mixers = [] - self.last_enabled_voice = None - - # Header label and language selector - self.header_label = QLabel( - "Adjust voice weights to create your preferred voice mix." - ) - self.header_label.setStyleSheet("font-size: 13px;") - self.header_label.setWordWrap(True) - header_row = QHBoxLayout() - header_row.addWidget(self.header_label, 1) - header_row.addStretch() - header_row.addWidget(QLabel("Language:")) - self.language_combo = QComboBox() - for code, desc in LANGUAGE_OPTIONS: - flag = get_resource_path("abogen.assets.flags", f"{code}.png") - if flag and os.path.exists(flag): - self.language_combo.addItem(QIcon(flag), desc, code) - else: - self.language_combo.addItem(desc, code) - # set current language for profile - prof = profiles.get(self.current_profile, {}) - lang = prof.get("language") if isinstance(prof, dict) else None - if not lang: - lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] - idx = self.language_combo.findData(lang) - if idx >= 0: - self.language_combo.setCurrentIndex(idx) - self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) - header_row.addWidget(self.language_combo) - # Preview current voice mix using main window's preview - self.btn_preview_mix = QPushButton("Preview", self) - self.btn_preview_mix.setToolTip("Preview current voice mix") - self.btn_preview_mix.clicked.connect(self.preview_current_mix) - header_row.addWidget(self.btn_preview_mix) - mixer_layout.addLayout(header_row) - - # Error message - self.error_label = QLabel( - "Please select at least one voice and set its weight above 0." - ) - self.error_label.setStyleSheet("color: red; font-weight: bold;") - self.error_label.setWordWrap(True) - self.error_label.hide() - mixer_layout.addWidget(self.error_label) - - # Voice weights display - self.weighted_sums_container = QWidget() - self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) - self.weighted_sums_layout.setSpacing(5) - self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) - mixer_layout.addWidget(self.weighted_sums_container) - - # Separator - separator = QFrame() - separator.setFrameShadow(QFrame.Shadow.Sunken) - mixer_layout.addWidget(separator) - - # Voice list scroll area - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded - ) - self.scroll_area.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAsNeeded - ) - self.scroll_area.viewport().installEventFilter(self) - - self.voice_list_widget = QWidget() - self.voice_list_layout = QHBoxLayout() - self.voice_list_widget.setLayout(self.voice_list_layout) - self.voice_list_widget.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - self.scroll_area.setWidget(self.voice_list_widget) - mixer_layout.addWidget(self.scroll_area, stretch=1) - - # Buttons - button_layout = QHBoxLayout() - clear_all_button = QPushButton("Clear all") - ok_button = QPushButton("OK") - cancel_button = QPushButton("Cancel") - - # Set OK button as default - ok_button.setDefault(True) - ok_button.setFocus() - - # Connect buttons - clear_all_button.clicked.connect(self.clear_all_voices) - ok_button.clicked.connect(self.accept) - cancel_button.clicked.connect(self.reject) - - button_layout.addStretch() - button_layout.addWidget(clear_all_button) - button_layout.addWidget(ok_button) - button_layout.addWidget(cancel_button) - mixer_layout.addLayout(button_layout) - - self.add_voices(initial_state or []) - self.update_weighted_sums() - - # assemble splitter - splitter.addWidget(profile_widget) - splitter.addWidget(mixer_widget) - splitter.setStretchFactor(1, 1) - # set as main layout - self.setLayout(QHBoxLayout()) - self.layout().addWidget(splitter) - - # Connect profile actions - self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) - # Track initial profile for proper dirty-state saving - self.last_profile_row = self.profile_list.currentRow() - self.btn_new_profile.clicked.connect(self.new_profile) - self.btn_export_profiles.clicked.connect(self.export_all_profiles) - self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) - # Note: Signal connections for voice mixers are already set up in add_voice() - # with debouncing for slider updates to prevent lag - - # Update profile colors on initialization to show status - self.update_profile_list_colors() - - def keyPressEvent(self, event): - # Bind Delete key to delete_profile when a profile is selected - if event.key() == Qt.Key.Key_Delete and self.profile_list.hasFocus(): - item = self.profile_list.currentItem() - if item: - self.delete_profile(item) - return - super().keyPressEvent(event) - - def _has_unsaved_changes(self): - # Only return True if there are actually modified (yellow background) profiles - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - # Only consider as unsaved if profile is marked dirty (yellow background) - if item.text().startswith("*"): - return True - return False - - def _prompt_save_changes(self): - dirty_indices = [ - i - for i in range(self.profile_list.count()) - if self.profile_list.item(i).text().startswith("*") - ] - parent = self.parent() - if len(dirty_indices) > 1: - msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" - ret = QMessageBox.question( - self, - "Unsaved Changes", - msg, - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Save, - ) - if ret == QMessageBox.StandardButton.Save: - # Save all using stored states - profiles = load_profiles() - for i in dirty_indices: - name = self.profile_list.item(i).text().lstrip("*") - state = self._profile_states.get(name) - if state is not None: - profiles[name] = state - self._profile_dirty[name] = False - save_profiles(profiles) - # clear states - for name in list(self._profile_states.keys()): - if name not in profiles: - continue - del self._profile_states[name] - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - # clear markers - for i in dirty_indices: - item = self.profile_list.item(i) - n = item.text().lstrip("*") - item.setText(n) - self.update_profile_save_buttons() - self.update_profile_list_colors() - return True - elif ret == QMessageBox.StandardButton.Discard: - # Discard all modifications - self._profile_states.clear() - for i in dirty_indices: - item = self.profile_list.item(i) - n = item.text().lstrip("*") - item.setText(n) - self._profile_dirty[n] = False - self.update_profile_save_buttons() - self.update_profile_list_colors() - # reload current profile - profiles = load_profiles() - if self.current_profile in profiles: - self.load_profile_state(self.current_profile) - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - return True - else: - return False - else: - # Fallback to original logic for 0 or 1 dirty profile - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Warning) - box.setWindowTitle("Unsaved Changes") - box.setText( - "You have unsaved changes in your profile. Do you want to save the changes?" - ) - box.setStandardButtons( - QMessageBox.StandardButton.Save - | QMessageBox.StandardButton.Discard - | QMessageBox.StandardButton.Cancel - ) - box.setDefaultButton(QMessageBox.StandardButton.Save) - ret = box.exec() - if ret == QMessageBox.StandardButton.Save: - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - if ( - self._profile_dirty.get(name, False) - or item.text().startswith("*") - or (name == self.current_profile) - ): - self.profile_list.setCurrentRow(i) - self.save_profile_by_name(name) - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - return True - elif ret == QMessageBox.StandardButton.Discard: - profiles = load_profiles() - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - self._profile_dirty[name] = False - if item.text().startswith("*"): - item.setText(name) - self.update_profile_save_buttons() - self.update_profile_list_colors() - if self.current_profile in profiles: - self.load_profile_state(self.current_profile) - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - return True - else: - return False - - def on_profile_selection_changed(self, row): - # Save dirty state for previous profile - if hasattr(self, "last_profile_row") and self.last_profile_row is not None: - prev_item = self.profile_list.item(self.last_profile_row) - if prev_item: - prev_name = prev_item.text().lstrip("*") - self._profile_dirty[prev_name] = prev_item.text().startswith("*") - # Do NOT auto-save if modifications pending - # load new profile - item = self.profile_list.item(row) - if item: - name = item.text().lstrip("*") - self.load_profile_state(name) - # Restore dirty state for this profile - dirty = self._profile_dirty.get(name, False) - if dirty and not item.text().startswith("*"): - item.setText("*" + item.text()) - elif not dirty and item.text().startswith("*"): - item.setText(item.text().lstrip("*")) - self.last_profile_row = row - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def add_voices(self, initial_state): - first_enabled_voice = None - for voice in get_voices("kokoro"): - language_code = voice[0] # First character is the language code - matching_voice = next( - (item for item in initial_state if item[0] == voice), None - ) - initial_status = matching_voice is not None - initial_weight = matching_voice[1] if matching_voice else 1.0 - voice_mixer = self.add_voice( - voice, language_code, initial_status, initial_weight - ) - if initial_status and first_enabled_voice is None: - first_enabled_voice = voice_mixer - - if first_enabled_voice: - QTimer.singleShot( - 0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice) - ) - - def add_voice( - self, voice_name, language_code, initial_status=False, initial_weight=1.0 - ): - voice_mixer = VoiceMixer( - voice_name, language_code, initial_status, initial_weight - ) - self.voice_mixers.append(voice_mixer) - self.voice_list_layout.addWidget(voice_mixer) - voice_mixer.checkbox.stateChanged.connect( - lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) - ) - # Use debounced updates for slider changes to prevent lag - voice_mixer.spin_box.valueChanged.connect(self._schedule_weighted_update) - voice_mixer.spin_box.valueChanged.connect(self._schedule_profile_modified) - # Checkbox changes are immediate since they're not high-frequency - voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) - voice_mixer.checkbox.stateChanged.connect( - lambda *_: self.mark_profile_modified() - ) - return voice_mixer - - def handle_voice_checkbox(self, voice_mixer, state): - if state == Qt.CheckState.Checked.value: - self.last_enabled_voice = voice_mixer.voice_name - # Checkbox changes are infrequent, so update immediately - self.update_weighted_sums() - - def get_selected_voices(self): - return [ - v - for v in (m.get_voice_weight() for m in self.voice_mixers) - if v and v[1] > 0 - ] - - def _schedule_weighted_update(self): - """Schedule a debounced weighted sums update.""" - self._pending_weighted_update = True - self._update_timer.start() # Restart the timer - - def _schedule_profile_modified(self): - """Schedule a debounced profile modified update.""" - self._pending_profile_modified = True - self._update_timer.start() # Restart the timer - - def _do_debounced_update(self): - """Execute pending debounced updates.""" - if self._pending_weighted_update: - self._pending_weighted_update = False - self.update_weighted_sums() - if self._pending_profile_modified: - self._pending_profile_modified = False - self.mark_profile_modified() - - def update_weighted_sums(self): - """Update the voice weights display. Optimized for in-place updates during slider movement.""" - # Get selected voices - selected = [ - (m.voice_name, m.spin_box.value()) - for m in self.voice_mixers - if m.checkbox.isChecked() and m.spin_box.value() > 0 - ] - - total = sum(w for _, w in selected) - # disable Preview if no voices selected, but don't enable while loading - if not getattr(self, "_loading", False): - self.btn_preview_mix.setEnabled(total > 0) - - if total > 0: - self.error_label.hide() - self.weighted_sums_container.show() - - # Reorder so last enabled voice is at the end - if self.last_enabled_voice and any( - name == self.last_enabled_voice for name, _ in selected - ): - others = [(n, w) for n, w in selected if n != self.last_enabled_voice] - last = [(n, w) for n, w in selected if n == self.last_enabled_voice] - selected = others + last - - # Get current voice names in display - current_names = set(self._voice_labels.keys()) - new_names = set(name for name, _ in selected) - - # Remove labels for voices no longer selected - for name in current_names - new_names: - label = self._voice_labels.pop(name) - self.weighted_sums_layout.removeWidget(label) - label.deleteLater() - - # Update or create labels - for name, weight in selected: - percentage = weight / total * 100 - label_text = f'{name}: {percentage:.1f}%' - - if name in self._voice_labels: - # Update existing label in-place (fast path) - self._voice_labels[name].setText(label_text) - else: - # Create new label only for newly added voices - voice_label = HoverLabel(label_text, name) - voice_label.setSizePolicy( - QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred - ) - voice_label.delete_button.clicked.connect( - lambda _, vn=name: self.disable_voice_by_name(vn) - ) - self._voice_labels[name] = voice_label - self.weighted_sums_layout.addWidget(voice_label) - else: - # Clear all labels when no voices selected - for label in self._voice_labels.values(): - self.weighted_sums_layout.removeWidget(label) - label.deleteLater() - self._voice_labels.clear() - self.error_label.show() - self.weighted_sums_container.hide() - - def disable_voice_by_name(self, voice_name): - for mixer in self.voice_mixers: - if mixer.voice_name == voice_name: - mixer.checkbox.setChecked(False) - break - - def clear_all_voices(self): - for mixer in self.voice_mixers: - mixer.checkbox.setChecked(False) - - def eventFilter(self, source, event): - if source is self.scroll_area.viewport() and event.type() == event.Type.Wheel: - # Skip if over an enabled slider - if any( - mixer.slider.underMouse() and mixer.slider.isEnabled() - for mixer in self.voice_mixers - ): - return False - - # Horizontal scrolling - horiz_bar = self.scroll_area.horizontalScrollBar() - delta = -120 if event.angleDelta().y() > 0 else 120 - horiz_bar.setValue(horiz_bar.value() + delta) - return True - return super().eventFilter(source, event) - - def load_profile_state(self, profile_name): - name = profile_name.lstrip("*") - profiles = load_profiles() - # Update cache when loading profiles - self._cached_profiles = profiles.copy() - # load voices and language from state or JSON - if name in self._profile_states: - state = self._profile_states[name] - else: - state = profiles.get(name, {}) - voices = state.get("voices") if isinstance(state, dict) else state - if voices is None: - voices = [] - lang = state.get("language") if isinstance(state, dict) else None - # apply language selection - if lang: - i = self.language_combo.findData(lang) - if i >= 0: - self.language_combo.blockSignals(True) - self.language_combo.setCurrentIndex(i) - self.language_combo.blockSignals(False) - self.current_profile = name - weights = {n: w for n, w in voices} - for vm in self.voice_mixers: - weight = weights.get(vm.voice_name, 0.0) - # block signals to avoid triggering updates - vm.checkbox.blockSignals(True) - vm.spin_box.blockSignals(True) - vm.slider.blockSignals(True) - vm.checkbox.setChecked(weight > 0) - val = weight if weight > 0 else 1.0 - vm.spin_box.setValue(val) - vm.slider.setValue(int(val * 100)) - # restore signals - vm.checkbox.blockSignals(False) - vm.spin_box.blockSignals(False) - vm.slider.blockSignals(False) - # sync enabled state - vm.toggle_inputs() - # Clear voice labels cache for clean update - for label in self._voice_labels.values(): - self.weighted_sums_layout.removeWidget(label) - label.deleteLater() - self._voice_labels.clear() - self.update_weighted_sums() - - def save_profile_by_name(self, name): - profiles = load_profiles() - state = self._profile_states.get(name, None) - if state is not None: - # ensure dict format - if isinstance(state, dict): - entry = state - else: - entry = {"voices": state, "language": self.language_combo.currentData()} - profiles[name] = entry - save_profiles(profiles) - # Update cache to stay in sync - self._cached_profiles = profiles.copy() - self._profile_dirty[name] = False - del self._profile_states[name] - self._virtual_new_profile = False - # Remove * marker - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - if item.text().lstrip("*") == name: - item.setText(name) - break - self.update_profile_list_colors() - self.update_profile_save_buttons() - self.update_weighted_sums() - - def _handle_zero_weight_profiles(self): - profiles = load_profiles() - if len(profiles) < 1: - return False - zero = [] - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - weights = profiles.get(name, {}).get("voices", []) - total = 0 - if isinstance(weights, list): - for entry in weights: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - zero.append((i, name)) - if not zero: - return False - msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" - reply = QMessageBox.question( - self, - "Invalid Profiles", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Yes, - ) - if reply == QMessageBox.StandardButton.Yes: - for i, name in reversed(zero): - self.profile_list.takeItem(i) - delete_profile(name) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self.update_profile_list_colors() - self.update_profile_save_buttons() - return False - else: - idx, _ = zero[0] - self.profile_list.setCurrentRow(idx) - return True - - def accept(self): - # If no profiles, treat as cancel - if self.profile_list.count() == 0: - # Update subtitle_mode to match combo before closing - if self.subtitle_combo: - parent = self.parent() - if parent is not None: - parent.subtitle_mode = self.subtitle_combo.currentText() - self.reject() - return - # Prompt to save if unsaved changes, then check for zero-weight error after save - if self._has_unsaved_changes(): - if not self._prompt_save_changes(): - return - if self._handle_zero_weight_profiles(): - return - selected_voices = self.get_selected_voices() - total_weight = sum(weight for _, weight in selected_voices) - if total_weight == 0: - QMessageBox.warning( - self, - "Invalid Weights", - "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", - ) - self.update_weighted_sums() - return - # Save weights to current profile - profiles = load_profiles() - profiles[self.current_profile] = { - "voices": selected_voices, - "language": self.language_combo.currentData(), - } - save_profiles(profiles) - # Mark this profile as not dirty - self._profile_dirty[self.current_profile] = False - super().accept() - - def reject(self): - # Restore parent's profile/mix state on cancel - parent = self.parent() - if parent is not None: - if hasattr(self, "_original_profile_name"): - parent.selected_profile_name = self._original_profile_name - if hasattr(self, "_original_mixed_voice_state"): - parent.mixed_voice_state = self._original_mixed_voice_state - # Prompt to save if unsaved changes, then check for zero-weight error after save - if self._has_unsaved_changes(): - if not self._prompt_save_changes(): - return - if self._handle_zero_weight_profiles(): - return - super().reject() - - def closeEvent(self, event): - # Restore parent's profile/mix state on close - parent = self.parent() - if parent is not None: - if hasattr(self, "_original_profile_name"): - parent.selected_profile_name = self._original_profile_name - if hasattr(self, "_original_mixed_voice_state"): - parent.mixed_voice_state = self._original_mixed_voice_state - # Prompt to save if unsaved changes, then check for zero-weight error after save - if self._has_unsaved_changes(): - if not self._prompt_save_changes(): - event.ignore() - return - if self._handle_zero_weight_profiles(): - event.ignore() - return - super().closeEvent(event) - - def _parse_rgba_to_qcolor(self, rgba_str): - from PyQt6.QtCore import Qt - from PyQt6.QtGui import QColor - - """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" - match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) - if match: - r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) - a_float = float(match.group(4)) - a_int = int(a_float * 255) - return QColor(r, g, b, a_int) - return Qt.GlobalColor.transparent - - def mark_profile_modified(self): - item = self.profile_list.currentItem() - if item and not item.text().startswith("*"): - item.setText("*" + item.text()) - # Flag profile as dirty and store unsaved state - name = self.current_profile - self._profile_dirty[name] = True - self._profile_states[name] = { - "voices": self.get_selected_voices(), - "language": self.language_combo.currentData(), - } - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def new_profile(self): - import re - - while True: - name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") - if not ok or not name: - break - name = name.strip() # Remove leading/trailing spaces - if not name: - continue - if not re.match(r"^[\w\- ]+$", name): - QMessageBox.warning( - self, - "Invalid Name", - "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", - ) - continue - profiles = load_profiles() - # Remove 'New profile' placeholder if not persisted in JSON - if ( - self.profile_list.count() == 1 - and self.profile_list.item(0).text() == "New profile" - and "New profile" not in profiles - ): - self.profile_list.takeItem(0) - self._virtual_new_profile = False - self._profile_dirty.pop("New profile", None) - if name in profiles: - QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") - continue - profiles[name] = { - "voices": [], - "language": self.language_combo.currentData(), - } - save_profiles(profiles) - self.profile_list.addItem( - QListWidgetItem( - QIcon(get_resource_path("abogen.assets", "profile.png")), name - ) - ) - self.profile_list.setCurrentRow(self.profile_list.count() - 1) - # reset UI mixers - for vm in self.voice_mixers: - vm.checkbox.setChecked(False) - vm.spin_box.setValue(1.0) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - break - self.update_profile_save_buttons() - self.update_profile_list_colors() - self.update_weighted_sums() - - def export_all_profiles(self): - # Prevent export if any profile has total weight 0 - profiles = load_profiles() - for name, weights in profiles.items(): - total = 0 - voices = weights.get("voices", []) - if isinstance(voices, list): - for entry in voices: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - QMessageBox.warning( - self, - "Export Blocked", - f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", - ) - return - path, _ = QFileDialog.getSaveFileName( - self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" - ) - if path: - export_profiles(path) - - def import_profiles_dialog(self): - path, _ = QFileDialog.getOpenFileName( - self, "Import Profiles", "", "JSON Files (*.json)" - ) - if path: - from abogen.voice_profiles import load_profiles, save_profiles - - # Try to read the file and count profiles - try: - import json - - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - # always expect abogen_voice_profiles wrapper - if not (isinstance(data, dict) and "abogen_voice_profiles" in data): - QMessageBox.warning( - self, - "Invalid File", - "This file is not a valid abogen voice profiles file.", - ) - return - imported_profiles = data["abogen_voice_profiles"] - if not isinstance(imported_profiles, dict): - QMessageBox.warning( - self, - "Invalid File", - "This file is not a valid abogen voice profiles file.", - ) - return - count = len(imported_profiles) - except Exception: - QMessageBox.warning( - self, "Import Error", "Could not read the selected file." - ) - return - if count == 0: - QMessageBox.information( - self, "No Profiles", "No profiles found in the selected file." - ) - return - profiles = load_profiles() - collisions = [name for name in imported_profiles if name in profiles] - # Combine prompts: show both import count and overwrite count if any - if count == 1: - orig_name = next(iter(imported_profiles.keys())) - msg = f"Profile '{orig_name}' will be imported." - if collisions: - msg += f"\nThis will overwrite an existing profile." - msg += "\nContinue?" - reply = QMessageBox.question( - self, - "Import Profile", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - profiles.update(imported_profiles) - save_profiles(profiles) - QMessageBox.information( - self, - "Profile Imported", - f"Profile '{orig_name}' imported successfully.", - ) - else: - msg = f"{count} profiles will be imported." - if collisions: - msg += f"\n{len(collisions)} profile(s) will be overwritten." - msg += "\nContinue?" - reply = QMessageBox.question( - self, - "Import Profiles", - msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - profiles.update(imported_profiles) - save_profiles(profiles) - QMessageBox.information( - self, - "Profiles Imported", - f"{count} profiles imported successfully.", - ) - # Refresh list - self.profile_list.clear() - profiles = load_profiles() - for nm in profiles: - self.profile_list.addItem( - QListWidgetItem( - QIcon(get_resource_path("abogen.assets", "profile.png")), nm - ) - ) - if self.profile_list.count() > 0: - self.profile_list.setCurrentRow(0) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self._virtual_new_profile = False - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def show_profile_context_menu(self, pos): - item = self.profile_list.itemAt(pos) - if not item: - return - name = item.text().lstrip("*") - menu = QMenu(self) - rename_act = QAction("Rename", self) - delete_act = QAction("Delete", self) - dup_act = QAction("Duplicate", self) - export_act = QAction("Export this profile", self) - menu.addAction(rename_act) - menu.addAction(dup_act) - menu.addAction(export_act) - menu.addAction(delete_act) - act = menu.exec(self.profile_list.viewport().mapToGlobal(pos)) - if act == rename_act: - self.rename_profile(item) - elif act == delete_act: - self.delete_profile(item) - elif act == dup_act: - self.duplicate_profile(item) - elif act == export_act: - self.export_selected_profile_item(item) - - def export_selected_profile_item(self, item): - if not item: - return - name = item.text().lstrip("*") - profiles = load_profiles() - weights = profiles.get(name, {}).get("voices", []) - total = 0 - if isinstance(weights, list): - for entry in weights: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - QMessageBox.warning( - self, - "Export Blocked", - f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", - ) - return - path, _ = QFileDialog.getSaveFileName( - self, "Export Profile", f"{name}.json", "JSON Files (*.json)" - ) - if path: - # Use abogen_voice_profiles wrapper for single profile export - with open(path, "w", encoding="utf-8") as f: - json.dump( - {"abogen_voice_profiles": {name: profiles.get(name, {})}}, - f, - indent=2, - ) - - def rename_profile(self, item): - name = item.text().lstrip("*") - # block if profile has unsaved changes and it's not a virtual New profile - if self._profile_dirty.get(name, False) and not ( - self._virtual_new_profile and name == "New profile" - ): - QMessageBox.warning( - self, "Unsaved Changes", "Please save the profile before renaming." - ) - return - old = item.text().lstrip("*") - import re - - while True: - new, ok = QInputDialog.getText( - self, "Rename Profile", f"Profile name:", text=old - ) - if not ok or not new or new == old: - break - new = new.strip() # Remove leading/trailing spaces - if not new: - continue - if not re.match(r"^[\w\- ]+$", new): - QMessageBox.warning( - self, - "Invalid Name", - "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", - ) - continue - - profiles = load_profiles() - if new in profiles: - QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") - continue - - # Special case for renaming the virtual "New profile" - if self._virtual_new_profile and name == "New profile": - # Create the profile with the new name - profiles[new] = { - "voices": self.get_selected_voices(), - "language": self.language_combo.currentData(), - } - save_profiles(profiles) - - # Update tracking properties - self._virtual_new_profile = False - self._profile_dirty.pop("New profile", None) - self._profile_dirty[new] = False - - # Update the current profile name - self.current_profile = new - item.setText(new) - else: - # Standard renaming for regular profiles - profiles[new] = profiles.pop(old) - save_profiles(profiles) - item.setText(new) - - # Update the current profile name if it was renamed - if self.current_profile == old: - self.current_profile = new - - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - break - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def delete_profile(self, item): - name = item.text().lstrip("*") - if self._virtual_new_profile and name == "New profile": - row = self.profile_list.row(item) - self.profile_list.takeItem(row) - self._virtual_new_profile = False - self._profile_dirty.pop("New profile", None) - self.update_profile_save_buttons() - self.update_profile_list_colors() - return - reply = QMessageBox.question( - self, - "Delete Profile", - f"Delete profile '{name}'?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - delete_profile(name) - row = self.profile_list.row(item) - self.profile_list.takeItem(row) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def duplicate_profile(self, item): - name = item.text().lstrip("*") - # block duplicating if profile has unsaved changes - if self._profile_dirty.get(name, False): - QMessageBox.warning( - self, "Unsaved Changes", "Please save the profile before duplicating." - ) - return - src = item.text().lstrip("*") - profiles = load_profiles() - base = f"{src}_duplicate" - new = base - i = 1 - while new in profiles: - new = f"{base}{i}" - i += 1 - duplicate_profile(src, new) - self.profile_list.addItem( - QListWidgetItem( - QIcon(get_resource_path("abogen.assets", "profile.png")), new - ) - ) - parent = self.parent() - if hasattr(parent, "populate_profiles_in_voice_combo"): - parent.populate_profiles_in_voice_combo() - self.update_profile_save_buttons() - self.update_profile_list_colors() - - def update_profile_save_buttons(self): - # Remove all save buttons first - for i in range(self.profile_list.count()): - self.profile_list.setItemWidget(self.profile_list.item(i), None) - # Add save button to dirty profiles - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - if item.text().startswith("*"): - widget = SaveButtonWidget( - self.profile_list, name, self.save_profile_by_name - ) - self.profile_list.setItemWidget(item, widget) - - def update_profile_list_colors(self): - from PyQt6.QtCore import Qt - - # Use cached profiles to avoid disk reads during slider updates - profiles = self._cached_profiles - for i in range(self.profile_list.count()): - item = self.profile_list.item(i) - name = item.text().lstrip("*") - if self._virtual_new_profile and name == "New profile": - color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) - item.setData(Qt.ItemDataRole.BackgroundRole, color) - elif item.text().startswith("*"): - color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) - item.setData(Qt.ItemDataRole.BackgroundRole, color) - else: - item.setData( - Qt.ItemDataRole.BackgroundRole, - self.profile_list.palette().base().color(), - ) - weights = profiles.get(name, {}).get("voices", []) - total = 0 - if isinstance(weights, list): - for entry in weights: - if ( - isinstance(entry, (list, tuple)) - and len(entry) == 2 - and isinstance(entry[1], (int, float)) - ): - total += entry[1] - if total == 0: - color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND")) - item.setData(Qt.ItemDataRole.BackgroundRole, color) - self.update_profile_save_buttons() - - def preview_current_mix(self): - # Disable preview until playback completes - self.btn_preview_mix.setEnabled(False) - self.btn_preview_mix.setText("Loading...") - self._loading = True - parent = self.parent() - if parent and hasattr(parent, "preview_voice"): - # Apply mixed voices and selected language - parent.mixed_voice_state = self.get_selected_voices() - 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 - ) - # Reset start flag and trigger preview - self._started = False - parent.preview_voice() - # Poll preview_playing: wait for start then end - self._preview_poll_timer = QTimer(self) - self._preview_poll_timer.timeout.connect(self._check_preview_done) - self._preview_poll_timer.start(200) - - def _check_preview_done(self): - parent = self.parent() - if parent and hasattr(parent, "preview_playing"): - # Mark when playback starts - if parent.preview_playing: - self._started = True - # Update button text to "Playing..." when playback starts - self.btn_preview_mix.setText("Playing...") - # Once started and then stopped, re-enable - elif getattr(self, "_started", False): - self.btn_preview_mix.setEnabled(True) - self.btn_preview_mix.setText("Preview") - self._loading = False - self._preview_poll_timer.stop() +import json +import os +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QCheckBox, + QLabel, + QHBoxLayout, + QDoubleSpinBox, + QSlider, + QScrollArea, + QWidget, + QPushButton, + QSizePolicy, + QMessageBox, + QFrame, + QLayout, + QStyle, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QSplitter, + QMenu, + QApplication, + QComboBox, +) +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, +) +from abogen.tts_plugin.utils import get_voices +import re +import platform +from abogen.utils import get_resource_path +from abogen.voice_profiles import ( + load_profiles, + save_profiles, + delete_profile, + duplicate_profile, + export_profiles, +) + + +# Constants +VOICE_MIXER_WIDTH = 100 +SLIDER_WIDTH = 32 +MIN_WINDOW_WIDTH = 600 +MIN_WINDOW_HEIGHT = 400 +INITIAL_WINDOW_WIDTH = 1200 +INITIAL_WINDOW_HEIGHT = 500 + +# Language options for the language selector loaded from constants +LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items()) + + +class SaveButtonWidget(QWidget): + def __init__(self, parent, profile_name, save_callback): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.save_btn = QPushButton("Save", self) + self.save_btn.setFixedWidth(48) + self.save_btn.clicked.connect(lambda: save_callback(profile_name)) + layout.addStretch() + layout.addWidget(self.save_btn) + self.setLayout(layout) + + +class FlowLayout(QLayout): + def __init__(self, parent=None, margin=0, spacing=-1): + super().__init__(parent) + if parent: + self.setContentsMargins(margin, margin, margin, margin) + self.setSpacing(spacing) + self._item_list = [] + + def __del__(self): + item = self.takeAt(0) + while item: + item = self.takeAt(0) + + def addItem(self, item): + self._item_list.append(item) + + def count(self): + return len(self._item_list) + + def expandingDirections(self): + return Qt.Orientation(0) + + def hasHeightForWidth(self): + return True + + def sizeHint(self): + return self.minimumSize() + + def itemAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list[index] + return None + + def takeAt(self, index): + if 0 <= index < len(self._item_list): + return self._item_list.pop(index) + return None + + def heightForWidth(self, width): + return self._do_layout(QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super().setGeometry(rect) + self._do_layout(rect, False) + + def minimumSize(self): + size = QSize() + for item in self._item_list: + size = size.expandedTo(item.minimumSize()) + margin, _, _, _ = self.getContentsMargins() + size += QSize(2 * margin, 2 * margin) + return size + + def _do_layout(self, rect, test_only): + x, y = rect.x(), rect.y() + line_height = 0 + spacing = self.spacing() + + for item in self._item_list: + style = self.parentWidget().style() if self.parentWidget() else QStyle() + layout_spacing_x = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, + ) + layout_spacing_y = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, + ) + space_x = spacing if spacing >= 0 else layout_spacing_x + space_y = spacing if spacing >= 0 else layout_spacing_y + + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > rect.right() and line_height > 0: + x = rect.x() + y = y + line_height + space_y + next_x = x + item.sizeHint().width() + space_x + line_height = 0 + + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + + x = next_x + line_height = max(line_height, item.sizeHint().height()) + + return y + line_height - rect.y() + + +class VoiceMixer(QWidget): + def __init__( + self, voice_name, language_code, initial_status=False, initial_weight=0.0 + ): + super().__init__() + self.voice_name = voice_name + self.setFixedWidth(VOICE_MIXER_WIDTH) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + # TODO Set CSS for rounded corners + # self.setObjectName("VoiceMixer") + # self.setStyleSheet(self.ROUNDED_CSS) + + layout = QVBoxLayout() + + # Name label at the top + name = voice_name + layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter) + + # Voice name label with gender icon + is_female = self.voice_name in get_voices("kokoro") and self.voice_name[1] == "f" + + # Icons layout (flag and gender) + icons_layout = QHBoxLayout() + icons_layout.setSpacing(3) + icons_layout.setAlignment( + Qt.AlignmentFlag.AlignCenter + ) # Center the icons horizontally + + # Flag icon + flag_icon_path = get_resource_path( + "abogen.assets.flags", f"{language_code}.png" + ) + gender_icon_path = get_resource_path( + "abogen.assets", "female.png" if is_female else "male.png" + ) + flag_label = QLabel() + gender_label = QLabel() + flag_pixmap = QPixmap(flag_icon_path) + flag_label.setPixmap( + flag_pixmap.scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + gender_pixmap = QPixmap(gender_icon_path) + gender_label.setPixmap( + gender_pixmap.scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + icons_layout.addWidget(flag_label) + icons_layout.addWidget(gender_label) + + # Add icons layout + layout.addLayout(icons_layout) + + # Checkbox (now below icons) + self.checkbox = QCheckBox() + self.checkbox.setChecked(initial_status) + self.checkbox.stateChanged.connect(self.toggle_inputs) + layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter) + + # Spinbox and slider + self.spin_box = QDoubleSpinBox() + self.spin_box.setRange(0, 1) + self.spin_box.setSingleStep(0.01) + self.spin_box.setDecimals(2) + self.spin_box.setValue(initial_weight) + + self.slider = QSlider(Qt.Orientation.Vertical) + self.slider.setRange(0, 100) + self.slider.setValue(int(initial_weight * 100)) + self.slider.setSizePolicy( + QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding + ) + self.slider.setFixedWidth(SLIDER_WIDTH) + + # Apply slider styling after widget is added to window (see showEvent) + self._slider_style_applied = False + + # Connect controls with internal sync only (no external updates) + self.slider.valueChanged.connect(self._on_slider_changed) + self.spin_box.valueChanged.connect(self._on_spinbox_changed) + + # Flag to prevent recursive updates + self._syncing = False + + # Layout for slider and labels + slider_layout = QVBoxLayout() + slider_layout.addWidget(self.spin_box) + slider_layout.addWidget(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter)) + + slider_center_layout = QHBoxLayout() + slider_center_layout.addWidget( + self.slider, alignment=Qt.AlignmentFlag.AlignHCenter + ) + slider_center_layout.setContentsMargins(0, 0, 0, 0) + + slider_center_widget = QWidget() + slider_center_widget.setLayout(slider_center_layout) + + slider_layout.addWidget(slider_center_widget, stretch=1) + slider_layout.addWidget(QLabel("0", alignment=Qt.AlignmentFlag.AlignCenter)) + slider_layout.setStretch(2, 1) + + layout.addLayout(slider_layout, stretch=1) + self.setLayout(layout) + self.toggle_inputs() + + def showEvent(self, event): + super().showEvent(event) + # Apply slider styling once when widget is shown and has access to parent + if not self._slider_style_applied: + self._slider_style_applied = True + + # Fix slider in Windows + if platform.system() == "Windows": + appstyle = QApplication.instance().style().objectName().lower() + if appstyle != "windowsvista": + # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] + self.slider.setStyleSheet( + f""" + QSlider::groove:vertical:disabled {{ + background: {COLORS.get("GREY_BACKGROUND")}; + width: 4px; + border-radius: 4px; + }} + """ + ) + else: + # Apply same fix for Light theme on non-Windows systems + # Get theme from parent window's config + parent_window = self.window() + theme = "system" + while parent_window: + if hasattr(parent_window, "config"): + theme = parent_window.config.get("theme", "system") + break + parent_window = parent_window.parent() + + if theme == "light": + self.slider.setStyleSheet( + f""" + QSlider::groove:vertical:disabled {{ + background: {COLORS.get("GREY_BACKGROUND")}; + width: 4px; + border-radius: 4px; + }} + """ + ) + + def toggle_inputs(self): + is_enabled = self.checkbox.isChecked() + self.spin_box.setEnabled(is_enabled) + self.slider.setEnabled(is_enabled) + + def _on_slider_changed(self, val): + """Handle slider value change - sync to spinbox without triggering external updates.""" + if self._syncing: + return + self._syncing = True + self.spin_box.setValue(val / 100) + self._syncing = False + + def _on_spinbox_changed(self, val): + """Handle spinbox value change - sync to slider without triggering external updates.""" + if self._syncing: + return + self._syncing = True + self.slider.setValue(int(val * 100)) + self._syncing = False + + def get_voice_weight(self): + if self.checkbox.isChecked(): + return self.voice_name, self.spin_box.value() + return None + + +class HoverLabel(QLabel): + def __init__(self, text, voice_name, parent=None): + super().__init__(text, parent) + self.voice_name = voice_name + self.setMouseTracking(True) + self.setStyleSheet( + "background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;" + ) + + # Create delete button + self.delete_button = QPushButton("×", self) + self.delete_button.setFixedSize(16, 16) + self.delete_button.setStyleSheet( + f""" + QPushButton {{ + background-color: {COLORS.get("RED")}; + color: white; + border-radius: 7px; + font-weight: bold; + font-size: 12px; + border: none; + padding: 0px; + margin: 0px; + }} + QPushButton:hover {{ + background-color: red; + }} + """ + ) + # Make sure the entire button is clickable, not just the text + self.delete_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.delete_button.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, False + ) + self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.delete_button.hide() + + def resizeEvent(self, event): + super().resizeEvent(event) + # Position the button in the top-right corner with a small margin + self.delete_button.move(self.width() - 16, +0) + + def enterEvent(self, event): + self.delete_button.show() + + def leaveEvent(self, event): + self.delete_button.hide() + + +class VoiceFormulaDialog(QDialog): + def __init__(self, parent=None, initial_state=None, selected_profile=None): + super().__init__(parent) + # Store original profile/mix state for restoration on cancel + self._original_profile_name = None + self._original_mixed_voice_state = None + if parent is not None: + self._original_profile_name = getattr(parent, "selected_profile_name", None) + self._original_mixed_voice_state = getattr( + parent, "mixed_voice_state", None + ) + profiles = load_profiles() + self._virtual_new_profile = False + if not profiles: + # No profiles: show 'New profile' in the list, unsaved, not in JSON + self.current_profile = "New profile" + self._profile_dirty = {"New profile": True} + self._virtual_new_profile = True + profiles = {} # Do not add to JSON yet + else: + self.current_profile = ( + selected_profile + if selected_profile in profiles + else list(profiles.keys())[0] + ) + self._profile_dirty = {name: False for name in profiles} + # Track unsaved states per profile + self._profile_states = {} + # Cache for loaded profiles to avoid repeated disk reads + self._cached_profiles = profiles.copy() + + # Debounce timer for slider updates (prevents lag during rapid slider movement) + self._update_timer = QTimer(self) + self._update_timer.setSingleShot(True) + self._update_timer.setInterval(30) # 30ms debounce + self._update_timer.timeout.connect(self._do_debounced_update) + self._pending_weighted_update = False + self._pending_profile_modified = False + + # Cache for voice weight labels to enable in-place updates + self._voice_labels = {} # voice_name -> HoverLabel widget + + # Add subtitle_combo reference if parent has it + self.subtitle_combo = None + if parent is not None and hasattr(parent, "subtitle_combo"): + self.subtitle_combo = parent.subtitle_combo + # Create main container layout with profile section and mixer section + splitter = QSplitter(Qt.Orientation.Horizontal) + # Profile section + profile_widget = QWidget() + profile_layout = QVBoxLayout(profile_widget) + profile_layout.setContentsMargins(0, 0, 0, 0) + # Profile header and save/new buttons + header_layout = QHBoxLayout() + header_layout.addWidget(QLabel("Profiles:")) + header_layout.addStretch() + self.btn_new_profile = QPushButton("New profile") + header_layout.addWidget(self.btn_new_profile) + profile_layout.addLayout(header_layout) + # Profile list + self.profile_list = QListWidget() + self.profile_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection) + self.profile_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows) + self.profile_list.setStyleSheet( + "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" + ) + icon = QIcon(get_resource_path("abogen.assets", "profile.png")) + if self._virtual_new_profile: + item = QListWidgetItem(icon, "New profile") + self.profile_list.addItem(item) + self.profile_list.setCurrentRow(0) + else: + for name in profiles: + item = QListWidgetItem(icon, name) + self.profile_list.addItem(item) + idx = list(profiles.keys()).index(self.current_profile) + self.profile_list.setCurrentRow(idx) + profile_layout.addWidget(self.profile_list) + self.profile_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.profile_list.customContextMenuRequested.connect( + self.show_profile_context_menu + ) + self.profile_list.setItemWidget = ( + self.profile_list.setItemWidget + ) # for type hints + # Save and management buttons + mgmt_layout = QVBoxLayout() + self.btn_import_profiles = QPushButton("Import profile(s)") + mgmt_layout.addWidget(self.btn_import_profiles) + self.btn_export_profiles = QPushButton("Export profiles") + mgmt_layout.addWidget(self.btn_export_profiles) + profile_layout.addLayout(mgmt_layout) + # prepare mixer widget + mixer_widget = QWidget() + mixer_layout = QVBoxLayout(mixer_widget) + mixer_layout.setContentsMargins(5, 0, 0, 0) + + self.setWindowTitle("Voice Mixer") + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) + self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) + self.voice_mixers = [] + self.last_enabled_voice = None + + # Header label and language selector + self.header_label = QLabel( + "Adjust voice weights to create your preferred voice mix." + ) + self.header_label.setStyleSheet("font-size: 13px;") + self.header_label.setWordWrap(True) + header_row = QHBoxLayout() + header_row.addWidget(self.header_label, 1) + header_row.addStretch() + header_row.addWidget(QLabel("Language:")) + self.language_combo = QComboBox() + for code, desc in LANGUAGE_OPTIONS: + flag = get_resource_path("abogen.assets.flags", f"{code}.png") + if flag and os.path.exists(flag): + self.language_combo.addItem(QIcon(flag), desc, code) + else: + self.language_combo.addItem(desc, code) + # set current language for profile + prof = profiles.get(self.current_profile, {}) + lang = prof.get("language") if isinstance(prof, dict) else None + if not lang: + lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] + idx = self.language_combo.findData(lang) + if idx >= 0: + self.language_combo.setCurrentIndex(idx) + self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) + header_row.addWidget(self.language_combo) + # Preview current voice mix using main window's preview + self.btn_preview_mix = QPushButton("Preview", self) + self.btn_preview_mix.setToolTip("Preview current voice mix") + self.btn_preview_mix.clicked.connect(self.preview_current_mix) + header_row.addWidget(self.btn_preview_mix) + mixer_layout.addLayout(header_row) + + # Error message + self.error_label = QLabel( + "Please select at least one voice and set its weight above 0." + ) + self.error_label.setStyleSheet("color: red; font-weight: bold;") + self.error_label.setWordWrap(True) + self.error_label.hide() + mixer_layout.addWidget(self.error_label) + + # Voice weights display + self.weighted_sums_container = QWidget() + self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) + self.weighted_sums_layout.setSpacing(5) + self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) + mixer_layout.addWidget(self.weighted_sums_container) + + # Separator + separator = QFrame() + separator.setFrameShadow(QFrame.Shadow.Sunken) + mixer_layout.addWidget(separator) + + # Voice list scroll area + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + self.scroll_area.viewport().installEventFilter(self) + + self.voice_list_widget = QWidget() + self.voice_list_layout = QHBoxLayout() + self.voice_list_widget.setLayout(self.voice_list_layout) + self.voice_list_widget.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self.scroll_area.setWidget(self.voice_list_widget) + mixer_layout.addWidget(self.scroll_area, stretch=1) + + # Buttons + button_layout = QHBoxLayout() + clear_all_button = QPushButton("Clear all") + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + + # Set OK button as default + ok_button.setDefault(True) + ok_button.setFocus() + + # Connect buttons + clear_all_button.clicked.connect(self.clear_all_voices) + ok_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) + + button_layout.addStretch() + button_layout.addWidget(clear_all_button) + button_layout.addWidget(ok_button) + button_layout.addWidget(cancel_button) + mixer_layout.addLayout(button_layout) + + self.add_voices(initial_state or []) + self.update_weighted_sums() + + # assemble splitter + splitter.addWidget(profile_widget) + splitter.addWidget(mixer_widget) + splitter.setStretchFactor(1, 1) + # set as main layout + self.setLayout(QHBoxLayout()) + self.layout().addWidget(splitter) + + # Connect profile actions + self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) + # Track initial profile for proper dirty-state saving + self.last_profile_row = self.profile_list.currentRow() + self.btn_new_profile.clicked.connect(self.new_profile) + self.btn_export_profiles.clicked.connect(self.export_all_profiles) + self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) + # Note: Signal connections for voice mixers are already set up in add_voice() + # with debouncing for slider updates to prevent lag + + # Update profile colors on initialization to show status + self.update_profile_list_colors() + + def keyPressEvent(self, event): + # Bind Delete key to delete_profile when a profile is selected + if event.key() == Qt.Key.Key_Delete and self.profile_list.hasFocus(): + item = self.profile_list.currentItem() + if item: + self.delete_profile(item) + return + super().keyPressEvent(event) + + def _has_unsaved_changes(self): + # Only return True if there are actually modified (yellow background) profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + # Only consider as unsaved if profile is marked dirty (yellow background) + if item.text().startswith("*"): + return True + return False + + def _prompt_save_changes(self): + dirty_indices = [ + i + for i in range(self.profile_list.count()) + if self.profile_list.item(i).text().startswith("*") + ] + parent = self.parent() + if len(dirty_indices) > 1: + msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" + ret = QMessageBox.question( + self, + "Unsaved Changes", + msg, + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Save, + ) + if ret == QMessageBox.StandardButton.Save: + # Save all using stored states + profiles = load_profiles() + for i in dirty_indices: + name = self.profile_list.item(i).text().lstrip("*") + state = self._profile_states.get(name) + if state is not None: + profiles[name] = state + self._profile_dirty[name] = False + save_profiles(profiles) + # clear states + for name in list(self._profile_states.keys()): + if name not in profiles: + continue + del self._profile_states[name] + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + # clear markers + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return True + elif ret == QMessageBox.StandardButton.Discard: + # Discard all modifications + self._profile_states.clear() + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self._profile_dirty[n] = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + # reload current profile + profiles = load_profiles() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + else: + # Fallback to original logic for 0 or 1 dirty profile + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Unsaved Changes") + box.setText( + "You have unsaved changes in your profile. Do you want to save the changes?" + ) + box.setStandardButtons( + QMessageBox.StandardButton.Save + | QMessageBox.StandardButton.Discard + | QMessageBox.StandardButton.Cancel + ) + box.setDefaultButton(QMessageBox.StandardButton.Save) + ret = box.exec() + if ret == QMessageBox.StandardButton.Save: + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if ( + self._profile_dirty.get(name, False) + or item.text().startswith("*") + or (name == self.current_profile) + ): + self.profile_list.setCurrentRow(i) + self.save_profile_by_name(name) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + elif ret == QMessageBox.StandardButton.Discard: + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + self._profile_dirty[name] = False + if item.text().startswith("*"): + item.setText(name) + self.update_profile_save_buttons() + self.update_profile_list_colors() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + + def on_profile_selection_changed(self, row): + # Save dirty state for previous profile + if hasattr(self, "last_profile_row") and self.last_profile_row is not None: + prev_item = self.profile_list.item(self.last_profile_row) + if prev_item: + prev_name = prev_item.text().lstrip("*") + self._profile_dirty[prev_name] = prev_item.text().startswith("*") + # Do NOT auto-save if modifications pending + # load new profile + item = self.profile_list.item(row) + if item: + name = item.text().lstrip("*") + self.load_profile_state(name) + # Restore dirty state for this profile + dirty = self._profile_dirty.get(name, False) + if dirty and not item.text().startswith("*"): + item.setText("*" + item.text()) + elif not dirty and item.text().startswith("*"): + item.setText(item.text().lstrip("*")) + self.last_profile_row = row + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def add_voices(self, initial_state): + first_enabled_voice = None + for voice in get_voices("kokoro"): + language_code = voice[0] # First character is the language code + matching_voice = next( + (item for item in initial_state if item[0] == voice), None + ) + initial_status = matching_voice is not None + initial_weight = matching_voice[1] if matching_voice else 1.0 + voice_mixer = self.add_voice( + voice, language_code, initial_status, initial_weight + ) + if initial_status and first_enabled_voice is None: + first_enabled_voice = voice_mixer + + if first_enabled_voice: + QTimer.singleShot( + 0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice) + ) + + def add_voice( + self, voice_name, language_code, initial_status=False, initial_weight=1.0 + ): + voice_mixer = VoiceMixer( + voice_name, language_code, initial_status, initial_weight + ) + self.voice_mixers.append(voice_mixer) + self.voice_list_layout.addWidget(voice_mixer) + voice_mixer.checkbox.stateChanged.connect( + lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) + ) + # Use debounced updates for slider changes to prevent lag + voice_mixer.spin_box.valueChanged.connect(self._schedule_weighted_update) + voice_mixer.spin_box.valueChanged.connect(self._schedule_profile_modified) + # Checkbox changes are immediate since they're not high-frequency + voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) + voice_mixer.checkbox.stateChanged.connect( + lambda *_: self.mark_profile_modified() + ) + return voice_mixer + + def handle_voice_checkbox(self, voice_mixer, state): + if state == Qt.CheckState.Checked.value: + self.last_enabled_voice = voice_mixer.voice_name + # Checkbox changes are infrequent, so update immediately + self.update_weighted_sums() + + def get_selected_voices(self): + return [ + v + for v in (m.get_voice_weight() for m in self.voice_mixers) + if v and v[1] > 0 + ] + + def _schedule_weighted_update(self): + """Schedule a debounced weighted sums update.""" + self._pending_weighted_update = True + self._update_timer.start() # Restart the timer + + def _schedule_profile_modified(self): + """Schedule a debounced profile modified update.""" + self._pending_profile_modified = True + self._update_timer.start() # Restart the timer + + def _do_debounced_update(self): + """Execute pending debounced updates.""" + if self._pending_weighted_update: + self._pending_weighted_update = False + self.update_weighted_sums() + if self._pending_profile_modified: + self._pending_profile_modified = False + self.mark_profile_modified() + + def update_weighted_sums(self): + """Update the voice weights display. Optimized for in-place updates during slider movement.""" + # Get selected voices + selected = [ + (m.voice_name, m.spin_box.value()) + for m in self.voice_mixers + if m.checkbox.isChecked() and m.spin_box.value() > 0 + ] + + total = sum(w for _, w in selected) + # disable Preview if no voices selected, but don't enable while loading + if not getattr(self, "_loading", False): + self.btn_preview_mix.setEnabled(total > 0) + + if total > 0: + self.error_label.hide() + self.weighted_sums_container.show() + + # Reorder so last enabled voice is at the end + if self.last_enabled_voice and any( + name == self.last_enabled_voice for name, _ in selected + ): + others = [(n, w) for n, w in selected if n != self.last_enabled_voice] + last = [(n, w) for n, w in selected if n == self.last_enabled_voice] + selected = others + last + + # Get current voice names in display + current_names = set(self._voice_labels.keys()) + new_names = set(name for name, _ in selected) + + # Remove labels for voices no longer selected + for name in current_names - new_names: + label = self._voice_labels.pop(name) + self.weighted_sums_layout.removeWidget(label) + label.deleteLater() + + # Update or create labels + for name, weight in selected: + percentage = weight / total * 100 + label_text = f'{name}: {percentage:.1f}%' + + if name in self._voice_labels: + # Update existing label in-place (fast path) + self._voice_labels[name].setText(label_text) + else: + # Create new label only for newly added voices + voice_label = HoverLabel(label_text, name) + voice_label.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred + ) + voice_label.delete_button.clicked.connect( + lambda _, vn=name: self.disable_voice_by_name(vn) + ) + self._voice_labels[name] = voice_label + self.weighted_sums_layout.addWidget(voice_label) + else: + # Clear all labels when no voices selected + for label in self._voice_labels.values(): + self.weighted_sums_layout.removeWidget(label) + label.deleteLater() + self._voice_labels.clear() + self.error_label.show() + self.weighted_sums_container.hide() + + def disable_voice_by_name(self, voice_name): + for mixer in self.voice_mixers: + if mixer.voice_name == voice_name: + mixer.checkbox.setChecked(False) + break + + def clear_all_voices(self): + for mixer in self.voice_mixers: + mixer.checkbox.setChecked(False) + + def eventFilter(self, source, event): + if source is self.scroll_area.viewport() and event.type() == event.Type.Wheel: + # Skip if over an enabled slider + if any( + mixer.slider.underMouse() and mixer.slider.isEnabled() + for mixer in self.voice_mixers + ): + return False + + # Horizontal scrolling + horiz_bar = self.scroll_area.horizontalScrollBar() + delta = -120 if event.angleDelta().y() > 0 else 120 + horiz_bar.setValue(horiz_bar.value() + delta) + return True + return super().eventFilter(source, event) + + def load_profile_state(self, profile_name): + name = profile_name.lstrip("*") + profiles = load_profiles() + # Update cache when loading profiles + self._cached_profiles = profiles.copy() + # load voices and language from state or JSON + if name in self._profile_states: + state = self._profile_states[name] + else: + state = profiles.get(name, {}) + voices = state.get("voices") if isinstance(state, dict) else state + if voices is None: + voices = [] + lang = state.get("language") if isinstance(state, dict) else None + # apply language selection + if lang: + i = self.language_combo.findData(lang) + if i >= 0: + self.language_combo.blockSignals(True) + self.language_combo.setCurrentIndex(i) + self.language_combo.blockSignals(False) + self.current_profile = name + weights = {n: w for n, w in voices} + for vm in self.voice_mixers: + weight = weights.get(vm.voice_name, 0.0) + # block signals to avoid triggering updates + vm.checkbox.blockSignals(True) + vm.spin_box.blockSignals(True) + vm.slider.blockSignals(True) + vm.checkbox.setChecked(weight > 0) + val = weight if weight > 0 else 1.0 + vm.spin_box.setValue(val) + vm.slider.setValue(int(val * 100)) + # restore signals + vm.checkbox.blockSignals(False) + vm.spin_box.blockSignals(False) + vm.slider.blockSignals(False) + # sync enabled state + vm.toggle_inputs() + # Clear voice labels cache for clean update + for label in self._voice_labels.values(): + self.weighted_sums_layout.removeWidget(label) + label.deleteLater() + self._voice_labels.clear() + self.update_weighted_sums() + + def save_profile_by_name(self, name): + profiles = load_profiles() + state = self._profile_states.get(name, None) + if state is not None: + # ensure dict format + if isinstance(state, dict): + entry = state + else: + entry = {"voices": state, "language": self.language_combo.currentData()} + profiles[name] = entry + save_profiles(profiles) + # Update cache to stay in sync + self._cached_profiles = profiles.copy() + self._profile_dirty[name] = False + del self._profile_states[name] + self._virtual_new_profile = False + # Remove * marker + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + if item.text().lstrip("*") == name: + item.setText(name) + break + self.update_profile_list_colors() + self.update_profile_save_buttons() + self.update_weighted_sums() + + def _handle_zero_weight_profiles(self): + profiles = load_profiles() + if len(profiles) < 1: + return False + zero = [] + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + zero.append((i, name)) + if not zero: + return False + msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" + reply = QMessageBox.question( + self, + "Invalid Profiles", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes, + ) + if reply == QMessageBox.StandardButton.Yes: + for i, name in reversed(zero): + self.profile_list.takeItem(i) + delete_profile(name) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_list_colors() + self.update_profile_save_buttons() + return False + else: + idx, _ = zero[0] + self.profile_list.setCurrentRow(idx) + return True + + def accept(self): + # If no profiles, treat as cancel + if self.profile_list.count() == 0: + # Update subtitle_mode to match combo before closing + if self.subtitle_combo: + parent = self.parent() + if parent is not None: + parent.subtitle_mode = self.subtitle_combo.currentText() + self.reject() + return + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return + selected_voices = self.get_selected_voices() + total_weight = sum(weight for _, weight in selected_voices) + if total_weight == 0: + QMessageBox.warning( + self, + "Invalid Weights", + "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", + ) + self.update_weighted_sums() + return + # Save weights to current profile + profiles = load_profiles() + profiles[self.current_profile] = { + "voices": selected_voices, + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + # Mark this profile as not dirty + self._profile_dirty[self.current_profile] = False + super().accept() + + def reject(self): + # Restore parent's profile/mix state on cancel + parent = self.parent() + if parent is not None: + if hasattr(self, "_original_profile_name"): + parent.selected_profile_name = self._original_profile_name + if hasattr(self, "_original_mixed_voice_state"): + parent.mixed_voice_state = self._original_mixed_voice_state + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return + super().reject() + + def closeEvent(self, event): + # Restore parent's profile/mix state on close + parent = self.parent() + if parent is not None: + if hasattr(self, "_original_profile_name"): + parent.selected_profile_name = self._original_profile_name + if hasattr(self, "_original_mixed_voice_state"): + parent.mixed_voice_state = self._original_mixed_voice_state + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + event.ignore() + return + if self._handle_zero_weight_profiles(): + event.ignore() + return + super().closeEvent(event) + + def _parse_rgba_to_qcolor(self, rgba_str): + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QColor + + """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" + match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) + if match: + r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3)) + a_float = float(match.group(4)) + a_int = int(a_float * 255) + return QColor(r, g, b, a_int) + return Qt.GlobalColor.transparent + + def mark_profile_modified(self): + item = self.profile_list.currentItem() + if item and not item.text().startswith("*"): + item.setText("*" + item.text()) + # Flag profile as dirty and store unsaved state + name = self.current_profile + self._profile_dirty[name] = True + self._profile_states[name] = { + "voices": self.get_selected_voices(), + "language": self.language_combo.currentData(), + } + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def new_profile(self): + import re + + while True: + name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") + if not ok or not name: + break + name = name.strip() # Remove leading/trailing spaces + if not name: + continue + if not re.match(r"^[\w\- ]+$", name): + QMessageBox.warning( + self, + "Invalid Name", + "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", + ) + continue + profiles = load_profiles() + # Remove 'New profile' placeholder if not persisted in JSON + if ( + self.profile_list.count() == 1 + and self.profile_list.item(0).text() == "New profile" + and "New profile" not in profiles + ): + self.profile_list.takeItem(0) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + if name in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + continue + profiles[name] = { + "voices": [], + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), name + ) + ) + self.profile_list.setCurrentRow(self.profile_list.count() - 1) + # reset UI mixers + for vm in self.voice_mixers: + vm.checkbox.setChecked(False) + vm.spin_box.setValue(1.0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + break + self.update_profile_save_buttons() + self.update_profile_list_colors() + self.update_weighted_sums() + + def export_all_profiles(self): + # Prevent export if any profile has total weight 0 + profiles = load_profiles() + for name, weights in profiles.items(): + total = 0 + voices = weights.get("voices", []) + if isinstance(voices, list): + for entry in voices: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" + ) + if path: + export_profiles(path) + + def import_profiles_dialog(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import Profiles", "", "JSON Files (*.json)" + ) + if path: + from abogen.voice_profiles import load_profiles, save_profiles + + # Try to read the file and count profiles + try: + import json + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if not (isinstance(data, dict) and "abogen_voice_profiles" in data): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + imported_profiles = data["abogen_voice_profiles"] + if not isinstance(imported_profiles, dict): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + count = len(imported_profiles) + except Exception: + QMessageBox.warning( + self, "Import Error", "Could not read the selected file." + ) + return + if count == 0: + QMessageBox.information( + self, "No Profiles", "No profiles found in the selected file." + ) + return + profiles = load_profiles() + collisions = [name for name in imported_profiles if name in profiles] + # Combine prompts: show both import count and overwrite count if any + if count == 1: + orig_name = next(iter(imported_profiles.keys())) + msg = f"Profile '{orig_name}' will be imported." + if collisions: + msg += f"\nThis will overwrite an existing profile." + msg += "\nContinue?" + reply = QMessageBox.question( + self, + "Import Profile", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profile Imported", + f"Profile '{orig_name}' imported successfully.", + ) + else: + msg = f"{count} profiles will be imported." + if collisions: + msg += f"\n{len(collisions)} profile(s) will be overwritten." + msg += "\nContinue?" + reply = QMessageBox.question( + self, + "Import Profiles", + msg, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profiles Imported", + f"{count} profiles imported successfully.", + ) + # Refresh list + self.profile_list.clear() + profiles = load_profiles() + for nm in profiles: + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), nm + ) + ) + if self.profile_list.count() > 0: + self.profile_list.setCurrentRow(0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self._virtual_new_profile = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def show_profile_context_menu(self, pos): + item = self.profile_list.itemAt(pos) + if not item: + return + name = item.text().lstrip("*") + menu = QMenu(self) + rename_act = QAction("Rename", self) + delete_act = QAction("Delete", self) + dup_act = QAction("Duplicate", self) + export_act = QAction("Export this profile", self) + menu.addAction(rename_act) + menu.addAction(dup_act) + menu.addAction(export_act) + menu.addAction(delete_act) + act = menu.exec(self.profile_list.viewport().mapToGlobal(pos)) + if act == rename_act: + self.rename_profile(item) + elif act == delete_act: + self.delete_profile(item) + elif act == dup_act: + self.duplicate_profile(item) + elif act == export_act: + self.export_selected_profile_item(item) + + def export_selected_profile_item(self, item): + if not item: + return + name = item.text().lstrip("*") + profiles = load_profiles() + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profile", f"{name}.json", "JSON Files (*.json)" + ) + if path: + # Use abogen_voice_profiles wrapper for single profile export + with open(path, "w", encoding="utf-8") as f: + json.dump( + {"abogen_voice_profiles": {name: profiles.get(name, {})}}, + f, + indent=2, + ) + + def rename_profile(self, item): + name = item.text().lstrip("*") + # block if profile has unsaved changes and it's not a virtual New profile + if self._profile_dirty.get(name, False) and not ( + self._virtual_new_profile and name == "New profile" + ): + QMessageBox.warning( + self, "Unsaved Changes", "Please save the profile before renaming." + ) + return + old = item.text().lstrip("*") + import re + + while True: + new, ok = QInputDialog.getText( + self, "Rename Profile", f"Profile name:", text=old + ) + if not ok or not new or new == old: + break + new = new.strip() # Remove leading/trailing spaces + if not new: + continue + if not re.match(r"^[\w\- ]+$", new): + QMessageBox.warning( + self, + "Invalid Name", + "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.", + ) + continue + + profiles = load_profiles() + if new in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + continue + + # Special case for renaming the virtual "New profile" + if self._virtual_new_profile and name == "New profile": + # Create the profile with the new name + profiles[new] = { + "voices": self.get_selected_voices(), + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + + # Update tracking properties + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + self._profile_dirty[new] = False + + # Update the current profile name + self.current_profile = new + item.setText(new) + else: + # Standard renaming for regular profiles + profiles[new] = profiles.pop(old) + save_profiles(profiles) + item.setText(new) + + # Update the current profile name if it was renamed + if self.current_profile == old: + self.current_profile = new + + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + break + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def delete_profile(self, item): + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return + reply = QMessageBox.question( + self, + "Delete Profile", + f"Delete profile '{name}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + delete_profile(name) + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def duplicate_profile(self, item): + name = item.text().lstrip("*") + # block duplicating if profile has unsaved changes + if self._profile_dirty.get(name, False): + QMessageBox.warning( + self, "Unsaved Changes", "Please save the profile before duplicating." + ) + return + src = item.text().lstrip("*") + profiles = load_profiles() + base = f"{src}_duplicate" + new = base + i = 1 + while new in profiles: + new = f"{base}{i}" + i += 1 + duplicate_profile(src, new) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), new + ) + ) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def update_profile_save_buttons(self): + # Remove all save buttons first + for i in range(self.profile_list.count()): + self.profile_list.setItemWidget(self.profile_list.item(i), None) + # Add save button to dirty profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if item.text().startswith("*"): + widget = SaveButtonWidget( + self.profile_list, name, self.save_profile_by_name + ) + self.profile_list.setItemWidget(item, widget) + + def update_profile_list_colors(self): + from PyQt6.QtCore import Qt + + # Use cached profiles to avoid disk reads during slider updates + profiles = self._cached_profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) + item.setData(Qt.ItemDataRole.BackgroundRole, color) + elif item.text().startswith("*"): + color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) + item.setData(Qt.ItemDataRole.BackgroundRole, color) + else: + item.setData( + Qt.ItemDataRole.BackgroundRole, + self.profile_list.palette().base().color(), + ) + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND")) + item.setData(Qt.ItemDataRole.BackgroundRole, color) + self.update_profile_save_buttons() + + def preview_current_mix(self): + # Disable preview until playback completes + self.btn_preview_mix.setEnabled(False) + self.btn_preview_mix.setText("Loading...") + self._loading = True + parent = self.parent() + if parent and hasattr(parent, "preview_voice"): + # Apply mixed voices and selected language + parent.mixed_voice_state = self.get_selected_voices() + 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 + ) + # Reset start flag and trigger preview + self._started = False + parent.preview_voice() + # Poll preview_playing: wait for start then end + self._preview_poll_timer = QTimer(self) + self._preview_poll_timer.timeout.connect(self._check_preview_done) + self._preview_poll_timer.start(200) + + def _check_preview_done(self): + parent = self.parent() + if parent and hasattr(parent, "preview_playing"): + # Mark when playback starts + if parent.preview_playing: + self._started = True + # Update button text to "Playing..." when playback starts + self.btn_preview_mix.setText("Playing...") + # Once started and then stopped, re-enable + elif getattr(self, "_started", False): + self.btn_preview_mix.setEnabled(True) + self.btn_preview_mix.setText("Preview") + self._loading = False + self._preview_poll_timer.stop() diff --git a/abogen/subtitle_utils.py b/abogen/subtitle_utils.py index 489ed24..15fbaf2 100644 --- a/abogen/subtitle_utils.py +++ b/abogen/subtitle_utils.py @@ -1,584 +1,584 @@ -import re -import platform -from abogen.utils import detect_encoding, load_config -from abogen.constants import SAMPLE_VOICE_TEXTS - -# Pre-compile frequently used regex patterns for better performance -_METADATA_TAG_PATTERN = re.compile(r"<[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
-
-
-@dataclass(frozen=True)
-class DebugWavArtifact:
- label: str
- filename: str
- code: Optional[str] = None
- text: Optional[str] = None
-
-
-def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
- """Resolve settings voice strings into a pipeline-ready voice spec.
-
- Supports "profile:" by converting it into a concrete voice formula.
- Returns (resolved_voice_spec, profile_name, profile_language).
- """
-
- from abogen.webui.routes.utils.voice import resolve_voice_setting
-
- return resolve_voice_setting(value)
-
-
-def _load_pipeline(language: str, use_gpu: bool) -> Any:
- device = "cpu"
- if use_gpu:
- device = _select_device()
- return create_pipeline("kokoro", lang_code=language, device=device)
-
-
-def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
- raw = str(text or "")
- matches = list(_MARKER_RE.finditer(raw))
- cases: List[Tuple[str, str]] = []
- if not matches:
- return cases
- for idx, match in enumerate(matches):
- code = match.group("code")
- start = match.end()
- end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
- snippet = raw[start:end]
- # Keep it small and predictable: collapse whitespace.
- snippet = " ".join(snippet.strip().split())
- cases.append((code, snippet))
- return cases
-
-
-def _spoken_id(code: str) -> str:
- # Make IDs pronounceable and stable (avoid reading as a word).
- out: List[str] = []
- for ch in str(code or ""):
- if ch == "_":
- out.append(" ")
- elif ch.isalnum():
- out.append(ch)
- else:
- out.append(" ")
- # Add spaces between alnum to encourage letter-by-letter reading.
- spaced = " ".join("".join(out).split())
- return spaced
-
-
-def run_debug_tts_wavs(
- *,
- output_root: Path,
- settings: Mapping[str, Any],
- epub_path: Optional[Path] = None,
-) -> Dict[str, Any]:
- """Generate WAV artifacts for the debug EPUB samples.
-
- Writes:
- - overall.wav: concatenation of all samples
- - case_.wav: each sample rendered separately
- - manifest.json: metadata + file list
- """
-
- output_root = Path(output_root)
- output_root.mkdir(parents=True, exist_ok=True)
-
- run_id = uuid.uuid4().hex
- run_dir = output_root / "debug" / run_id
- run_dir.mkdir(parents=True, exist_ok=True)
-
- if epub_path is None:
- epub_path = run_dir / "abogen_debug_samples.epub"
- build_debug_epub(epub_path)
- else:
- epub_path = Path(epub_path)
-
- extraction = extract_from_path(epub_path)
- combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
- cases = _extract_cases_from_text(combined_text)
-
- # Prefer the canonical sample catalog for text (EPUB extraction may include headings).
- try:
- from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
-
- sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
- except Exception:
- sample_text_by_code = {}
-
- expected = list(iter_expected_codes())
- found_codes = {code for code, _ in cases}
- missing = [code for code in expected if code not in found_codes]
- 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)
- 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)
-
- # Settings may store "profile:" which is not a Kokoro voice ID.
- # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
- # doesn't attempt to download a non-existent "voices/profile:.pt".
- try:
- resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
- if resolved_voice:
- voice_spec = resolved_voice
- if profile_language:
- language = str(profile_language).strip() or language
- except Exception:
- # Voice profile resolution is best-effort; fall back to raw voice_spec.
- pass
-
- # Best-effort voice caching (only for known Kokoro internal voices).
- voice_ids = _spec_to_voice_ids(voice_spec)
- if voice_ids:
- try:
- ensure_voice_assets(voice_ids)
- except Exception:
- # Network / optional dependency variance; debug runner can still proceed.
- pass
-
- pipeline = _load_pipeline(language, use_gpu)
- voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
-
- apostrophe_config = build_apostrophe_config(settings=settings)
- normalization_settings = dict(settings)
-
- artifacts: List[DebugWavArtifact] = []
-
- overall_path = run_dir / "overall.wav"
- overall_audio: List[np.ndarray] = []
-
- def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
- normalized = (
- normalize_for_pipeline(
- text,
- config=apostrophe_config,
- settings=normalization_settings,
- )
- if apply_normalization
- else str(text or "")
- )
- parts: List[np.ndarray] = []
- for segment in pipeline(
- normalized,
- voice=voice_choice,
- speed=speed,
- split_pattern=SPLIT_PATTERN,
- ):
- audio = _to_float32(getattr(segment, "audio", None))
- if audio.size:
- parts.append(audio)
- if not parts:
- return np.zeros(0, dtype="float32")
- return np.concatenate(parts).astype("float32", copy=False)
-
- pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
- between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
-
- # Per sample
- for code, snippet in cases:
- snippet = sample_text_by_code.get(code, snippet)
- if not snippet:
- continue
- id_audio = synth(_spoken_id(code), apply_normalization=False)
- text_audio = synth(snippet, apply_normalization=True)
- audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
- filename = f"case_{code}.wav"
- path = run_dir / filename
- # Write float32 PCM WAV.
- import soundfile as sf
-
- sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
- artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
- overall_audio.append(audio)
- overall_audio.append(between_cases)
-
- # Overall
- if overall_audio:
- combined = np.concatenate(overall_audio).astype("float32", copy=False)
- else:
- combined = np.zeros(0, dtype="float32")
- import soundfile as sf
-
- sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
- artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
-
- manifest = {
- "run_id": run_id,
- "epub": str(epub_path),
- "artifacts": [artifact.__dict__ for artifact in artifacts],
- "sample_rate": SAMPLE_RATE,
- }
- (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
- return manifest
+from __future__ import annotations
+
+import json
+import re
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
+
+import numpy as np
+
+from abogen.debug_tts_samples import MARKER_PREFIX, MARKER_SUFFIX, build_debug_epub, iter_expected_codes
+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, SPLIT_PATTERN, _select_device, _to_float32, _resolve_voice, _spec_to_voice_ids
+from abogen.tts_plugin.utils import create_pipeline
+
+
+_MARKER_RE = re.compile(re.escape(MARKER_PREFIX) + r"(?P[A-Z0-9_]+)" + re.escape(MARKER_SUFFIX))
+
+
+@dataclass(frozen=True)
+class DebugWavArtifact:
+ label: str
+ filename: str
+ code: Optional[str] = None
+ text: Optional[str] = None
+
+
+def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]:
+ """Resolve settings voice strings into a pipeline-ready voice spec.
+
+ Supports "profile:" by converting it into a concrete voice formula.
+ Returns (resolved_voice_spec, profile_name, profile_language).
+ """
+
+ from abogen.webui.routes.utils.voice import resolve_voice_setting
+
+ return resolve_voice_setting(value)
+
+
+def _load_pipeline(language: str, use_gpu: bool) -> Any:
+ device = "cpu"
+ if use_gpu:
+ device = _select_device()
+ return create_pipeline("kokoro", lang_code=language, device=device)
+
+
+def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]:
+ raw = str(text or "")
+ matches = list(_MARKER_RE.finditer(raw))
+ cases: List[Tuple[str, str]] = []
+ if not matches:
+ return cases
+ for idx, match in enumerate(matches):
+ code = match.group("code")
+ start = match.end()
+ end = matches[idx + 1].start() if idx + 1 < len(matches) else len(raw)
+ snippet = raw[start:end]
+ # Keep it small and predictable: collapse whitespace.
+ snippet = " ".join(snippet.strip().split())
+ cases.append((code, snippet))
+ return cases
+
+
+def _spoken_id(code: str) -> str:
+ # Make IDs pronounceable and stable (avoid reading as a word).
+ out: List[str] = []
+ for ch in str(code or ""):
+ if ch == "_":
+ out.append(" ")
+ elif ch.isalnum():
+ out.append(ch)
+ else:
+ out.append(" ")
+ # Add spaces between alnum to encourage letter-by-letter reading.
+ spaced = " ".join("".join(out).split())
+ return spaced
+
+
+def run_debug_tts_wavs(
+ *,
+ output_root: Path,
+ settings: Mapping[str, Any],
+ epub_path: Optional[Path] = None,
+) -> Dict[str, Any]:
+ """Generate WAV artifacts for the debug EPUB samples.
+
+ Writes:
+ - overall.wav: concatenation of all samples
+ - case_.wav: each sample rendered separately
+ - manifest.json: metadata + file list
+ """
+
+ output_root = Path(output_root)
+ output_root.mkdir(parents=True, exist_ok=True)
+
+ run_id = uuid.uuid4().hex
+ run_dir = output_root / "debug" / run_id
+ run_dir.mkdir(parents=True, exist_ok=True)
+
+ if epub_path is None:
+ epub_path = run_dir / "abogen_debug_samples.epub"
+ build_debug_epub(epub_path)
+ else:
+ epub_path = Path(epub_path)
+
+ extraction = extract_from_path(epub_path)
+ combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters)
+ cases = _extract_cases_from_text(combined_text)
+
+ # Prefer the canonical sample catalog for text (EPUB extraction may include headings).
+ try:
+ from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES
+
+ sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES}
+ except Exception:
+ sample_text_by_code = {}
+
+ expected = list(iter_expected_codes())
+ found_codes = {code for code, _ in cases}
+ missing = [code for code in expected if code not in found_codes]
+ 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)
+ 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)
+
+ # Settings may store "profile:" which is not a Kokoro voice ID.
+ # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro
+ # doesn't attempt to download a non-existent "voices/profile:.pt".
+ try:
+ resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec)
+ if resolved_voice:
+ voice_spec = resolved_voice
+ if profile_language:
+ language = str(profile_language).strip() or language
+ except Exception:
+ # Voice profile resolution is best-effort; fall back to raw voice_spec.
+ pass
+
+ # Best-effort voice caching (only for known Kokoro internal voices).
+ voice_ids = _spec_to_voice_ids(voice_spec)
+ if voice_ids:
+ try:
+ ensure_voice_assets(voice_ids)
+ except Exception:
+ # Network / optional dependency variance; debug runner can still proceed.
+ pass
+
+ pipeline = _load_pipeline(language, use_gpu)
+ voice_choice = _resolve_voice(pipeline, voice_spec, use_gpu)
+
+ apostrophe_config = build_apostrophe_config(settings=settings)
+ normalization_settings = dict(settings)
+
+ artifacts: List[DebugWavArtifact] = []
+
+ overall_path = run_dir / "overall.wav"
+ overall_audio: List[np.ndarray] = []
+
+ def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray:
+ normalized = (
+ normalize_for_pipeline(
+ text,
+ config=apostrophe_config,
+ settings=normalization_settings,
+ )
+ if apply_normalization
+ else str(text or "")
+ )
+ parts: List[np.ndarray] = []
+ for segment in pipeline(
+ normalized,
+ voice=voice_choice,
+ speed=speed,
+ split_pattern=SPLIT_PATTERN,
+ ):
+ audio = _to_float32(getattr(segment, "audio", None))
+ if audio.size:
+ parts.append(audio)
+ if not parts:
+ return np.zeros(0, dtype="float32")
+ return np.concatenate(parts).astype("float32", copy=False)
+
+ pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32")
+ between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32")
+
+ # Per sample
+ for code, snippet in cases:
+ snippet = sample_text_by_code.get(code, snippet)
+ if not snippet:
+ continue
+ id_audio = synth(_spoken_id(code), apply_normalization=False)
+ text_audio = synth(snippet, apply_normalization=True)
+ audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False)
+ filename = f"case_{code}.wav"
+ path = run_dir / filename
+ # Write float32 PCM WAV.
+ import soundfile as sf
+
+ sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT")
+ artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet))
+ overall_audio.append(audio)
+ overall_audio.append(between_cases)
+
+ # Overall
+ if overall_audio:
+ combined = np.concatenate(overall_audio).astype("float32", copy=False)
+ else:
+ combined = np.zeros(0, dtype="float32")
+ import soundfile as sf
+
+ sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT")
+ artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None))
+
+ manifest = {
+ "run_id": run_id,
+ "epub": str(epub_path),
+ "artifacts": [artifact.__dict__ for artifact in artifacts],
+ "sample_rate": SAMPLE_RATE,
+ }
+ (run_dir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
+ try:
+ pipeline.dispose()
+ except Exception:
+ pass
+ return manifest
diff --git a/abogen/webui/routes/api.py b/abogen/webui/routes/api.py
index 28c1e9b..87c0f3d 100644
--- a/abogen/webui/routes/api.py
+++ b/abogen/webui/routes/api.py
@@ -1,681 +1,681 @@
-from typing import Any, Dict, Mapping, List, Optional
-import base64
-import uuid
-from pathlib import Path
-
-from flask import Blueprint, request, jsonify, send_file, url_for, current_app
-from flask.typing import ResponseReturnValue
-
-from abogen.webui.routes.utils.settings import (
- load_settings,
- load_integration_settings,
- coerce_float,
- coerce_bool,
- audiobookshelf_settings_from_payload,
- calibre_settings_from_payload,
-)
-from abogen.voice_profiles import (
- load_profiles,
- save_profiles,
- delete_profile,
- duplicate_profile,
- serialize_profiles,
- import_profiles_data,
- export_profiles_payload,
- normalize_profile_entry,
-)
-from abogen.webui.routes.utils.common import split_profile_spec
-from abogen.webui.routes.utils.preview import synthesize_preview, generate_preview_audio
-from abogen.webui.routes.utils.voice import formula_from_profile
-from abogen.normalization_settings import (
- build_llm_configuration,
- build_apostrophe_config,
- apply_overrides,
-)
-from abogen.llm_client import list_models, LLMClientError
-from abogen.kokoro_text_normalization import normalize_for_pipeline
-from abogen.tts_plugin.utils import is_plugin_registered
-from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
-from abogen.integrations.calibre_opds import (
- CalibreOPDSClient,
- CalibreOPDSError,
-)
-from abogen.webui.routes.utils.service import get_service
-from abogen.webui.routes.utils.form import build_pending_job_from_extraction
-from abogen.text_extractor import extract_from_path
-from werkzeug.utils import secure_filename
-
-api_bp = Blueprint("api", __name__)
-
-# --- Voice Profile Routes ---
-
-@api_bp.get("/voice-profiles")
-def api_get_voice_profiles() -> ResponseReturnValue:
- profiles = load_profiles()
- return jsonify(profiles)
-
-@api_bp.post("/voice-profiles")
-def api_save_voice_profile() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- name = str(payload.get("name") or "").strip()
- original_name = str(payload.get("originalName") or "").strip() or None
-
- profile = payload.get("profile")
- if profile is None:
- # Speaker Studio payload format
- provider = str(payload.get("provider") or "kokoro").strip().lower()
- if not is_plugin_registered(provider):
- provider = "kokoro"
- if provider == "supertonic":
- profile = {
- "provider": "supertonic",
- "language": str(payload.get("language") or "a").strip().lower() or "a",
- "voice": payload.get("voice"),
- "total_steps": payload.get("total_steps") or payload.get("supertonic_total_steps"),
- "speed": payload.get("speed") or payload.get("supertonic_speed"),
- }
- else:
- profile = {
- "provider": "kokoro",
- "language": str(payload.get("language") or "a").strip().lower() or "a",
- "voices": payload.get("voices") or [],
- }
-
- if not name or not profile:
- return jsonify({"error": "Name and profile are required"}), 400
-
- profiles = load_profiles()
-
- normalized = normalize_profile_entry(profile)
- if not normalized:
- return jsonify({"error": "Invalid profile payload"}), 400
-
- if original_name and original_name in profiles and original_name != name:
- del profiles[original_name]
-
- profiles[name] = normalized
- save_profiles(profiles)
-
- return jsonify({"success": True, "profile": name, "profiles": serialize_profiles()})
-
-@api_bp.delete("/voice-profiles/")
-def api_delete_voice_profile(name: str) -> ResponseReturnValue:
- delete_profile(name)
- return jsonify({"success": True, "profiles": serialize_profiles()})
-
-
-@api_bp.post("/voice-profiles//duplicate")
-def api_duplicate_voice_profile(name: str) -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- new_name = str(payload.get("name") or "").strip()
- if not new_name:
- return jsonify({"error": "Name is required"}), 400
- duplicate_profile(name, new_name)
- return jsonify({"success": True, "profile": new_name, "profiles": serialize_profiles()})
-
-
-@api_bp.post("/voice-profiles/import")
-def api_import_voice_profiles() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- data = payload.get("data")
- replace_existing = bool(payload.get("replace_existing"))
- if not isinstance(data, dict):
- return jsonify({"error": "Invalid profile payload"}), 400
- try:
- imported = import_profiles_data(data, replace_existing=replace_existing)
- except Exception as exc:
- return jsonify({"error": str(exc)}), 400
- return jsonify({"success": True, "imported": imported, "profiles": serialize_profiles()})
-
-
-@api_bp.get("/voice-profiles/export")
-def api_export_voice_profiles() -> ResponseReturnValue:
- names_param = request.args.get("names")
- names = None
- if names_param:
- names = [item.strip() for item in names_param.split(",") if item.strip()]
- payload = export_profiles_payload(names)
- import io
- import json
-
- data = json.dumps(payload, indent=2).encode("utf-8")
- filename = "voice_profiles.json" if not names else "voice_profiles_export.json"
- return send_file(
- io.BytesIO(data),
- mimetype="application/json",
- as_attachment=True,
- download_name=filename,
- )
-
-
-@api_bp.post("/voice-profiles/preview")
-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"
- speed = coerce_float(payload.get("speed"), 1.0)
- max_seconds = coerce_float(payload.get("max_seconds"), 8.0)
-
- settings = load_settings()
- use_gpu = settings.get("use_gpu", False)
-
- # Accept a direct formula string or a full profile entry.
- formula = str(payload.get("formula") or "").strip()
- profile_name = str(payload.get("profile") or "").strip()
- provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
- supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
-
- voice_spec = ""
- resolved_provider = provider or "kokoro"
-
- profiles = load_profiles()
- if resolved_provider == "supertonic" and not profile_name:
- voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
- # Allow per-speaker overrides via payload.
- supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps)
- speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed)
- elif profile_name:
- entry = profiles.get(profile_name)
- normalized_entry = normalize_profile_entry(entry)
- if not normalized_entry:
- return jsonify({"error": "Unknown profile"}), 404
- resolved_provider = str(normalized_entry.get("provider") or "kokoro")
- if resolved_provider == "supertonic":
- voice_spec = str(normalized_entry.get("voice") or "M1")
- supertonic_total_steps = int(normalized_entry.get("total_steps") or supertonic_total_steps)
- 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)
- elif formula:
- voice_spec = formula
- resolved_provider = "kokoro"
- else:
- # Raw voices payload -> Kokoro mix.
- voices = payload.get("voices") or []
- pseudo = {"provider": "kokoro", "language": language, "voices": voices}
- normalized_entry = normalize_profile_entry(pseudo)
- voice_spec = formula_from_profile(normalized_entry) or ""
- resolved_provider = "kokoro"
-
- if not voice_spec:
- return jsonify({"error": "Unable to resolve preview voice"}), 400
-
- try:
- return synthesize_preview(
- text=text,
- voice_spec=voice_spec,
- language=language,
- speed=speed,
- use_gpu=use_gpu,
- tts_provider=resolved_provider,
- supertonic_total_steps=supertonic_total_steps,
- max_seconds=max_seconds,
- )
- except Exception as exc:
- return jsonify({"error": str(exc)}), 500
-
-@api_bp.post("/speaker-preview")
-def api_speaker_preview() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- 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")
- speed_value = payload.get("speed")
- speed = coerce_float(speed_value, 1.0)
- tts_provider = str(payload.get("tts_provider") or "").strip().lower()
- supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
-
- settings = load_settings()
- use_gpu = settings.get("use_gpu", False)
-
- base_spec, speaker_name = split_profile_spec(voice)
- resolved_provider = tts_provider if is_plugin_registered(tts_provider) else ""
-
- if speaker_name:
- entry = normalize_profile_entry(load_profiles().get(speaker_name))
- if entry:
- resolved_provider = str(entry.get("provider") or resolved_provider or "")
- if resolved_provider == "supertonic":
- voice = str(entry.get("voice") or "M1")
- supertonic_total_steps = int(entry.get("total_steps") or supertonic_total_steps)
- if speed_value is None:
- speed = coerce_float(entry.get("speed"), speed)
- elif resolved_provider == "kokoro":
- voice = formula_from_profile(entry) or (base_spec or voice)
-
- if not resolved_provider:
- resolved_provider = "supertonic" if str(base_spec or "").strip() in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro"
-
- pronunciation_overrides = None
- manual_overrides = None
- speakers = None
- if pending_id:
- try:
- pending = get_service().get_pending_job(pending_id)
- except Exception:
- pending = None
- if pending is not None:
- manual_overrides = getattr(pending, "manual_overrides", None)
- pronunciation_overrides = getattr(pending, "pronunciation_overrides", None)
- speakers = getattr(pending, "speakers", None)
-
- try:
- return synthesize_preview(
- text=text,
- voice_spec=voice,
- language=language,
- speed=speed,
- use_gpu=use_gpu
- ,
- tts_provider=resolved_provider,
- supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
- pronunciation_overrides=pronunciation_overrides,
- manual_overrides=manual_overrides,
- speakers=speakers,
- )
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
-# --- Integration Routes ---
-
-
-def _opds_metadata_overrides(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]:
- metadata_overrides: Dict[str, Any] = {}
-
- def _stringify_metadata_value(value: Any) -> str:
- if value is None:
- return ""
- if isinstance(value, (list, tuple, set)):
- parts = [str(item).strip() for item in value if item is not None]
- parts = [part for part in parts if part]
- return ", ".join(parts)
- return str(value).strip()
-
- raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
- series_name = str(raw_series or "").strip()
- if series_name:
- metadata_overrides["series"] = series_name
- metadata_overrides.setdefault("series_name", series_name)
-
- series_index_value = (
- metadata_payload.get("series_index")
- or metadata_payload.get("series_position")
- or metadata_payload.get("series_sequence")
- or metadata_payload.get("book_number")
- )
- if series_index_value is not None:
- series_index_text = str(series_index_value).strip()
- if series_index_text:
- metadata_overrides.setdefault("series_index", series_index_text)
- metadata_overrides.setdefault("series_position", series_index_text)
- metadata_overrides.setdefault("series_sequence", series_index_text)
- metadata_overrides.setdefault("book_number", series_index_text)
-
- tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
- if tags_value:
- tags_text = _stringify_metadata_value(tags_value)
- if tags_text:
- metadata_overrides.setdefault("tags", tags_text)
- metadata_overrides.setdefault("keywords", tags_text)
- metadata_overrides.setdefault("genre", tags_text)
-
- description_value = metadata_payload.get("description") or metadata_payload.get("summary")
- if description_value:
- description_text = _stringify_metadata_value(description_value)
- if description_text:
- metadata_overrides.setdefault("description", description_text)
- metadata_overrides.setdefault("summary", description_text)
-
- subtitle_value = (
- metadata_payload.get("subtitle")
- or metadata_payload.get("sub_title")
- or metadata_payload.get("calibre_subtitle")
- )
- if subtitle_value:
- subtitle_text = _stringify_metadata_value(subtitle_value)
- if subtitle_text:
- metadata_overrides.setdefault("subtitle", subtitle_text)
-
- publisher_value = metadata_payload.get("publisher")
- if publisher_value:
- publisher_text = _stringify_metadata_value(publisher_value)
- if publisher_text:
- metadata_overrides.setdefault("publisher", publisher_text)
-
- # Author mapping: Abogen templates look for either 'authors' or 'author'.
- authors_value = (
- metadata_payload.get("authors")
- or metadata_payload.get("author")
- or metadata_payload.get("creator")
- or metadata_payload.get("dc_creator")
- )
- if authors_value:
- authors_text = _stringify_metadata_value(authors_value)
- if authors_text:
- metadata_overrides.setdefault("authors", authors_text)
- metadata_overrides.setdefault("author", authors_text)
-
- return metadata_overrides
-
-@api_bp.get("/integrations/calibre-opds/feed")
-def api_calibre_opds_feed() -> ResponseReturnValue:
- integrations = load_integration_settings()
- calibre_settings = integrations.get("calibre_opds", {})
-
- payload = {
- "base_url": calibre_settings.get("base_url"),
- "username": calibre_settings.get("username"),
- "password": calibre_settings.get("password"),
- "verify_ssl": calibre_settings.get("verify_ssl", True),
- }
-
- if not payload.get("base_url"):
- return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
-
- try:
- client = CalibreOPDSClient(
- base_url=payload.get("base_url") or "",
- username=payload.get("username"),
- password=payload.get("password"),
- verify=bool(payload.get("verify_ssl", True)),
- )
- except ValueError as exc:
- return jsonify({"error": str(exc)}), 400
-
- href = request.args.get("href", type=str)
- query = request.args.get("q", type=str)
- letter = request.args.get("letter", type=str)
-
- try:
- if letter:
- feed = client.browse_letter(letter, start_href=href)
- elif query:
- feed = client.search(query, start_href=href)
- else:
- feed = client.fetch_feed(href)
- except CalibreOPDSError as exc:
- return jsonify({"error": str(exc)}), 502
- except Exception as exc:
- return jsonify({"error": f"Unexpected error: {str(exc)}"}), 500
-
- return jsonify({
- "feed": feed.to_dict(),
- "href": href or "",
- "query": query or "",
- })
-
-@api_bp.post("/integrations/audiobookshelf/folders")
-def api_abs_folders() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- # Use the helper to resolve saved tokens when use_saved_token is set
- settings = audiobookshelf_settings_from_payload(payload)
- host = settings.get("base_url")
- token = settings.get("api_token")
- library_id = settings.get("library_id")
-
- if not host or not token:
- return jsonify({"error": "Base URL and API token are required"}), 400
-
- if not library_id:
- return jsonify({"error": "Library ID is required to list folders"}), 400
-
- try:
- config = AudiobookshelfConfig(base_url=host, api_token=token, library_id=library_id)
- client = AudiobookshelfClient(config)
- folders = client.list_folders()
- return jsonify({"folders": folders})
- except Exception as e:
- return jsonify({"error": str(e)}), 400
-
-@api_bp.post("/integrations/audiobookshelf/test")
-def api_abs_test() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- # Use the helper to resolve saved tokens when use_saved_token is set
- settings = audiobookshelf_settings_from_payload(payload)
- host = settings.get("base_url")
- token = settings.get("api_token")
-
- if not host or not token:
- return jsonify({"error": "Base URL and API token are required"}), 400
-
- try:
- config = AudiobookshelfConfig(base_url=host, api_token=token)
- client = AudiobookshelfClient(config)
- # Just getting libraries is a good enough test
- client.get_libraries()
- return jsonify({"success": True, "message": "Connection successful."})
- except Exception as e:
- return jsonify({"error": str(e)}), 400
-
-@api_bp.post("/integrations/calibre-opds/test")
-def api_calibre_opds_test() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- # Use the helper to resolve saved passwords when use_saved_password is set
- settings = calibre_settings_from_payload(payload)
- base_url = settings.get("base_url")
- username = settings.get("username")
- password = settings.get("password")
- verify_ssl = settings.get("verify_ssl", False)
-
- if not base_url:
- return jsonify({"error": "Base URL is required"}), 400
-
- try:
- client = CalibreOPDSClient(
- base_url=base_url,
- username=username,
- password=password,
- verify=verify_ssl,
- timeout=10.0
- )
- client.fetch_feed()
- return jsonify({"success": True, "message": "Connection successful."})
- except Exception as e:
- return jsonify({"error": str(e)}), 400
-
-@api_bp.post("/integrations/calibre-opds/import")
-def api_calibre_opds_import() -> ResponseReturnValue:
- if not request.is_json:
- return jsonify({"error": "Expected JSON payload."}), 400
-
- data = request.get_json(force=True, silent=True) or {}
- href = str(data.get("href") or "").strip()
-
- if not href:
- return jsonify({"error": "Download URL (href) is required."}), 400
-
- metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
- metadata_overrides: Dict[str, Any] = {}
- if isinstance(metadata_payload, Mapping):
- metadata_overrides = _opds_metadata_overrides(metadata_payload)
-
- settings = load_settings()
- integrations = load_integration_settings()
- calibre_settings = integrations.get("calibre_opds", {})
-
- try:
- client = CalibreOPDSClient(
- base_url=calibre_settings.get("base_url") or "",
- username=calibre_settings.get("username"),
- password=calibre_settings.get("password"),
- verify=bool(calibre_settings.get("verify_ssl", True)),
- )
-
- temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
- temp_dir.mkdir(exist_ok=True)
-
- resource = client.download(href)
- filename = resource.filename
- content = resource.content
-
- if not filename:
- filename = f"{uuid.uuid4().hex}.epub"
-
- file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
- file_path.write_bytes(content)
-
- extraction = extract_from_path(file_path)
-
- if metadata_overrides:
- extraction.metadata.update(metadata_overrides)
-
- result = build_pending_job_from_extraction(
- stored_path=file_path,
- original_name=filename,
- extraction=extraction,
- form={},
- settings=settings,
- profiles=serialize_profiles(),
- metadata_overrides=metadata_overrides,
- )
-
- get_service().store_pending_job(result.pending)
-
- return jsonify({
- "success": True,
- "status": "imported",
- "pending_id": result.pending.id,
- "redirect_url": url_for("main.wizard_step", step="book", pending_id=result.pending.id)
- })
-
- except Exception as e:
- return jsonify({"error": str(e)}), 500
-
-# --- LLM Routes ---
-
-@api_bp.post("/llm/models")
-def api_llm_models() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=False) or {}
- current_settings = load_settings()
-
- base_url = str(payload.get("base_url") or payload.get("llm_base_url") or current_settings.get("llm_base_url") or "").strip()
- if not base_url:
- return jsonify({"error": "LLM base URL is required."}), 400
-
- api_key = str(payload.get("api_key") or payload.get("llm_api_key") or current_settings.get("llm_api_key") or "")
- timeout = coerce_float(payload.get("timeout"), current_settings.get("llm_timeout", 30.0))
-
- overrides = {
- "llm_base_url": base_url,
- "llm_api_key": api_key,
- "llm_timeout": timeout,
- }
-
- merged = apply_overrides(current_settings, overrides)
- configuration = build_llm_configuration(merged)
- try:
- models = list_models(configuration)
- except LLMClientError as exc:
- return jsonify({"error": str(exc)}), 400
- return jsonify({"models": models})
-
-@api_bp.post("/llm/preview")
-def api_llm_preview() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=False) or {}
- sample_text = str(payload.get("text") or "").strip()
- if not sample_text:
- return jsonify({"error": "Text is required."}), 400
-
- base_settings = load_settings()
- overrides: Dict[str, Any] = {
- "llm_base_url": str(
- payload.get("base_url")
- or payload.get("llm_base_url")
- or base_settings.get("llm_base_url")
- or ""
- ).strip(),
- "llm_api_key": str(
- payload.get("api_key")
- or payload.get("llm_api_key")
- or base_settings.get("llm_api_key")
- or ""
- ),
- "llm_model": str(
- payload.get("model")
- or payload.get("llm_model")
- or base_settings.get("llm_model")
- or ""
- ),
- "llm_prompt": payload.get("prompt") or payload.get("llm_prompt") or base_settings.get("llm_prompt"),
- "llm_context_mode": payload.get("context_mode") or base_settings.get("llm_context_mode"),
- "llm_timeout": coerce_float(payload.get("timeout"), base_settings.get("llm_timeout", 30.0)),
- "normalization_apostrophe_mode": "llm",
- }
-
- merged = apply_overrides(base_settings, overrides)
- if not merged.get("llm_base_url"):
- return jsonify({"error": "LLM base URL is required."}), 400
- if not merged.get("llm_model"):
- return jsonify({"error": "Select an LLM model before previewing."}), 400
-
- apostrophe_config = build_apostrophe_config(settings=merged)
- try:
- normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged)
- except LLMClientError as exc:
- return jsonify({"error": str(exc)}), 400
-
- context = {
- "text": sample_text,
- "normalized_text": normalized_text,
- }
- return jsonify(context)
-
-# --- Normalization Routes ---
-
-@api_bp.post("/normalization/preview")
-def api_normalization_preview() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=False) or {}
- sample_text = str(payload.get("text") or "").strip()
- if not sample_text:
- return jsonify({"error": "Sample text is required."}), 400
-
- base_settings = load_settings()
- # We might want to apply overrides from payload if any normalization settings are passed
- # For now, just use base settings as in original code (presumably)
-
- apostrophe_config = build_apostrophe_config(settings=base_settings)
- try:
- normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=base_settings)
- except Exception as exc:
- return jsonify({"error": str(exc)}), 400
-
- return jsonify({
- "text": sample_text,
- "normalized_text": normalized_text,
- })
-
-@api_bp.post("/entity-pronunciation/preview")
-def api_entity_pronunciation_preview() -> ResponseReturnValue:
- payload = request.get_json(force=True, silent=True) or {}
- token = payload.get("token", "").strip()
- pronunciation = payload.get("pronunciation", "").strip()
- voice = payload.get("voice", "").strip()
- language = payload.get("language", "a").strip()
-
- if not token and not pronunciation:
- return jsonify({"error": "Token or pronunciation required"}), 400
-
- text_to_speak = pronunciation if pronunciation else token
-
- if not voice:
- settings = load_settings()
- voice = settings.get("default_voice", "af_heart")
-
- try:
- # Check GPU setting
- settings = load_settings()
- use_gpu = coerce_bool(settings.get("use_gpu"), False)
-
- audio_bytes = generate_preview_audio(
- text=text_to_speak,
- voice_spec=voice,
- language=language,
- speed=1.0,
- use_gpu=use_gpu,
- )
- audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
- return jsonify({"audio_base64": audio_base64})
- except Exception as e:
- return jsonify({"error": str(e)}), 400
+from typing import Any, Dict, Mapping, List, Optional
+import base64
+import uuid
+from pathlib import Path
+
+from flask import Blueprint, request, jsonify, send_file, url_for, current_app
+from flask.typing import ResponseReturnValue
+
+from abogen.webui.routes.utils.settings import (
+ load_settings,
+ load_integration_settings,
+ coerce_float,
+ coerce_bool,
+ audiobookshelf_settings_from_payload,
+ calibre_settings_from_payload,
+)
+from abogen.voice_profiles import (
+ load_profiles,
+ save_profiles,
+ delete_profile,
+ duplicate_profile,
+ serialize_profiles,
+ import_profiles_data,
+ export_profiles_payload,
+ normalize_profile_entry,
+)
+from abogen.webui.routes.utils.common import split_profile_spec
+from abogen.webui.routes.utils.preview import synthesize_preview, generate_preview_audio
+from abogen.webui.routes.utils.voice import formula_from_profile
+from abogen.normalization_settings import (
+ build_llm_configuration,
+ build_apostrophe_config,
+ apply_overrides,
+)
+from abogen.llm_client import list_models, LLMClientError
+from abogen.kokoro_text_normalization import normalize_for_pipeline
+from abogen.tts_plugin.utils import is_plugin_registered
+from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig
+from abogen.integrations.calibre_opds import (
+ CalibreOPDSClient,
+ CalibreOPDSError,
+)
+from abogen.webui.routes.utils.service import get_service
+from abogen.webui.routes.utils.form import build_pending_job_from_extraction
+from abogen.text_extractor import extract_from_path
+from werkzeug.utils import secure_filename
+
+api_bp = Blueprint("api", __name__)
+
+# --- Voice Profile Routes ---
+
+@api_bp.get("/voice-profiles")
+def api_get_voice_profiles() -> ResponseReturnValue:
+ profiles = load_profiles()
+ return jsonify(profiles)
+
+@api_bp.post("/voice-profiles")
+def api_save_voice_profile() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ name = str(payload.get("name") or "").strip()
+ original_name = str(payload.get("originalName") or "").strip() or None
+
+ profile = payload.get("profile")
+ if profile is None:
+ # Speaker Studio payload format
+ provider = str(payload.get("provider") or "kokoro").strip().lower()
+ if not is_plugin_registered(provider):
+ provider = "kokoro"
+ if provider == "supertonic":
+ profile = {
+ "provider": "supertonic",
+ "language": str(payload.get("language") or "a").strip().lower() or "a",
+ "voice": payload.get("voice"),
+ "total_steps": payload.get("total_steps") or payload.get("supertonic_total_steps"),
+ "speed": payload.get("speed") or payload.get("supertonic_speed"),
+ }
+ else:
+ profile = {
+ "provider": "kokoro",
+ "language": str(payload.get("language") or "a").strip().lower() or "a",
+ "voices": payload.get("voices") or [],
+ }
+
+ if not name or not profile:
+ return jsonify({"error": "Name and profile are required"}), 400
+
+ profiles = load_profiles()
+
+ normalized = normalize_profile_entry(profile)
+ if not normalized:
+ return jsonify({"error": "Invalid profile payload"}), 400
+
+ if original_name and original_name in profiles and original_name != name:
+ del profiles[original_name]
+
+ profiles[name] = normalized
+ save_profiles(profiles)
+
+ return jsonify({"success": True, "profile": name, "profiles": serialize_profiles()})
+
+@api_bp.delete("/voice-profiles/")
+def api_delete_voice_profile(name: str) -> ResponseReturnValue:
+ delete_profile(name)
+ return jsonify({"success": True, "profiles": serialize_profiles()})
+
+
+@api_bp.post("/voice-profiles//duplicate")
+def api_duplicate_voice_profile(name: str) -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ new_name = str(payload.get("name") or "").strip()
+ if not new_name:
+ return jsonify({"error": "Name is required"}), 400
+ duplicate_profile(name, new_name)
+ return jsonify({"success": True, "profile": new_name, "profiles": serialize_profiles()})
+
+
+@api_bp.post("/voice-profiles/import")
+def api_import_voice_profiles() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ data = payload.get("data")
+ replace_existing = bool(payload.get("replace_existing"))
+ if not isinstance(data, dict):
+ return jsonify({"error": "Invalid profile payload"}), 400
+ try:
+ imported = import_profiles_data(data, replace_existing=replace_existing)
+ except Exception as exc:
+ return jsonify({"error": str(exc)}), 400
+ return jsonify({"success": True, "imported": imported, "profiles": serialize_profiles()})
+
+
+@api_bp.get("/voice-profiles/export")
+def api_export_voice_profiles() -> ResponseReturnValue:
+ names_param = request.args.get("names")
+ names = None
+ if names_param:
+ names = [item.strip() for item in names_param.split(",") if item.strip()]
+ payload = export_profiles_payload(names)
+ import io
+ import json
+
+ data = json.dumps(payload, indent=2).encode("utf-8")
+ filename = "voice_profiles.json" if not names else "voice_profiles_export.json"
+ return send_file(
+ io.BytesIO(data),
+ mimetype="application/json",
+ as_attachment=True,
+ download_name=filename,
+ )
+
+
+@api_bp.post("/voice-profiles/preview")
+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"
+ speed = coerce_float(payload.get("speed"), 1.0)
+ max_seconds = coerce_float(payload.get("max_seconds"), 8.0)
+
+ settings = load_settings()
+ use_gpu = settings.get("use_gpu", False)
+
+ # Accept a direct formula string or a full profile entry.
+ formula = str(payload.get("formula") or "").strip()
+ profile_name = str(payload.get("profile") or "").strip()
+ provider = str(payload.get("tts_provider") or payload.get("provider") or "").strip().lower() or None
+ supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or settings.get("supertonic_total_steps") or 5)
+
+ voice_spec = ""
+ resolved_provider = provider or "kokoro"
+
+ profiles = load_profiles()
+ if resolved_provider == "supertonic" and not profile_name:
+ voice_spec = str(payload.get("voice") or payload.get("supertonic_voice") or "M1").strip() or "M1"
+ # Allow per-speaker overrides via payload.
+ supertonic_total_steps = int(payload.get("supertonic_total_steps") or payload.get("total_steps") or supertonic_total_steps)
+ speed = coerce_float(payload.get("supertonic_speed") or payload.get("speed"), speed)
+ elif profile_name:
+ entry = profiles.get(profile_name)
+ normalized_entry = normalize_profile_entry(entry)
+ if not normalized_entry:
+ return jsonify({"error": "Unknown profile"}), 404
+ resolved_provider = str(normalized_entry.get("provider") or "kokoro")
+ if resolved_provider == "supertonic":
+ voice_spec = str(normalized_entry.get("voice") or "M1")
+ supertonic_total_steps = int(normalized_entry.get("total_steps") or supertonic_total_steps)
+ 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)
+ elif formula:
+ voice_spec = formula
+ resolved_provider = "kokoro"
+ else:
+ # Raw voices payload -> Kokoro mix.
+ voices = payload.get("voices") or []
+ pseudo = {"provider": "kokoro", "language": language, "voices": voices}
+ normalized_entry = normalize_profile_entry(pseudo)
+ voice_spec = formula_from_profile(normalized_entry) or ""
+ resolved_provider = "kokoro"
+
+ if not voice_spec:
+ return jsonify({"error": "Unable to resolve preview voice"}), 400
+
+ try:
+ return synthesize_preview(
+ text=text,
+ voice_spec=voice_spec,
+ language=language,
+ speed=speed,
+ use_gpu=use_gpu,
+ tts_provider=resolved_provider,
+ supertonic_total_steps=supertonic_total_steps,
+ max_seconds=max_seconds,
+ )
+ except Exception as exc:
+ return jsonify({"error": str(exc)}), 500
+
+@api_bp.post("/speaker-preview")
+def api_speaker_preview() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ 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")
+ speed_value = payload.get("speed")
+ speed = coerce_float(speed_value, 1.0)
+ tts_provider = str(payload.get("tts_provider") or "").strip().lower()
+ supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5)
+
+ settings = load_settings()
+ use_gpu = settings.get("use_gpu", False)
+
+ base_spec, speaker_name = split_profile_spec(voice)
+ resolved_provider = tts_provider if is_plugin_registered(tts_provider) else ""
+
+ if speaker_name:
+ entry = normalize_profile_entry(load_profiles().get(speaker_name))
+ if entry:
+ resolved_provider = str(entry.get("provider") or resolved_provider or "")
+ if resolved_provider == "supertonic":
+ voice = str(entry.get("voice") or "M1")
+ supertonic_total_steps = int(entry.get("total_steps") or supertonic_total_steps)
+ if speed_value is None:
+ speed = coerce_float(entry.get("speed"), speed)
+ elif resolved_provider == "kokoro":
+ voice = formula_from_profile(entry) or (base_spec or voice)
+
+ if not resolved_provider:
+ resolved_provider = "supertonic" if str(base_spec or "").strip() in {"M1","M2","M3","M4","M5","F1","F2","F3","F4","F5"} else "kokoro"
+
+ pronunciation_overrides = None
+ manual_overrides = None
+ speakers = None
+ if pending_id:
+ try:
+ pending = get_service().get_pending_job(pending_id)
+ except Exception:
+ pending = None
+ if pending is not None:
+ manual_overrides = getattr(pending, "manual_overrides", None)
+ pronunciation_overrides = getattr(pending, "pronunciation_overrides", None)
+ speakers = getattr(pending, "speakers", None)
+
+ try:
+ return synthesize_preview(
+ text=text,
+ voice_spec=voice,
+ language=language,
+ speed=speed,
+ use_gpu=use_gpu
+ ,
+ tts_provider=resolved_provider,
+ supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5),
+ pronunciation_overrides=pronunciation_overrides,
+ manual_overrides=manual_overrides,
+ speakers=speakers,
+ )
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+# --- Integration Routes ---
+
+
+def _opds_metadata_overrides(metadata_payload: Mapping[str, Any]) -> Dict[str, Any]:
+ metadata_overrides: Dict[str, Any] = {}
+
+ def _stringify_metadata_value(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, (list, tuple, set)):
+ parts = [str(item).strip() for item in value if item is not None]
+ parts = [part for part in parts if part]
+ return ", ".join(parts)
+ return str(value).strip()
+
+ raw_series = metadata_payload.get("series") or metadata_payload.get("series_name")
+ series_name = str(raw_series or "").strip()
+ if series_name:
+ metadata_overrides["series"] = series_name
+ metadata_overrides.setdefault("series_name", series_name)
+
+ series_index_value = (
+ metadata_payload.get("series_index")
+ or metadata_payload.get("series_position")
+ or metadata_payload.get("series_sequence")
+ or metadata_payload.get("book_number")
+ )
+ if series_index_value is not None:
+ series_index_text = str(series_index_value).strip()
+ if series_index_text:
+ metadata_overrides.setdefault("series_index", series_index_text)
+ metadata_overrides.setdefault("series_position", series_index_text)
+ metadata_overrides.setdefault("series_sequence", series_index_text)
+ metadata_overrides.setdefault("book_number", series_index_text)
+
+ tags_value = metadata_payload.get("tags") or metadata_payload.get("keywords")
+ if tags_value:
+ tags_text = _stringify_metadata_value(tags_value)
+ if tags_text:
+ metadata_overrides.setdefault("tags", tags_text)
+ metadata_overrides.setdefault("keywords", tags_text)
+ metadata_overrides.setdefault("genre", tags_text)
+
+ description_value = metadata_payload.get("description") or metadata_payload.get("summary")
+ if description_value:
+ description_text = _stringify_metadata_value(description_value)
+ if description_text:
+ metadata_overrides.setdefault("description", description_text)
+ metadata_overrides.setdefault("summary", description_text)
+
+ subtitle_value = (
+ metadata_payload.get("subtitle")
+ or metadata_payload.get("sub_title")
+ or metadata_payload.get("calibre_subtitle")
+ )
+ if subtitle_value:
+ subtitle_text = _stringify_metadata_value(subtitle_value)
+ if subtitle_text:
+ metadata_overrides.setdefault("subtitle", subtitle_text)
+
+ publisher_value = metadata_payload.get("publisher")
+ if publisher_value:
+ publisher_text = _stringify_metadata_value(publisher_value)
+ if publisher_text:
+ metadata_overrides.setdefault("publisher", publisher_text)
+
+ # Author mapping: Abogen templates look for either 'authors' or 'author'.
+ authors_value = (
+ metadata_payload.get("authors")
+ or metadata_payload.get("author")
+ or metadata_payload.get("creator")
+ or metadata_payload.get("dc_creator")
+ )
+ if authors_value:
+ authors_text = _stringify_metadata_value(authors_value)
+ if authors_text:
+ metadata_overrides.setdefault("authors", authors_text)
+ metadata_overrides.setdefault("author", authors_text)
+
+ return metadata_overrides
+
+@api_bp.get("/integrations/calibre-opds/feed")
+def api_calibre_opds_feed() -> ResponseReturnValue:
+ integrations = load_integration_settings()
+ calibre_settings = integrations.get("calibre_opds", {})
+
+ payload = {
+ "base_url": calibre_settings.get("base_url"),
+ "username": calibre_settings.get("username"),
+ "password": calibre_settings.get("password"),
+ "verify_ssl": calibre_settings.get("verify_ssl", True),
+ }
+
+ if not payload.get("base_url"):
+ return jsonify({"error": "Calibre OPDS base URL is not configured."}), 400
+
+ try:
+ client = CalibreOPDSClient(
+ base_url=payload.get("base_url") or "",
+ username=payload.get("username"),
+ password=payload.get("password"),
+ verify=bool(payload.get("verify_ssl", True)),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+
+ href = request.args.get("href", type=str)
+ query = request.args.get("q", type=str)
+ letter = request.args.get("letter", type=str)
+
+ try:
+ if letter:
+ feed = client.browse_letter(letter, start_href=href)
+ elif query:
+ feed = client.search(query, start_href=href)
+ else:
+ feed = client.fetch_feed(href)
+ except CalibreOPDSError as exc:
+ return jsonify({"error": str(exc)}), 502
+ except Exception as exc:
+ return jsonify({"error": f"Unexpected error: {str(exc)}"}), 500
+
+ return jsonify({
+ "feed": feed.to_dict(),
+ "href": href or "",
+ "query": query or "",
+ })
+
+@api_bp.post("/integrations/audiobookshelf/folders")
+def api_abs_folders() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ # Use the helper to resolve saved tokens when use_saved_token is set
+ settings = audiobookshelf_settings_from_payload(payload)
+ host = settings.get("base_url")
+ token = settings.get("api_token")
+ library_id = settings.get("library_id")
+
+ if not host or not token:
+ return jsonify({"error": "Base URL and API token are required"}), 400
+
+ if not library_id:
+ return jsonify({"error": "Library ID is required to list folders"}), 400
+
+ try:
+ config = AudiobookshelfConfig(base_url=host, api_token=token, library_id=library_id)
+ client = AudiobookshelfClient(config)
+ folders = client.list_folders()
+ return jsonify({"folders": folders})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 400
+
+@api_bp.post("/integrations/audiobookshelf/test")
+def api_abs_test() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ # Use the helper to resolve saved tokens when use_saved_token is set
+ settings = audiobookshelf_settings_from_payload(payload)
+ host = settings.get("base_url")
+ token = settings.get("api_token")
+
+ if not host or not token:
+ return jsonify({"error": "Base URL and API token are required"}), 400
+
+ try:
+ config = AudiobookshelfConfig(base_url=host, api_token=token)
+ client = AudiobookshelfClient(config)
+ # Just getting libraries is a good enough test
+ client.get_libraries()
+ return jsonify({"success": True, "message": "Connection successful."})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 400
+
+@api_bp.post("/integrations/calibre-opds/test")
+def api_calibre_opds_test() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ # Use the helper to resolve saved passwords when use_saved_password is set
+ settings = calibre_settings_from_payload(payload)
+ base_url = settings.get("base_url")
+ username = settings.get("username")
+ password = settings.get("password")
+ verify_ssl = settings.get("verify_ssl", False)
+
+ if not base_url:
+ return jsonify({"error": "Base URL is required"}), 400
+
+ try:
+ client = CalibreOPDSClient(
+ base_url=base_url,
+ username=username,
+ password=password,
+ verify=verify_ssl,
+ timeout=10.0
+ )
+ client.fetch_feed()
+ return jsonify({"success": True, "message": "Connection successful."})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 400
+
+@api_bp.post("/integrations/calibre-opds/import")
+def api_calibre_opds_import() -> ResponseReturnValue:
+ if not request.is_json:
+ return jsonify({"error": "Expected JSON payload."}), 400
+
+ data = request.get_json(force=True, silent=True) or {}
+ href = str(data.get("href") or "").strip()
+
+ if not href:
+ return jsonify({"error": "Download URL (href) is required."}), 400
+
+ metadata_payload = data.get("metadata") if isinstance(data, Mapping) else None
+ metadata_overrides: Dict[str, Any] = {}
+ if isinstance(metadata_payload, Mapping):
+ metadata_overrides = _opds_metadata_overrides(metadata_payload)
+
+ settings = load_settings()
+ integrations = load_integration_settings()
+ calibre_settings = integrations.get("calibre_opds", {})
+
+ try:
+ client = CalibreOPDSClient(
+ base_url=calibre_settings.get("base_url") or "",
+ username=calibre_settings.get("username"),
+ password=calibre_settings.get("password"),
+ verify=bool(calibre_settings.get("verify_ssl", True)),
+ )
+
+ temp_dir = Path(current_app.config.get("UPLOAD_FOLDER", "uploads"))
+ temp_dir.mkdir(exist_ok=True)
+
+ resource = client.download(href)
+ filename = resource.filename
+ content = resource.content
+
+ if not filename:
+ filename = f"{uuid.uuid4().hex}.epub"
+
+ file_path = temp_dir / f"{uuid.uuid4().hex}_{filename}"
+ file_path.write_bytes(content)
+
+ extraction = extract_from_path(file_path)
+
+ if metadata_overrides:
+ extraction.metadata.update(metadata_overrides)
+
+ result = build_pending_job_from_extraction(
+ stored_path=file_path,
+ original_name=filename,
+ extraction=extraction,
+ form={},
+ settings=settings,
+ profiles=serialize_profiles(),
+ metadata_overrides=metadata_overrides,
+ )
+
+ get_service().store_pending_job(result.pending)
+
+ return jsonify({
+ "success": True,
+ "status": "imported",
+ "pending_id": result.pending.id,
+ "redirect_url": url_for("main.wizard_step", step="book", pending_id=result.pending.id)
+ })
+
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+# --- LLM Routes ---
+
+@api_bp.post("/llm/models")
+def api_llm_models() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=False) or {}
+ current_settings = load_settings()
+
+ base_url = str(payload.get("base_url") or payload.get("llm_base_url") or current_settings.get("llm_base_url") or "").strip()
+ if not base_url:
+ return jsonify({"error": "LLM base URL is required."}), 400
+
+ api_key = str(payload.get("api_key") or payload.get("llm_api_key") or current_settings.get("llm_api_key") or "")
+ timeout = coerce_float(payload.get("timeout"), current_settings.get("llm_timeout", 30.0))
+
+ overrides = {
+ "llm_base_url": base_url,
+ "llm_api_key": api_key,
+ "llm_timeout": timeout,
+ }
+
+ merged = apply_overrides(current_settings, overrides)
+ configuration = build_llm_configuration(merged)
+ try:
+ models = list_models(configuration)
+ except LLMClientError as exc:
+ return jsonify({"error": str(exc)}), 400
+ return jsonify({"models": models})
+
+@api_bp.post("/llm/preview")
+def api_llm_preview() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=False) or {}
+ sample_text = str(payload.get("text") or "").strip()
+ if not sample_text:
+ return jsonify({"error": "Text is required."}), 400
+
+ base_settings = load_settings()
+ overrides: Dict[str, Any] = {
+ "llm_base_url": str(
+ payload.get("base_url")
+ or payload.get("llm_base_url")
+ or base_settings.get("llm_base_url")
+ or ""
+ ).strip(),
+ "llm_api_key": str(
+ payload.get("api_key")
+ or payload.get("llm_api_key")
+ or base_settings.get("llm_api_key")
+ or ""
+ ),
+ "llm_model": str(
+ payload.get("model")
+ or payload.get("llm_model")
+ or base_settings.get("llm_model")
+ or ""
+ ),
+ "llm_prompt": payload.get("prompt") or payload.get("llm_prompt") or base_settings.get("llm_prompt"),
+ "llm_context_mode": payload.get("context_mode") or base_settings.get("llm_context_mode"),
+ "llm_timeout": coerce_float(payload.get("timeout"), base_settings.get("llm_timeout", 30.0)),
+ "normalization_apostrophe_mode": "llm",
+ }
+
+ merged = apply_overrides(base_settings, overrides)
+ if not merged.get("llm_base_url"):
+ return jsonify({"error": "LLM base URL is required."}), 400
+ if not merged.get("llm_model"):
+ return jsonify({"error": "Select an LLM model before previewing."}), 400
+
+ apostrophe_config = build_apostrophe_config(settings=merged)
+ try:
+ normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=merged)
+ except LLMClientError as exc:
+ return jsonify({"error": str(exc)}), 400
+
+ context = {
+ "text": sample_text,
+ "normalized_text": normalized_text,
+ }
+ return jsonify(context)
+
+# --- Normalization Routes ---
+
+@api_bp.post("/normalization/preview")
+def api_normalization_preview() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=False) or {}
+ sample_text = str(payload.get("text") or "").strip()
+ if not sample_text:
+ return jsonify({"error": "Sample text is required."}), 400
+
+ base_settings = load_settings()
+ # We might want to apply overrides from payload if any normalization settings are passed
+ # For now, just use base settings as in original code (presumably)
+
+ apostrophe_config = build_apostrophe_config(settings=base_settings)
+ try:
+ normalized_text = normalize_for_pipeline(sample_text, config=apostrophe_config, settings=base_settings)
+ except Exception as exc:
+ return jsonify({"error": str(exc)}), 400
+
+ return jsonify({
+ "text": sample_text,
+ "normalized_text": normalized_text,
+ })
+
+@api_bp.post("/entity-pronunciation/preview")
+def api_entity_pronunciation_preview() -> ResponseReturnValue:
+ payload = request.get_json(force=True, silent=True) or {}
+ token = payload.get("token", "").strip()
+ pronunciation = payload.get("pronunciation", "").strip()
+ voice = payload.get("voice", "").strip()
+ language = payload.get("language", "a").strip()
+
+ if not token and not pronunciation:
+ return jsonify({"error": "Token or pronunciation required"}), 400
+
+ text_to_speak = pronunciation if pronunciation else token
+
+ if not voice:
+ settings = load_settings()
+ voice = settings.get("default_voice", "af_heart")
+
+ try:
+ # Check GPU setting
+ settings = load_settings()
+ use_gpu = coerce_bool(settings.get("use_gpu"), False)
+
+ audio_bytes = generate_preview_audio(
+ text=text_to_speak,
+ voice_spec=voice,
+ language=language,
+ speed=1.0,
+ use_gpu=use_gpu,
+ )
+ audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")
+ return jsonify({"audio_base64": audio_base64})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 400
diff --git a/abogen/webui/routes/utils/form.py b/abogen/webui/routes/utils/form.py
index 39e033f..8732ed7 100644
--- a/abogen/webui/routes/utils/form.py
+++ b/abogen/webui/routes/utils/form.py
@@ -1,1098 +1,1098 @@
-import re
-import time
-import uuid
-from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
-from flask import request, render_template, jsonify
-from flask.typing import ResponseReturnValue
-
-from abogen.webui.service import PendingJob, JobStatus
-from abogen.webui.routes.utils.service import get_service
-from abogen.tts_plugin.utils import is_plugin_registered
-from abogen.webui.routes.utils.settings import (
- load_settings,
- coerce_bool,
- coerce_int,
- _CHUNK_LEVEL_VALUES,
- _DEFAULT_ANALYSIS_THRESHOLD,
- _NORMALIZATION_BOOLEAN_KEYS,
- _NORMALIZATION_STRING_KEYS,
- SAVE_MODE_LABELS,
- audiobookshelf_manual_available,
-)
-from abogen.webui.routes.utils.voice import (
- parse_voice_formula,
- formula_from_profile,
- resolve_voice_setting,
- resolve_voice_choice,
- prepare_speaker_metadata,
- template_options,
-)
-from abogen.webui.routes.utils.entity import sync_pronunciation_overrides
-from abogen.webui.routes.utils.epub import job_download_flags
-from abogen.webui.routes.utils.common import split_profile_spec
-from abogen.utils import calculate_text_length
-from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
-from abogen.chunking import ChunkLevel, build_chunks_for_chapters
-from abogen.tts_plugin.utils import get_default_voice
-from abogen.speaker_configs import get_config
-from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
-from dataclasses import dataclass
-from pathlib import Path
-import mimetypes
-
-@dataclass
-class PendingBuildResult:
- pending: PendingJob
- selected_speaker_config: Optional[str]
- config_languages: List[str]
- speaker_config_payload: Optional[Dict[str, Any]]
-
-_WIZARD_STEP_ORDER = ["book", "chapters", "entities"]
-_WIZARD_STEP_META = {
- "book": {
- "index": 1,
- "title": "Book parameters",
- "hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.",
- },
- "chapters": {
- "index": 2,
- "title": "Select chapters",
- "hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
- },
- "entities": {
- "index": 3,
- "title": "Review entities",
- "hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.",
- },
-}
-
-_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
- (re.compile(r"\btitle\s+page\b"), 3.0),
- (re.compile(r"\bcopyright\b"), 2.4),
- (re.compile(r"\btable\s+of\s+contents\b"), 2.8),
- (re.compile(r"\bcontents\b"), 2.0),
- (re.compile(r"\backnowledg(e)?ments?\b"), 2.0),
- (re.compile(r"\bdedication\b"), 2.0),
- (re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4),
- (re.compile(r"\balso\s+by\b"), 2.0),
- (re.compile(r"\bpraise\s+for\b"), 2.0),
- (re.compile(r"\bcolophon\b"), 2.2),
- (re.compile(r"\bpublication\s+data\b"), 2.2),
- (re.compile(r"\btranscriber'?s?\s+note\b"), 2.2),
- (re.compile(r"\bglossary\b"), 2.0),
- (re.compile(r"\bindex\b"), 2.0),
- (re.compile(r"\bbibliograph(y|ies)\b"), 2.0),
- (re.compile(r"\breferences\b"), 1.8),
- (re.compile(r"\bappendix\b"), 1.9),
-]
-
-_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [
- re.compile(r"\bchapter\b"),
- re.compile(r"\bbook\b"),
- re.compile(r"\bpart\b"),
- re.compile(r"\bsection\b"),
- re.compile(r"\bscene\b"),
- re.compile(r"\bprologue\b"),
- re.compile(r"\bepilogue\b"),
- re.compile(r"\bintroduction\b"),
- re.compile(r"\bstory\b"),
-]
-
-_SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [
- ("copyright", 1.2),
- ("all rights reserved", 1.1),
- ("isbn", 0.9),
- ("library of congress", 1.0),
- ("table of contents", 1.0),
- ("dedicated to", 0.8),
- ("acknowledg", 0.8),
- ("printed in", 0.6),
- ("permission", 0.6),
- ("publisher", 0.5),
- ("praise for", 0.9),
- ("also by", 0.9),
- ("glossary", 0.8),
- ("index", 0.8),
- ("newsletter", 3.2),
- ("mailing list", 2.6),
- ("sign-up", 2.2),
-]
-
-def supplement_score(title: str, text: str, index: int) -> float:
- normalized_title = (title or "").lower()
- score = 0.0
-
- for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS:
- if pattern.search(normalized_title):
- score += weight
-
- for pattern in _CONTENT_TITLE_PATTERNS:
- if pattern.search(normalized_title):
- score -= 2.0
-
- stripped_text = (text or "").strip()
- length = len(stripped_text)
- if length <= 150:
- score += 0.9
- elif length <= 400:
- score += 0.6
- elif length <= 800:
- score += 0.35
-
- lowercase_text = stripped_text.lower()
- for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS:
- if keyword in lowercase_text:
- score += weight
-
- if index == 0 and score > 0:
- score += 0.25
-
- return score
-
-
-def should_preselect_chapter(
- title: str,
- text: str,
- index: int,
- total_count: int,
-) -> bool:
- if total_count <= 1:
- return True
- score = supplement_score(title, text, index)
- return score < 1.9
-
-
-def ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None:
- if not chapters:
- return
- if any(chapter.get("enabled") for chapter in chapters):
- return
- best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0))
- chapters[best_index]["enabled"] = True
-
-def apply_prepare_form(
- pending: PendingJob, form: Mapping[str, Any]
-) -> tuple[
- ChunkLevel,
- List[Dict[str, Any]],
- List[Dict[str, Any]],
- List[str],
- int,
- str,
- bool,
- bool,
-]:
- raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
- if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
- raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph"
- pending.chunk_level = raw_chunk_level
- chunk_level_literal = cast(ChunkLevel, pending.chunk_level)
-
- pending.speaker_mode = "single"
-
- pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), False)
-
- threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
- raw_threshold = form.get("speaker_analysis_threshold")
- if raw_threshold is not None:
- pending.speaker_analysis_threshold = coerce_int(
- raw_threshold,
- threshold_default,
- minimum=1,
- maximum=25,
- )
- else:
- pending.speaker_analysis_threshold = threshold_default
-
- if not pending.speakers:
- narrator: Dict[str, Any] = {
- "id": "narrator",
- "label": "Narrator",
- "voice": pending.voice,
- }
- if pending.voice_profile:
- narrator["voice_profile"] = pending.voice_profile
- pending.speakers = {"narrator": narrator}
- else:
- existing_narrator = pending.speakers.get("narrator")
- if isinstance(existing_narrator, dict):
- existing_narrator.setdefault("id", "narrator")
- existing_narrator["label"] = existing_narrator.get("label", "Narrator")
- existing_narrator["voice"] = pending.voice
- if pending.voice_profile:
- existing_narrator["voice_profile"] = pending.voice_profile
- pending.speakers["narrator"] = existing_narrator
-
- selected_config = (form.get("applied_speaker_config") or "").strip()
- apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
- persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
-
- pending.applied_speaker_config = selected_config or None
-
- errors: List[str] = []
-
- if isinstance(pending.speakers, dict):
- for speaker_id, payload in list(pending.speakers.items()):
- if not isinstance(payload, dict):
- continue
- field_key = f"speaker-{speaker_id}-pronunciation"
- raw_value = form.get(field_key, "")
- pronunciation = raw_value.strip()
- if pronunciation:
- payload["pronunciation"] = pronunciation
- else:
- payload.pop("pronunciation", None)
-
- voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip()
- formula_key = f"speaker-{speaker_id}-formula"
- formula_value = (form.get(formula_key) or "").strip()
- has_formula = False
- if formula_value:
- try:
- parse_voice_formula(formula_value)
- except ValueError as exc:
- label = payload.get("label") or speaker_id.replace("_", " ").title()
- errors.append(f"Invalid custom mix for {label}: {exc}")
- else:
- payload["voice_formula"] = formula_value
- payload["resolved_voice"] = formula_value
- payload.pop("voice_profile", None)
- has_formula = True
- else:
- payload.pop("voice_formula", None)
-
- if voice_value == "__custom_mix":
- voice_value = ""
-
- if voice_value:
- payload["voice"] = voice_value
- if not has_formula:
- payload["resolved_voice"] = voice_value
- else:
- payload.pop("voice", None)
- if not has_formula:
- payload.pop("resolved_voice", None)
-
- lang_key = f"speaker-{speaker_id}-languages"
- languages: List[str] = []
- getter = getattr(form, "getlist", None)
- if callable(getter):
- values = cast(Iterable[str], getter(lang_key))
- languages = [code.strip() for code in values if code]
- else:
- raw_langs = form.get(lang_key)
- if isinstance(raw_langs, str):
- languages = [item.strip() for item in raw_langs.split(",") if item.strip()]
- payload["config_languages"] = languages
-
- profiles = serialize_profiles()
- raw_delay = form.get("chapter_intro_delay")
- if raw_delay is not None:
- raw_normalized = raw_delay.strip()
- if raw_normalized:
- try:
- pending.chapter_intro_delay = max(0.0, float(raw_normalized))
- except ValueError:
- errors.append("Enter a valid number for the chapter intro delay.")
- else:
- pending.chapter_intro_delay = 0.0
-
- intro_values: List[str] = []
- getter = getattr(form, "getlist", None)
- if callable(getter):
- raw_intro_values = getter("read_title_intro")
- if raw_intro_values:
- intro_values = list(cast(Iterable[str], raw_intro_values))
- else:
- raw_intro = form.get("read_title_intro")
- if raw_intro is not None:
- intro_values = [raw_intro]
- if intro_values:
- pending.read_title_intro = coerce_bool(intro_values[-1], pending.read_title_intro)
- elif hasattr(form, "__contains__") and "read_title_intro" in form:
- pending.read_title_intro = False
-
- outro_values: List[str] = []
- if callable(getter):
- raw_outro_values = getter("read_closing_outro")
- if raw_outro_values:
- outro_values = list(cast(Iterable[str], raw_outro_values))
- else:
- raw_outro = form.get("read_closing_outro")
- if raw_outro is not None:
- outro_values = [raw_outro]
- if outro_values:
- pending.read_closing_outro = coerce_bool(
- outro_values[-1], getattr(pending, "read_closing_outro", True)
- )
- elif hasattr(form, "__contains__") and "read_closing_outro" in form:
- pending.read_closing_outro = False
-
- caps_values: List[str] = []
- if callable(getter):
- raw_caps_values = getter("normalize_chapter_opening_caps")
- if raw_caps_values:
- caps_values = list(cast(Iterable[str], raw_caps_values))
- else:
- raw_caps = form.get("normalize_chapter_opening_caps")
- if raw_caps is not None:
- caps_values = [raw_caps]
- if caps_values:
- pending.normalize_chapter_opening_caps = coerce_bool(
- caps_values[-1], getattr(pending, "normalize_chapter_opening_caps", True)
- )
- elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form:
- pending.normalize_chapter_opening_caps = False
-
- overrides: List[Dict[str, Any]] = []
- selected_total = 0
-
- for index, chapter in enumerate(pending.chapters):
- enabled = form.get(f"chapter-{index}-enabled") == "on"
- title_input = (form.get(f"chapter-{index}-title") or "").strip()
- title = title_input or chapter.get("title") or f"Chapter {index + 1}"
- voice_selection = form.get(f"chapter-{index}-voice", "__default")
- formula_input = (form.get(f"chapter-{index}-formula") or "").strip()
-
- entry: Dict[str, Any] = {
- "id": chapter.get("id") or f"{index:04d}",
- "index": index,
- "order": index,
- "source_title": chapter.get("title") or title,
- "title": title,
- "text": chapter.get("text", ""),
- "enabled": enabled,
- }
- entry["characters"] = calculate_text_length(entry["text"])
-
- if enabled:
- if voice_selection.startswith("voice:"):
- entry["voice"] = voice_selection.split(":", 1)[1]
- entry["resolved_voice"] = entry["voice"]
- elif voice_selection.startswith("profile:"):
- profile_name = voice_selection.split(":", 1)[1]
- entry["voice_profile"] = profile_name
- profile_entry = profiles.get(profile_name) or {}
- formula_value = formula_from_profile(profile_entry)
- if formula_value:
- entry["voice_formula"] = formula_value
- entry["resolved_voice"] = formula_value
- else:
- errors.append(f"Profile '{profile_name}' has no configured voices.")
- elif voice_selection == "formula":
- if not formula_input:
- errors.append(f"Provide a custom formula for chapter {index + 1}.")
- else:
- try:
- parse_voice_formula(formula_input)
- except ValueError as exc:
- errors.append(str(exc))
- else:
- entry["voice_formula"] = formula_input
- entry["resolved_voice"] = formula_input
- selected_total += entry["characters"]
-
- overrides.append(entry)
- pending.chapters[index] = dict(entry)
-
- enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
-
- heteronym_entries = getattr(pending, "heteronym_overrides", None)
- if isinstance(heteronym_entries, list) and heteronym_entries:
- for entry in heteronym_entries:
- if not isinstance(entry, dict):
- continue
- entry_id = str(entry.get("entry_id") or entry.get("id") or "").strip()
- if not entry_id:
- continue
- raw_choice = form.get(f"heteronym-{entry_id}-choice")
- if raw_choice is None:
- continue
- choice = str(raw_choice).strip()
- if not choice:
- continue
- options = entry.get("options")
- if isinstance(options, list) and options:
- allowed = {
- str(opt.get("key")).strip()
- for opt in options
- if isinstance(opt, dict) and str(opt.get("key") or "").strip()
- }
- if allowed and choice not in allowed:
- continue
- entry["choice"] = choice
-
- sync_pronunciation_overrides(pending)
-
- return (
- chunk_level_literal,
- overrides,
- enabled_overrides,
- errors,
- selected_total,
- selected_config,
- apply_config_requested,
- persist_config_requested,
- )
-
-def apply_book_step_form(
- pending: PendingJob,
- form: Mapping[str, Any],
- *,
- settings: Mapping[str, Any],
- profiles: Mapping[str, Any],
-) -> None:
- language_fallback = pending.language or settings.get("language", "en")
- raw_language = (form.get("language") or language_fallback or "en").strip()
- if raw_language:
- pending.language = raw_language
-
- subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip()
- if subtitle_mode:
- pending.subtitle_mode = subtitle_mode
-
- pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), bool(pending.generate_epub3))
-
- chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
- raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or chunk_level_default).strip().lower()
- if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
- raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else (pending.chunk_level or "paragraph")
- pending.chunk_level = raw_chunk_level
-
- threshold_default = pending.speaker_analysis_threshold or settings.get("speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
- raw_threshold = form.get("speaker_analysis_threshold")
- if raw_threshold is not None:
- pending.speaker_analysis_threshold = coerce_int(
- raw_threshold,
- threshold_default,
- minimum=1,
- maximum=25,
- )
-
- raw_delay = form.get("chapter_intro_delay")
- if raw_delay is not None:
- try:
- pending.chapter_intro_delay = max(0.0, float(str(raw_delay).strip() or 0.0))
- except ValueError:
- pass
-
- intro_default = pending.read_title_intro if isinstance(pending.read_title_intro, bool) else bool(settings.get("read_title_intro", False))
- intro_values: List[str] = []
- getter = getattr(form, "getlist", None)
- if callable(getter):
- raw_intro_values = getter("read_title_intro")
- if raw_intro_values:
- intro_values = list(cast(Iterable[str], raw_intro_values))
- else:
- raw_intro_flag = form.get("read_title_intro")
- if raw_intro_flag is not None:
- intro_values = [raw_intro_flag]
- if intro_values:
- pending.read_title_intro = coerce_bool(intro_values[-1], intro_default)
- elif hasattr(form, "__contains__") and "read_title_intro" in form:
- pending.read_title_intro = False
- else:
- pending.read_title_intro = intro_default
-
- outro_default = (
- pending.read_closing_outro
- if isinstance(getattr(pending, "read_closing_outro", None), bool)
- else bool(settings.get("read_closing_outro", True))
- )
- outro_values: List[str] = []
- if callable(getter):
- raw_outro_values = getter("read_closing_outro")
- if raw_outro_values:
- outro_values = list(cast(Iterable[str], raw_outro_values))
- else:
- raw_outro_flag = form.get("read_closing_outro")
- if raw_outro_flag is not None:
- outro_values = [raw_outro_flag]
- if outro_values:
- pending.read_closing_outro = coerce_bool(outro_values[-1], outro_default)
- elif hasattr(form, "__contains__") and "read_closing_outro" in form:
- pending.read_closing_outro = False
- else:
- pending.read_closing_outro = outro_default
-
- caps_default = (
- pending.normalize_chapter_opening_caps
- if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool)
- else bool(settings.get("normalize_chapter_opening_caps", True))
- )
- caps_values: List[str] = []
- getter = getattr(form, "getlist", None)
- if callable(getter):
- raw_caps_values = getter("normalize_chapter_opening_caps")
- if raw_caps_values:
- caps_values = list(cast(Iterable[str], raw_caps_values))
- else:
- raw_caps_flag = form.get("normalize_chapter_opening_caps")
- if raw_caps_flag is not None:
- caps_values = [raw_caps_flag]
- if caps_values:
- pending.normalize_chapter_opening_caps = coerce_bool(caps_values[-1], caps_default)
- elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form:
- pending.normalize_chapter_opening_caps = False
- else:
- pending.normalize_chapter_opening_caps = caps_default
-
- def _extract_checkbox(name: str, default: bool) -> bool:
- values: List[str] = []
- getter = getattr(form, "getlist", None)
- if callable(getter):
- raw_values = getter(name)
- if raw_values:
- values = list(cast(Iterable[str], raw_values))
- else:
- raw_flag = form.get(name)
- if raw_flag is not None:
- values = [raw_flag]
- if values:
- return coerce_bool(values[-1], default)
- if hasattr(form, "__contains__") and name in form:
- return False
- return default
-
- overrides_existing = getattr(pending, "normalization_overrides", None)
- overrides: Dict[str, Any] = dict(overrides_existing or {})
- for key in _NORMALIZATION_BOOLEAN_KEYS:
- default_toggle = overrides.get(key, bool(settings.get(key, True)))
- overrides[key] = _extract_checkbox(key, default_toggle)
- for key in _NORMALIZATION_STRING_KEYS:
- default_val = overrides.get(key, str(settings.get(key, "")))
- val = form.get(key)
- if val is not None:
- overrides[key] = str(val)
- else:
- overrides[key] = default_val
- pending.normalization_overrides = overrides
-
- speed_value = form.get("speed")
- if speed_value is not None:
- try:
- pending.speed = float(speed_value)
- except ValueError:
- pass
-
- # NOTE: Do not auto-set a global TTS provider at the book level based on the
- # narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
- # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
- # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
- provider_value = str(form.get("tts_provider") or "").strip().lower()
- if is_plugin_registered(provider_value):
- pending.tts_provider = provider_value
-
- # Determine the base speaker selection (saved speaker ref or raw voice).
- narrator_voice_raw = (
- form.get("voice")
- or pending.voice
- or settings.get("default_speaker")
- or settings.get("default_voice")
- or ""
- ).strip()
-
- profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
- base_spec, _selected_speaker_name = split_profile_spec(narrator_voice_raw)
-
- profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
- custom_formula_raw = (form.get("voice_formula") or "").strip()
- narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip()
- resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
- narrator_voice_raw,
- profiles=profiles_map,
- )
-
- if profile_selection in {"__standard", "", None} and inferred_profile:
- profile_selection = inferred_profile
-
- if profile_selection == "__formula":
- profile_name = ""
- custom_formula = custom_formula_raw
- elif profile_selection in {"__standard", "", None}:
- profile_name = ""
- custom_formula = ""
- else:
- profile_name = profile_selection
- custom_formula = ""
-
- base_voice_spec = resolved_default_voice or narrator_voice_raw
- if not base_voice_spec:
- base_voice_spec = get_default_voice("kokoro")
-
- voice_choice, resolved_language, selected_profile = resolve_voice_choice(
- pending.language,
- base_voice_spec,
- profile_name,
- custom_formula,
- profiles_map,
- )
-
- if resolved_language:
- pending.language = resolved_language
-
- if profile_selection == "__formula" and custom_formula_raw:
- pending.voice = custom_formula_raw
- pending.voice_profile = None
- elif profile_selection not in {"__standard", "", None, "__formula"}:
- pending.voice_profile = selected_profile or profile_selection
- pending.voice = voice_choice
- else:
- pending.voice_profile = None
- fallback_voice = base_voice_spec or narrator_voice_raw
- pending.voice = voice_choice or fallback_voice
-
- pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
-
- # Metadata updates
- if "meta_title" in form:
- pending.metadata_tags["title"] = str(form.get("meta_title", "")).strip()
-
- if "meta_subtitle" in form:
- pending.metadata_tags["subtitle"] = str(form.get("meta_subtitle", "")).strip()
-
- if "meta_author" in form:
- authors = str(form.get("meta_author", "")).strip()
- pending.metadata_tags["authors"] = authors
- pending.metadata_tags["author"] = authors
-
- if "meta_series" in form:
- series = str(form.get("meta_series", "")).strip()
- pending.metadata_tags["series"] = series
- pending.metadata_tags["series_name"] = series
- pending.metadata_tags["seriesname"] = series
- pending.metadata_tags["series_title"] = series
- pending.metadata_tags["seriestitle"] = series
- # If user manually edits series, update opds_series too so it persists
- if "opds_series" in pending.metadata_tags:
- pending.metadata_tags["opds_series"] = series
-
- if "meta_series_index" in form:
- idx = str(form.get("meta_series_index", "")).strip()
- pending.metadata_tags["series_index"] = idx
- pending.metadata_tags["series_sequence"] = idx
-
- if "meta_publisher" in form:
- pending.metadata_tags["publisher"] = str(form.get("meta_publisher", "")).strip()
-
- if "meta_description" in form:
- desc = str(form.get("meta_description", "")).strip()
- pending.metadata_tags["description"] = desc
- pending.metadata_tags["summary"] = desc
-
- if coerce_bool(form.get("remove_cover"), False):
- pending.cover_image_path = None
- pending.cover_image_mime = None
-
-def persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]:
- cover_bytes = getattr(extraction_result, "cover_image", None)
- if not cover_bytes:
- return None, None
-
- mime = getattr(extraction_result, "cover_mime", None)
- extension = mimetypes.guess_extension(mime or "") or ".png"
- base_stem = Path(stored_path).stem or "cover"
- candidate = stored_path.parent / f"{base_stem}_cover{extension}"
- counter = 1
- while candidate.exists():
- candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}"
- counter += 1
-
- try:
- candidate.write_bytes(cover_bytes)
- except OSError:
- return None, None
-
- return candidate, mime
-
-def build_pending_job_from_extraction(
- *,
- stored_path: Path,
- original_name: str,
- extraction: Any,
- form: Mapping[str, Any],
- settings: Mapping[str, Any],
- profiles: Mapping[str, Any],
- metadata_overrides: Optional[Mapping[str, Any]] = None,
-) -> PendingBuildResult:
- profiles_map = dict(profiles)
- cover_path, cover_mime = persist_cover_image(extraction, stored_path)
-
- if getattr(extraction, "chapters", None):
- original_titles = [chapter.title for chapter in extraction.chapters]
- normalized_titles = normalize_roman_numeral_titles(original_titles)
- if normalized_titles != original_titles:
- for chapter, new_title in zip(extraction.chapters, normalized_titles):
- chapter.title = new_title
-
- metadata_tags = dict(getattr(extraction, "metadata", {}) or {})
- if metadata_overrides:
- normalized_keys = {str(existing_key).casefold(): str(existing_key) for existing_key in metadata_tags.keys()}
- for key, value in metadata_overrides.items():
- if value is None:
- continue
- key_text = str(key or "").strip()
- if not key_text:
- continue
- value_text = str(value).strip()
- if not value_text:
- continue
- lookup = key_text.casefold()
- existing_key = normalized_keys.get(lookup)
- if existing_key:
- existing_value = str(metadata_tags.get(existing_key) or "").strip()
- if existing_value:
- continue
- target_key = existing_key
- else:
- target_key = key_text
- normalized_keys[lookup] = target_key
- metadata_tags[target_key] = value_text
-
- total_chars = getattr(extraction, "total_characters", None) or calculate_text_length(
- getattr(extraction, "combined_text", "")
- )
- chapters_source = getattr(extraction, "chapters", []) or []
- total_chapter_count = len(chapters_source)
- chapters_payload: List[Dict[str, Any]] = []
- for index, chapter in enumerate(chapters_source):
- enabled = should_preselect_chapter(chapter.title, chapter.text, index, total_chapter_count)
- chapters_payload.append(
- {
- "id": f"{index:04d}",
- "index": index,
- "title": chapter.title,
- "text": chapter.text,
- "characters": calculate_text_length(chapter.text),
- "enabled": enabled,
- }
- )
-
- if not chapters_payload:
- chapters_payload.append(
- {
- "id": "0000",
- "index": 0,
- "title": original_name,
- "text": "",
- "characters": 0,
- "enabled": True,
- }
- )
-
- ensure_at_least_one_chapter_enabled(chapters_payload)
-
- language = str(form.get("language") or "a").strip() or "a"
- profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
- default_voice_setting = settings.get("default_voice") or ""
- resolved_default_voice, inferred_profile, inferred_language = resolve_voice_setting(
- default_voice_setting,
- profiles=profiles_map,
- )
- base_voice_input = str(form.get("voice") or "").strip()
- profile_selection = (form.get("voice_profile") or "__standard").strip()
- custom_formula_raw = str(form.get("voice_formula") or "").strip()
-
- if profile_selection in {"__standard", ""} and inferred_profile:
- profile_selection = inferred_profile
-
- base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
- if not base_voice:
- base_voice = get_default_voice("kokoro")
- selected_speaker_config = (form.get("speaker_config") or "").strip()
- speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
-
- if profile_selection == "__formula":
- profile_name = ""
- custom_formula = custom_formula_raw
- elif profile_selection in {"__standard", ""}:
- profile_name = ""
- custom_formula = ""
- else:
- profile_name = profile_selection
- custom_formula = ""
-
- voice, language, selected_profile = resolve_voice_choice(
- language,
- base_voice,
- profile_name,
- custom_formula,
- profiles_map,
- )
-
- try:
- speed = float(form.get("speed", 1.0))
- except (TypeError, ValueError):
- speed = 1.0
-
- subtitle_mode = str(form.get("subtitle_mode") or "Disabled")
- output_format = settings["output_format"]
- subtitle_format = settings["subtitle_format"]
- save_mode_key = settings["save_mode"]
- save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"])
- replace_single_newlines = settings["replace_single_newlines"]
- use_gpu = settings["use_gpu"]
- save_chapters_separately = settings["save_chapters_separately"]
- merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately
- save_as_project = settings["save_as_project"]
- separate_chapters_format = settings["separate_chapters_format"]
- silence_between_chapters = settings["silence_between_chapters"]
- chapter_intro_delay = settings["chapter_intro_delay"]
- read_title_intro = settings["read_title_intro"]
- read_closing_outro = settings.get("read_closing_outro", True)
- normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"]
- max_subtitle_words = settings["max_subtitle_words"]
- auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
-
- chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
- raw_chunk_level = str(form.get("chunk_level") or chunk_level_default).strip().lower()
- if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
- raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph"
- chunk_level_value = raw_chunk_level
- chunk_level_literal = cast(ChunkLevel, chunk_level_value)
-
- speaker_mode_value = "single"
-
- generate_epub3_default = bool(settings.get("generate_epub3", False))
- generate_epub3 = coerce_bool(form.get("generate_epub3"), generate_epub3_default)
-
- selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")]
- raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal)
- analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence")
-
- analysis_threshold = coerce_int(
- settings.get("speaker_analysis_threshold"),
- _DEFAULT_ANALYSIS_THRESHOLD,
- minimum=1,
- maximum=25,
- )
-
- initial_analysis = False
- (
- processed_chunks,
- speakers,
- analysis_payload,
- config_languages,
- _,
- ) = prepare_speaker_metadata(
- chapters=selected_chapter_sources,
- chunks=raw_chunks,
- analysis_chunks=analysis_chunks,
- voice=voice,
- voice_profile=selected_profile or None,
- threshold=analysis_threshold,
- run_analysis=initial_analysis,
- speaker_config=speaker_config_payload,
- apply_config=bool(speaker_config_payload),
- )
-
- def _extract_checkbox(name: str, default: bool) -> bool:
- values: List[str] = []
- getter = getattr(form, "getlist", None)
- if callable(getter):
- raw_values = getter(name)
- if raw_values:
- values = list(cast(Iterable[str], raw_values))
- else:
- raw_flag = form.get(name)
- if raw_flag is not None:
- values = [raw_flag]
- if values:
- return coerce_bool(values[-1], default)
- return default
-
- normalization_overrides = {}
- for key in _NORMALIZATION_BOOLEAN_KEYS:
- default_val = bool(settings.get(key, True))
- normalization_overrides[key] = _extract_checkbox(key, default_val)
-
- for key in _NORMALIZATION_STRING_KEYS:
- default_val = str(settings.get(key, ""))
- val = form.get(key)
- if val is not None:
- normalization_overrides[key] = str(val)
- else:
- normalization_overrides[key] = default_val
-
- pending = PendingJob(
- id=uuid.uuid4().hex,
- original_filename=original_name,
- stored_path=stored_path,
- language=language,
- voice=voice,
- speed=speed,
- use_gpu=use_gpu,
- subtitle_mode=subtitle_mode,
- output_format=output_format,
- save_mode=save_mode,
- output_folder=None,
- replace_single_newlines=replace_single_newlines,
- subtitle_format=subtitle_format,
- total_characters=total_chars,
- save_chapters_separately=save_chapters_separately,
- merge_chapters_at_end=merge_chapters_at_end,
- separate_chapters_format=separate_chapters_format,
- silence_between_chapters=silence_between_chapters,
- save_as_project=save_as_project,
- voice_profile=selected_profile or None,
- max_subtitle_words=max_subtitle_words,
- metadata_tags=metadata_tags,
- chapters=chapters_payload,
- normalization_overrides=normalization_overrides,
- created_at=time.time(),
- cover_image_path=cover_path,
- cover_image_mime=cover_mime,
- chapter_intro_delay=chapter_intro_delay,
- read_title_intro=bool(read_title_intro),
- read_closing_outro=bool(read_closing_outro),
- normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
- auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
- chunk_level=chunk_level_value,
- speaker_mode=speaker_mode_value,
- generate_epub3=generate_epub3,
- chunks=processed_chunks,
- speakers=speakers,
- speaker_analysis=analysis_payload,
- speaker_analysis_threshold=analysis_threshold,
- analysis_requested=initial_analysis,
- )
-
- return PendingBuildResult(
- pending=pending,
- selected_speaker_config=selected_speaker_config or None,
- config_languages=list(config_languages or []),
- speaker_config_payload=speaker_config_payload,
- )
-
-def render_jobs_panel() -> str:
- jobs = get_service().list_jobs()
- active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED}
- active_jobs = [job for job in jobs if job.status in active_statuses]
- active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at))
- finished_jobs = [job for job in jobs if job.status not in active_statuses]
- download_flags = {job.id: job_download_flags(job) for job in jobs}
- return render_template(
- "partials/jobs.html",
- active_jobs=active_jobs,
- finished_jobs=finished_jobs[:5],
- total_finished=len(finished_jobs),
- JobStatus=JobStatus,
- download_flags=download_flags,
- audiobookshelf_manual_available=audiobookshelf_manual_available(),
- )
-
-
-def normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str:
- if pending is None:
- default_step = "book"
- else:
- default_step = "chapters"
- if not step:
- chosen = default_step
- else:
- normalized = step.strip().lower()
- if normalized in {"", "upload", "settings"}:
- chosen = default_step
- elif normalized == "speakers":
- chosen = "entities"
- elif normalized in _WIZARD_STEP_ORDER:
- chosen = normalized
- else:
- chosen = default_step
- return chosen
-
-
-def wants_wizard_json() -> bool:
- format_hint = request.args.get("format", "").strip().lower()
- if format_hint == "json":
- return True
- accept_header = (request.headers.get("Accept") or "").lower()
- if "application/json" in accept_header:
- return True
- requested_with = (request.headers.get("X-Requested-With") or "").lower()
- if requested_with in {"xmlhttprequest", "fetch"}:
- return True
- wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower()
- return wizard_header == "json"
-
-
-def render_wizard_partial(
- pending: Optional[PendingJob],
- step: str,
- *,
- error: Optional[str] = None,
- notice: Optional[str] = None,
-) -> str:
- templates = {
- "book": "partials/new_job_step_book.html",
- "chapters": "partials/new_job_step_chapters.html",
- "entities": "partials/new_job_step_entities.html",
- }
- template_name = templates[step]
- context: Dict[str, Any] = {
- "pending": pending,
- "readonly": False,
- "options": template_options(),
- "settings": load_settings(),
- "error": error,
- "notice": notice,
- }
- return render_template(template_name, **context)
-
-
-def wizard_step_payload(
- pending: Optional[PendingJob],
- step: str,
- html: str,
- *,
- error: Optional[str] = None,
- notice: Optional[str] = None,
-) -> Dict[str, Any]:
- meta = _WIZARD_STEP_META.get(step, {})
- try:
- active_index = _WIZARD_STEP_ORDER.index(step)
- except ValueError:
- active_index = 0
- max_recorded_index = active_index
- if pending is not None:
- stored_index = int(getattr(pending, "wizard_max_step_index", -1))
- if stored_index < 0:
- stored_index = -1
- max_recorded_index = max(active_index, stored_index)
- max_allowed = len(_WIZARD_STEP_ORDER) - 1
- if max_recorded_index > max_allowed:
- max_recorded_index = max_allowed
- if stored_index != max_recorded_index:
- pending.wizard_max_step_index = max_recorded_index
- get_service().store_pending_job(pending)
- else:
- max_allowed = len(_WIZARD_STEP_ORDER) - 1
- if max_recorded_index > max_allowed:
- max_recorded_index = max_allowed
- completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx <= max_recorded_index]
- return {
- "step": step,
- "step_index": int(meta.get("index", active_index + 1)),
- "total_steps": len(_WIZARD_STEP_ORDER),
- "title": meta.get("title", ""),
- "hint": meta.get("hint", ""),
- "html": html,
- "completed_steps": completed,
- "pending_id": pending.id if pending else "",
- "filename": pending.original_filename if pending and pending.original_filename else "",
- "error": error or "",
- "notice": notice or "",
- }
-
-
-def wizard_json_response(
- pending: Optional[PendingJob],
- step: str,
- *,
- error: Optional[str] = None,
- notice: Optional[str] = None,
- status: int = 200,
-) -> ResponseReturnValue:
- html = render_wizard_partial(pending, step, error=error, notice=notice)
- payload = wizard_step_payload(pending, step, html, error=error, notice=notice)
- return jsonify(payload), status
+import re
+import time
+import uuid
+from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
+from flask import request, render_template, jsonify
+from flask.typing import ResponseReturnValue
+
+from abogen.webui.service import PendingJob, JobStatus
+from abogen.webui.routes.utils.service import get_service
+from abogen.tts_plugin.utils import is_plugin_registered
+from abogen.webui.routes.utils.settings import (
+ load_settings,
+ coerce_bool,
+ coerce_int,
+ _CHUNK_LEVEL_VALUES,
+ _DEFAULT_ANALYSIS_THRESHOLD,
+ _NORMALIZATION_BOOLEAN_KEYS,
+ _NORMALIZATION_STRING_KEYS,
+ SAVE_MODE_LABELS,
+ audiobookshelf_manual_available,
+)
+from abogen.webui.routes.utils.voice import (
+ parse_voice_formula,
+ formula_from_profile,
+ resolve_voice_setting,
+ resolve_voice_choice,
+ prepare_speaker_metadata,
+ template_options,
+)
+from abogen.webui.routes.utils.entity import sync_pronunciation_overrides
+from abogen.webui.routes.utils.epub import job_download_flags
+from abogen.webui.routes.utils.common import split_profile_spec
+from abogen.utils import calculate_text_length
+from abogen.voice_profiles import serialize_profiles, normalize_profile_entry
+from abogen.chunking import ChunkLevel, build_chunks_for_chapters
+from abogen.tts_plugin.utils import get_default_voice
+from abogen.speaker_configs import get_config
+from abogen.kokoro_text_normalization import normalize_roman_numeral_titles
+from dataclasses import dataclass
+from pathlib import Path
+import mimetypes
+
+@dataclass
+class PendingBuildResult:
+ pending: PendingJob
+ selected_speaker_config: Optional[str]
+ config_languages: List[str]
+ speaker_config_payload: Optional[Dict[str, Any]]
+
+_WIZARD_STEP_ORDER = ["book", "chapters", "entities"]
+_WIZARD_STEP_META = {
+ "book": {
+ "index": 1,
+ "title": "Book parameters",
+ "hint": "Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.",
+ },
+ "chapters": {
+ "index": 2,
+ "title": "Select chapters",
+ "hint": "Choose which chapters to convert. We'll analyse entities automatically when you continue.",
+ },
+ "entities": {
+ "index": 3,
+ "title": "Review entities",
+ "hint": "Assign pronunciations, voices, and manual overrides before queueing the conversion.",
+ },
+}
+
+_SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [
+ (re.compile(r"\btitle\s+page\b"), 3.0),
+ (re.compile(r"\bcopyright\b"), 2.4),
+ (re.compile(r"\btable\s+of\s+contents\b"), 2.8),
+ (re.compile(r"\bcontents\b"), 2.0),
+ (re.compile(r"\backnowledg(e)?ments?\b"), 2.0),
+ (re.compile(r"\bdedication\b"), 2.0),
+ (re.compile(r"\babout\s+the\s+author(s)?\b"), 2.4),
+ (re.compile(r"\balso\s+by\b"), 2.0),
+ (re.compile(r"\bpraise\s+for\b"), 2.0),
+ (re.compile(r"\bcolophon\b"), 2.2),
+ (re.compile(r"\bpublication\s+data\b"), 2.2),
+ (re.compile(r"\btranscriber'?s?\s+note\b"), 2.2),
+ (re.compile(r"\bglossary\b"), 2.0),
+ (re.compile(r"\bindex\b"), 2.0),
+ (re.compile(r"\bbibliograph(y|ies)\b"), 2.0),
+ (re.compile(r"\breferences\b"), 1.8),
+ (re.compile(r"\bappendix\b"), 1.9),
+]
+
+_CONTENT_TITLE_PATTERNS: List[re.Pattern[str]] = [
+ re.compile(r"\bchapter\b"),
+ re.compile(r"\bbook\b"),
+ re.compile(r"\bpart\b"),
+ re.compile(r"\bsection\b"),
+ re.compile(r"\bscene\b"),
+ re.compile(r"\bprologue\b"),
+ re.compile(r"\bepilogue\b"),
+ re.compile(r"\bintroduction\b"),
+ re.compile(r"\bstory\b"),
+]
+
+_SUPPLEMENT_TEXT_KEYWORDS: List[tuple[str, float]] = [
+ ("copyright", 1.2),
+ ("all rights reserved", 1.1),
+ ("isbn", 0.9),
+ ("library of congress", 1.0),
+ ("table of contents", 1.0),
+ ("dedicated to", 0.8),
+ ("acknowledg", 0.8),
+ ("printed in", 0.6),
+ ("permission", 0.6),
+ ("publisher", 0.5),
+ ("praise for", 0.9),
+ ("also by", 0.9),
+ ("glossary", 0.8),
+ ("index", 0.8),
+ ("newsletter", 3.2),
+ ("mailing list", 2.6),
+ ("sign-up", 2.2),
+]
+
+def supplement_score(title: str, text: str, index: int) -> float:
+ normalized_title = (title or "").lower()
+ score = 0.0
+
+ for pattern, weight in _SUPPLEMENT_TITLE_PATTERNS:
+ if pattern.search(normalized_title):
+ score += weight
+
+ for pattern in _CONTENT_TITLE_PATTERNS:
+ if pattern.search(normalized_title):
+ score -= 2.0
+
+ stripped_text = (text or "").strip()
+ length = len(stripped_text)
+ if length <= 150:
+ score += 0.9
+ elif length <= 400:
+ score += 0.6
+ elif length <= 800:
+ score += 0.35
+
+ lowercase_text = stripped_text.lower()
+ for keyword, weight in _SUPPLEMENT_TEXT_KEYWORDS:
+ if keyword in lowercase_text:
+ score += weight
+
+ if index == 0 and score > 0:
+ score += 0.25
+
+ return score
+
+
+def should_preselect_chapter(
+ title: str,
+ text: str,
+ index: int,
+ total_count: int,
+) -> bool:
+ if total_count <= 1:
+ return True
+ score = supplement_score(title, text, index)
+ return score < 1.9
+
+
+def ensure_at_least_one_chapter_enabled(chapters: List[Dict[str, Any]]) -> None:
+ if not chapters:
+ return
+ if any(chapter.get("enabled") for chapter in chapters):
+ return
+ best_index = max(range(len(chapters)), key=lambda idx: chapters[idx].get("characters", 0))
+ chapters[best_index]["enabled"] = True
+
+def apply_prepare_form(
+ pending: PendingJob, form: Mapping[str, Any]
+) -> tuple[
+ ChunkLevel,
+ List[Dict[str, Any]],
+ List[Dict[str, Any]],
+ List[str],
+ int,
+ str,
+ bool,
+ bool,
+]:
+ raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower()
+ if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
+ raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph"
+ pending.chunk_level = raw_chunk_level
+ chunk_level_literal = cast(ChunkLevel, pending.chunk_level)
+
+ pending.speaker_mode = "single"
+
+ pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), False)
+
+ threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
+ raw_threshold = form.get("speaker_analysis_threshold")
+ if raw_threshold is not None:
+ pending.speaker_analysis_threshold = coerce_int(
+ raw_threshold,
+ threshold_default,
+ minimum=1,
+ maximum=25,
+ )
+ else:
+ pending.speaker_analysis_threshold = threshold_default
+
+ if not pending.speakers:
+ narrator: Dict[str, Any] = {
+ "id": "narrator",
+ "label": "Narrator",
+ "voice": pending.voice,
+ }
+ if pending.voice_profile:
+ narrator["voice_profile"] = pending.voice_profile
+ pending.speakers = {"narrator": narrator}
+ else:
+ existing_narrator = pending.speakers.get("narrator")
+ if isinstance(existing_narrator, dict):
+ existing_narrator.setdefault("id", "narrator")
+ existing_narrator["label"] = existing_narrator.get("label", "Narrator")
+ existing_narrator["voice"] = pending.voice
+ if pending.voice_profile:
+ existing_narrator["voice_profile"] = pending.voice_profile
+ pending.speakers["narrator"] = existing_narrator
+
+ selected_config = (form.get("applied_speaker_config") or "").strip()
+ apply_config_requested = str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"}
+ persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"}
+
+ pending.applied_speaker_config = selected_config or None
+
+ errors: List[str] = []
+
+ if isinstance(pending.speakers, dict):
+ for speaker_id, payload in list(pending.speakers.items()):
+ if not isinstance(payload, dict):
+ continue
+ field_key = f"speaker-{speaker_id}-pronunciation"
+ raw_value = form.get(field_key, "")
+ pronunciation = raw_value.strip()
+ if pronunciation:
+ payload["pronunciation"] = pronunciation
+ else:
+ payload.pop("pronunciation", None)
+
+ voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip()
+ formula_key = f"speaker-{speaker_id}-formula"
+ formula_value = (form.get(formula_key) or "").strip()
+ has_formula = False
+ if formula_value:
+ try:
+ parse_voice_formula(formula_value)
+ except ValueError as exc:
+ label = payload.get("label") or speaker_id.replace("_", " ").title()
+ errors.append(f"Invalid custom mix for {label}: {exc}")
+ else:
+ payload["voice_formula"] = formula_value
+ payload["resolved_voice"] = formula_value
+ payload.pop("voice_profile", None)
+ has_formula = True
+ else:
+ payload.pop("voice_formula", None)
+
+ if voice_value == "__custom_mix":
+ voice_value = ""
+
+ if voice_value:
+ payload["voice"] = voice_value
+ if not has_formula:
+ payload["resolved_voice"] = voice_value
+ else:
+ payload.pop("voice", None)
+ if not has_formula:
+ payload.pop("resolved_voice", None)
+
+ lang_key = f"speaker-{speaker_id}-languages"
+ languages: List[str] = []
+ getter = getattr(form, "getlist", None)
+ if callable(getter):
+ values = cast(Iterable[str], getter(lang_key))
+ languages = [code.strip() for code in values if code]
+ else:
+ raw_langs = form.get(lang_key)
+ if isinstance(raw_langs, str):
+ languages = [item.strip() for item in raw_langs.split(",") if item.strip()]
+ payload["config_languages"] = languages
+
+ profiles = serialize_profiles()
+ raw_delay = form.get("chapter_intro_delay")
+ if raw_delay is not None:
+ raw_normalized = raw_delay.strip()
+ if raw_normalized:
+ try:
+ pending.chapter_intro_delay = max(0.0, float(raw_normalized))
+ except ValueError:
+ errors.append("Enter a valid number for the chapter intro delay.")
+ else:
+ pending.chapter_intro_delay = 0.0
+
+ intro_values: List[str] = []
+ getter = getattr(form, "getlist", None)
+ if callable(getter):
+ raw_intro_values = getter("read_title_intro")
+ if raw_intro_values:
+ intro_values = list(cast(Iterable[str], raw_intro_values))
+ else:
+ raw_intro = form.get("read_title_intro")
+ if raw_intro is not None:
+ intro_values = [raw_intro]
+ if intro_values:
+ pending.read_title_intro = coerce_bool(intro_values[-1], pending.read_title_intro)
+ elif hasattr(form, "__contains__") and "read_title_intro" in form:
+ pending.read_title_intro = False
+
+ outro_values: List[str] = []
+ if callable(getter):
+ raw_outro_values = getter("read_closing_outro")
+ if raw_outro_values:
+ outro_values = list(cast(Iterable[str], raw_outro_values))
+ else:
+ raw_outro = form.get("read_closing_outro")
+ if raw_outro is not None:
+ outro_values = [raw_outro]
+ if outro_values:
+ pending.read_closing_outro = coerce_bool(
+ outro_values[-1], getattr(pending, "read_closing_outro", True)
+ )
+ elif hasattr(form, "__contains__") and "read_closing_outro" in form:
+ pending.read_closing_outro = False
+
+ caps_values: List[str] = []
+ if callable(getter):
+ raw_caps_values = getter("normalize_chapter_opening_caps")
+ if raw_caps_values:
+ caps_values = list(cast(Iterable[str], raw_caps_values))
+ else:
+ raw_caps = form.get("normalize_chapter_opening_caps")
+ if raw_caps is not None:
+ caps_values = [raw_caps]
+ if caps_values:
+ pending.normalize_chapter_opening_caps = coerce_bool(
+ caps_values[-1], getattr(pending, "normalize_chapter_opening_caps", True)
+ )
+ elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form:
+ pending.normalize_chapter_opening_caps = False
+
+ overrides: List[Dict[str, Any]] = []
+ selected_total = 0
+
+ for index, chapter in enumerate(pending.chapters):
+ enabled = form.get(f"chapter-{index}-enabled") == "on"
+ title_input = (form.get(f"chapter-{index}-title") or "").strip()
+ title = title_input or chapter.get("title") or f"Chapter {index + 1}"
+ voice_selection = form.get(f"chapter-{index}-voice", "__default")
+ formula_input = (form.get(f"chapter-{index}-formula") or "").strip()
+
+ entry: Dict[str, Any] = {
+ "id": chapter.get("id") or f"{index:04d}",
+ "index": index,
+ "order": index,
+ "source_title": chapter.get("title") or title,
+ "title": title,
+ "text": chapter.get("text", ""),
+ "enabled": enabled,
+ }
+ entry["characters"] = calculate_text_length(entry["text"])
+
+ if enabled:
+ if voice_selection.startswith("voice:"):
+ entry["voice"] = voice_selection.split(":", 1)[1]
+ entry["resolved_voice"] = entry["voice"]
+ elif voice_selection.startswith("profile:"):
+ profile_name = voice_selection.split(":", 1)[1]
+ entry["voice_profile"] = profile_name
+ profile_entry = profiles.get(profile_name) or {}
+ formula_value = formula_from_profile(profile_entry)
+ if formula_value:
+ entry["voice_formula"] = formula_value
+ entry["resolved_voice"] = formula_value
+ else:
+ errors.append(f"Profile '{profile_name}' has no configured voices.")
+ elif voice_selection == "formula":
+ if not formula_input:
+ errors.append(f"Provide a custom formula for chapter {index + 1}.")
+ else:
+ try:
+ parse_voice_formula(formula_input)
+ except ValueError as exc:
+ errors.append(str(exc))
+ else:
+ entry["voice_formula"] = formula_input
+ entry["resolved_voice"] = formula_input
+ selected_total += entry["characters"]
+
+ overrides.append(entry)
+ pending.chapters[index] = dict(entry)
+
+ enabled_overrides = [entry for entry in overrides if entry.get("enabled")]
+
+ heteronym_entries = getattr(pending, "heteronym_overrides", None)
+ if isinstance(heteronym_entries, list) and heteronym_entries:
+ for entry in heteronym_entries:
+ if not isinstance(entry, dict):
+ continue
+ entry_id = str(entry.get("entry_id") or entry.get("id") or "").strip()
+ if not entry_id:
+ continue
+ raw_choice = form.get(f"heteronym-{entry_id}-choice")
+ if raw_choice is None:
+ continue
+ choice = str(raw_choice).strip()
+ if not choice:
+ continue
+ options = entry.get("options")
+ if isinstance(options, list) and options:
+ allowed = {
+ str(opt.get("key")).strip()
+ for opt in options
+ if isinstance(opt, dict) and str(opt.get("key") or "").strip()
+ }
+ if allowed and choice not in allowed:
+ continue
+ entry["choice"] = choice
+
+ sync_pronunciation_overrides(pending)
+
+ return (
+ chunk_level_literal,
+ overrides,
+ enabled_overrides,
+ errors,
+ selected_total,
+ selected_config,
+ apply_config_requested,
+ persist_config_requested,
+ )
+
+def apply_book_step_form(
+ pending: PendingJob,
+ form: Mapping[str, Any],
+ *,
+ settings: Mapping[str, Any],
+ profiles: Mapping[str, Any],
+) -> None:
+ language_fallback = pending.language or settings.get("language", "en")
+ raw_language = (form.get("language") or language_fallback or "en").strip()
+ if raw_language:
+ pending.language = raw_language
+
+ subtitle_mode = (form.get("subtitle_mode") or pending.subtitle_mode or "Disabled").strip()
+ if subtitle_mode:
+ pending.subtitle_mode = subtitle_mode
+
+ pending.generate_epub3 = coerce_bool(form.get("generate_epub3"), bool(pending.generate_epub3))
+
+ chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
+ raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or chunk_level_default).strip().lower()
+ if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
+ raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else (pending.chunk_level or "paragraph")
+ pending.chunk_level = raw_chunk_level
+
+ threshold_default = pending.speaker_analysis_threshold or settings.get("speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD)
+ raw_threshold = form.get("speaker_analysis_threshold")
+ if raw_threshold is not None:
+ pending.speaker_analysis_threshold = coerce_int(
+ raw_threshold,
+ threshold_default,
+ minimum=1,
+ maximum=25,
+ )
+
+ raw_delay = form.get("chapter_intro_delay")
+ if raw_delay is not None:
+ try:
+ pending.chapter_intro_delay = max(0.0, float(str(raw_delay).strip() or 0.0))
+ except ValueError:
+ pass
+
+ intro_default = pending.read_title_intro if isinstance(pending.read_title_intro, bool) else bool(settings.get("read_title_intro", False))
+ intro_values: List[str] = []
+ getter = getattr(form, "getlist", None)
+ if callable(getter):
+ raw_intro_values = getter("read_title_intro")
+ if raw_intro_values:
+ intro_values = list(cast(Iterable[str], raw_intro_values))
+ else:
+ raw_intro_flag = form.get("read_title_intro")
+ if raw_intro_flag is not None:
+ intro_values = [raw_intro_flag]
+ if intro_values:
+ pending.read_title_intro = coerce_bool(intro_values[-1], intro_default)
+ elif hasattr(form, "__contains__") and "read_title_intro" in form:
+ pending.read_title_intro = False
+ else:
+ pending.read_title_intro = intro_default
+
+ outro_default = (
+ pending.read_closing_outro
+ if isinstance(getattr(pending, "read_closing_outro", None), bool)
+ else bool(settings.get("read_closing_outro", True))
+ )
+ outro_values: List[str] = []
+ if callable(getter):
+ raw_outro_values = getter("read_closing_outro")
+ if raw_outro_values:
+ outro_values = list(cast(Iterable[str], raw_outro_values))
+ else:
+ raw_outro_flag = form.get("read_closing_outro")
+ if raw_outro_flag is not None:
+ outro_values = [raw_outro_flag]
+ if outro_values:
+ pending.read_closing_outro = coerce_bool(outro_values[-1], outro_default)
+ elif hasattr(form, "__contains__") and "read_closing_outro" in form:
+ pending.read_closing_outro = False
+ else:
+ pending.read_closing_outro = outro_default
+
+ caps_default = (
+ pending.normalize_chapter_opening_caps
+ if isinstance(getattr(pending, "normalize_chapter_opening_caps", None), bool)
+ else bool(settings.get("normalize_chapter_opening_caps", True))
+ )
+ caps_values: List[str] = []
+ getter = getattr(form, "getlist", None)
+ if callable(getter):
+ raw_caps_values = getter("normalize_chapter_opening_caps")
+ if raw_caps_values:
+ caps_values = list(cast(Iterable[str], raw_caps_values))
+ else:
+ raw_caps_flag = form.get("normalize_chapter_opening_caps")
+ if raw_caps_flag is not None:
+ caps_values = [raw_caps_flag]
+ if caps_values:
+ pending.normalize_chapter_opening_caps = coerce_bool(caps_values[-1], caps_default)
+ elif hasattr(form, "__contains__") and "normalize_chapter_opening_caps" in form:
+ pending.normalize_chapter_opening_caps = False
+ else:
+ pending.normalize_chapter_opening_caps = caps_default
+
+ def _extract_checkbox(name: str, default: bool) -> bool:
+ values: List[str] = []
+ getter = getattr(form, "getlist", None)
+ if callable(getter):
+ raw_values = getter(name)
+ if raw_values:
+ values = list(cast(Iterable[str], raw_values))
+ else:
+ raw_flag = form.get(name)
+ if raw_flag is not None:
+ values = [raw_flag]
+ if values:
+ return coerce_bool(values[-1], default)
+ if hasattr(form, "__contains__") and name in form:
+ return False
+ return default
+
+ overrides_existing = getattr(pending, "normalization_overrides", None)
+ overrides: Dict[str, Any] = dict(overrides_existing or {})
+ for key in _NORMALIZATION_BOOLEAN_KEYS:
+ default_toggle = overrides.get(key, bool(settings.get(key, True)))
+ overrides[key] = _extract_checkbox(key, default_toggle)
+ for key in _NORMALIZATION_STRING_KEYS:
+ default_val = overrides.get(key, str(settings.get(key, "")))
+ val = form.get(key)
+ if val is not None:
+ overrides[key] = str(val)
+ else:
+ overrides[key] = default_val
+ pending.normalization_overrides = overrides
+
+ speed_value = form.get("speed")
+ if speed_value is not None:
+ try:
+ pending.speed = float(speed_value)
+ except ValueError:
+ pass
+
+ # NOTE: Do not auto-set a global TTS provider at the book level based on the
+ # narrator defaults. Provider is resolved per-speaker/per-chunk from the voice
+ # spec (e.g. "speaker:Name" for saved speakers, or a Kokoro mix formula).
+ # This enables mixed-provider conversions (e.g. narrator=SuperTonic, characters=Kokoro).
+ provider_value = str(form.get("tts_provider") or "").strip().lower()
+ if is_plugin_registered(provider_value):
+ pending.tts_provider = provider_value
+
+ # Determine the base speaker selection (saved speaker ref or raw voice).
+ narrator_voice_raw = (
+ form.get("voice")
+ or pending.voice
+ or settings.get("default_speaker")
+ or settings.get("default_voice")
+ or ""
+ ).strip()
+
+ profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
+ base_spec, _selected_speaker_name = split_profile_spec(narrator_voice_raw)
+
+ profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip()
+ custom_formula_raw = (form.get("voice_formula") or "").strip()
+ narrator_voice_raw = (base_spec or narrator_voice_raw or settings.get("default_voice") or "").strip()
+ resolved_default_voice, inferred_profile, _ = resolve_voice_setting(
+ narrator_voice_raw,
+ profiles=profiles_map,
+ )
+
+ if profile_selection in {"__standard", "", None} and inferred_profile:
+ profile_selection = inferred_profile
+
+ if profile_selection == "__formula":
+ profile_name = ""
+ custom_formula = custom_formula_raw
+ elif profile_selection in {"__standard", "", None}:
+ profile_name = ""
+ custom_formula = ""
+ else:
+ profile_name = profile_selection
+ custom_formula = ""
+
+ base_voice_spec = resolved_default_voice or narrator_voice_raw
+ if not base_voice_spec:
+ base_voice_spec = get_default_voice("kokoro")
+
+ voice_choice, resolved_language, selected_profile = resolve_voice_choice(
+ pending.language,
+ base_voice_spec,
+ profile_name,
+ custom_formula,
+ profiles_map,
+ )
+
+ if resolved_language:
+ pending.language = resolved_language
+
+ if profile_selection == "__formula" and custom_formula_raw:
+ pending.voice = custom_formula_raw
+ pending.voice_profile = None
+ elif profile_selection not in {"__standard", "", None, "__formula"}:
+ pending.voice_profile = selected_profile or profile_selection
+ pending.voice = voice_choice
+ else:
+ pending.voice_profile = None
+ fallback_voice = base_voice_spec or narrator_voice_raw
+ pending.voice = voice_choice or fallback_voice
+
+ pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None
+
+ # Metadata updates
+ if "meta_title" in form:
+ pending.metadata_tags["title"] = str(form.get("meta_title", "")).strip()
+
+ if "meta_subtitle" in form:
+ pending.metadata_tags["subtitle"] = str(form.get("meta_subtitle", "")).strip()
+
+ if "meta_author" in form:
+ authors = str(form.get("meta_author", "")).strip()
+ pending.metadata_tags["authors"] = authors
+ pending.metadata_tags["author"] = authors
+
+ if "meta_series" in form:
+ series = str(form.get("meta_series", "")).strip()
+ pending.metadata_tags["series"] = series
+ pending.metadata_tags["series_name"] = series
+ pending.metadata_tags["seriesname"] = series
+ pending.metadata_tags["series_title"] = series
+ pending.metadata_tags["seriestitle"] = series
+ # If user manually edits series, update opds_series too so it persists
+ if "opds_series" in pending.metadata_tags:
+ pending.metadata_tags["opds_series"] = series
+
+ if "meta_series_index" in form:
+ idx = str(form.get("meta_series_index", "")).strip()
+ pending.metadata_tags["series_index"] = idx
+ pending.metadata_tags["series_sequence"] = idx
+
+ if "meta_publisher" in form:
+ pending.metadata_tags["publisher"] = str(form.get("meta_publisher", "")).strip()
+
+ if "meta_description" in form:
+ desc = str(form.get("meta_description", "")).strip()
+ pending.metadata_tags["description"] = desc
+ pending.metadata_tags["summary"] = desc
+
+ if coerce_bool(form.get("remove_cover"), False):
+ pending.cover_image_path = None
+ pending.cover_image_mime = None
+
+def persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]:
+ cover_bytes = getattr(extraction_result, "cover_image", None)
+ if not cover_bytes:
+ return None, None
+
+ mime = getattr(extraction_result, "cover_mime", None)
+ extension = mimetypes.guess_extension(mime or "") or ".png"
+ base_stem = Path(stored_path).stem or "cover"
+ candidate = stored_path.parent / f"{base_stem}_cover{extension}"
+ counter = 1
+ while candidate.exists():
+ candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}"
+ counter += 1
+
+ try:
+ candidate.write_bytes(cover_bytes)
+ except OSError:
+ return None, None
+
+ return candidate, mime
+
+def build_pending_job_from_extraction(
+ *,
+ stored_path: Path,
+ original_name: str,
+ extraction: Any,
+ form: Mapping[str, Any],
+ settings: Mapping[str, Any],
+ profiles: Mapping[str, Any],
+ metadata_overrides: Optional[Mapping[str, Any]] = None,
+) -> PendingBuildResult:
+ profiles_map = dict(profiles)
+ cover_path, cover_mime = persist_cover_image(extraction, stored_path)
+
+ if getattr(extraction, "chapters", None):
+ original_titles = [chapter.title for chapter in extraction.chapters]
+ normalized_titles = normalize_roman_numeral_titles(original_titles)
+ if normalized_titles != original_titles:
+ for chapter, new_title in zip(extraction.chapters, normalized_titles):
+ chapter.title = new_title
+
+ metadata_tags = dict(getattr(extraction, "metadata", {}) or {})
+ if metadata_overrides:
+ normalized_keys = {str(existing_key).casefold(): str(existing_key) for existing_key in metadata_tags.keys()}
+ for key, value in metadata_overrides.items():
+ if value is None:
+ continue
+ key_text = str(key or "").strip()
+ if not key_text:
+ continue
+ value_text = str(value).strip()
+ if not value_text:
+ continue
+ lookup = key_text.casefold()
+ existing_key = normalized_keys.get(lookup)
+ if existing_key:
+ existing_value = str(metadata_tags.get(existing_key) or "").strip()
+ if existing_value:
+ continue
+ target_key = existing_key
+ else:
+ target_key = key_text
+ normalized_keys[lookup] = target_key
+ metadata_tags[target_key] = value_text
+
+ total_chars = getattr(extraction, "total_characters", None) or calculate_text_length(
+ getattr(extraction, "combined_text", "")
+ )
+ chapters_source = getattr(extraction, "chapters", []) or []
+ total_chapter_count = len(chapters_source)
+ chapters_payload: List[Dict[str, Any]] = []
+ for index, chapter in enumerate(chapters_source):
+ enabled = should_preselect_chapter(chapter.title, chapter.text, index, total_chapter_count)
+ chapters_payload.append(
+ {
+ "id": f"{index:04d}",
+ "index": index,
+ "title": chapter.title,
+ "text": chapter.text,
+ "characters": calculate_text_length(chapter.text),
+ "enabled": enabled,
+ }
+ )
+
+ if not chapters_payload:
+ chapters_payload.append(
+ {
+ "id": "0000",
+ "index": 0,
+ "title": original_name,
+ "text": "",
+ "characters": 0,
+ "enabled": True,
+ }
+ )
+
+ ensure_at_least_one_chapter_enabled(chapters_payload)
+
+ language = str(form.get("language") or "a").strip() or "a"
+ profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {})
+ default_voice_setting = settings.get("default_voice") or ""
+ resolved_default_voice, inferred_profile, inferred_language = resolve_voice_setting(
+ default_voice_setting,
+ profiles=profiles_map,
+ )
+ base_voice_input = str(form.get("voice") or "").strip()
+ profile_selection = (form.get("voice_profile") or "__standard").strip()
+ custom_formula_raw = str(form.get("voice_formula") or "").strip()
+
+ if profile_selection in {"__standard", ""} and inferred_profile:
+ profile_selection = inferred_profile
+
+ base_voice = base_voice_input or resolved_default_voice or str(default_voice_setting).strip()
+ if not base_voice:
+ base_voice = get_default_voice("kokoro")
+ selected_speaker_config = (form.get("speaker_config") or "").strip()
+ speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None
+
+ if profile_selection == "__formula":
+ profile_name = ""
+ custom_formula = custom_formula_raw
+ elif profile_selection in {"__standard", ""}:
+ profile_name = ""
+ custom_formula = ""
+ else:
+ profile_name = profile_selection
+ custom_formula = ""
+
+ voice, language, selected_profile = resolve_voice_choice(
+ language,
+ base_voice,
+ profile_name,
+ custom_formula,
+ profiles_map,
+ )
+
+ try:
+ speed = float(form.get("speed", 1.0))
+ except (TypeError, ValueError):
+ speed = 1.0
+
+ subtitle_mode = str(form.get("subtitle_mode") or "Disabled")
+ output_format = settings["output_format"]
+ subtitle_format = settings["subtitle_format"]
+ save_mode_key = settings["save_mode"]
+ save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"])
+ replace_single_newlines = settings["replace_single_newlines"]
+ use_gpu = settings["use_gpu"]
+ save_chapters_separately = settings["save_chapters_separately"]
+ merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately
+ save_as_project = settings["save_as_project"]
+ separate_chapters_format = settings["separate_chapters_format"]
+ silence_between_chapters = settings["silence_between_chapters"]
+ chapter_intro_delay = settings["chapter_intro_delay"]
+ read_title_intro = settings["read_title_intro"]
+ read_closing_outro = settings.get("read_closing_outro", True)
+ normalize_chapter_opening_caps = settings["normalize_chapter_opening_caps"]
+ max_subtitle_words = settings["max_subtitle_words"]
+ auto_prefix_chapter_titles = settings["auto_prefix_chapter_titles"]
+
+ chunk_level_default = str(settings.get("chunk_level", "paragraph")).strip().lower()
+ raw_chunk_level = str(form.get("chunk_level") or chunk_level_default).strip().lower()
+ if raw_chunk_level not in _CHUNK_LEVEL_VALUES:
+ raw_chunk_level = chunk_level_default if chunk_level_default in _CHUNK_LEVEL_VALUES else "paragraph"
+ chunk_level_value = raw_chunk_level
+ chunk_level_literal = cast(ChunkLevel, chunk_level_value)
+
+ speaker_mode_value = "single"
+
+ generate_epub3_default = bool(settings.get("generate_epub3", False))
+ generate_epub3 = coerce_bool(form.get("generate_epub3"), generate_epub3_default)
+
+ selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")]
+ raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal)
+ analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence")
+
+ analysis_threshold = coerce_int(
+ settings.get("speaker_analysis_threshold"),
+ _DEFAULT_ANALYSIS_THRESHOLD,
+ minimum=1,
+ maximum=25,
+ )
+
+ initial_analysis = False
+ (
+ processed_chunks,
+ speakers,
+ analysis_payload,
+ config_languages,
+ _,
+ ) = prepare_speaker_metadata(
+ chapters=selected_chapter_sources,
+ chunks=raw_chunks,
+ analysis_chunks=analysis_chunks,
+ voice=voice,
+ voice_profile=selected_profile or None,
+ threshold=analysis_threshold,
+ run_analysis=initial_analysis,
+ speaker_config=speaker_config_payload,
+ apply_config=bool(speaker_config_payload),
+ )
+
+ def _extract_checkbox(name: str, default: bool) -> bool:
+ values: List[str] = []
+ getter = getattr(form, "getlist", None)
+ if callable(getter):
+ raw_values = getter(name)
+ if raw_values:
+ values = list(cast(Iterable[str], raw_values))
+ else:
+ raw_flag = form.get(name)
+ if raw_flag is not None:
+ values = [raw_flag]
+ if values:
+ return coerce_bool(values[-1], default)
+ return default
+
+ normalization_overrides = {}
+ for key in _NORMALIZATION_BOOLEAN_KEYS:
+ default_val = bool(settings.get(key, True))
+ normalization_overrides[key] = _extract_checkbox(key, default_val)
+
+ for key in _NORMALIZATION_STRING_KEYS:
+ default_val = str(settings.get(key, ""))
+ val = form.get(key)
+ if val is not None:
+ normalization_overrides[key] = str(val)
+ else:
+ normalization_overrides[key] = default_val
+
+ pending = PendingJob(
+ id=uuid.uuid4().hex,
+ original_filename=original_name,
+ stored_path=stored_path,
+ language=language,
+ voice=voice,
+ speed=speed,
+ use_gpu=use_gpu,
+ subtitle_mode=subtitle_mode,
+ output_format=output_format,
+ save_mode=save_mode,
+ output_folder=None,
+ replace_single_newlines=replace_single_newlines,
+ subtitle_format=subtitle_format,
+ total_characters=total_chars,
+ save_chapters_separately=save_chapters_separately,
+ merge_chapters_at_end=merge_chapters_at_end,
+ separate_chapters_format=separate_chapters_format,
+ silence_between_chapters=silence_between_chapters,
+ save_as_project=save_as_project,
+ voice_profile=selected_profile or None,
+ max_subtitle_words=max_subtitle_words,
+ metadata_tags=metadata_tags,
+ chapters=chapters_payload,
+ normalization_overrides=normalization_overrides,
+ created_at=time.time(),
+ cover_image_path=cover_path,
+ cover_image_mime=cover_mime,
+ chapter_intro_delay=chapter_intro_delay,
+ read_title_intro=bool(read_title_intro),
+ read_closing_outro=bool(read_closing_outro),
+ normalize_chapter_opening_caps=bool(normalize_chapter_opening_caps),
+ auto_prefix_chapter_titles=bool(auto_prefix_chapter_titles),
+ chunk_level=chunk_level_value,
+ speaker_mode=speaker_mode_value,
+ generate_epub3=generate_epub3,
+ chunks=processed_chunks,
+ speakers=speakers,
+ speaker_analysis=analysis_payload,
+ speaker_analysis_threshold=analysis_threshold,
+ analysis_requested=initial_analysis,
+ )
+
+ return PendingBuildResult(
+ pending=pending,
+ selected_speaker_config=selected_speaker_config or None,
+ config_languages=list(config_languages or []),
+ speaker_config_payload=speaker_config_payload,
+ )
+
+def render_jobs_panel() -> str:
+ jobs = get_service().list_jobs()
+ active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED}
+ active_jobs = [job for job in jobs if job.status in active_statuses]
+ active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at))
+ finished_jobs = [job for job in jobs if job.status not in active_statuses]
+ download_flags = {job.id: job_download_flags(job) for job in jobs}
+ return render_template(
+ "partials/jobs.html",
+ active_jobs=active_jobs,
+ finished_jobs=finished_jobs[:5],
+ total_finished=len(finished_jobs),
+ JobStatus=JobStatus,
+ download_flags=download_flags,
+ audiobookshelf_manual_available=audiobookshelf_manual_available(),
+ )
+
+
+def normalize_wizard_step(step: Optional[str], pending: Optional[PendingJob] = None) -> str:
+ if pending is None:
+ default_step = "book"
+ else:
+ default_step = "chapters"
+ if not step:
+ chosen = default_step
+ else:
+ normalized = step.strip().lower()
+ if normalized in {"", "upload", "settings"}:
+ chosen = default_step
+ elif normalized == "speakers":
+ chosen = "entities"
+ elif normalized in _WIZARD_STEP_ORDER:
+ chosen = normalized
+ else:
+ chosen = default_step
+ return chosen
+
+
+def wants_wizard_json() -> bool:
+ format_hint = request.args.get("format", "").strip().lower()
+ if format_hint == "json":
+ return True
+ accept_header = (request.headers.get("Accept") or "").lower()
+ if "application/json" in accept_header:
+ return True
+ requested_with = (request.headers.get("X-Requested-With") or "").lower()
+ if requested_with in {"xmlhttprequest", "fetch"}:
+ return True
+ wizard_header = (request.headers.get("X-Abogen-Wizard") or "").lower()
+ return wizard_header == "json"
+
+
+def render_wizard_partial(
+ pending: Optional[PendingJob],
+ step: str,
+ *,
+ error: Optional[str] = None,
+ notice: Optional[str] = None,
+) -> str:
+ templates = {
+ "book": "partials/new_job_step_book.html",
+ "chapters": "partials/new_job_step_chapters.html",
+ "entities": "partials/new_job_step_entities.html",
+ }
+ template_name = templates[step]
+ context: Dict[str, Any] = {
+ "pending": pending,
+ "readonly": False,
+ "options": template_options(),
+ "settings": load_settings(),
+ "error": error,
+ "notice": notice,
+ }
+ return render_template(template_name, **context)
+
+
+def wizard_step_payload(
+ pending: Optional[PendingJob],
+ step: str,
+ html: str,
+ *,
+ error: Optional[str] = None,
+ notice: Optional[str] = None,
+) -> Dict[str, Any]:
+ meta = _WIZARD_STEP_META.get(step, {})
+ try:
+ active_index = _WIZARD_STEP_ORDER.index(step)
+ except ValueError:
+ active_index = 0
+ max_recorded_index = active_index
+ if pending is not None:
+ stored_index = int(getattr(pending, "wizard_max_step_index", -1))
+ if stored_index < 0:
+ stored_index = -1
+ max_recorded_index = max(active_index, stored_index)
+ max_allowed = len(_WIZARD_STEP_ORDER) - 1
+ if max_recorded_index > max_allowed:
+ max_recorded_index = max_allowed
+ if stored_index != max_recorded_index:
+ pending.wizard_max_step_index = max_recorded_index
+ get_service().store_pending_job(pending)
+ else:
+ max_allowed = len(_WIZARD_STEP_ORDER) - 1
+ if max_recorded_index > max_allowed:
+ max_recorded_index = max_allowed
+ completed = [slug for idx, slug in enumerate(_WIZARD_STEP_ORDER) if idx <= max_recorded_index]
+ return {
+ "step": step,
+ "step_index": int(meta.get("index", active_index + 1)),
+ "total_steps": len(_WIZARD_STEP_ORDER),
+ "title": meta.get("title", ""),
+ "hint": meta.get("hint", ""),
+ "html": html,
+ "completed_steps": completed,
+ "pending_id": pending.id if pending else "",
+ "filename": pending.original_filename if pending and pending.original_filename else "",
+ "error": error or "",
+ "notice": notice or "",
+ }
+
+
+def wizard_json_response(
+ pending: Optional[PendingJob],
+ step: str,
+ *,
+ error: Optional[str] = None,
+ notice: Optional[str] = None,
+ status: int = 200,
+) -> ResponseReturnValue:
+ html = render_wizard_partial(pending, step, error=error, notice=notice)
+ payload = wizard_step_payload(pending, step, html, error=error, notice=notice)
+ return jsonify(payload), status
diff --git a/abogen/webui/routes/utils/settings.py b/abogen/webui/routes/utils/settings.py
index 589f8a0..c041608 100644
--- a/abogen/webui/routes/utils/settings.py
+++ b/abogen/webui/routes/utils/settings.py
@@ -1,752 +1,752 @@
-import os
-import re
-from typing import Any, Dict, Mapping, Optional
-
-from abogen.constants import (
- LANGUAGE_DESCRIPTIONS,
- SUBTITLE_FORMATS,
- SUPPORTED_SOUND_FORMATS,
-)
-from abogen.tts_plugin.utils import get_default_voice
-from abogen.normalization_settings import (
- DEFAULT_LLM_PROMPT,
- environment_llm_defaults,
-)
-from abogen.utils import load_config, save_config
-from abogen.integrations.calibre_opds import CalibreOPDSClient
-from abogen.integrations.audiobookshelf import AudiobookshelfConfig
-from abogen.webui.routes.utils.common import split_profile_spec
-
-SAVE_MODE_LABELS = {
- "save_next_to_input": "Save next to input file",
- "save_to_desktop": "Save to Desktop",
- "choose_output_folder": "Choose output folder",
- "default_output": "Use default save location",
-}
-
-LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
-
-_CHUNK_LEVEL_OPTIONS = [
- {"value": "paragraph", "label": "Paragraphs"},
- {"value": "sentence", "label": "Sentences"},
-]
-
-_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
-
-_DEFAULT_ANALYSIS_THRESHOLD = 3
-
-_APOSTROPHE_MODE_OPTIONS = [
- {"value": "off", "label": "Off"},
- {"value": "spacy", "label": "spaCy (built-in)"},
- {"value": "llm", "label": "LLM assisted"},
-]
-
-_NORMALIZATION_BOOLEAN_KEYS = {
- "normalization_numbers",
- "normalization_titles",
- "normalization_terminal",
- "normalization_phoneme_hints",
- "normalization_caps_quotes",
- "normalization_currency",
- "normalization_footnotes",
- "normalization_internet_slang",
- "normalization_apostrophes_contractions",
- "normalization_apostrophes_plural_possessives",
- "normalization_apostrophes_sibilant_possessives",
- "normalization_apostrophes_decades",
- "normalization_apostrophes_leading_elisions",
- "normalization_contraction_aux_be",
- "normalization_contraction_aux_have",
- "normalization_contraction_modal_will",
- "normalization_contraction_modal_would",
- "normalization_contraction_negation_not",
- "normalization_contraction_let_us",
-}
-
-_NORMALIZATION_STRING_KEYS = {
- "normalization_numbers_year_style",
- "normalization_apostrophe_mode",
-}
-
-BOOLEAN_SETTINGS = {
- "replace_single_newlines",
- "use_gpu",
- "save_chapters_separately",
- "merge_chapters_at_end",
- "save_as_project",
- "generate_epub3",
- "enable_entity_recognition",
- "read_title_intro",
- "read_closing_outro",
- "auto_prefix_chapter_titles",
- "normalize_chapter_opening_caps",
- "normalization_numbers",
- "normalization_titles",
- "normalization_terminal",
- "normalization_phoneme_hints",
- "normalization_caps_quotes",
- "normalization_currency",
- "normalization_footnotes",
- "normalization_internet_slang",
- "normalization_apostrophes_contractions",
- "normalization_apostrophes_plural_possessives",
- "normalization_apostrophes_sibilant_possessives",
- "normalization_apostrophes_decades",
- "normalization_apostrophes_leading_elisions",
- "normalization_contraction_aux_be",
- "normalization_contraction_aux_have",
- "normalization_contraction_modal_will",
- "normalization_contraction_modal_would",
- "normalization_contraction_negation_not",
- "normalization_contraction_let_us",
-}
-
-FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"}
-INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"}
-
-_NORMALIZATION_GROUPS = [
- {
- "label": "General Rules",
- "options": [
- {"key": "normalization_numbers", "label": "Convert grouped numbers to words"},
- {"key": "normalization_currency", "label": "Convert currency symbols ($10 → ten dollars)"},
- {"key": "normalization_titles", "label": "Expand titles and suffixes (Dr., St., Jr., …)"},
- {"key": "normalization_internet_slang", "label": "Expand internet slang (pls → please)"},
- {"key": "normalization_footnotes", "label": "Remove footnote indicators ([1], [2])"},
- {"key": "normalization_terminal", "label": "Ensure sentences end with terminal punctuation"},
- {"key": "normalization_caps_quotes", "label": "Convert ALL CAPS dialogue inside quotes"},
- ]
- },
- {
- "label": "Apostrophes & Contractions",
- "options": [
- {"key": "normalization_apostrophes_contractions", "label": "Expand contractions (it's → it is)"},
- {"key": "normalization_apostrophes_plural_possessives", "label": "Collapse plural possessives (dogs' → dogs)"},
- {"key": "normalization_apostrophes_sibilant_possessives", "label": "Mark sibilant possessives (boss's → boss + IZ marker)"},
- {"key": "normalization_apostrophes_decades", "label": "Expand decades ('90s → 1990s)"},
- {"key": "normalization_apostrophes_leading_elisions", "label": "Expand leading elisions ('tis → it is)"},
- {"key": "normalization_phoneme_hints", "label": "Add phoneme hints for possessives"},
- {"key": "normalization_contraction_aux_be", "label": "Expand auxiliary 'be' (I'm → I am)"},
- {"key": "normalization_contraction_aux_have", "label": "Expand auxiliary 'have' (I've → I have)"},
- {"key": "normalization_contraction_modal_will", "label": "Expand modal 'will' (I'll → I will)"},
- {"key": "normalization_contraction_modal_would", "label": "Expand modal 'would' (I'd → I would)"},
- {"key": "normalization_contraction_negation_not", "label": "Expand negation 'not' (don't → do not)"},
- {"key": "normalization_contraction_let_us", "label": "Expand 'let's' → let us"},
- ]
- }
-]
-
-
-def integration_defaults() -> Dict[str, Dict[str, Any]]:
- return {
- "calibre_opds": {
- "enabled": False,
- "base_url": "",
- "username": "",
- "password": "",
- "verify_ssl": True,
- },
- "audiobookshelf": {
- "enabled": False,
- "base_url": "",
- "api_token": "",
- "library_id": "",
- "collection_id": "",
- "folder_id": "",
- "verify_ssl": True,
- "send_cover": True,
- "send_chapters": True,
- "send_subtitles": False,
- "auto_send": False,
- "timeout": 30.0,
- },
- }
-
-
-def has_output_override() -> bool:
- return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
-
-
-def settings_defaults() -> Dict[str, Any]:
- llm_env_defaults = environment_llm_defaults()
- return {
- "output_format": "wav",
- "subtitle_format": "srt",
- "save_mode": "default_output" if has_output_override() else "save_next_to_input",
- "default_speaker": "",
- "default_voice": get_default_voice("kokoro"),
- "supertonic_total_steps": 5,
- "supertonic_speed": 1.0,
- "replace_single_newlines": False,
- "use_gpu": True,
- "save_chapters_separately": False,
- "merge_chapters_at_end": True,
- "save_as_project": False,
- "separate_chapters_format": "wav",
- "silence_between_chapters": 2.0,
- "chapter_intro_delay": 0.5,
- "read_title_intro": False,
- "read_closing_outro": True,
- "normalize_chapter_opening_caps": True,
- "max_subtitle_words": 50,
- "chunk_level": "paragraph",
- "enable_entity_recognition": True,
- "generate_epub3": False,
- "auto_prefix_chapter_titles": True,
- "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
- "speaker_pronunciation_sentence": "This is {{name}} speaking.",
- "speaker_random_languages": [],
- "llm_base_url": llm_env_defaults.get("llm_base_url", ""),
- "llm_api_key": llm_env_defaults.get("llm_api_key", ""),
- "llm_model": llm_env_defaults.get("llm_model", ""),
- "llm_timeout": llm_env_defaults.get("llm_timeout", 30.0),
- "llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT),
- "llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"),
- "normalization_numbers": True,
- "normalization_currency": True,
- "normalization_footnotes": True,
- "normalization_titles": True,
- "normalization_terminal": True,
- "normalization_phoneme_hints": True,
- "normalization_caps_quotes": True,
- "normalization_internet_slang": False,
- "normalization_apostrophes_contractions": True,
- "normalization_apostrophes_plural_possessives": True,
- "normalization_apostrophes_sibilant_possessives": True,
- "normalization_apostrophes_decades": True,
- "normalization_apostrophes_leading_elisions": True,
- "normalization_apostrophe_mode": "spacy",
- "normalization_numbers_year_style": "american",
- "normalization_contraction_aux_be": True,
- "normalization_contraction_aux_have": True,
- "normalization_contraction_modal_will": True,
- "normalization_contraction_modal_would": True,
- "normalization_contraction_negation_not": True,
- "normalization_contraction_let_us": True,
- }
-
-
-def llm_ready(settings: Mapping[str, Any]) -> bool:
- base_url = str(settings.get("llm_base_url") or "").strip()
- return bool(base_url)
-
-
-_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
-
-
-def render_prompt_template(template: str, context: Mapping[str, str]) -> str:
- if not template:
- return ""
-
- def _replace(match: re.Match[str]) -> str:
- key = match.group(1)
- return context.get(key, "")
-
- return _PROMPT_TOKEN_RE.sub(_replace, template)
-
-
-def coerce_bool(value: Any, default: bool) -> bool:
- if isinstance(value, bool):
- return value
- if isinstance(value, str):
- return value.lower() in {"true", "1", "yes", "on"}
- if value is None:
- return default
- return bool(value)
-
-
-def coerce_float(value: Any, default: float) -> float:
- try:
- return max(0.0, float(value))
- except (TypeError, ValueError):
- return default
-
-
-def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
- try:
- parsed = int(value)
- except (TypeError, ValueError):
- return default
- return max(minimum, min(parsed, maximum))
-
-
-def normalize_save_mode(value: Any, default: str) -> str:
- if isinstance(value, str):
- if value in SAVE_MODE_LABELS:
- return value
- if value in LEGACY_SAVE_MODE_MAP:
- return LEGACY_SAVE_MODE_MAP[value]
- return default
-
-
-def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
- if key in BOOLEAN_SETTINGS:
- return coerce_bool(value, defaults[key])
- if key in FLOAT_SETTINGS:
- return coerce_float(value, defaults[key])
- if key in INT_SETTINGS:
- return coerce_int(value, defaults[key])
- if key == "save_mode":
- return normalize_save_mode(value, defaults[key])
- if key == "output_format":
- return value if value in SUPPORTED_SOUND_FORMATS else defaults[key]
- if key == "subtitle_format":
- valid = {item[0] for item in SUBTITLE_FORMATS}
- return value if value in valid else defaults[key]
- if key == "separate_chapters_format":
- if isinstance(value, str):
- normalized = value.lower()
- if normalized in {"wav", "flac", "mp3", "opus"}:
- return normalized
- return defaults[key]
- if key == "default_voice":
- if isinstance(value, str):
- text = value.strip()
- if not text:
- return defaults[key]
- spec, profile_name = split_profile_spec(text)
- if profile_name:
- return f"speaker:{profile_name}"
- return spec
- return defaults[key]
- if key == "default_speaker":
- if isinstance(value, str):
- text = value.strip()
- if not text:
- return ""
- spec, profile_name = split_profile_spec(text)
- if profile_name:
- return f"speaker:{profile_name}"
- return spec
- return ""
- if key == "chunk_level":
- if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
- return value
- return defaults[key]
- if key == "normalization_apostrophe_mode":
- if isinstance(value, str):
- normalized_mode = value.strip().lower()
- if normalized_mode in {"off", "spacy", "llm"}:
- return normalized_mode
- return defaults[key]
- if key == "normalization_numbers_year_style":
- if isinstance(value, str):
- normalized_style = value.strip().lower()
- if normalized_style in {"american", "off"}:
- return normalized_style
- return defaults[key]
- if key == "llm_context_mode":
- if isinstance(value, str):
- normalized_scope = value.strip().lower()
- if normalized_scope == "sentence":
- return normalized_scope
- return defaults[key]
- if key == "llm_prompt":
- candidate = str(value or "").strip()
- return candidate if candidate else defaults[key]
- if key in {"llm_base_url", "llm_api_key", "llm_model"}:
- return str(value or "").strip()
- if key == "speaker_random_languages":
- if isinstance(value, (list, tuple, set)):
- return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
- 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 defaults.get(key, [])
- if key == "supertonic_total_steps":
- try:
- steps = int(value)
- except (TypeError, ValueError):
- return defaults.get(key, 5)
- return max(2, min(15, steps))
- if key == "supertonic_speed":
- try:
- speed = float(value)
- except (TypeError, ValueError):
- return defaults.get(key, 1.0)
- return max(0.7, min(2.0, speed))
- return value if value is not None else defaults.get(key)
-
-
-def load_settings() -> Dict[str, Any]:
- defaults = settings_defaults()
- cfg = load_config() or {}
- settings: Dict[str, Any] = {}
- for key, default in defaults.items():
- raw_value = cfg.get(key, default)
- settings[key] = normalize_setting_value(key, raw_value, defaults)
- return settings
-
-
-def load_integration_settings() -> Dict[str, Dict[str, Any]]:
- defaults = integration_defaults()
- cfg = load_config() or {}
- # Integrations are stored under the "integrations" key in the config
- stored_integrations = cfg.get("integrations", {})
- if not isinstance(stored_integrations, Mapping):
- stored_integrations = {}
-
- integrations: Dict[str, Dict[str, Any]] = {}
- for key, default in defaults.items():
- stored = stored_integrations.get(key)
- merged: Dict[str, Any] = dict(default)
- if isinstance(stored, Mapping):
- for field, default_value in default.items():
- value = stored.get(field, default_value)
- if isinstance(default_value, bool):
- merged[field] = coerce_bool(value, default_value)
- elif isinstance(default_value, float):
- try:
- merged[field] = float(value)
- except (TypeError, ValueError):
- merged[field] = default_value
- elif isinstance(default_value, int):
- try:
- merged[field] = int(value)
- except (TypeError, ValueError):
- merged[field] = default_value
- else:
- merged[field] = str(value or "")
- if key == "calibre_opds":
- merged["has_password"] = bool(isinstance(stored, Mapping) and stored.get("password"))
- # Do not clear the password here, let the template decide whether to show it or not
- # merged["password"] = ""
- elif key == "audiobookshelf":
- merged["has_api_token"] = bool(isinstance(stored, Mapping) and stored.get("api_token"))
- # Do not clear the token here
- # merged["api_token"] = ""
- integrations[key] = merged
-
- # Environment variable fallbacks for Calibre OPDS
- calibre = integrations["calibre_opds"]
- if not calibre.get("base_url"):
- calibre["base_url"] = os.environ.get("CALIBRE_SERVER_HOST", "")
- if not calibre.get("username"):
- calibre["username"] = os.environ.get("OPDS_USERNAME", "")
- if not calibre.get("password"):
- calibre["password"] = os.environ.get("OPDS_PASSWORD", "")
-
- # If we have a password (from storage or env), mark it as present for the UI
- if calibre.get("password"):
- calibre["has_password"] = True
-
- # Auto-enable if configured via env but not explicitly disabled in config
- stored_calibre = stored_integrations.get("calibre_opds")
- if stored_calibre is None and calibre.get("base_url"):
- calibre["enabled"] = True
-
- return integrations
-
-
-def stored_integration_config(name: str) -> Dict[str, Any]:
- cfg = load_config() or {}
- # Check under "integrations" first (new structure)
- integrations = cfg.get("integrations")
- if isinstance(integrations, Mapping):
- entry = integrations.get(name)
- if isinstance(entry, Mapping):
- return dict(entry)
-
- # Fallback to top-level (legacy structure)
- entry = cfg.get(name)
- if isinstance(entry, Mapping):
- return dict(entry)
- return {}
-
-
-def calibre_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
- defaults = integration_defaults()["calibre_opds"]
- stored = stored_integration_config("calibre_opds")
-
- base_url = str(
- payload.get("base_url")
- or payload.get("calibre_opds_base_url")
- or stored.get("base_url")
- or ""
- ).strip()
- username = str(
- payload.get("username")
- or payload.get("calibre_opds_username")
- or stored.get("username")
- or ""
- ).strip()
- password_input = str(
- payload.get("password")
- or payload.get("calibre_opds_password")
- or ""
- ).strip()
- use_saved_password = coerce_bool(
- payload.get("use_saved_password")
- or payload.get("calibre_opds_use_saved_password"),
- False,
- )
- clear_saved_password = coerce_bool(
- payload.get("clear_saved_password")
- or payload.get("calibre_opds_password_clear"),
- False,
- )
- password = ""
- if password_input:
- password = password_input
- elif use_saved_password and not clear_saved_password:
- password = str(stored.get("password") or "")
-
- verify_ssl = coerce_bool(
- payload.get("verify_ssl")
- or payload.get("calibre_opds_verify_ssl"),
- defaults["verify_ssl"],
- )
- enabled = coerce_bool(
- payload.get("enabled")
- or payload.get("calibre_opds_enabled"),
- coerce_bool(stored.get("enabled"), False),
- )
-
- return {
- "enabled": enabled,
- "base_url": base_url,
- "username": username,
- "password": password,
- "verify_ssl": verify_ssl,
- }
-
-
-def audiobookshelf_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
- defaults = integration_defaults()["audiobookshelf"]
- stored = stored_integration_config("audiobookshelf")
-
- base_url = str(
- payload.get("base_url")
- or payload.get("audiobookshelf_base_url")
- or stored.get("base_url")
- or ""
- ).strip()
- library_id = str(
- payload.get("library_id")
- or payload.get("audiobookshelf_library_id")
- or stored.get("library_id")
- or ""
- ).strip()
- collection_id = str(
- payload.get("collection_id")
- or payload.get("audiobookshelf_collection_id")
- or stored.get("collection_id")
- or ""
- ).strip()
- folder_id = str(
- payload.get("folder_id")
- or payload.get("audiobookshelf_folder_id")
- or stored.get("folder_id")
- or ""
- ).strip()
- token_input = str(
- payload.get("api_token")
- or payload.get("audiobookshelf_api_token")
- or ""
- ).strip()
- use_saved_token = coerce_bool(
- payload.get("use_saved_token")
- or payload.get("audiobookshelf_use_saved_token"),
- False,
- )
- clear_saved_token = coerce_bool(
- payload.get("clear_saved_token")
- or payload.get("audiobookshelf_api_token_clear"),
- False,
- )
- if token_input:
- api_token = token_input
- elif use_saved_token and not clear_saved_token:
- api_token = str(stored.get("api_token") or "")
- else:
- api_token = ""
-
- verify_ssl = coerce_bool(
- payload.get("verify_ssl")
- or payload.get("audiobookshelf_verify_ssl"),
- defaults["verify_ssl"],
- )
- send_cover = coerce_bool(
- payload.get("send_cover")
- or payload.get("audiobookshelf_send_cover"),
- defaults["send_cover"],
- )
- send_chapters = coerce_bool(
- payload.get("send_chapters")
- or payload.get("audiobookshelf_send_chapters"),
- defaults["send_chapters"],
- )
- send_subtitles = coerce_bool(
- payload.get("send_subtitles")
- or payload.get("audiobookshelf_send_subtitles"),
- defaults["send_subtitles"],
- )
- auto_send = coerce_bool(
- payload.get("auto_send")
- or payload.get("audiobookshelf_auto_send"),
- defaults["auto_send"],
- )
- timeout_raw = (
- payload.get("timeout")
- or payload.get("audiobookshelf_timeout")
- or stored.get("timeout")
- or defaults["timeout"]
- )
- try:
- timeout = float(timeout_raw)
- except (TypeError, ValueError):
- timeout = defaults["timeout"]
-
- enabled = coerce_bool(
- payload.get("enabled")
- or payload.get("audiobookshelf_enabled"),
- coerce_bool(stored.get("enabled"), False),
- )
-
- return {
- "enabled": enabled,
- "base_url": base_url,
- "library_id": library_id,
- "collection_id": collection_id,
- "folder_id": folder_id,
- "api_token": api_token,
- "verify_ssl": verify_ssl,
- "send_cover": send_cover,
- "send_chapters": send_chapters,
- "send_subtitles": send_subtitles,
- "auto_send": auto_send,
- "timeout": timeout,
- }
-
-
-def build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[AudiobookshelfConfig]:
- base_url = str(settings.get("base_url") or "").strip()
- api_token = str(settings.get("api_token") or "").strip()
- library_id = str(settings.get("library_id") or "").strip()
- if not (base_url and api_token and library_id):
- return None
- try:
- timeout = float(settings.get("timeout", 3600.0))
- except (TypeError, ValueError):
- timeout = 3600.0
- return AudiobookshelfConfig(
- base_url=base_url,
- api_token=api_token,
- library_id=library_id,
- collection_id=(str(settings.get("collection_id") or "").strip() or None),
- folder_id=(str(settings.get("folder_id") or "").strip() or None),
- verify_ssl=coerce_bool(settings.get("verify_ssl"), True),
- send_cover=coerce_bool(settings.get("send_cover"), True),
- send_chapters=coerce_bool(settings.get("send_chapters"), True),
- send_subtitles=coerce_bool(settings.get("send_subtitles"), False),
- timeout=timeout,
- )
-
-
-def calibre_integration_enabled(
- integrations: Optional[Mapping[str, Any]] = None,
-) -> bool:
- if integrations is None:
- integrations = load_integration_settings()
- payload = integrations.get("calibre_opds") if isinstance(integrations, Mapping) else None
- if not isinstance(payload, Mapping):
- return False
- base_url = str(payload.get("base_url") or "").strip()
- enabled_flag = coerce_bool(payload.get("enabled"), False)
- return bool(enabled_flag and base_url)
-
-
-def audiobookshelf_manual_available() -> bool:
- settings = stored_integration_config("audiobookshelf")
- if not settings:
- return False
- return coerce_bool(settings.get("enabled"), False)
-
-
-def build_calibre_client(settings: Mapping[str, Any]) -> CalibreOPDSClient:
- base_url = str(settings.get("base_url") or "").strip()
- if not base_url:
- raise ValueError("Calibre OPDS base URL is required")
- username = str(settings.get("username") or "").strip() or None
- password = str(settings.get("password") or "").strip() or None
- verify_ssl = coerce_bool(settings.get("verify_ssl"), True)
- timeout_raw = settings.get("timeout", 15.0)
- try:
- timeout = float(timeout_raw)
- except (TypeError, ValueError):
- timeout = 15.0
- return CalibreOPDSClient(
- base_url,
- username=username,
- password=password,
- timeout=timeout,
- verify=verify_ssl,
- )
-
-
-def apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None:
- defaults = integration_defaults()
-
- current_calibre = dict(cfg.get("calibre_opds") or {})
- calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False)
- calibre_base = str(form.get("calibre_opds_base_url") or current_calibre.get("base_url") or "").strip()
- calibre_username = str(form.get("calibre_opds_username") or current_calibre.get("username") or "").strip()
- calibre_password_input = str(form.get("calibre_opds_password") or "")
- calibre_clear = coerce_bool(form.get("calibre_opds_password_clear"), False)
- if calibre_password_input:
- calibre_password = calibre_password_input
- elif calibre_clear:
- calibre_password = ""
- else:
- calibre_password = str(current_calibre.get("password") or "")
- calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), defaults["calibre_opds"]["verify_ssl"])
- cfg["calibre_opds"] = {
- "enabled": calibre_enabled,
- "base_url": calibre_base,
- "username": calibre_username,
- "password": calibre_password,
- "verify_ssl": calibre_verify,
- }
-
- current_abs = dict(cfg.get("audiobookshelf") or {})
- abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
- abs_base = str(form.get("audiobookshelf_base_url") or current_abs.get("base_url") or "").strip()
- abs_library = str(form.get("audiobookshelf_library_id") or current_abs.get("library_id") or "").strip()
- abs_collection = str(form.get("audiobookshelf_collection_id") or current_abs.get("collection_id") or "").strip()
- abs_folder = str(form.get("audiobookshelf_folder_id") or current_abs.get("folder_id") or "").strip()
- abs_token_input = str(form.get("audiobookshelf_api_token") or "")
- abs_token_clear = coerce_bool(form.get("audiobookshelf_api_token_clear"), False)
- if abs_token_input:
- abs_token = abs_token_input
- elif abs_token_clear:
- abs_token = ""
- else:
- abs_token = str(current_abs.get("api_token") or "")
- abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), defaults["audiobookshelf"]["verify_ssl"])
- abs_send_cover = coerce_bool(form.get("audiobookshelf_send_cover"), defaults["audiobookshelf"]["send_cover"])
- abs_send_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), defaults["audiobookshelf"]["send_chapters"])
- abs_send_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), defaults["audiobookshelf"]["send_subtitles"])
- abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), defaults["audiobookshelf"]["auto_send"])
- timeout_raw = form.get("audiobookshelf_timeout", current_abs.get("timeout", defaults["audiobookshelf"]["timeout"]))
- try:
- abs_timeout = float(timeout_raw)
- except (TypeError, ValueError):
- abs_timeout = defaults["audiobookshelf"]["timeout"]
- cfg["audiobookshelf"] = {
- "enabled": abs_enabled,
- "base_url": abs_base,
- "api_token": abs_token,
- "library_id": abs_library,
- "collection_id": abs_collection,
- "folder_id": abs_folder,
- "verify_ssl": abs_verify,
- "send_cover": abs_send_cover,
- "send_chapters": abs_send_chapters,
- "send_subtitles": abs_send_subtitles,
- "auto_send": abs_auto_send,
- "timeout": abs_timeout,
- }
-
-
-def save_settings(settings: Dict[str, Any]) -> None:
- save_config(settings)
+import os
+import re
+from typing import Any, Dict, Mapping, Optional
+
+from abogen.constants import (
+ LANGUAGE_DESCRIPTIONS,
+ SUBTITLE_FORMATS,
+ SUPPORTED_SOUND_FORMATS,
+)
+from abogen.tts_plugin.utils import get_default_voice
+from abogen.normalization_settings import (
+ DEFAULT_LLM_PROMPT,
+ environment_llm_defaults,
+)
+from abogen.utils import load_config, save_config
+from abogen.integrations.calibre_opds import CalibreOPDSClient
+from abogen.integrations.audiobookshelf import AudiobookshelfConfig
+from abogen.webui.routes.utils.common import split_profile_spec
+
+SAVE_MODE_LABELS = {
+ "save_next_to_input": "Save next to input file",
+ "save_to_desktop": "Save to Desktop",
+ "choose_output_folder": "Choose output folder",
+ "default_output": "Use default save location",
+}
+
+LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()}
+
+_CHUNK_LEVEL_OPTIONS = [
+ {"value": "paragraph", "label": "Paragraphs"},
+ {"value": "sentence", "label": "Sentences"},
+]
+
+_CHUNK_LEVEL_VALUES = {option["value"] for option in _CHUNK_LEVEL_OPTIONS}
+
+_DEFAULT_ANALYSIS_THRESHOLD = 3
+
+_APOSTROPHE_MODE_OPTIONS = [
+ {"value": "off", "label": "Off"},
+ {"value": "spacy", "label": "spaCy (built-in)"},
+ {"value": "llm", "label": "LLM assisted"},
+]
+
+_NORMALIZATION_BOOLEAN_KEYS = {
+ "normalization_numbers",
+ "normalization_titles",
+ "normalization_terminal",
+ "normalization_phoneme_hints",
+ "normalization_caps_quotes",
+ "normalization_currency",
+ "normalization_footnotes",
+ "normalization_internet_slang",
+ "normalization_apostrophes_contractions",
+ "normalization_apostrophes_plural_possessives",
+ "normalization_apostrophes_sibilant_possessives",
+ "normalization_apostrophes_decades",
+ "normalization_apostrophes_leading_elisions",
+ "normalization_contraction_aux_be",
+ "normalization_contraction_aux_have",
+ "normalization_contraction_modal_will",
+ "normalization_contraction_modal_would",
+ "normalization_contraction_negation_not",
+ "normalization_contraction_let_us",
+}
+
+_NORMALIZATION_STRING_KEYS = {
+ "normalization_numbers_year_style",
+ "normalization_apostrophe_mode",
+}
+
+BOOLEAN_SETTINGS = {
+ "replace_single_newlines",
+ "use_gpu",
+ "save_chapters_separately",
+ "merge_chapters_at_end",
+ "save_as_project",
+ "generate_epub3",
+ "enable_entity_recognition",
+ "read_title_intro",
+ "read_closing_outro",
+ "auto_prefix_chapter_titles",
+ "normalize_chapter_opening_caps",
+ "normalization_numbers",
+ "normalization_titles",
+ "normalization_terminal",
+ "normalization_phoneme_hints",
+ "normalization_caps_quotes",
+ "normalization_currency",
+ "normalization_footnotes",
+ "normalization_internet_slang",
+ "normalization_apostrophes_contractions",
+ "normalization_apostrophes_plural_possessives",
+ "normalization_apostrophes_sibilant_possessives",
+ "normalization_apostrophes_decades",
+ "normalization_apostrophes_leading_elisions",
+ "normalization_contraction_aux_be",
+ "normalization_contraction_aux_have",
+ "normalization_contraction_modal_will",
+ "normalization_contraction_modal_would",
+ "normalization_contraction_negation_not",
+ "normalization_contraction_let_us",
+}
+
+FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay", "llm_timeout"}
+INT_SETTINGS = {"max_subtitle_words", "speaker_analysis_threshold"}
+
+_NORMALIZATION_GROUPS = [
+ {
+ "label": "General Rules",
+ "options": [
+ {"key": "normalization_numbers", "label": "Convert grouped numbers to words"},
+ {"key": "normalization_currency", "label": "Convert currency symbols ($10 → ten dollars)"},
+ {"key": "normalization_titles", "label": "Expand titles and suffixes (Dr., St., Jr., …)"},
+ {"key": "normalization_internet_slang", "label": "Expand internet slang (pls → please)"},
+ {"key": "normalization_footnotes", "label": "Remove footnote indicators ([1], [2])"},
+ {"key": "normalization_terminal", "label": "Ensure sentences end with terminal punctuation"},
+ {"key": "normalization_caps_quotes", "label": "Convert ALL CAPS dialogue inside quotes"},
+ ]
+ },
+ {
+ "label": "Apostrophes & Contractions",
+ "options": [
+ {"key": "normalization_apostrophes_contractions", "label": "Expand contractions (it's → it is)"},
+ {"key": "normalization_apostrophes_plural_possessives", "label": "Collapse plural possessives (dogs' → dogs)"},
+ {"key": "normalization_apostrophes_sibilant_possessives", "label": "Mark sibilant possessives (boss's → boss + IZ marker)"},
+ {"key": "normalization_apostrophes_decades", "label": "Expand decades ('90s → 1990s)"},
+ {"key": "normalization_apostrophes_leading_elisions", "label": "Expand leading elisions ('tis → it is)"},
+ {"key": "normalization_phoneme_hints", "label": "Add phoneme hints for possessives"},
+ {"key": "normalization_contraction_aux_be", "label": "Expand auxiliary 'be' (I'm → I am)"},
+ {"key": "normalization_contraction_aux_have", "label": "Expand auxiliary 'have' (I've → I have)"},
+ {"key": "normalization_contraction_modal_will", "label": "Expand modal 'will' (I'll → I will)"},
+ {"key": "normalization_contraction_modal_would", "label": "Expand modal 'would' (I'd → I would)"},
+ {"key": "normalization_contraction_negation_not", "label": "Expand negation 'not' (don't → do not)"},
+ {"key": "normalization_contraction_let_us", "label": "Expand 'let's' → let us"},
+ ]
+ }
+]
+
+
+def integration_defaults() -> Dict[str, Dict[str, Any]]:
+ return {
+ "calibre_opds": {
+ "enabled": False,
+ "base_url": "",
+ "username": "",
+ "password": "",
+ "verify_ssl": True,
+ },
+ "audiobookshelf": {
+ "enabled": False,
+ "base_url": "",
+ "api_token": "",
+ "library_id": "",
+ "collection_id": "",
+ "folder_id": "",
+ "verify_ssl": True,
+ "send_cover": True,
+ "send_chapters": True,
+ "send_subtitles": False,
+ "auto_send": False,
+ "timeout": 30.0,
+ },
+ }
+
+
+def has_output_override() -> bool:
+ return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT"))
+
+
+def settings_defaults() -> Dict[str, Any]:
+ llm_env_defaults = environment_llm_defaults()
+ return {
+ "output_format": "wav",
+ "subtitle_format": "srt",
+ "save_mode": "default_output" if has_output_override() else "save_next_to_input",
+ "default_speaker": "",
+ "default_voice": get_default_voice("kokoro"),
+ "supertonic_total_steps": 5,
+ "supertonic_speed": 1.0,
+ "replace_single_newlines": False,
+ "use_gpu": True,
+ "save_chapters_separately": False,
+ "merge_chapters_at_end": True,
+ "save_as_project": False,
+ "separate_chapters_format": "wav",
+ "silence_between_chapters": 2.0,
+ "chapter_intro_delay": 0.5,
+ "read_title_intro": False,
+ "read_closing_outro": True,
+ "normalize_chapter_opening_caps": True,
+ "max_subtitle_words": 50,
+ "chunk_level": "paragraph",
+ "enable_entity_recognition": True,
+ "generate_epub3": False,
+ "auto_prefix_chapter_titles": True,
+ "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD,
+ "speaker_pronunciation_sentence": "This is {{name}} speaking.",
+ "speaker_random_languages": [],
+ "llm_base_url": llm_env_defaults.get("llm_base_url", ""),
+ "llm_api_key": llm_env_defaults.get("llm_api_key", ""),
+ "llm_model": llm_env_defaults.get("llm_model", ""),
+ "llm_timeout": llm_env_defaults.get("llm_timeout", 30.0),
+ "llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT),
+ "llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"),
+ "normalization_numbers": True,
+ "normalization_currency": True,
+ "normalization_footnotes": True,
+ "normalization_titles": True,
+ "normalization_terminal": True,
+ "normalization_phoneme_hints": True,
+ "normalization_caps_quotes": True,
+ "normalization_internet_slang": False,
+ "normalization_apostrophes_contractions": True,
+ "normalization_apostrophes_plural_possessives": True,
+ "normalization_apostrophes_sibilant_possessives": True,
+ "normalization_apostrophes_decades": True,
+ "normalization_apostrophes_leading_elisions": True,
+ "normalization_apostrophe_mode": "spacy",
+ "normalization_numbers_year_style": "american",
+ "normalization_contraction_aux_be": True,
+ "normalization_contraction_aux_have": True,
+ "normalization_contraction_modal_will": True,
+ "normalization_contraction_modal_would": True,
+ "normalization_contraction_negation_not": True,
+ "normalization_contraction_let_us": True,
+ }
+
+
+def llm_ready(settings: Mapping[str, Any]) -> bool:
+ base_url = str(settings.get("llm_base_url") or "").strip()
+ return bool(base_url)
+
+
+_PROMPT_TOKEN_RE = re.compile(r"{{\s*([a-zA-Z0-9_]+)\s*}}")
+
+
+def render_prompt_template(template: str, context: Mapping[str, str]) -> str:
+ if not template:
+ return ""
+
+ def _replace(match: re.Match[str]) -> str:
+ key = match.group(1)
+ return context.get(key, "")
+
+ return _PROMPT_TOKEN_RE.sub(_replace, template)
+
+
+def coerce_bool(value: Any, default: bool) -> bool:
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.lower() in {"true", "1", "yes", "on"}
+ if value is None:
+ return default
+ return bool(value)
+
+
+def coerce_float(value: Any, default: float) -> float:
+ try:
+ return max(0.0, float(value))
+ except (TypeError, ValueError):
+ return default
+
+
+def coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ return default
+ return max(minimum, min(parsed, maximum))
+
+
+def normalize_save_mode(value: Any, default: str) -> str:
+ if isinstance(value, str):
+ if value in SAVE_MODE_LABELS:
+ return value
+ if value in LEGACY_SAVE_MODE_MAP:
+ return LEGACY_SAVE_MODE_MAP[value]
+ return default
+
+
+def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any:
+ if key in BOOLEAN_SETTINGS:
+ return coerce_bool(value, defaults[key])
+ if key in FLOAT_SETTINGS:
+ return coerce_float(value, defaults[key])
+ if key in INT_SETTINGS:
+ return coerce_int(value, defaults[key])
+ if key == "save_mode":
+ return normalize_save_mode(value, defaults[key])
+ if key == "output_format":
+ return value if value in SUPPORTED_SOUND_FORMATS else defaults[key]
+ if key == "subtitle_format":
+ valid = {item[0] for item in SUBTITLE_FORMATS}
+ return value if value in valid else defaults[key]
+ if key == "separate_chapters_format":
+ if isinstance(value, str):
+ normalized = value.lower()
+ if normalized in {"wav", "flac", "mp3", "opus"}:
+ return normalized
+ return defaults[key]
+ if key == "default_voice":
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ return defaults[key]
+ spec, profile_name = split_profile_spec(text)
+ if profile_name:
+ return f"speaker:{profile_name}"
+ return spec
+ return defaults[key]
+ if key == "default_speaker":
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ return ""
+ spec, profile_name = split_profile_spec(text)
+ if profile_name:
+ return f"speaker:{profile_name}"
+ return spec
+ return ""
+ if key == "chunk_level":
+ if isinstance(value, str) and value in _CHUNK_LEVEL_VALUES:
+ return value
+ return defaults[key]
+ if key == "normalization_apostrophe_mode":
+ if isinstance(value, str):
+ normalized_mode = value.strip().lower()
+ if normalized_mode in {"off", "spacy", "llm"}:
+ return normalized_mode
+ return defaults[key]
+ if key == "normalization_numbers_year_style":
+ if isinstance(value, str):
+ normalized_style = value.strip().lower()
+ if normalized_style in {"american", "off"}:
+ return normalized_style
+ return defaults[key]
+ if key == "llm_context_mode":
+ if isinstance(value, str):
+ normalized_scope = value.strip().lower()
+ if normalized_scope == "sentence":
+ return normalized_scope
+ return defaults[key]
+ if key == "llm_prompt":
+ candidate = str(value or "").strip()
+ return candidate if candidate else defaults[key]
+ if key in {"llm_base_url", "llm_api_key", "llm_model"}:
+ return str(value or "").strip()
+ if key == "speaker_random_languages":
+ if isinstance(value, (list, tuple, set)):
+ return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS]
+ 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 defaults.get(key, [])
+ if key == "supertonic_total_steps":
+ try:
+ steps = int(value)
+ except (TypeError, ValueError):
+ return defaults.get(key, 5)
+ return max(2, min(15, steps))
+ if key == "supertonic_speed":
+ try:
+ speed = float(value)
+ except (TypeError, ValueError):
+ return defaults.get(key, 1.0)
+ return max(0.7, min(2.0, speed))
+ return value if value is not None else defaults.get(key)
+
+
+def load_settings() -> Dict[str, Any]:
+ defaults = settings_defaults()
+ cfg = load_config() or {}
+ settings: Dict[str, Any] = {}
+ for key, default in defaults.items():
+ raw_value = cfg.get(key, default)
+ settings[key] = normalize_setting_value(key, raw_value, defaults)
+ return settings
+
+
+def load_integration_settings() -> Dict[str, Dict[str, Any]]:
+ defaults = integration_defaults()
+ cfg = load_config() or {}
+ # Integrations are stored under the "integrations" key in the config
+ stored_integrations = cfg.get("integrations", {})
+ if not isinstance(stored_integrations, Mapping):
+ stored_integrations = {}
+
+ integrations: Dict[str, Dict[str, Any]] = {}
+ for key, default in defaults.items():
+ stored = stored_integrations.get(key)
+ merged: Dict[str, Any] = dict(default)
+ if isinstance(stored, Mapping):
+ for field, default_value in default.items():
+ value = stored.get(field, default_value)
+ if isinstance(default_value, bool):
+ merged[field] = coerce_bool(value, default_value)
+ elif isinstance(default_value, float):
+ try:
+ merged[field] = float(value)
+ except (TypeError, ValueError):
+ merged[field] = default_value
+ elif isinstance(default_value, int):
+ try:
+ merged[field] = int(value)
+ except (TypeError, ValueError):
+ merged[field] = default_value
+ else:
+ merged[field] = str(value or "")
+ if key == "calibre_opds":
+ merged["has_password"] = bool(isinstance(stored, Mapping) and stored.get("password"))
+ # Do not clear the password here, let the template decide whether to show it or not
+ # merged["password"] = ""
+ elif key == "audiobookshelf":
+ merged["has_api_token"] = bool(isinstance(stored, Mapping) and stored.get("api_token"))
+ # Do not clear the token here
+ # merged["api_token"] = ""
+ integrations[key] = merged
+
+ # Environment variable fallbacks for Calibre OPDS
+ calibre = integrations["calibre_opds"]
+ if not calibre.get("base_url"):
+ calibre["base_url"] = os.environ.get("CALIBRE_SERVER_HOST", "")
+ if not calibre.get("username"):
+ calibre["username"] = os.environ.get("OPDS_USERNAME", "")
+ if not calibre.get("password"):
+ calibre["password"] = os.environ.get("OPDS_PASSWORD", "")
+
+ # If we have a password (from storage or env), mark it as present for the UI
+ if calibre.get("password"):
+ calibre["has_password"] = True
+
+ # Auto-enable if configured via env but not explicitly disabled in config
+ stored_calibre = stored_integrations.get("calibre_opds")
+ if stored_calibre is None and calibre.get("base_url"):
+ calibre["enabled"] = True
+
+ return integrations
+
+
+def stored_integration_config(name: str) -> Dict[str, Any]:
+ cfg = load_config() or {}
+ # Check under "integrations" first (new structure)
+ integrations = cfg.get("integrations")
+ if isinstance(integrations, Mapping):
+ entry = integrations.get(name)
+ if isinstance(entry, Mapping):
+ return dict(entry)
+
+ # Fallback to top-level (legacy structure)
+ entry = cfg.get(name)
+ if isinstance(entry, Mapping):
+ return dict(entry)
+ return {}
+
+
+def calibre_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
+ defaults = integration_defaults()["calibre_opds"]
+ stored = stored_integration_config("calibre_opds")
+
+ base_url = str(
+ payload.get("base_url")
+ or payload.get("calibre_opds_base_url")
+ or stored.get("base_url")
+ or ""
+ ).strip()
+ username = str(
+ payload.get("username")
+ or payload.get("calibre_opds_username")
+ or stored.get("username")
+ or ""
+ ).strip()
+ password_input = str(
+ payload.get("password")
+ or payload.get("calibre_opds_password")
+ or ""
+ ).strip()
+ use_saved_password = coerce_bool(
+ payload.get("use_saved_password")
+ or payload.get("calibre_opds_use_saved_password"),
+ False,
+ )
+ clear_saved_password = coerce_bool(
+ payload.get("clear_saved_password")
+ or payload.get("calibre_opds_password_clear"),
+ False,
+ )
+ password = ""
+ if password_input:
+ password = password_input
+ elif use_saved_password and not clear_saved_password:
+ password = str(stored.get("password") or "")
+
+ verify_ssl = coerce_bool(
+ payload.get("verify_ssl")
+ or payload.get("calibre_opds_verify_ssl"),
+ defaults["verify_ssl"],
+ )
+ enabled = coerce_bool(
+ payload.get("enabled")
+ or payload.get("calibre_opds_enabled"),
+ coerce_bool(stored.get("enabled"), False),
+ )
+
+ return {
+ "enabled": enabled,
+ "base_url": base_url,
+ "username": username,
+ "password": password,
+ "verify_ssl": verify_ssl,
+ }
+
+
+def audiobookshelf_settings_from_payload(payload: Mapping[str, Any]) -> Dict[str, Any]:
+ defaults = integration_defaults()["audiobookshelf"]
+ stored = stored_integration_config("audiobookshelf")
+
+ base_url = str(
+ payload.get("base_url")
+ or payload.get("audiobookshelf_base_url")
+ or stored.get("base_url")
+ or ""
+ ).strip()
+ library_id = str(
+ payload.get("library_id")
+ or payload.get("audiobookshelf_library_id")
+ or stored.get("library_id")
+ or ""
+ ).strip()
+ collection_id = str(
+ payload.get("collection_id")
+ or payload.get("audiobookshelf_collection_id")
+ or stored.get("collection_id")
+ or ""
+ ).strip()
+ folder_id = str(
+ payload.get("folder_id")
+ or payload.get("audiobookshelf_folder_id")
+ or stored.get("folder_id")
+ or ""
+ ).strip()
+ token_input = str(
+ payload.get("api_token")
+ or payload.get("audiobookshelf_api_token")
+ or ""
+ ).strip()
+ use_saved_token = coerce_bool(
+ payload.get("use_saved_token")
+ or payload.get("audiobookshelf_use_saved_token"),
+ False,
+ )
+ clear_saved_token = coerce_bool(
+ payload.get("clear_saved_token")
+ or payload.get("audiobookshelf_api_token_clear"),
+ False,
+ )
+ if token_input:
+ api_token = token_input
+ elif use_saved_token and not clear_saved_token:
+ api_token = str(stored.get("api_token") or "")
+ else:
+ api_token = ""
+
+ verify_ssl = coerce_bool(
+ payload.get("verify_ssl")
+ or payload.get("audiobookshelf_verify_ssl"),
+ defaults["verify_ssl"],
+ )
+ send_cover = coerce_bool(
+ payload.get("send_cover")
+ or payload.get("audiobookshelf_send_cover"),
+ defaults["send_cover"],
+ )
+ send_chapters = coerce_bool(
+ payload.get("send_chapters")
+ or payload.get("audiobookshelf_send_chapters"),
+ defaults["send_chapters"],
+ )
+ send_subtitles = coerce_bool(
+ payload.get("send_subtitles")
+ or payload.get("audiobookshelf_send_subtitles"),
+ defaults["send_subtitles"],
+ )
+ auto_send = coerce_bool(
+ payload.get("auto_send")
+ or payload.get("audiobookshelf_auto_send"),
+ defaults["auto_send"],
+ )
+ timeout_raw = (
+ payload.get("timeout")
+ or payload.get("audiobookshelf_timeout")
+ or stored.get("timeout")
+ or defaults["timeout"]
+ )
+ try:
+ timeout = float(timeout_raw)
+ except (TypeError, ValueError):
+ timeout = defaults["timeout"]
+
+ enabled = coerce_bool(
+ payload.get("enabled")
+ or payload.get("audiobookshelf_enabled"),
+ coerce_bool(stored.get("enabled"), False),
+ )
+
+ return {
+ "enabled": enabled,
+ "base_url": base_url,
+ "library_id": library_id,
+ "collection_id": collection_id,
+ "folder_id": folder_id,
+ "api_token": api_token,
+ "verify_ssl": verify_ssl,
+ "send_cover": send_cover,
+ "send_chapters": send_chapters,
+ "send_subtitles": send_subtitles,
+ "auto_send": auto_send,
+ "timeout": timeout,
+ }
+
+
+def build_audiobookshelf_config(settings: Mapping[str, Any]) -> Optional[AudiobookshelfConfig]:
+ base_url = str(settings.get("base_url") or "").strip()
+ api_token = str(settings.get("api_token") or "").strip()
+ library_id = str(settings.get("library_id") or "").strip()
+ if not (base_url and api_token and library_id):
+ return None
+ try:
+ timeout = float(settings.get("timeout", 3600.0))
+ except (TypeError, ValueError):
+ timeout = 3600.0
+ return AudiobookshelfConfig(
+ base_url=base_url,
+ api_token=api_token,
+ library_id=library_id,
+ collection_id=(str(settings.get("collection_id") or "").strip() or None),
+ folder_id=(str(settings.get("folder_id") or "").strip() or None),
+ verify_ssl=coerce_bool(settings.get("verify_ssl"), True),
+ send_cover=coerce_bool(settings.get("send_cover"), True),
+ send_chapters=coerce_bool(settings.get("send_chapters"), True),
+ send_subtitles=coerce_bool(settings.get("send_subtitles"), False),
+ timeout=timeout,
+ )
+
+
+def calibre_integration_enabled(
+ integrations: Optional[Mapping[str, Any]] = None,
+) -> bool:
+ if integrations is None:
+ integrations = load_integration_settings()
+ payload = integrations.get("calibre_opds") if isinstance(integrations, Mapping) else None
+ if not isinstance(payload, Mapping):
+ return False
+ base_url = str(payload.get("base_url") or "").strip()
+ enabled_flag = coerce_bool(payload.get("enabled"), False)
+ return bool(enabled_flag and base_url)
+
+
+def audiobookshelf_manual_available() -> bool:
+ settings = stored_integration_config("audiobookshelf")
+ if not settings:
+ return False
+ return coerce_bool(settings.get("enabled"), False)
+
+
+def build_calibre_client(settings: Mapping[str, Any]) -> CalibreOPDSClient:
+ base_url = str(settings.get("base_url") or "").strip()
+ if not base_url:
+ raise ValueError("Calibre OPDS base URL is required")
+ username = str(settings.get("username") or "").strip() or None
+ password = str(settings.get("password") or "").strip() or None
+ verify_ssl = coerce_bool(settings.get("verify_ssl"), True)
+ timeout_raw = settings.get("timeout", 15.0)
+ try:
+ timeout = float(timeout_raw)
+ except (TypeError, ValueError):
+ timeout = 15.0
+ return CalibreOPDSClient(
+ base_url,
+ username=username,
+ password=password,
+ timeout=timeout,
+ verify=verify_ssl,
+ )
+
+
+def apply_integration_form(cfg: Dict[str, Any], form: Mapping[str, Any]) -> None:
+ defaults = integration_defaults()
+
+ current_calibre = dict(cfg.get("calibre_opds") or {})
+ calibre_enabled = coerce_bool(form.get("calibre_opds_enabled"), False)
+ calibre_base = str(form.get("calibre_opds_base_url") or current_calibre.get("base_url") or "").strip()
+ calibre_username = str(form.get("calibre_opds_username") or current_calibre.get("username") or "").strip()
+ calibre_password_input = str(form.get("calibre_opds_password") or "")
+ calibre_clear = coerce_bool(form.get("calibre_opds_password_clear"), False)
+ if calibre_password_input:
+ calibre_password = calibre_password_input
+ elif calibre_clear:
+ calibre_password = ""
+ else:
+ calibre_password = str(current_calibre.get("password") or "")
+ calibre_verify = coerce_bool(form.get("calibre_opds_verify_ssl"), defaults["calibre_opds"]["verify_ssl"])
+ cfg["calibre_opds"] = {
+ "enabled": calibre_enabled,
+ "base_url": calibre_base,
+ "username": calibre_username,
+ "password": calibre_password,
+ "verify_ssl": calibre_verify,
+ }
+
+ current_abs = dict(cfg.get("audiobookshelf") or {})
+ abs_enabled = coerce_bool(form.get("audiobookshelf_enabled"), False)
+ abs_base = str(form.get("audiobookshelf_base_url") or current_abs.get("base_url") or "").strip()
+ abs_library = str(form.get("audiobookshelf_library_id") or current_abs.get("library_id") or "").strip()
+ abs_collection = str(form.get("audiobookshelf_collection_id") or current_abs.get("collection_id") or "").strip()
+ abs_folder = str(form.get("audiobookshelf_folder_id") or current_abs.get("folder_id") or "").strip()
+ abs_token_input = str(form.get("audiobookshelf_api_token") or "")
+ abs_token_clear = coerce_bool(form.get("audiobookshelf_api_token_clear"), False)
+ if abs_token_input:
+ abs_token = abs_token_input
+ elif abs_token_clear:
+ abs_token = ""
+ else:
+ abs_token = str(current_abs.get("api_token") or "")
+ abs_verify = coerce_bool(form.get("audiobookshelf_verify_ssl"), defaults["audiobookshelf"]["verify_ssl"])
+ abs_send_cover = coerce_bool(form.get("audiobookshelf_send_cover"), defaults["audiobookshelf"]["send_cover"])
+ abs_send_chapters = coerce_bool(form.get("audiobookshelf_send_chapters"), defaults["audiobookshelf"]["send_chapters"])
+ abs_send_subtitles = coerce_bool(form.get("audiobookshelf_send_subtitles"), defaults["audiobookshelf"]["send_subtitles"])
+ abs_auto_send = coerce_bool(form.get("audiobookshelf_auto_send"), defaults["audiobookshelf"]["auto_send"])
+ timeout_raw = form.get("audiobookshelf_timeout", current_abs.get("timeout", defaults["audiobookshelf"]["timeout"]))
+ try:
+ abs_timeout = float(timeout_raw)
+ except (TypeError, ValueError):
+ abs_timeout = defaults["audiobookshelf"]["timeout"]
+ cfg["audiobookshelf"] = {
+ "enabled": abs_enabled,
+ "base_url": abs_base,
+ "api_token": abs_token,
+ "library_id": abs_library,
+ "collection_id": abs_collection,
+ "folder_id": abs_folder,
+ "verify_ssl": abs_verify,
+ "send_cover": abs_send_cover,
+ "send_chapters": abs_send_chapters,
+ "send_subtitles": abs_send_subtitles,
+ "auto_send": abs_auto_send,
+ "timeout": abs_timeout,
+ }
+
+
+def save_settings(settings: Dict[str, Any]) -> None:
+ save_config(settings)
diff --git a/abogen/webui/routes/utils/voice.py b/abogen/webui/routes/utils/voice.py
index 80c8960..cdb756b 100644
--- a/abogen/webui/routes/utils/voice.py
+++ b/abogen/webui/routes/utils/voice.py
@@ -1,808 +1,808 @@
-import threading
-from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
-import numpy as np
-
-from abogen.speaker_configs import slugify_label
-from abogen.speaker_analysis import analyze_speakers
-from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS
-from abogen.webui.routes.utils.common import split_profile_spec
-from abogen.voice_profiles import (
- load_profiles,
- serialize_profiles,
-)
-from abogen.voice_formulas import get_new_voice, parse_formula_terms
-from abogen.constants import (
- LANGUAGE_DESCRIPTIONS,
- SUBTITLE_FORMATS,
- SUPPORTED_SOUND_FORMATS,
- SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
- SAMPLE_VOICE_TEXTS,
-)
-from abogen.tts_plugin.utils import get_voices
-from abogen.speaker_configs import list_configs
-from abogen.tts_plugin.utils import create_pipeline
-from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
-
-_preview_pipeline_lock = threading.RLock()
-_preview_pipelines: Dict[Tuple[str, str], Any] = {}
-
-def build_narrator_roster(
- voice: str,
- voice_profile: Optional[str],
- existing: Optional[Mapping[str, Any]] = None,
-) -> Dict[str, Any]:
- roster: Dict[str, Any] = {
- "narrator": {
- "id": "narrator",
- "label": "Narrator",
- "voice": voice,
- }
- }
- if voice_profile:
- roster["narrator"]["voice_profile"] = voice_profile
- existing_entry: Optional[Mapping[str, Any]] = None
- if existing is not None:
- existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None
- if isinstance(existing_entry, Mapping):
- roster_entry = roster["narrator"]
- for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"):
- value = existing_entry.get(key)
- if value is not None and value != "":
- roster_entry[key] = value
- return roster
-
-
-def build_speaker_roster(
- analysis: Dict[str, Any],
- base_voice: str,
- voice_profile: Optional[str],
- existing: Optional[Mapping[str, Any]] = None,
- order: Optional[Iterable[str]] = None,
-) -> Dict[str, Any]:
- roster = build_narrator_roster(base_voice, voice_profile, existing)
- existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
- speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
- ordered_ids: Iterable[str]
- if order is not None:
- ordered_ids = [sid for sid in order if sid in speakers]
- else:
- ordered_ids = speakers.keys()
-
- for speaker_id in ordered_ids:
- payload = speakers.get(speaker_id, {})
- if speaker_id == "narrator":
- continue
- if isinstance(payload, Mapping) and payload.get("suppressed"):
- continue
- previous = existing_map.get(speaker_id)
- roster[speaker_id] = {
- "id": speaker_id,
- "label": payload.get("label") or speaker_id.replace("_", " ").title(),
- "analysis_confidence": payload.get("confidence"),
- "analysis_count": payload.get("count"),
- "gender": payload.get("gender", "unknown"),
- }
- detected_gender = payload.get("detected_gender")
- if detected_gender:
- roster[speaker_id]["detected_gender"] = detected_gender
- samples = payload.get("sample_quotes")
- if isinstance(samples, list):
- roster[speaker_id]["sample_quotes"] = samples
- if isinstance(previous, Mapping):
- for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
- value = previous.get(key)
- if value is not None and value != "":
- roster[speaker_id][key] = value
- if "sample_quotes" not in roster[speaker_id]:
- prev_samples = previous.get("sample_quotes")
- if isinstance(prev_samples, list):
- roster[speaker_id]["sample_quotes"] = prev_samples
- if "detected_gender" not in roster[speaker_id]:
- prev_detected = previous.get("detected_gender")
- if isinstance(prev_detected, str) and prev_detected:
- roster[speaker_id]["detected_gender"] = prev_detected
- return roster
-
-
-def match_configured_speaker(
- config_speakers: Mapping[str, Any],
- roster_id: str,
- roster_label: str,
-) -> Optional[Mapping[str, Any]]:
- if not config_speakers:
- return None
- entry = config_speakers.get(roster_id)
- if entry:
- return cast(Mapping[str, Any], entry)
- slug = slugify_label(roster_label)
- if slug != roster_id and slug in config_speakers:
- return cast(Mapping[str, Any], config_speakers[slug])
- lower_label = roster_label.strip().lower()
- for record in config_speakers.values():
- if not isinstance(record, Mapping):
- continue
- if str(record.get("label", "")).strip().lower() == lower_label:
- return record
- return None
-
-
-def apply_speaker_config_to_roster(
- roster: Mapping[str, Any],
- config: Optional[Mapping[str, Any]],
- *,
- persist_changes: bool = False,
- fallback_languages: Optional[Iterable[str]] = None,
-) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
- if not isinstance(roster, Mapping):
- effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
- return {}, effective_languages, None
- updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
- if not config:
- effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
- return updated_roster, effective_languages, None
-
- speakers_map = config.get("speakers")
- if not isinstance(speakers_map, Mapping):
- effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
- return updated_roster, effective_languages, None
-
- config_languages = config.get("languages")
- if isinstance(config_languages, list):
- allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
- else:
- allowed_languages = []
- if not allowed_languages and fallback_languages:
- allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
-
- default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
- used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
- narrator_voice = ""
- narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
- if isinstance(narrator_entry, Mapping):
- narrator_voice = str(
- narrator_entry.get("resolved_voice")
- or narrator_entry.get("default_voice")
- or ""
- ).strip()
- if narrator_voice:
- used_voices.add(narrator_voice)
-
- config_changed = False
- new_config_payload: Dict[str, Any] = {
- "language": config.get("language", "a"),
- "languages": allowed_languages,
- "default_voice": default_voice,
- "speakers": dict(speakers_map),
- "version": config.get("version", 1),
- "notes": config.get("notes", ""),
- }
-
- speakers_payload = new_config_payload["speakers"]
-
- for speaker_id, roster_entry in updated_roster.items():
- if speaker_id == "narrator":
- continue
- label = str(roster_entry.get("label") or speaker_id)
- config_entry = match_configured_speaker(speakers_map, speaker_id, label)
- if config_entry is None:
- continue
- voice_id = str(config_entry.get("voice") or "").strip()
- voice_profile = str(config_entry.get("voice_profile") or "").strip()
- voice_formula = str(config_entry.get("voice_formula") or "").strip()
- resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
- languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
- chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
- usable_languages = languages or allowed_languages
-
- if chosen_voice:
- roster_entry["resolved_voice"] = chosen_voice
- roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
- if voice_profile:
- roster_entry["voice_profile"] = voice_profile
- if voice_formula:
- roster_entry["voice_formula"] = voice_formula
- roster_entry["resolved_voice"] = voice_formula
- if not voice_formula and not voice_profile and resolved_voice:
- roster_entry["resolved_voice"] = resolved_voice
- roster_entry["config_languages"] = usable_languages or []
-
- if chosen_voice:
- used_voices.add(chosen_voice)
-
- # persist updates back to config payload if required
- if persist_changes:
- slug = config_entry.get("id") or slugify_label(label)
- speakers_payload[slug] = {
- "id": slug,
- "label": label,
- "gender": config_entry.get("gender", "unknown"),
- "voice": voice_id,
- "voice_profile": voice_profile,
- "voice_formula": voice_formula,
- "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
- "languages": usable_languages,
- }
-
- new_config = new_config_payload if (persist_changes and config_changed) else None
- return updated_roster, allowed_languages, new_config
-
-
-def filter_voice_catalog(
- catalog: Iterable[Mapping[str, Any]],
- *,
- gender: str,
- allowed_languages: Optional[Iterable[str]] = None,
-) -> List[str]:
- allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
- gender_normalized = (gender or "unknown").lower()
- gender_code = ""
- if gender_normalized == "male":
- gender_code = "m"
- elif gender_normalized == "female":
- gender_code = "f"
-
- matches: List[str] = []
- seen: set[str] = set()
-
- def _consider(entry: Mapping[str, Any]) -> None:
- voice_id = entry.get("id")
- if not isinstance(voice_id, str) or not voice_id:
- return
- if voice_id in seen:
- return
- seen.add(voice_id)
- matches.append(voice_id)
-
- primary: List[Mapping[str, Any]] = []
- fallback: List[Mapping[str, Any]] = []
- for entry in catalog:
- if not isinstance(entry, Mapping):
- continue
- voice_lang = str(entry.get("language", "")).lower()
- voice_gender_code = str(entry.get("gender_code", "")).lower()
- if allowed_set and voice_lang not in allowed_set:
- continue
- if gender_code and voice_gender_code != gender_code:
- fallback.append(entry)
- continue
- primary.append(entry)
-
- for entry in primary:
- _consider(entry)
-
- if not matches:
- for entry in fallback:
- _consider(entry)
-
- if not matches:
- for entry in catalog:
- if isinstance(entry, Mapping):
- _consider(entry)
-
- return matches
-
-
-def build_voice_catalog() -> List[Dict[str, str]]:
- 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 ""
- catalog.append(
- {
- "id": voice_id,
- "language": language_code,
- "language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
- "gender": gender_map.get(gender_code, "Unknown"),
- "gender_code": gender_code,
- "display_name": rest.replace("_", " ").title() if rest else voice_id,
- }
- )
- return catalog
-
-
-def inject_recommended_voices(
- roster: Mapping[str, Any],
- *,
- fallback_languages: Optional[Iterable[str]] = None,
-) -> None:
- voice_catalog = build_voice_catalog()
- fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
- for speaker_id, payload in roster.items():
- if not isinstance(payload, dict):
- continue
- languages = payload.get("config_languages")
- if isinstance(languages, list) and languages:
- language_list = languages
- else:
- language_list = fallback_list
- gender = str(payload.get("gender", "unknown"))
- payload["recommended_voices"] = filter_voice_catalog(
- voice_catalog,
- gender=gender,
- allowed_languages=language_list,
- )
-
-
-def extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]:
- getter = getattr(form, "getlist", None)
-
- def _get_list(name: str) -> List[str]:
- if callable(getter):
- values = cast(Iterable[Any], getter(name))
- return [str(value).strip() for value in values if value]
- raw_value = form.get(name)
- if isinstance(raw_value, str):
- return [item.strip() for item in raw_value.split(",") if item.strip()]
- return []
-
- name = (form.get("config_name") or "").strip()
- language = str(form.get("config_language") or "a").strip() or "a"
- allowed_languages = []
- default_voice = (form.get("config_default_voice") or "").strip()
- notes = (form.get("config_notes") or "").strip()
-
- try:
- parsed = int(form.get("config_version") or 1)
- version = max(1, min(parsed, 9999))
- except (TypeError, ValueError):
- version = 1
-
- speaker_rows = _get_list("speaker_rows")
- speakers: Dict[str, Dict[str, Any]] = {}
- for row_key in speaker_rows:
- prefix = f"speaker-{row_key}-"
- label = (form.get(prefix + "label") or "").strip()
- if not label:
- continue
- raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower()
- gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown"
- voice = (form.get(prefix + "voice") or "").strip()
- voice_profile = (form.get(prefix + "profile") or "").strip()
- voice_formula = (form.get(prefix + "formula") or "").strip()
- speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
- speakers[speaker_id] = {
- "id": speaker_id,
- "label": label,
- "gender": gender,
- "voice": voice,
- "voice_profile": voice_profile,
- "voice_formula": voice_formula,
- "resolved_voice": voice_formula or voice,
- "languages": [],
- }
-
- payload = {
- "language": language,
- "languages": allowed_languages,
- "default_voice": default_voice,
- "speakers": speakers,
- "notes": notes,
- "version": version,
- }
-
- errors: List[str] = []
- if not name:
- errors.append("Configuration name is required.")
- if not speakers:
- errors.append("Add at least one speaker to the configuration.")
-
- return name, payload, errors
-
-
-def prepare_speaker_metadata(
- *,
- chapters: List[Dict[str, Any]],
- chunks: List[Dict[str, Any]],
- analysis_chunks: Optional[List[Dict[str, Any]]] = None,
- voice: str,
- voice_profile: Optional[str],
- threshold: int,
- existing_roster: Optional[Mapping[str, Any]] = None,
- run_analysis: bool = True,
- speaker_config: Optional[Mapping[str, Any]] = None,
- apply_config: bool = False,
- persist_config: bool = False,
-) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
- chunk_list = [dict(chunk) for chunk in chunks]
- analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
- threshold_value = max(1, int(threshold))
- analysis_enabled = run_analysis
- settings_state = load_settings()
- global_random_languages = [
- code
- for code in settings_state.get("speaker_random_languages", [])
- if isinstance(code, str) and code
- ]
-
- if not analysis_enabled:
- for chunk in chunk_list:
- chunk["speaker_id"] = "narrator"
- chunk["speaker_label"] = "Narrator"
- analysis_payload = {
- "version": "1.0",
- "narrator": "narrator",
- "assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list},
- "speakers": {
- "narrator": {
- "id": "narrator",
- "label": "Narrator",
- "count": len(chunk_list),
- "confidence": "low",
- "sample_quotes": [],
- "suppressed": False,
- }
- },
- "suppressed": [],
- "stats": {
- "total_chunks": len(chunk_list),
- "explicit_chunks": 0,
- "active_speakers": 0,
- "unique_speakers": 1,
- "suppressed": 0,
- },
- }
- roster = build_narrator_roster(voice, voice_profile, existing_roster)
- narrator_pron = roster["narrator"].get("pronunciation")
- if narrator_pron:
- analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
- return chunk_list, roster, analysis_payload, [], None
-
- analysis_result = analyze_speakers(
- chapters,
- analysis_source,
- threshold=threshold_value,
- max_speakers=0,
- )
- analysis_payload = analysis_result.to_dict()
- speakers_payload = analysis_payload.get("speakers", {})
- ordered_ids = [
- sid
- for sid, meta in sorted(
- (
- (sid, meta)
- for sid, meta in speakers_payload.items()
- if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
- ),
- key=lambda item: item[1].get("count", 0),
- reverse=True,
- )
- ]
- analysis_payload["ordered_speakers"] = ordered_ids
- assignments = analysis_payload.get("assignments", {})
- suppressed_ids = analysis_payload.get("suppressed", [])
- suppressed_details: List[Dict[str, Any]] = []
- speakers_payload = analysis_payload.get("speakers", {})
- if isinstance(suppressed_ids, Iterable):
- for suppressed_id in suppressed_ids:
- speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None
- if isinstance(speaker_meta, dict):
- suppressed_details.append(
- {
- "id": suppressed_id,
- "label": speaker_meta.get("label")
- or str(suppressed_id).replace("_", " ").title(),
- "pronunciation": speaker_meta.get("pronunciation"),
- }
- )
- else:
- suppressed_details.append(
- {
- "id": suppressed_id,
- "label": str(suppressed_id).replace("_", " ").title(),
- "pronunciation": None,
- }
- )
- analysis_payload["suppressed_details"] = suppressed_details
- roster = build_speaker_roster(
- analysis_payload,
- voice,
- voice_profile,
- existing=existing_roster,
- order=analysis_payload.get("ordered_speakers"),
- )
- applied_languages: List[str] = []
- updated_config: Optional[Dict[str, Any]] = None
- if apply_config and speaker_config:
- roster, applied_languages, updated_config = apply_speaker_config_to_roster(
- roster,
- speaker_config,
- persist_changes=persist_config,
- fallback_languages=global_random_languages,
- )
- speakers_payload = analysis_payload.get("speakers")
- if isinstance(speakers_payload, dict):
- for roster_id, roster_payload in roster.items():
- speaker_meta = speakers_payload.get(roster_id)
- if isinstance(speaker_meta, dict):
- for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
- value = roster_payload.get(key)
- if value:
- speaker_meta[key] = value
- effective_languages: List[str] = []
- if applied_languages:
- effective_languages = applied_languages
- elif isinstance(analysis_payload.get("config_languages"), list):
- effective_languages = [
- code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
- ]
- elif global_random_languages:
- effective_languages = list(global_random_languages)
-
- if effective_languages:
- analysis_payload["config_languages"] = effective_languages
- speakers_payload = analysis_payload.get("speakers")
- if isinstance(speakers_payload, dict):
- for roster_id, roster_payload in roster.items():
- if roster_id in speakers_payload and isinstance(roster_payload, dict):
- pronunciation_value = roster_payload.get("pronunciation")
- if pronunciation_value:
- speakers_payload[roster_id]["pronunciation"] = pronunciation_value
-
- fallback_languages = effective_languages or []
- inject_recommended_voices(roster, fallback_languages=fallback_languages)
-
- for chunk in chunk_list:
- chunk_id = str(chunk.get("id"))
- speaker_id = assignments.get(chunk_id, "narrator")
- chunk["speaker_id"] = speaker_id
- speaker_meta = roster.get(speaker_id)
- chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
-
- return chunk_list, roster, analysis_payload, applied_languages, updated_config
-
-
-def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
- voices = entry.get("voices") or []
- if not voices:
- return None
- total = sum(weight for _, weight in voices)
- if total <= 0:
- return None
-
- def _format_weight(value: float) -> str:
- normalized = value / total if total else 0.0
- return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
-
- parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
- return "+".join(parts) if parts else None
-
-
-def template_options() -> Dict[str, Any]:
- current_settings = load_settings()
- profiles = serialize_profiles()
- ordered_profiles = sorted(profiles.items())
- profile_options = []
- for name, entry in ordered_profiles:
- provider = str((entry or {}).get("provider") or "kokoro").strip().lower()
- profile_options.append(
- {
- "name": name,
- "language": (entry or {}).get("language", ""),
- "provider": provider,
- "formula": formula_from_profile(entry or {}) or "",
- "voice": (entry or {}).get("voice", ""),
- "total_steps": (entry or {}).get("total_steps"),
- "speed": (entry or {}).get("speed"),
- }
- )
- voice_catalog = build_voice_catalog()
- return {
- "languages": LANGUAGE_DESCRIPTIONS,
- "voices": get_voices("kokoro"),
- "subtitle_formats": SUBTITLE_FORMATS,
- "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
- "output_formats": SUPPORTED_SOUND_FORMATS,
- "voice_profiles": ordered_profiles,
- "voice_profile_options": profile_options,
- "separate_formats": ["wav", "flac", "mp3", "opus"],
- "voice_catalog": voice_catalog,
- "voice_catalog_map": {entry["id"]: entry for entry in voice_catalog},
- "sample_voice_texts": SAMPLE_VOICE_TEXTS,
- "voice_profiles_data": profiles,
- "speaker_configs": list_configs(),
- "chunk_levels": _CHUNK_LEVEL_OPTIONS,
- "speaker_analysis_threshold": current_settings.get(
- "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD
- ),
- "speaker_pronunciation_sentence": current_settings.get(
- "speaker_pronunciation_sentence", settings_defaults()["speaker_pronunciation_sentence"]
- ),
- "apostrophe_modes": _APOSTROPHE_MODE_OPTIONS,
- "normalization_groups": _NORMALIZATION_GROUPS,
- }
-
-
-def resolve_profile_voice(
- profile_name: Optional[str],
- *,
- profiles: Optional[Mapping[str, Any]] = None,
-) -> tuple[str, Optional[str]]:
- if not profile_name:
- return "", None
- source = profiles if isinstance(profiles, Mapping) else None
- if source is None:
- source = load_profiles()
- entry = source.get(profile_name) if isinstance(source, Mapping) else None
- if not isinstance(entry, Mapping):
- return "", None
- formula = formula_from_profile(dict(entry)) or ""
- language = entry.get("language") if isinstance(entry.get("language"), str) else None
- if isinstance(language, str):
- language = language.strip().lower() or None
- return formula, language
-
-
-def resolve_voice_setting(
- value: Any,
- *,
- profiles: Optional[Mapping[str, Any]] = None,
-) -> tuple[str, Optional[str], Optional[str]]:
- base_spec, profile_name = split_profile_spec(value)
- if profile_name:
- formula, language = resolve_profile_voice(profile_name, profiles=profiles)
- return formula or "", profile_name, language
- return base_spec, None, None
-
-
-def resolve_voice_choice(
- language: str,
- base_voice: str,
- profile_name: str,
- custom_formula: str,
- profiles: Dict[str, Any],
-) -> tuple[str, str, Optional[str]]:
- resolved_voice = base_voice
- resolved_language = language
- selected_profile = None
-
- if profile_name:
- from abogen.voice_profiles import normalize_profile_entry
-
- entry_raw = profiles.get(profile_name)
- entry = normalize_profile_entry(entry_raw)
- provider = str((entry or {}).get("provider") or "").strip().lower()
-
- # Provider-aware behavior:
- # - Kokoro profiles typically represent mixes (formula strings).
- # - SuperTonic profiles represent a discrete voice id + settings.
- # In that case, we return a speaker reference so downstream can
- # resolve provider per-speaker and allow mixed-provider casting.
- if provider == "supertonic":
- resolved_voice = f"speaker:{profile_name}"
- selected_profile = profile_name
- profile_language = (entry or {}).get("language")
- if profile_language:
- resolved_language = str(profile_language)
- else:
- formula = formula_from_profile(entry or {}) if entry else None
- if formula:
- resolved_voice = formula
- selected_profile = profile_name
- profile_language = (entry or {}).get("language")
- if profile_language:
- resolved_language = profile_language
-
- if custom_formula:
- resolved_voice = custom_formula
- selected_profile = None
-
- return resolved_voice, resolved_language, selected_profile
-
-
-def parse_voice_formula(formula: str) -> List[tuple[str, float]]:
- voices = parse_formula_terms(formula)
- total = sum(weight for _, weight in voices)
- if total <= 0:
- raise ValueError("Voice weights must sum to a positive value")
- return voices
-
-
-def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
- sanitized: List[Dict[str, Any]] = []
- for entry in entries or []:
- if isinstance(entry, dict):
- voice_id = entry.get("id") or entry.get("voice")
- if not voice_id:
- continue
- enabled = entry.get("enabled", True)
- if not enabled:
- continue
- sanitized.append({"voice": voice_id, "weight": entry.get("weight")})
- elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
- sanitized.append({"voice": entry[0], "weight": entry[1]})
- return sanitized
-
-
-def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
- voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0]
- if not voices:
- return None
- total = sum(weight for _, weight in voices)
- if total <= 0:
- return None
-
- def _format_value(value: float) -> str:
- normalized = value / total if total else 0.0
- return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
-
- parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
- return "+".join(parts)
-
-
-def profiles_payload() -> Dict[str, Any]:
- return {"profiles": serialize_profiles()}
-
-
-def get_preview_pipeline(language: str, device: str):
- key = (language, device)
- with _preview_pipeline_lock:
- pipeline = _preview_pipelines.get(key)
- if pipeline is not None:
- return pipeline
- pipeline = create_pipeline("kokoro", lang_code=language, device=device)
- _preview_pipelines[key] = pipeline
- return pipeline
-
-
-def synthesize_audio_from_normalized(
- *,
- normalized_text: str,
- voice_spec: str,
- language: str,
- speed: float,
- use_gpu: bool,
- max_seconds: float,
-) -> np.ndarray:
- if not normalized_text.strip():
- raise ValueError("Preview text is required")
-
- device = "cpu"
- if use_gpu:
- try:
- device = _select_device()
- except Exception:
- device = "cpu"
- use_gpu = False
-
- pipeline = get_preview_pipeline(language, device)
- if pipeline is None:
- raise RuntimeError("Preview pipeline is unavailable")
-
- voice_choice: Any = voice_spec
- if voice_spec and "*" in voice_spec:
- voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
-
- segments = pipeline(
- normalized_text,
- voice=voice_choice,
- speed=speed,
- split_pattern=SPLIT_PATTERN,
- )
-
- audio_chunks: List[np.ndarray] = []
- accumulated = 0
- max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
-
- for segment in segments:
- graphemes = getattr(segment, "graphemes", "").strip()
- if not graphemes:
- continue
- audio = _to_float32(getattr(segment, "audio", None))
- if audio.size == 0:
- continue
- remaining = max_samples - accumulated
- if remaining <= 0:
- break
- if audio.shape[0] > remaining:
- audio = audio[:remaining]
- audio_chunks.append(audio)
- accumulated += audio.shape[0]
- if accumulated >= max_samples:
- break
-
- if not audio_chunks:
- raise RuntimeError("Preview could not be generated")
-
- return np.concatenate(audio_chunks)
+import threading
+from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast
+import numpy as np
+
+from abogen.speaker_configs import slugify_label
+from abogen.speaker_analysis import analyze_speakers
+from abogen.webui.routes.utils.settings import load_settings, settings_defaults, _DEFAULT_ANALYSIS_THRESHOLD, _CHUNK_LEVEL_OPTIONS, _APOSTROPHE_MODE_OPTIONS, _NORMALIZATION_GROUPS
+from abogen.webui.routes.utils.common import split_profile_spec
+from abogen.voice_profiles import (
+ load_profiles,
+ serialize_profiles,
+)
+from abogen.voice_formulas import get_new_voice, parse_formula_terms
+from abogen.constants import (
+ LANGUAGE_DESCRIPTIONS,
+ SUBTITLE_FORMATS,
+ SUPPORTED_SOUND_FORMATS,
+ SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
+ SAMPLE_VOICE_TEXTS,
+)
+from abogen.tts_plugin.utils import get_voices
+from abogen.speaker_configs import list_configs
+from abogen.tts_plugin.utils import create_pipeline
+from abogen.webui.conversion_runner import _select_device, _to_float32, SAMPLE_RATE, SPLIT_PATTERN
+
+_preview_pipeline_lock = threading.RLock()
+_preview_pipelines: Dict[Tuple[str, str], Any] = {}
+
+def build_narrator_roster(
+ voice: str,
+ voice_profile: Optional[str],
+ existing: Optional[Mapping[str, Any]] = None,
+) -> Dict[str, Any]:
+ roster: Dict[str, Any] = {
+ "narrator": {
+ "id": "narrator",
+ "label": "Narrator",
+ "voice": voice,
+ }
+ }
+ if voice_profile:
+ roster["narrator"]["voice_profile"] = voice_profile
+ existing_entry: Optional[Mapping[str, Any]] = None
+ if existing is not None:
+ existing_entry = existing.get("narrator") if isinstance(existing, Mapping) else None
+ if isinstance(existing_entry, Mapping):
+ roster_entry = roster["narrator"]
+ for key in ("label", "voice", "voice_profile", "voice_formula", "pronunciation"):
+ value = existing_entry.get(key)
+ if value is not None and value != "":
+ roster_entry[key] = value
+ return roster
+
+
+def build_speaker_roster(
+ analysis: Dict[str, Any],
+ base_voice: str,
+ voice_profile: Optional[str],
+ existing: Optional[Mapping[str, Any]] = None,
+ order: Optional[Iterable[str]] = None,
+) -> Dict[str, Any]:
+ roster = build_narrator_roster(base_voice, voice_profile, existing)
+ existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {}
+ speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {}
+ ordered_ids: Iterable[str]
+ if order is not None:
+ ordered_ids = [sid for sid in order if sid in speakers]
+ else:
+ ordered_ids = speakers.keys()
+
+ for speaker_id in ordered_ids:
+ payload = speakers.get(speaker_id, {})
+ if speaker_id == "narrator":
+ continue
+ if isinstance(payload, Mapping) and payload.get("suppressed"):
+ continue
+ previous = existing_map.get(speaker_id)
+ roster[speaker_id] = {
+ "id": speaker_id,
+ "label": payload.get("label") or speaker_id.replace("_", " ").title(),
+ "analysis_confidence": payload.get("confidence"),
+ "analysis_count": payload.get("count"),
+ "gender": payload.get("gender", "unknown"),
+ }
+ detected_gender = payload.get("detected_gender")
+ if detected_gender:
+ roster[speaker_id]["detected_gender"] = detected_gender
+ samples = payload.get("sample_quotes")
+ if isinstance(samples, list):
+ roster[speaker_id]["sample_quotes"] = samples
+ if isinstance(previous, Mapping):
+ for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"):
+ value = previous.get(key)
+ if value is not None and value != "":
+ roster[speaker_id][key] = value
+ if "sample_quotes" not in roster[speaker_id]:
+ prev_samples = previous.get("sample_quotes")
+ if isinstance(prev_samples, list):
+ roster[speaker_id]["sample_quotes"] = prev_samples
+ if "detected_gender" not in roster[speaker_id]:
+ prev_detected = previous.get("detected_gender")
+ if isinstance(prev_detected, str) and prev_detected:
+ roster[speaker_id]["detected_gender"] = prev_detected
+ return roster
+
+
+def match_configured_speaker(
+ config_speakers: Mapping[str, Any],
+ roster_id: str,
+ roster_label: str,
+) -> Optional[Mapping[str, Any]]:
+ if not config_speakers:
+ return None
+ entry = config_speakers.get(roster_id)
+ if entry:
+ return cast(Mapping[str, Any], entry)
+ slug = slugify_label(roster_label)
+ if slug != roster_id and slug in config_speakers:
+ return cast(Mapping[str, Any], config_speakers[slug])
+ lower_label = roster_label.strip().lower()
+ for record in config_speakers.values():
+ if not isinstance(record, Mapping):
+ continue
+ if str(record.get("label", "")).strip().lower() == lower_label:
+ return record
+ return None
+
+
+def apply_speaker_config_to_roster(
+ roster: Mapping[str, Any],
+ config: Optional[Mapping[str, Any]],
+ *,
+ persist_changes: bool = False,
+ fallback_languages: Optional[Iterable[str]] = None,
+) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
+ if not isinstance(roster, Mapping):
+ effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
+ return {}, effective_languages, None
+ updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)}
+ if not config:
+ effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
+ return updated_roster, effective_languages, None
+
+ speakers_map = config.get("speakers")
+ if not isinstance(speakers_map, Mapping):
+ effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
+ return updated_roster, effective_languages, None
+
+ config_languages = config.get("languages")
+ if isinstance(config_languages, list):
+ allowed_languages = [code for code in config_languages if isinstance(code, str) and code]
+ else:
+ allowed_languages = []
+ if not allowed_languages and fallback_languages:
+ allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code]
+
+ default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else ""
+ used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None}
+ narrator_voice = ""
+ narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None
+ if isinstance(narrator_entry, Mapping):
+ narrator_voice = str(
+ narrator_entry.get("resolved_voice")
+ or narrator_entry.get("default_voice")
+ or ""
+ ).strip()
+ if narrator_voice:
+ used_voices.add(narrator_voice)
+
+ config_changed = False
+ new_config_payload: Dict[str, Any] = {
+ "language": config.get("language", "a"),
+ "languages": allowed_languages,
+ "default_voice": default_voice,
+ "speakers": dict(speakers_map),
+ "version": config.get("version", 1),
+ "notes": config.get("notes", ""),
+ }
+
+ speakers_payload = new_config_payload["speakers"]
+
+ for speaker_id, roster_entry in updated_roster.items():
+ if speaker_id == "narrator":
+ continue
+ label = str(roster_entry.get("label") or speaker_id)
+ config_entry = match_configured_speaker(speakers_map, speaker_id, label)
+ if config_entry is None:
+ continue
+ voice_id = str(config_entry.get("voice") or "").strip()
+ voice_profile = str(config_entry.get("voice_profile") or "").strip()
+ voice_formula = str(config_entry.get("voice_formula") or "").strip()
+ resolved_voice = str(config_entry.get("resolved_voice") or "").strip()
+ languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else []
+ chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice")
+ usable_languages = languages or allowed_languages
+
+ if chosen_voice:
+ roster_entry["resolved_voice"] = chosen_voice
+ roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice)
+ if voice_profile:
+ roster_entry["voice_profile"] = voice_profile
+ if voice_formula:
+ roster_entry["voice_formula"] = voice_formula
+ roster_entry["resolved_voice"] = voice_formula
+ if not voice_formula and not voice_profile and resolved_voice:
+ roster_entry["resolved_voice"] = resolved_voice
+ roster_entry["config_languages"] = usable_languages or []
+
+ if chosen_voice:
+ used_voices.add(chosen_voice)
+
+ # persist updates back to config payload if required
+ if persist_changes:
+ slug = config_entry.get("id") or slugify_label(label)
+ speakers_payload[slug] = {
+ "id": slug,
+ "label": label,
+ "gender": config_entry.get("gender", "unknown"),
+ "voice": voice_id,
+ "voice_profile": voice_profile,
+ "voice_formula": voice_formula,
+ "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id),
+ "languages": usable_languages,
+ }
+
+ new_config = new_config_payload if (persist_changes and config_changed) else None
+ return updated_roster, allowed_languages, new_config
+
+
+def filter_voice_catalog(
+ catalog: Iterable[Mapping[str, Any]],
+ *,
+ gender: str,
+ allowed_languages: Optional[Iterable[str]] = None,
+) -> List[str]:
+ allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code}
+ gender_normalized = (gender or "unknown").lower()
+ gender_code = ""
+ if gender_normalized == "male":
+ gender_code = "m"
+ elif gender_normalized == "female":
+ gender_code = "f"
+
+ matches: List[str] = []
+ seen: set[str] = set()
+
+ def _consider(entry: Mapping[str, Any]) -> None:
+ voice_id = entry.get("id")
+ if not isinstance(voice_id, str) or not voice_id:
+ return
+ if voice_id in seen:
+ return
+ seen.add(voice_id)
+ matches.append(voice_id)
+
+ primary: List[Mapping[str, Any]] = []
+ fallback: List[Mapping[str, Any]] = []
+ for entry in catalog:
+ if not isinstance(entry, Mapping):
+ continue
+ voice_lang = str(entry.get("language", "")).lower()
+ voice_gender_code = str(entry.get("gender_code", "")).lower()
+ if allowed_set and voice_lang not in allowed_set:
+ continue
+ if gender_code and voice_gender_code != gender_code:
+ fallback.append(entry)
+ continue
+ primary.append(entry)
+
+ for entry in primary:
+ _consider(entry)
+
+ if not matches:
+ for entry in fallback:
+ _consider(entry)
+
+ if not matches:
+ for entry in catalog:
+ if isinstance(entry, Mapping):
+ _consider(entry)
+
+ return matches
+
+
+def build_voice_catalog() -> List[Dict[str, str]]:
+ 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 ""
+ catalog.append(
+ {
+ "id": voice_id,
+ "language": language_code,
+ "language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()),
+ "gender": gender_map.get(gender_code, "Unknown"),
+ "gender_code": gender_code,
+ "display_name": rest.replace("_", " ").title() if rest else voice_id,
+ }
+ )
+ return catalog
+
+
+def inject_recommended_voices(
+ roster: Mapping[str, Any],
+ *,
+ fallback_languages: Optional[Iterable[str]] = None,
+) -> None:
+ voice_catalog = build_voice_catalog()
+ fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code]
+ for speaker_id, payload in roster.items():
+ if not isinstance(payload, dict):
+ continue
+ languages = payload.get("config_languages")
+ if isinstance(languages, list) and languages:
+ language_list = languages
+ else:
+ language_list = fallback_list
+ gender = str(payload.get("gender", "unknown"))
+ payload["recommended_voices"] = filter_voice_catalog(
+ voice_catalog,
+ gender=gender,
+ allowed_languages=language_list,
+ )
+
+
+def extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]:
+ getter = getattr(form, "getlist", None)
+
+ def _get_list(name: str) -> List[str]:
+ if callable(getter):
+ values = cast(Iterable[Any], getter(name))
+ return [str(value).strip() for value in values if value]
+ raw_value = form.get(name)
+ if isinstance(raw_value, str):
+ return [item.strip() for item in raw_value.split(",") if item.strip()]
+ return []
+
+ name = (form.get("config_name") or "").strip()
+ language = str(form.get("config_language") or "a").strip() or "a"
+ allowed_languages = []
+ default_voice = (form.get("config_default_voice") or "").strip()
+ notes = (form.get("config_notes") or "").strip()
+
+ try:
+ parsed = int(form.get("config_version") or 1)
+ version = max(1, min(parsed, 9999))
+ except (TypeError, ValueError):
+ version = 1
+
+ speaker_rows = _get_list("speaker_rows")
+ speakers: Dict[str, Dict[str, Any]] = {}
+ for row_key in speaker_rows:
+ prefix = f"speaker-{row_key}-"
+ label = (form.get(prefix + "label") or "").strip()
+ if not label:
+ continue
+ raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower()
+ gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown"
+ voice = (form.get(prefix + "voice") or "").strip()
+ voice_profile = (form.get(prefix + "profile") or "").strip()
+ voice_formula = (form.get(prefix + "formula") or "").strip()
+ speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label)
+ speakers[speaker_id] = {
+ "id": speaker_id,
+ "label": label,
+ "gender": gender,
+ "voice": voice,
+ "voice_profile": voice_profile,
+ "voice_formula": voice_formula,
+ "resolved_voice": voice_formula or voice,
+ "languages": [],
+ }
+
+ payload = {
+ "language": language,
+ "languages": allowed_languages,
+ "default_voice": default_voice,
+ "speakers": speakers,
+ "notes": notes,
+ "version": version,
+ }
+
+ errors: List[str] = []
+ if not name:
+ errors.append("Configuration name is required.")
+ if not speakers:
+ errors.append("Add at least one speaker to the configuration.")
+
+ return name, payload, errors
+
+
+def prepare_speaker_metadata(
+ *,
+ chapters: List[Dict[str, Any]],
+ chunks: List[Dict[str, Any]],
+ analysis_chunks: Optional[List[Dict[str, Any]]] = None,
+ voice: str,
+ voice_profile: Optional[str],
+ threshold: int,
+ existing_roster: Optional[Mapping[str, Any]] = None,
+ run_analysis: bool = True,
+ speaker_config: Optional[Mapping[str, Any]] = None,
+ apply_config: bool = False,
+ persist_config: bool = False,
+) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]:
+ chunk_list = [dict(chunk) for chunk in chunks]
+ analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)]
+ threshold_value = max(1, int(threshold))
+ analysis_enabled = run_analysis
+ settings_state = load_settings()
+ global_random_languages = [
+ code
+ for code in settings_state.get("speaker_random_languages", [])
+ if isinstance(code, str) and code
+ ]
+
+ if not analysis_enabled:
+ for chunk in chunk_list:
+ chunk["speaker_id"] = "narrator"
+ chunk["speaker_label"] = "Narrator"
+ analysis_payload = {
+ "version": "1.0",
+ "narrator": "narrator",
+ "assignments": {str(chunk.get("id")): "narrator" for chunk in chunk_list},
+ "speakers": {
+ "narrator": {
+ "id": "narrator",
+ "label": "Narrator",
+ "count": len(chunk_list),
+ "confidence": "low",
+ "sample_quotes": [],
+ "suppressed": False,
+ }
+ },
+ "suppressed": [],
+ "stats": {
+ "total_chunks": len(chunk_list),
+ "explicit_chunks": 0,
+ "active_speakers": 0,
+ "unique_speakers": 1,
+ "suppressed": 0,
+ },
+ }
+ roster = build_narrator_roster(voice, voice_profile, existing_roster)
+ narrator_pron = roster["narrator"].get("pronunciation")
+ if narrator_pron:
+ analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron
+ return chunk_list, roster, analysis_payload, [], None
+
+ analysis_result = analyze_speakers(
+ chapters,
+ analysis_source,
+ threshold=threshold_value,
+ max_speakers=0,
+ )
+ analysis_payload = analysis_result.to_dict()
+ speakers_payload = analysis_payload.get("speakers", {})
+ ordered_ids = [
+ sid
+ for sid, meta in sorted(
+ (
+ (sid, meta)
+ for sid, meta in speakers_payload.items()
+ if sid != "narrator" and isinstance(meta, Mapping) and not meta.get("suppressed")
+ ),
+ key=lambda item: item[1].get("count", 0),
+ reverse=True,
+ )
+ ]
+ analysis_payload["ordered_speakers"] = ordered_ids
+ assignments = analysis_payload.get("assignments", {})
+ suppressed_ids = analysis_payload.get("suppressed", [])
+ suppressed_details: List[Dict[str, Any]] = []
+ speakers_payload = analysis_payload.get("speakers", {})
+ if isinstance(suppressed_ids, Iterable):
+ for suppressed_id in suppressed_ids:
+ speaker_meta = speakers_payload.get(suppressed_id) if isinstance(speakers_payload, dict) else None
+ if isinstance(speaker_meta, dict):
+ suppressed_details.append(
+ {
+ "id": suppressed_id,
+ "label": speaker_meta.get("label")
+ or str(suppressed_id).replace("_", " ").title(),
+ "pronunciation": speaker_meta.get("pronunciation"),
+ }
+ )
+ else:
+ suppressed_details.append(
+ {
+ "id": suppressed_id,
+ "label": str(suppressed_id).replace("_", " ").title(),
+ "pronunciation": None,
+ }
+ )
+ analysis_payload["suppressed_details"] = suppressed_details
+ roster = build_speaker_roster(
+ analysis_payload,
+ voice,
+ voice_profile,
+ existing=existing_roster,
+ order=analysis_payload.get("ordered_speakers"),
+ )
+ applied_languages: List[str] = []
+ updated_config: Optional[Dict[str, Any]] = None
+ if apply_config and speaker_config:
+ roster, applied_languages, updated_config = apply_speaker_config_to_roster(
+ roster,
+ speaker_config,
+ persist_changes=persist_config,
+ fallback_languages=global_random_languages,
+ )
+ speakers_payload = analysis_payload.get("speakers")
+ if isinstance(speakers_payload, dict):
+ for roster_id, roster_payload in roster.items():
+ speaker_meta = speakers_payload.get(roster_id)
+ if isinstance(speaker_meta, dict):
+ for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"):
+ value = roster_payload.get(key)
+ if value:
+ speaker_meta[key] = value
+ effective_languages: List[str] = []
+ if applied_languages:
+ effective_languages = applied_languages
+ elif isinstance(analysis_payload.get("config_languages"), list):
+ effective_languages = [
+ code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code
+ ]
+ elif global_random_languages:
+ effective_languages = list(global_random_languages)
+
+ if effective_languages:
+ analysis_payload["config_languages"] = effective_languages
+ speakers_payload = analysis_payload.get("speakers")
+ if isinstance(speakers_payload, dict):
+ for roster_id, roster_payload in roster.items():
+ if roster_id in speakers_payload and isinstance(roster_payload, dict):
+ pronunciation_value = roster_payload.get("pronunciation")
+ if pronunciation_value:
+ speakers_payload[roster_id]["pronunciation"] = pronunciation_value
+
+ fallback_languages = effective_languages or []
+ inject_recommended_voices(roster, fallback_languages=fallback_languages)
+
+ for chunk in chunk_list:
+ chunk_id = str(chunk.get("id"))
+ speaker_id = assignments.get(chunk_id, "narrator")
+ chunk["speaker_id"] = speaker_id
+ speaker_meta = roster.get(speaker_id)
+ chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id
+
+ return chunk_list, roster, analysis_payload, applied_languages, updated_config
+
+
+def formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
+ voices = entry.get("voices") or []
+ if not voices:
+ return None
+ total = sum(weight for _, weight in voices)
+ if total <= 0:
+ return None
+
+ def _format_weight(value: float) -> str:
+ normalized = value / total if total else 0.0
+ return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
+
+ parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
+ return "+".join(parts) if parts else None
+
+
+def template_options() -> Dict[str, Any]:
+ current_settings = load_settings()
+ profiles = serialize_profiles()
+ ordered_profiles = sorted(profiles.items())
+ profile_options = []
+ for name, entry in ordered_profiles:
+ provider = str((entry or {}).get("provider") or "kokoro").strip().lower()
+ profile_options.append(
+ {
+ "name": name,
+ "language": (entry or {}).get("language", ""),
+ "provider": provider,
+ "formula": formula_from_profile(entry or {}) or "",
+ "voice": (entry or {}).get("voice", ""),
+ "total_steps": (entry or {}).get("total_steps"),
+ "speed": (entry or {}).get("speed"),
+ }
+ )
+ voice_catalog = build_voice_catalog()
+ return {
+ "languages": LANGUAGE_DESCRIPTIONS,
+ "voices": get_voices("kokoro"),
+ "subtitle_formats": SUBTITLE_FORMATS,
+ "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
+ "output_formats": SUPPORTED_SOUND_FORMATS,
+ "voice_profiles": ordered_profiles,
+ "voice_profile_options": profile_options,
+ "separate_formats": ["wav", "flac", "mp3", "opus"],
+ "voice_catalog": voice_catalog,
+ "voice_catalog_map": {entry["id"]: entry for entry in voice_catalog},
+ "sample_voice_texts": SAMPLE_VOICE_TEXTS,
+ "voice_profiles_data": profiles,
+ "speaker_configs": list_configs(),
+ "chunk_levels": _CHUNK_LEVEL_OPTIONS,
+ "speaker_analysis_threshold": current_settings.get(
+ "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD
+ ),
+ "speaker_pronunciation_sentence": current_settings.get(
+ "speaker_pronunciation_sentence", settings_defaults()["speaker_pronunciation_sentence"]
+ ),
+ "apostrophe_modes": _APOSTROPHE_MODE_OPTIONS,
+ "normalization_groups": _NORMALIZATION_GROUPS,
+ }
+
+
+def resolve_profile_voice(
+ profile_name: Optional[str],
+ *,
+ profiles: Optional[Mapping[str, Any]] = None,
+) -> tuple[str, Optional[str]]:
+ if not profile_name:
+ return "", None
+ source = profiles if isinstance(profiles, Mapping) else None
+ if source is None:
+ source = load_profiles()
+ entry = source.get(profile_name) if isinstance(source, Mapping) else None
+ if not isinstance(entry, Mapping):
+ return "", None
+ formula = formula_from_profile(dict(entry)) or ""
+ language = entry.get("language") if isinstance(entry.get("language"), str) else None
+ if isinstance(language, str):
+ language = language.strip().lower() or None
+ return formula, language
+
+
+def resolve_voice_setting(
+ value: Any,
+ *,
+ profiles: Optional[Mapping[str, Any]] = None,
+) -> tuple[str, Optional[str], Optional[str]]:
+ base_spec, profile_name = split_profile_spec(value)
+ if profile_name:
+ formula, language = resolve_profile_voice(profile_name, profiles=profiles)
+ return formula or "", profile_name, language
+ return base_spec, None, None
+
+
+def resolve_voice_choice(
+ language: str,
+ base_voice: str,
+ profile_name: str,
+ custom_formula: str,
+ profiles: Dict[str, Any],
+) -> tuple[str, str, Optional[str]]:
+ resolved_voice = base_voice
+ resolved_language = language
+ selected_profile = None
+
+ if profile_name:
+ from abogen.voice_profiles import normalize_profile_entry
+
+ entry_raw = profiles.get(profile_name)
+ entry = normalize_profile_entry(entry_raw)
+ provider = str((entry or {}).get("provider") or "").strip().lower()
+
+ # Provider-aware behavior:
+ # - Kokoro profiles typically represent mixes (formula strings).
+ # - SuperTonic profiles represent a discrete voice id + settings.
+ # In that case, we return a speaker reference so downstream can
+ # resolve provider per-speaker and allow mixed-provider casting.
+ if provider == "supertonic":
+ resolved_voice = f"speaker:{profile_name}"
+ selected_profile = profile_name
+ profile_language = (entry or {}).get("language")
+ if profile_language:
+ resolved_language = str(profile_language)
+ else:
+ formula = formula_from_profile(entry or {}) if entry else None
+ if formula:
+ resolved_voice = formula
+ selected_profile = profile_name
+ profile_language = (entry or {}).get("language")
+ if profile_language:
+ resolved_language = profile_language
+
+ if custom_formula:
+ resolved_voice = custom_formula
+ selected_profile = None
+
+ return resolved_voice, resolved_language, selected_profile
+
+
+def parse_voice_formula(formula: str) -> List[tuple[str, float]]:
+ voices = parse_formula_terms(formula)
+ total = sum(weight for _, weight in voices)
+ if total <= 0:
+ raise ValueError("Voice weights must sum to a positive value")
+ return voices
+
+
+def sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]:
+ sanitized: List[Dict[str, Any]] = []
+ for entry in entries or []:
+ if isinstance(entry, dict):
+ voice_id = entry.get("id") or entry.get("voice")
+ if not voice_id:
+ continue
+ enabled = entry.get("enabled", True)
+ if not enabled:
+ continue
+ sanitized.append({"voice": voice_id, "weight": entry.get("weight")})
+ elif isinstance(entry, (list, tuple)) and len(entry) >= 2:
+ sanitized.append({"voice": entry[0], "weight": entry[1]})
+ return sanitized
+
+
+def pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]:
+ voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0]
+ if not voices:
+ return None
+ total = sum(weight for _, weight in voices)
+ if total <= 0:
+ return None
+
+ def _format_value(value: float) -> str:
+ normalized = value / total if total else 0.0
+ return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
+
+ parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices]
+ return "+".join(parts)
+
+
+def profiles_payload() -> Dict[str, Any]:
+ return {"profiles": serialize_profiles()}
+
+
+def get_preview_pipeline(language: str, device: str):
+ key = (language, device)
+ with _preview_pipeline_lock:
+ pipeline = _preview_pipelines.get(key)
+ if pipeline is not None:
+ return pipeline
+ pipeline = create_pipeline("kokoro", lang_code=language, device=device)
+ _preview_pipelines[key] = pipeline
+ return pipeline
+
+
+def synthesize_audio_from_normalized(
+ *,
+ normalized_text: str,
+ voice_spec: str,
+ language: str,
+ speed: float,
+ use_gpu: bool,
+ max_seconds: float,
+) -> np.ndarray:
+ if not normalized_text.strip():
+ raise ValueError("Preview text is required")
+
+ device = "cpu"
+ if use_gpu:
+ try:
+ device = _select_device()
+ except Exception:
+ device = "cpu"
+ use_gpu = False
+
+ pipeline = get_preview_pipeline(language, device)
+ if pipeline is None:
+ raise RuntimeError("Preview pipeline is unavailable")
+
+ voice_choice: Any = voice_spec
+ if voice_spec and "*" in voice_spec:
+ voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
+
+ segments = pipeline(
+ normalized_text,
+ voice=voice_choice,
+ speed=speed,
+ split_pattern=SPLIT_PATTERN,
+ )
+
+ audio_chunks: List[np.ndarray] = []
+ accumulated = 0
+ max_samples = int(max(1.0, max_seconds) * SAMPLE_RATE)
+
+ for segment in segments:
+ graphemes = getattr(segment, "graphemes", "").strip()
+ if not graphemes:
+ continue
+ audio = _to_float32(getattr(segment, "audio", None))
+ if audio.size == 0:
+ continue
+ remaining = max_samples - accumulated
+ if remaining <= 0:
+ break
+ if audio.shape[0] > remaining:
+ audio = audio[:remaining]
+ audio_chunks.append(audio)
+ accumulated += audio.shape[0]
+ if accumulated >= max_samples:
+ break
+
+ if not audio_chunks:
+ raise RuntimeError("Preview could not be generated")
+
+ return np.concatenate(audio_chunks)
diff --git a/plugins/supertonic/pipeline.py b/plugins/supertonic/pipeline.py
index aabf3d7..a215149 100644
--- a/plugins/supertonic/pipeline.py
+++ b/plugins/supertonic/pipeline.py
@@ -1,266 +1,266 @@
-"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
-
-This module provides the SuperTonicPipeline class and supporting utilities
-used by the SuperTonic plugin. It is independent of the legacy
-abogen.tts_backends module.
-"""
-
-from __future__ import annotations
-
-import ast
-import logging
-import re
-from typing import Any, Iterable, Iterator, Optional
-
-import numpy as np
-
-logger = logging.getLogger(__name__)
-
-
-def _ensure_float32_mono(wav: Any) -> np.ndarray:
- arr = np.asarray(wav, dtype="float32")
- if arr.ndim == 2:
- if arr.shape[0] == 1 and arr.shape[1] > 1:
- arr = arr.reshape(-1)
- else:
- arr = arr[:, 0]
- return arr.reshape(-1)
-
-
-def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
- if src_rate == dst_rate:
- return audio
- if audio.size == 0:
- return audio
- ratio = dst_rate / float(src_rate)
- new_len = int(round(audio.size * ratio))
- if new_len <= 1:
- return np.zeros(0, dtype="float32")
- x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
- x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
- return np.interp(x_new, x_old, audio).astype("float32", copy=False)
-
-
-def _split_text(
- text: str, *, split_pattern: Optional[str], max_chunk_length: int
-) -> list[str]:
- stripped = (text or "").strip()
- if not stripped:
- return []
- parts: list[str]
- if split_pattern:
- try:
- parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
- except re.error:
- parts = [stripped]
- else:
- parts = [stripped]
-
- result: list[str] = []
- for part in parts:
- if len(part) <= max_chunk_length:
- result.append(part)
- continue
- start = 0
- while start < len(part):
- end = min(len(part), start + max_chunk_length)
- if end < len(part):
- ws = part.rfind(" ", start, end)
- if ws > start + 40:
- end = ws
- chunk = part[start:end].strip()
- if chunk:
- result.append(chunk)
- start = end
- return result
-
-
-_UNSUPPORTED_CHARS_RE = re.compile(
- r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
-)
-
-
-def _parse_unsupported_characters(error: BaseException) -> list[str]:
- """Best-effort extraction of unsupported characters from SuperTonic errors."""
- message = " ".join(
- str(part) for part in getattr(error, "args", ()) if part is not None
- ) or str(error)
- match = _UNSUPPORTED_CHARS_RE.search(message)
- if not match:
- return []
-
- raw = match.group(1)
- try:
- value = ast.literal_eval(raw)
- except Exception:
- return []
-
- if isinstance(value, (list, tuple)):
- out: list[str] = []
- for item in value:
- if item is None:
- continue
- s = str(item)
- if s:
- out.append(s)
- return out
-
- if isinstance(value, str) and value:
- return [value]
-
- return []
-
-
-def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
- result = text
- for item in unsupported:
- if not item:
- continue
- result = result.replace(item, "")
- return result
-
-
-def _configure_supertonic_gpu() -> None:
- """Patch supertonic's config to enable GPU acceleration if available."""
- try:
- import onnxruntime as ort
-
- available = ort.get_available_providers()
-
- providers = []
- if "CUDAExecutionProvider" in available:
- providers.append("CUDAExecutionProvider")
- providers.append("CPUExecutionProvider")
-
- import supertonic.config as supertonic_config
- import supertonic.loader as supertonic_loader
-
- supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
- supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
- logger.info("Supertonic ONNX providers configured: %s", providers)
- except Exception as exc:
- logger.warning("Could not configure supertonic GPU providers: %s", exc)
-
-
-class SupertonicSegment:
- """A single synthesized audio segment."""
-
- __slots__ = ("graphemes", "audio")
-
- def __init__(self, graphemes: str, audio: np.ndarray) -> None:
- self.graphemes = graphemes
- self.audio = audio
-
-
-class SupertonicPipeline:
- """Minimal adapter that mimics Kokoro's pipeline iteration interface."""
-
- def __init__(
- self,
- *,
- sample_rate: int,
- auto_download: bool = True,
- total_steps: int = 5,
- max_chunk_length: int = 300,
- ) -> None:
- self.sample_rate = int(sample_rate)
- self.total_steps = int(total_steps)
- self.max_chunk_length = int(max_chunk_length)
-
- _configure_supertonic_gpu()
-
- try:
- from supertonic import TTS # type: ignore[import-not-found]
- except Exception as exc: # pragma: no cover
- raise RuntimeError(
- "Supertonic is not installed. Install it with `pip install supertonic`."
- ) from exc
-
- self._tts = TTS(auto_download=auto_download)
-
- def __call__(
- self,
- text: str,
- *,
- voice: str,
- speed: float,
- split_pattern: Optional[str] = None,
- total_steps: Optional[int] = None,
- ) -> Iterator[SupertonicSegment]:
- voice_name = (voice or "").strip() or "M1"
- steps = int(total_steps) if total_steps is not None else self.total_steps
- steps = max(2, min(15, steps))
- speed_value = float(speed) if speed is not None else 1.0
- speed_value = max(0.7, min(2.0, speed_value))
-
- style = self._tts.get_voice_style(voice_name=voice_name)
- chunks = _split_text(
- text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
- )
- for chunk in chunks:
- chunk_to_speak = chunk
- removed: set[str] = set()
- last_exc: Exception | None = None
-
- for attempt in range(3):
- try:
- wav, duration = self._tts.synthesize(
- text=chunk_to_speak,
- voice_style=style,
- total_steps=steps,
- speed=speed_value,
- max_chunk_length=self.max_chunk_length,
- silence_duration=0.0,
- verbose=False,
- )
- break
- except ValueError as exc:
- last_exc = exc
- unsupported = _parse_unsupported_characters(exc)
- if not unsupported:
- raise
-
- removed.update(unsupported)
- sanitized = _remove_unsupported_characters(
- chunk_to_speak, unsupported
- ).strip()
-
- if sanitized == chunk_to_speak.strip():
- raise
-
- chunk_to_speak = sanitized
- if not chunk_to_speak:
- logger.warning(
- "SuperTonic: dropped a chunk after removing unsupported characters: %s",
- sorted(removed),
- )
- break
-
- if attempt == 0:
- logger.warning(
- "SuperTonic: removed unsupported characters %s and retried.",
- sorted(removed),
- )
- else:
- assert last_exc is not None
- raise last_exc
-
- if not chunk_to_speak:
- continue
-
- audio = _ensure_float32_mono(wav)
-
- src_rate = self.sample_rate
- try:
- dur = float(duration)
- if dur > 0 and audio.size > 0:
- inferred = int(round(audio.size / dur))
- if 8000 <= inferred <= 96000:
- src_rate = inferred
- except Exception:
- pass
-
- if src_rate != self.sample_rate:
- audio = _resample_linear(audio, src_rate, self.sample_rate)
-
- yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
+"""SuperTonic Pipeline — self-contained TTS pipeline for the plugin.
+
+This module provides the SuperTonicPipeline class and supporting utilities
+used by the SuperTonic plugin. It is independent of the legacy
+abogen.tts_backends module.
+"""
+
+from __future__ import annotations
+
+import ast
+import logging
+import re
+from typing import Any, Iterable, Iterator, Optional
+
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+def _ensure_float32_mono(wav: Any) -> np.ndarray:
+ arr = np.asarray(wav, dtype="float32")
+ if arr.ndim == 2:
+ if arr.shape[0] == 1 and arr.shape[1] > 1:
+ arr = arr.reshape(-1)
+ else:
+ arr = arr[:, 0]
+ return arr.reshape(-1)
+
+
+def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray:
+ if src_rate == dst_rate:
+ return audio
+ if audio.size == 0:
+ return audio
+ ratio = dst_rate / float(src_rate)
+ new_len = int(round(audio.size * ratio))
+ if new_len <= 1:
+ return np.zeros(0, dtype="float32")
+ x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False)
+ x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False)
+ return np.interp(x_new, x_old, audio).astype("float32", copy=False)
+
+
+def _split_text(
+ text: str, *, split_pattern: Optional[str], max_chunk_length: int
+) -> list[str]:
+ stripped = (text or "").strip()
+ if not stripped:
+ return []
+ parts: list[str]
+ if split_pattern:
+ try:
+ parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()]
+ except re.error:
+ parts = [stripped]
+ else:
+ parts = [stripped]
+
+ result: list[str] = []
+ for part in parts:
+ if len(part) <= max_chunk_length:
+ result.append(part)
+ continue
+ start = 0
+ while start < len(part):
+ end = min(len(part), start + max_chunk_length)
+ if end < len(part):
+ ws = part.rfind(" ", start, end)
+ if ws > start + 40:
+ end = ws
+ chunk = part[start:end].strip()
+ if chunk:
+ result.append(chunk)
+ start = end
+ return result
+
+
+_UNSUPPORTED_CHARS_RE = re.compile(
+ r"unsupported character\(s\):\s*(\[[^\]]*\])", re.IGNORECASE
+)
+
+
+def _parse_unsupported_characters(error: BaseException) -> list[str]:
+ """Best-effort extraction of unsupported characters from SuperTonic errors."""
+ message = " ".join(
+ str(part) for part in getattr(error, "args", ()) if part is not None
+ ) or str(error)
+ match = _UNSUPPORTED_CHARS_RE.search(message)
+ if not match:
+ return []
+
+ raw = match.group(1)
+ try:
+ value = ast.literal_eval(raw)
+ except Exception:
+ return []
+
+ if isinstance(value, (list, tuple)):
+ out: list[str] = []
+ for item in value:
+ if item is None:
+ continue
+ s = str(item)
+ if s:
+ out.append(s)
+ return out
+
+ if isinstance(value, str) and value:
+ return [value]
+
+ return []
+
+
+def _remove_unsupported_characters(text: str, unsupported: Iterable[str]) -> str:
+ result = text
+ for item in unsupported:
+ if not item:
+ continue
+ result = result.replace(item, "")
+ return result
+
+
+def _configure_supertonic_gpu() -> None:
+ """Patch supertonic's config to enable GPU acceleration if available."""
+ try:
+ import onnxruntime as ort
+
+ available = ort.get_available_providers()
+
+ providers = []
+ if "CUDAExecutionProvider" in available:
+ providers.append("CUDAExecutionProvider")
+ providers.append("CPUExecutionProvider")
+
+ import supertonic.config as supertonic_config
+ import supertonic.loader as supertonic_loader
+
+ supertonic_config.DEFAULT_ONNX_PROVIDERS = providers
+ supertonic_loader.DEFAULT_ONNX_PROVIDERS = providers
+ logger.info("Supertonic ONNX providers configured: %s", providers)
+ except Exception as exc:
+ logger.warning("Could not configure supertonic GPU providers: %s", exc)
+
+
+class SupertonicSegment:
+ """A single synthesized audio segment."""
+
+ __slots__ = ("graphemes", "audio")
+
+ def __init__(self, graphemes: str, audio: np.ndarray) -> None:
+ self.graphemes = graphemes
+ self.audio = audio
+
+
+class SupertonicPipeline:
+ """Minimal adapter that mimics Kokoro's pipeline iteration interface."""
+
+ def __init__(
+ self,
+ *,
+ sample_rate: int,
+ auto_download: bool = True,
+ total_steps: int = 5,
+ max_chunk_length: int = 300,
+ ) -> None:
+ self.sample_rate = int(sample_rate)
+ self.total_steps = int(total_steps)
+ self.max_chunk_length = int(max_chunk_length)
+
+ _configure_supertonic_gpu()
+
+ try:
+ from supertonic import TTS # type: ignore[import-not-found]
+ except Exception as exc: # pragma: no cover
+ raise RuntimeError(
+ "Supertonic is not installed. Install it with `pip install supertonic`."
+ ) from exc
+
+ self._tts = TTS(auto_download=auto_download)
+
+ def __call__(
+ self,
+ text: str,
+ *,
+ voice: str,
+ speed: float,
+ split_pattern: Optional[str] = None,
+ total_steps: Optional[int] = None,
+ ) -> Iterator[SupertonicSegment]:
+ voice_name = (voice or "").strip() or "M1"
+ steps = int(total_steps) if total_steps is not None else self.total_steps
+ steps = max(2, min(15, steps))
+ speed_value = float(speed) if speed is not None else 1.0
+ speed_value = max(0.7, min(2.0, speed_value))
+
+ style = self._tts.get_voice_style(voice_name=voice_name)
+ chunks = _split_text(
+ text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length
+ )
+ for chunk in chunks:
+ chunk_to_speak = chunk
+ removed: set[str] = set()
+ last_exc: Exception | None = None
+
+ for attempt in range(3):
+ try:
+ wav, duration = self._tts.synthesize(
+ text=chunk_to_speak,
+ voice_style=style,
+ total_steps=steps,
+ speed=speed_value,
+ max_chunk_length=self.max_chunk_length,
+ silence_duration=0.0,
+ verbose=False,
+ )
+ break
+ except ValueError as exc:
+ last_exc = exc
+ unsupported = _parse_unsupported_characters(exc)
+ if not unsupported:
+ raise
+
+ removed.update(unsupported)
+ sanitized = _remove_unsupported_characters(
+ chunk_to_speak, unsupported
+ ).strip()
+
+ if sanitized == chunk_to_speak.strip():
+ raise
+
+ chunk_to_speak = sanitized
+ if not chunk_to_speak:
+ logger.warning(
+ "SuperTonic: dropped a chunk after removing unsupported characters: %s",
+ sorted(removed),
+ )
+ break
+
+ if attempt == 0:
+ logger.warning(
+ "SuperTonic: removed unsupported characters %s and retried.",
+ sorted(removed),
+ )
+ else:
+ assert last_exc is not None
+ raise last_exc
+
+ if not chunk_to_speak:
+ continue
+
+ audio = _ensure_float32_mono(wav)
+
+ src_rate = self.sample_rate
+ try:
+ dur = float(duration)
+ if dur > 0 and audio.size > 0:
+ inferred = int(round(audio.size / dur))
+ if 8000 <= inferred <= 96000:
+ src_rate = inferred
+ except Exception:
+ pass
+
+ if src_rate != self.sample_rate:
+ audio = _resample_linear(audio, src_rate, self.sample_rate)
+
+ yield SupertonicSegment(graphemes=chunk_to_speak, audio=audio)
diff --git a/tests/contracts/__init__.py b/tests/contracts/__init__.py
index 28b505e..b91c11a 100644
--- a/tests/contracts/__init__.py
+++ b/tests/contracts/__init__.py
@@ -1,5 +1,5 @@
-"""Contract tests for the TTS Plugin API.
-
-This package contains reusable contract tests that any TTS plugin implementation
-must satisfy. Tests use only the public API and are engine-agnostic.
-"""
+"""Contract tests for the TTS Plugin API.
+
+This package contains reusable contract tests that any TTS plugin implementation
+must satisfy. Tests use only the public API and are engine-agnostic.
+"""
diff --git a/tests/contracts/conftest.py b/tests/contracts/conftest.py
index d2b80cb..065242b 100644
--- a/tests/contracts/conftest.py
+++ b/tests/contracts/conftest.py
@@ -1,231 +1,231 @@
-"""Shared fixtures and stubs for contract tests.
-
-This module provides minimal stub implementations that satisfy the public API
-for testing purposes. These stubs do NOT contain real business logic.
-"""
-
-from __future__ import annotations
-
-import logging
-from pathlib import Path
-from typing import Iterator
-
-import pytest
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.host_context import HostContext
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- EngineConfig,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-
-
-class FakeHttpClient:
- """Stub HTTP client that satisfies the HttpClient protocol."""
-
- def get(self, url: str, **kwargs: object) -> object:
- return None
-
- def post(self, url: str, **kwargs: object) -> object:
- return None
-
-
-class FakeEngineSession:
- """Stub EngineSession for testing protocol compliance."""
-
- def __init__(self) -> None:
- self._disposed = False
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Session disposed")
- return SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class FakeStreamingSession:
- """Stub EngineSession with StreamingSynthesizer capability."""
-
- def __init__(self) -> None:
- self._disposed = False
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Session disposed")
- return SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
-
- def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Session disposed")
- for i in range(3):
- yield b"\x00" * 50
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class FakeCancelableSession:
- """Stub EngineSession with CancelableSession capability."""
-
- def __init__(self) -> None:
- self._disposed = False
- self._cancelled = False
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Session disposed")
- if self._cancelled:
- from abogen.tts_plugin.errors import CancelledError
-
- raise CancelledError("Cancelled")
- return SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
-
- def cancel(self) -> None:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Session disposed")
- self._cancelled = True
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class FakeEngine:
- """Stub Engine for testing protocol compliance."""
-
- def __init__(self, session_class: type = FakeEngineSession) -> None:
- self._disposed = False
- self._session_class = session_class
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Engine disposed")
- return self._session_class()
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class FakeVoiceListerEngine:
- """Stub Engine that also implements VoiceLister."""
-
- def __init__(self) -> None:
- self._disposed = False
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Engine disposed")
- return FakeEngineSession()
-
- def listVoices(self, sourceId: str) -> list:
- from abogen.tts_plugin.manifest import VoiceManifest
-
- return [
- VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
- VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
- ]
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class FakePreviewEngine:
- """Stub Engine that also implements PreviewGenerator."""
-
- def __init__(self) -> None:
- self._disposed = False
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- from abogen.tts_plugin.errors import EngineError
-
- raise EngineError("Engine disposed")
- return FakeEngineSession()
-
- def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
- return SynthesizedAudio(
- data=b"\x00" * 50,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=0.5),
- )
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-@pytest.fixture
-def fake_http_client() -> FakeHttpClient:
- return FakeHttpClient()
-
-
-@pytest.fixture
-def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
- return HostContext(
- config_dir=tmp_path,
- logger=logging.getLogger("test"),
- http_client=fake_http_client,
- )
-
-
-@pytest.fixture
-def fake_engine() -> FakeEngine:
- return FakeEngine()
-
-
-@pytest.fixture
-def fake_session() -> FakeEngineSession:
- return FakeEngineSession()
-
-
-@pytest.fixture
-def default_voice() -> VoiceSelection:
- return VoiceSelection(source="builtin", key="af_nova")
-
-
-@pytest.fixture
-def default_format() -> AudioFormat:
- return AudioFormat(mime="audio/wav", extension="wav")
-
-
-@pytest.fixture
-def default_request(
- default_voice: VoiceSelection, default_format: AudioFormat
-) -> SynthesisRequest:
- return SynthesisRequest(
- text="Hello, world!",
- voice=default_voice,
- parameters=ParameterValues(values={}),
- format=default_format,
- )
+"""Shared fixtures and stubs for contract tests.
+
+This module provides minimal stub implementations that satisfy the public API
+for testing purposes. These stubs do NOT contain real business logic.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Iterator
+
+import pytest
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.host_context import HostContext
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ EngineConfig,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+
+
+class FakeHttpClient:
+ """Stub HTTP client that satisfies the HttpClient protocol."""
+
+ def get(self, url: str, **kwargs: object) -> object:
+ return None
+
+ def post(self, url: str, **kwargs: object) -> object:
+ return None
+
+
+class FakeEngineSession:
+ """Stub EngineSession for testing protocol compliance."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Session disposed")
+ return SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class FakeStreamingSession:
+ """Stub EngineSession with StreamingSynthesizer capability."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Session disposed")
+ return SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+
+ def synthesizeStream(self, request: SynthesisRequest) -> Iterator[bytes]:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Session disposed")
+ for i in range(3):
+ yield b"\x00" * 50
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class FakeCancelableSession:
+ """Stub EngineSession with CancelableSession capability."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+ self._cancelled = False
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Session disposed")
+ if self._cancelled:
+ from abogen.tts_plugin.errors import CancelledError
+
+ raise CancelledError("Cancelled")
+ return SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+
+ def cancel(self) -> None:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Session disposed")
+ self._cancelled = True
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class FakeEngine:
+ """Stub Engine for testing protocol compliance."""
+
+ def __init__(self, session_class: type = FakeEngineSession) -> None:
+ self._disposed = False
+ self._session_class = session_class
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Engine disposed")
+ return self._session_class()
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class FakeVoiceListerEngine:
+ """Stub Engine that also implements VoiceLister."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Engine disposed")
+ return FakeEngineSession()
+
+ def listVoices(self, sourceId: str) -> list:
+ from abogen.tts_plugin.manifest import VoiceManifest
+
+ return [
+ VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
+ VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
+ ]
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class FakePreviewEngine:
+ """Stub Engine that also implements PreviewGenerator."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ from abogen.tts_plugin.errors import EngineError
+
+ raise EngineError("Engine disposed")
+ return FakeEngineSession()
+
+ def generatePreview(self, voice: VoiceSelection, text: str) -> SynthesizedAudio:
+ return SynthesizedAudio(
+ data=b"\x00" * 50,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=0.5),
+ )
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+@pytest.fixture
+def fake_http_client() -> FakeHttpClient:
+ return FakeHttpClient()
+
+
+@pytest.fixture
+def host_context(tmp_path: Path, fake_http_client: FakeHttpClient) -> HostContext:
+ return HostContext(
+ config_dir=tmp_path,
+ logger=logging.getLogger("test"),
+ http_client=fake_http_client,
+ )
+
+
+@pytest.fixture
+def fake_engine() -> FakeEngine:
+ return FakeEngine()
+
+
+@pytest.fixture
+def fake_session() -> FakeEngineSession:
+ return FakeEngineSession()
+
+
+@pytest.fixture
+def default_voice() -> VoiceSelection:
+ return VoiceSelection(source="builtin", key="af_nova")
+
+
+@pytest.fixture
+def default_format() -> AudioFormat:
+ return AudioFormat(mime="audio/wav", extension="wav")
+
+
+@pytest.fixture
+def default_request(
+ default_voice: VoiceSelection, default_format: AudioFormat
+) -> SynthesisRequest:
+ return SynthesisRequest(
+ text="Hello, world!",
+ voice=default_voice,
+ parameters=ParameterValues(values={}),
+ format=default_format,
+ )
diff --git a/tests/contracts/engine_contract.py b/tests/contracts/engine_contract.py
index 94f7088..c4bfb2b 100644
--- a/tests/contracts/engine_contract.py
+++ b/tests/contracts/engine_contract.py
@@ -1,120 +1,120 @@
-"""Base contract tests for Engine implementations.
-
-Any new TTS plugin must inherit from these classes to verify
-it satisfies the Engine/EngineSession protocol.
-
-Usage:
- from tests.contracts.engine_contract import EngineContractMixin
-
- class TestMyEngine(EngineContractMixin):
- @pytest.fixture
- def engine(self):
- return create_my_engine()
-"""
-
-from __future__ import annotations
-
-import pytest
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.errors import EngineError
-from abogen.tts_plugin.types import (
- AudioFormat,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-
-
-class EngineContractMixin:
- """Base contract tests for Engine implementations.
-
- Subclasses must define a module-level ``engine`` fixture returning
- a fully initialized Engine instance. The tests below will use it
- via pytest's standard fixture resolution.
- """
-
- def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
- return SynthesisRequest(
- text=text,
- voice=VoiceSelection(source="builtin", key=voice or "default"),
- parameters=ParameterValues(values={}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
- # ── Engine protocol ──────────────────────────────────────
-
- def test_engine_satisfies_protocol(self, engine: Engine) -> None:
- assert isinstance(engine, Engine)
-
- def test_create_session_returns_session(self, engine: Engine) -> None:
- session = engine.createSession()
- assert isinstance(session, EngineSession)
- session.dispose()
-
- def test_create_session_returns_new_instances(self, engine: Engine) -> None:
- s1 = engine.createSession()
- s2 = engine.createSession()
- assert s1 is not s2
- s1.dispose()
- s2.dispose()
-
- def test_dispose_is_idempotent(self, engine: Engine) -> None:
- engine.dispose()
- engine.dispose()
-
- def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
- engine.dispose()
- with pytest.raises(EngineError):
- engine.createSession()
-
- # ── Session protocol ─────────────────────────────────────
-
- def test_session_satisfies_protocol(self, engine: Engine) -> None:
- session = engine.createSession()
- assert isinstance(session, EngineSession)
- session.dispose()
- engine.dispose()
-
- def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
- session = engine.createSession()
- result = session.synthesize(self._req())
- assert isinstance(result, SynthesizedAudio)
- assert isinstance(result.data, bytes)
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
- def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
- session = engine.createSession()
- session.dispose()
- session.dispose()
- engine.dispose()
-
- def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
- session = engine.createSession()
- session.dispose()
- with pytest.raises(EngineError):
- session.synthesize(self._req())
- engine.dispose()
-
- def test_session_multiple_synthesize(self, engine: Engine) -> None:
- session = engine.createSession()
- r1 = session.synthesize(self._req())
- r2 = session.synthesize(self._req())
- assert isinstance(r1.data, bytes)
- assert isinstance(r2.data, bytes)
- session.dispose()
- engine.dispose()
-
- # ── Lifecycle ────────────────────────────────────────────
-
- def test_full_lifecycle(self, engine: Engine) -> None:
- s1 = engine.createSession()
- s2 = engine.createSession()
- s1.synthesize(self._req())
- s2.synthesize(self._req())
- s1.dispose()
- s2.dispose()
- engine.dispose()
+"""Base contract tests for Engine implementations.
+
+Any new TTS plugin must inherit from these classes to verify
+it satisfies the Engine/EngineSession protocol.
+
+Usage:
+ from tests.contracts.engine_contract import EngineContractMixin
+
+ class TestMyEngine(EngineContractMixin):
+ @pytest.fixture
+ def engine(self):
+ return create_my_engine()
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.errors import EngineError
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+
+
+class EngineContractMixin:
+ """Base contract tests for Engine implementations.
+
+ Subclasses must define a module-level ``engine`` fixture returning
+ a fully initialized Engine instance. The tests below will use it
+ via pytest's standard fixture resolution.
+ """
+
+ def _req(self, text: str = "Hello", voice: str | None = None) -> SynthesisRequest:
+ return SynthesisRequest(
+ text=text,
+ voice=VoiceSelection(source="builtin", key=voice or "default"),
+ parameters=ParameterValues(values={}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+ # ── Engine protocol ──────────────────────────────────────
+
+ def test_engine_satisfies_protocol(self, engine: Engine) -> None:
+ assert isinstance(engine, Engine)
+
+ def test_create_session_returns_session(self, engine: Engine) -> None:
+ session = engine.createSession()
+ assert isinstance(session, EngineSession)
+ session.dispose()
+
+ def test_create_session_returns_new_instances(self, engine: Engine) -> None:
+ s1 = engine.createSession()
+ s2 = engine.createSession()
+ assert s1 is not s2
+ s1.dispose()
+ s2.dispose()
+
+ def test_dispose_is_idempotent(self, engine: Engine) -> None:
+ engine.dispose()
+ engine.dispose()
+
+ def test_create_session_after_dispose_raises(self, engine: Engine) -> None:
+ engine.dispose()
+ with pytest.raises(EngineError):
+ engine.createSession()
+
+ # ── Session protocol ─────────────────────────────────────
+
+ def test_session_satisfies_protocol(self, engine: Engine) -> None:
+ session = engine.createSession()
+ assert isinstance(session, EngineSession)
+ session.dispose()
+ engine.dispose()
+
+ def test_session_synthesize_returns_audio(self, engine: Engine) -> None:
+ session = engine.createSession()
+ result = session.synthesize(self._req())
+ assert isinstance(result, SynthesizedAudio)
+ assert isinstance(result.data, bytes)
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+ def test_session_dispose_is_idempotent(self, engine: Engine) -> None:
+ session = engine.createSession()
+ session.dispose()
+ session.dispose()
+ engine.dispose()
+
+ def test_session_synthesize_after_dispose_raises(self, engine: Engine) -> None:
+ session = engine.createSession()
+ session.dispose()
+ with pytest.raises(EngineError):
+ session.synthesize(self._req())
+ engine.dispose()
+
+ def test_session_multiple_synthesize(self, engine: Engine) -> None:
+ session = engine.createSession()
+ r1 = session.synthesize(self._req())
+ r2 = session.synthesize(self._req())
+ assert isinstance(r1.data, bytes)
+ assert isinstance(r2.data, bytes)
+ session.dispose()
+ engine.dispose()
+
+ # ── Lifecycle ────────────────────────────────────────────
+
+ def test_full_lifecycle(self, engine: Engine) -> None:
+ s1 = engine.createSession()
+ s2 = engine.createSession()
+ s1.synthesize(self._req())
+ s2.synthesize(self._req())
+ s1.dispose()
+ s2.dispose()
+ engine.dispose()
diff --git a/tests/contracts/test_capabilities_contract.py b/tests/contracts/test_capabilities_contract.py
index d1b5afa..a0d7f3e 100644
--- a/tests/contracts/test_capabilities_contract.py
+++ b/tests/contracts/test_capabilities_contract.py
@@ -1,183 +1,183 @@
-"""Contract tests for capability interfaces.
-
-These tests verify that capability interfaces satisfy the architectural requirements:
-- VoiceLister: lists voices for a source
-- PreviewGenerator: generates preview audio
-- StreamingSynthesizer: yields audio chunks
-- CancelableSession: cancels in-progress synthesis
-"""
-
-import pytest
-
-from abogen.tts_plugin.capabilities import (
- CancelableSession,
- PreviewGenerator,
- StreamingSynthesizer,
- VoiceLister,
-)
-from abogen.tts_plugin.errors import CancelledError, EngineError
-from abogen.tts_plugin.manifest import VoiceManifest
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-
-from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
-
-
-class TestVoiceListerProtocolContract:
- """Contract tests for VoiceLister protocol."""
-
- def test_voice_lister_is_protocol(self) -> None:
- assert hasattr(VoiceLister, "__protocol_attrs__")
-
- def test_voice_lister_satisfied_by_engine(self) -> None:
- engine = FakeVoiceListerEngine()
- assert isinstance(engine, VoiceLister)
-
- def test_list_voices_returns_list(self) -> None:
- engine = FakeVoiceListerEngine()
- voices = engine.listVoices("builtin")
- assert isinstance(voices, list)
-
- def test_list_voices_returns_voice_manifests(self) -> None:
- engine = FakeVoiceListerEngine()
- voices = engine.listVoices("builtin")
- for voice in voices:
- assert isinstance(voice, VoiceManifest)
-
- def test_list_voices_has_required_fields(self) -> None:
- engine = FakeVoiceListerEngine()
- voices = engine.listVoices("builtin")
- for voice in voices:
- assert hasattr(voice, "id")
- assert hasattr(voice, "name")
- assert hasattr(voice, "tags")
-
-
-class TestPreviewGeneratorProtocolContract:
- """Contract tests for PreviewGenerator protocol."""
-
- def test_preview_generator_is_protocol(self) -> None:
- assert hasattr(PreviewGenerator, "__protocol_attrs__")
-
- def test_preview_generator_satisfied_by_engine(self) -> None:
- from .conftest import FakePreviewEngine
-
- engine = FakePreviewEngine()
- assert isinstance(engine, PreviewGenerator)
-
- def test_generate_preview_returns_synthesized_audio(self) -> None:
- from .conftest import FakePreviewEngine
-
- engine = FakePreviewEngine()
- voice = VoiceSelection(source="builtin", key="af_nova")
- result = engine.generatePreview(voice, "Hello")
- assert isinstance(result, SynthesizedAudio)
-
- def test_generate_preview_has_valid_data(self) -> None:
- from .conftest import FakePreviewEngine
-
- engine = FakePreviewEngine()
- voice = VoiceSelection(source="builtin", key="af_nova")
- result = engine.generatePreview(voice, "Hello")
- assert isinstance(result.data, bytes)
- assert len(result.data) > 0
-
-
-class TestStreamingSynthesizerProtocolContract:
- """Contract tests for StreamingSynthesizer protocol."""
-
- def test_streaming_synthesizer_is_protocol(self) -> None:
- assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
-
- def test_streaming_session_satisfies_protocol(self) -> None:
- session = FakeStreamingSession()
- assert isinstance(session, StreamingSynthesizer)
-
- def test_synthesize_stream_yields_bytes(self) -> None:
- session = FakeStreamingSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- chunks = list(session.synthesizeStream(request))
- assert len(chunks) > 0
- for chunk in chunks:
- assert isinstance(chunk, bytes)
-
- def test_streaming_iterator_exhaustion(self) -> None:
- """Architecture spec: Iterator exhaustion = synthesis complete."""
- session = FakeStreamingSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- chunks = list(session.synthesizeStream(request))
- assert len(chunks) == 3
-
- def test_streaming_after_dispose_raises(self) -> None:
- """Architecture spec: After dispose(), methods raise EngineError."""
- session = FakeStreamingSession()
- session.dispose()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- with pytest.raises(EngineError):
- list(session.synthesizeStream(request))
-
-
-class TestCancelableSessionProtocolContract:
- """Contract tests for CancelableSession protocol."""
-
- def test_cancelable_session_is_protocol(self) -> None:
- assert hasattr(CancelableSession, "__protocol_attrs__")
-
- def test_cancelable_session_satisfies_protocol(self) -> None:
- session = FakeCancelableSession()
- assert isinstance(session, CancelableSession)
-
- def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
- """Architecture spec: cancel() causes synthesize() to raise CancelledError."""
- session = FakeCancelableSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
- # Cancel
- session.cancel()
-
- # synthesize should raise CancelledError
- with pytest.raises(CancelledError):
- session.synthesize(request)
-
- def test_cancel_after_dispose_raises(self) -> None:
- """Architecture spec: cancel() raises EngineError if called after dispose()."""
- session = FakeCancelableSession()
- session.dispose()
- with pytest.raises(EngineError):
- session.cancel()
-
- def test_session_usable_after_cancel(self) -> None:
- """Architecture spec: EngineSession remains usable after cancellation."""
- session = FakeCancelableSession()
-
- # Cancel
- session.cancel()
-
- # Dispose and create new session for synthesis
- session.dispose()
+"""Contract tests for capability interfaces.
+
+These tests verify that capability interfaces satisfy the architectural requirements:
+- VoiceLister: lists voices for a source
+- PreviewGenerator: generates preview audio
+- StreamingSynthesizer: yields audio chunks
+- CancelableSession: cancels in-progress synthesis
+"""
+
+import pytest
+
+from abogen.tts_plugin.capabilities import (
+ CancelableSession,
+ PreviewGenerator,
+ StreamingSynthesizer,
+ VoiceLister,
+)
+from abogen.tts_plugin.errors import CancelledError, EngineError
+from abogen.tts_plugin.manifest import VoiceManifest
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+
+from .conftest import FakeCancelableSession, FakeStreamingSession, FakeVoiceListerEngine
+
+
+class TestVoiceListerProtocolContract:
+ """Contract tests for VoiceLister protocol."""
+
+ def test_voice_lister_is_protocol(self) -> None:
+ assert hasattr(VoiceLister, "__protocol_attrs__")
+
+ def test_voice_lister_satisfied_by_engine(self) -> None:
+ engine = FakeVoiceListerEngine()
+ assert isinstance(engine, VoiceLister)
+
+ def test_list_voices_returns_list(self) -> None:
+ engine = FakeVoiceListerEngine()
+ voices = engine.listVoices("builtin")
+ assert isinstance(voices, list)
+
+ def test_list_voices_returns_voice_manifests(self) -> None:
+ engine = FakeVoiceListerEngine()
+ voices = engine.listVoices("builtin")
+ for voice in voices:
+ assert isinstance(voice, VoiceManifest)
+
+ def test_list_voices_has_required_fields(self) -> None:
+ engine = FakeVoiceListerEngine()
+ voices = engine.listVoices("builtin")
+ for voice in voices:
+ assert hasattr(voice, "id")
+ assert hasattr(voice, "name")
+ assert hasattr(voice, "tags")
+
+
+class TestPreviewGeneratorProtocolContract:
+ """Contract tests for PreviewGenerator protocol."""
+
+ def test_preview_generator_is_protocol(self) -> None:
+ assert hasattr(PreviewGenerator, "__protocol_attrs__")
+
+ def test_preview_generator_satisfied_by_engine(self) -> None:
+ from .conftest import FakePreviewEngine
+
+ engine = FakePreviewEngine()
+ assert isinstance(engine, PreviewGenerator)
+
+ def test_generate_preview_returns_synthesized_audio(self) -> None:
+ from .conftest import FakePreviewEngine
+
+ engine = FakePreviewEngine()
+ voice = VoiceSelection(source="builtin", key="af_nova")
+ result = engine.generatePreview(voice, "Hello")
+ assert isinstance(result, SynthesizedAudio)
+
+ def test_generate_preview_has_valid_data(self) -> None:
+ from .conftest import FakePreviewEngine
+
+ engine = FakePreviewEngine()
+ voice = VoiceSelection(source="builtin", key="af_nova")
+ result = engine.generatePreview(voice, "Hello")
+ assert isinstance(result.data, bytes)
+ assert len(result.data) > 0
+
+
+class TestStreamingSynthesizerProtocolContract:
+ """Contract tests for StreamingSynthesizer protocol."""
+
+ def test_streaming_synthesizer_is_protocol(self) -> None:
+ assert hasattr(StreamingSynthesizer, "__protocol_attrs__")
+
+ def test_streaming_session_satisfies_protocol(self) -> None:
+ session = FakeStreamingSession()
+ assert isinstance(session, StreamingSynthesizer)
+
+ def test_synthesize_stream_yields_bytes(self) -> None:
+ session = FakeStreamingSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ chunks = list(session.synthesizeStream(request))
+ assert len(chunks) > 0
+ for chunk in chunks:
+ assert isinstance(chunk, bytes)
+
+ def test_streaming_iterator_exhaustion(self) -> None:
+ """Architecture spec: Iterator exhaustion = synthesis complete."""
+ session = FakeStreamingSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ chunks = list(session.synthesizeStream(request))
+ assert len(chunks) == 3
+
+ def test_streaming_after_dispose_raises(self) -> None:
+ """Architecture spec: After dispose(), methods raise EngineError."""
+ session = FakeStreamingSession()
+ session.dispose()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ with pytest.raises(EngineError):
+ list(session.synthesizeStream(request))
+
+
+class TestCancelableSessionProtocolContract:
+ """Contract tests for CancelableSession protocol."""
+
+ def test_cancelable_session_is_protocol(self) -> None:
+ assert hasattr(CancelableSession, "__protocol_attrs__")
+
+ def test_cancelable_session_satisfies_protocol(self) -> None:
+ session = FakeCancelableSession()
+ assert isinstance(session, CancelableSession)
+
+ def test_cancel_causes_synthesize_to_raise_cancelled(self) -> None:
+ """Architecture spec: cancel() causes synthesize() to raise CancelledError."""
+ session = FakeCancelableSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+ # Cancel
+ session.cancel()
+
+ # synthesize should raise CancelledError
+ with pytest.raises(CancelledError):
+ session.synthesize(request)
+
+ def test_cancel_after_dispose_raises(self) -> None:
+ """Architecture spec: cancel() raises EngineError if called after dispose()."""
+ session = FakeCancelableSession()
+ session.dispose()
+ with pytest.raises(EngineError):
+ session.cancel()
+
+ def test_session_usable_after_cancel(self) -> None:
+ """Architecture spec: EngineSession remains usable after cancellation."""
+ session = FakeCancelableSession()
+
+ # Cancel
+ session.cancel()
+
+ # Dispose and create new session for synthesis
+ session.dispose()
diff --git a/tests/contracts/test_engine_contract.py b/tests/contracts/test_engine_contract.py
index bc73faf..b90c223 100644
--- a/tests/contracts/test_engine_contract.py
+++ b/tests/contracts/test_engine_contract.py
@@ -1,106 +1,106 @@
-"""Contract tests for Engine protocol.
-
-These tests verify that Engine implementations satisfy the architectural requirements:
-- createSession() returns EngineSession
-- dispose() is idempotent
-- After dispose(), createSession() raises EngineError
-- Engine is thread-safe for createSession()
-"""
-
-import pytest
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.errors import EngineError
-
-from .conftest import FakeEngine, FakeEngineSession
-
-
-class TestEngineProtocolContract:
- """Contract tests for the Engine protocol itself."""
-
- def test_engine_is_protocol(self) -> None:
- assert hasattr(Engine, "__protocol_attrs__")
-
- def test_engine_session_is_protocol(self) -> None:
- assert hasattr(EngineSession, "__protocol_attrs__")
-
- def test_fake_engine_satisfies_protocol(self) -> None:
- engine = FakeEngine()
- assert isinstance(engine, Engine)
-
- def test_fake_session_satisfies_protocol(self) -> None:
- session = FakeEngineSession()
- assert isinstance(session, EngineSession)
-
-
-class TestEngineCreateSessionContract:
- """Contract tests for Engine.createSession()."""
-
- def test_create_session_returns_engine_session(self) -> None:
- engine = FakeEngine()
- session = engine.createSession()
- assert isinstance(session, EngineSession)
-
- def test_create_session_returns_new_instance(self) -> None:
- engine = FakeEngine()
- session1 = engine.createSession()
- session2 = engine.createSession()
- assert session1 is not session2
-
- def test_create_session_ownership_transfers(self) -> None:
- """Architecture spec: Ownership transfers to caller."""
- engine = FakeEngine()
- session = engine.createSession()
- assert isinstance(session, EngineSession)
-
-
-class TestEngineDisposeContract:
- """Contract tests for Engine.dispose()."""
-
- def test_dispose_is_idempotent(self) -> None:
- """Architecture spec: dispose() is idempotent."""
- engine = FakeEngine()
- engine.dispose()
- engine.dispose() # Should not raise
-
- def test_dispose_never_raises(self) -> None:
- """Architecture spec: dispose() never raises exceptions."""
- engine = FakeEngine()
- engine.dispose() # Should not raise
-
- def test_create_session_after_dispose_raises(self) -> None:
- """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
- engine = FakeEngine()
- engine.dispose()
- with pytest.raises(EngineError):
- engine.createSession()
-
-
-class TestEngineLifecycleContract:
- """Contract tests for Engine lifecycle."""
-
- def test_full_lifecycle(self) -> None:
- """Test complete engine lifecycle: create -> sessions -> dispose."""
- engine = FakeEngine()
-
- # Create sessions
- session1 = engine.createSession()
- session2 = engine.createSession()
-
- # Use sessions
- assert isinstance(session1, EngineSession)
- assert isinstance(session2, EngineSession)
-
- # Dispose sessions
- session1.dispose()
- session2.dispose()
-
- # Dispose engine
- engine.dispose()
-
- def test_engine_disposed_session_raises(self) -> None:
- """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
- engine = FakeEngine()
- engine.dispose()
- with pytest.raises(EngineError):
- engine.createSession()
+"""Contract tests for Engine protocol.
+
+These tests verify that Engine implementations satisfy the architectural requirements:
+- createSession() returns EngineSession
+- dispose() is idempotent
+- After dispose(), createSession() raises EngineError
+- Engine is thread-safe for createSession()
+"""
+
+import pytest
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.errors import EngineError
+
+from .conftest import FakeEngine, FakeEngineSession
+
+
+class TestEngineProtocolContract:
+ """Contract tests for the Engine protocol itself."""
+
+ def test_engine_is_protocol(self) -> None:
+ assert hasattr(Engine, "__protocol_attrs__")
+
+ def test_engine_session_is_protocol(self) -> None:
+ assert hasattr(EngineSession, "__protocol_attrs__")
+
+ def test_fake_engine_satisfies_protocol(self) -> None:
+ engine = FakeEngine()
+ assert isinstance(engine, Engine)
+
+ def test_fake_session_satisfies_protocol(self) -> None:
+ session = FakeEngineSession()
+ assert isinstance(session, EngineSession)
+
+
+class TestEngineCreateSessionContract:
+ """Contract tests for Engine.createSession()."""
+
+ def test_create_session_returns_engine_session(self) -> None:
+ engine = FakeEngine()
+ session = engine.createSession()
+ assert isinstance(session, EngineSession)
+
+ def test_create_session_returns_new_instance(self) -> None:
+ engine = FakeEngine()
+ session1 = engine.createSession()
+ session2 = engine.createSession()
+ assert session1 is not session2
+
+ def test_create_session_ownership_transfers(self) -> None:
+ """Architecture spec: Ownership transfers to caller."""
+ engine = FakeEngine()
+ session = engine.createSession()
+ assert isinstance(session, EngineSession)
+
+
+class TestEngineDisposeContract:
+ """Contract tests for Engine.dispose()."""
+
+ def test_dispose_is_idempotent(self) -> None:
+ """Architecture spec: dispose() is idempotent."""
+ engine = FakeEngine()
+ engine.dispose()
+ engine.dispose() # Should not raise
+
+ def test_dispose_never_raises(self) -> None:
+ """Architecture spec: dispose() never raises exceptions."""
+ engine = FakeEngine()
+ engine.dispose() # Should not raise
+
+ def test_create_session_after_dispose_raises(self) -> None:
+ """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
+ engine = FakeEngine()
+ engine.dispose()
+ with pytest.raises(EngineError):
+ engine.createSession()
+
+
+class TestEngineLifecycleContract:
+ """Contract tests for Engine lifecycle."""
+
+ def test_full_lifecycle(self) -> None:
+ """Test complete engine lifecycle: create -> sessions -> dispose."""
+ engine = FakeEngine()
+
+ # Create sessions
+ session1 = engine.createSession()
+ session2 = engine.createSession()
+
+ # Use sessions
+ assert isinstance(session1, EngineSession)
+ assert isinstance(session2, EngineSession)
+
+ # Dispose sessions
+ session1.dispose()
+ session2.dispose()
+
+ # Dispose engine
+ engine.dispose()
+
+ def test_engine_disposed_session_raises(self) -> None:
+ """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
+ engine = FakeEngine()
+ engine.dispose()
+ with pytest.raises(EngineError):
+ engine.createSession()
diff --git a/tests/contracts/test_errors_contract.py b/tests/contracts/test_errors_contract.py
index 8da3479..265a070 100644
--- a/tests/contracts/test_errors_contract.py
+++ b/tests/contracts/test_errors_contract.py
@@ -1,85 +1,85 @@
-"""Contract tests for error hierarchy.
-
-These tests verify that the error hierarchy satisfies the architectural requirements:
-- All errors inherit from EngineError
-- EngineError inherits from Exception
-- Each error type is properly classified
-"""
-
-import pytest
-
-from abogen.tts_plugin.errors import (
- CancelledError,
- ConfigurationError,
- EngineError,
- InternalError,
- InvalidInputError,
- ModelLoadError,
- ModelNotFoundError,
- NetworkError,
-)
-
-
-class TestErrorHierarchyContract:
- """Contract tests for the error hierarchy."""
-
- def test_engine_error_is_exception(self) -> None:
- assert issubclass(EngineError, Exception)
-
- def test_all_errors_inherit_from_engine_error(self) -> None:
- error_classes = [
- ModelNotFoundError,
- ModelLoadError,
- NetworkError,
- InvalidInputError,
- ConfigurationError,
- CancelledError,
- InternalError,
- ]
- for error_class in error_classes:
- assert issubclass(error_class, EngineError), (
- f"{error_class.__name__} must inherit from EngineError"
- )
-
- def test_all_errors_are_catchable(self) -> None:
- error_classes = [
- EngineError,
- ModelNotFoundError,
- ModelLoadError,
- NetworkError,
- InvalidInputError,
- ConfigurationError,
- CancelledError,
- InternalError,
- ]
- for error_class in error_classes:
- with pytest.raises(EngineError):
- raise error_class("test message")
-
- def test_error_message_preserved(self) -> None:
- msg = "Model not found: bert-base"
- with pytest.raises(ModelNotFoundError, match=msg):
- raise ModelNotFoundError(msg)
-
- def test_error_can_be_caught_as_engine_error(self) -> None:
- with pytest.raises(EngineError):
- raise ModelNotFoundError("test")
-
- def test_cancelled_error_is_engine_error(self) -> None:
- """CancelledError is a subtype of EngineError per architecture spec."""
- assert issubclass(CancelledError, EngineError)
-
- def test_error_hierarchy_no_cycles(self) -> None:
- """Verify no circular inheritance."""
- error_classes = [
- EngineError,
- ModelNotFoundError,
- ModelLoadError,
- NetworkError,
- InvalidInputError,
- ConfigurationError,
- CancelledError,
- InternalError,
- ]
- for cls in error_classes:
- assert cls not in cls.__bases__
+"""Contract tests for error hierarchy.
+
+These tests verify that the error hierarchy satisfies the architectural requirements:
+- All errors inherit from EngineError
+- EngineError inherits from Exception
+- Each error type is properly classified
+"""
+
+import pytest
+
+from abogen.tts_plugin.errors import (
+ CancelledError,
+ ConfigurationError,
+ EngineError,
+ InternalError,
+ InvalidInputError,
+ ModelLoadError,
+ ModelNotFoundError,
+ NetworkError,
+)
+
+
+class TestErrorHierarchyContract:
+ """Contract tests for the error hierarchy."""
+
+ def test_engine_error_is_exception(self) -> None:
+ assert issubclass(EngineError, Exception)
+
+ def test_all_errors_inherit_from_engine_error(self) -> None:
+ error_classes = [
+ ModelNotFoundError,
+ ModelLoadError,
+ NetworkError,
+ InvalidInputError,
+ ConfigurationError,
+ CancelledError,
+ InternalError,
+ ]
+ for error_class in error_classes:
+ assert issubclass(error_class, EngineError), (
+ f"{error_class.__name__} must inherit from EngineError"
+ )
+
+ def test_all_errors_are_catchable(self) -> None:
+ error_classes = [
+ EngineError,
+ ModelNotFoundError,
+ ModelLoadError,
+ NetworkError,
+ InvalidInputError,
+ ConfigurationError,
+ CancelledError,
+ InternalError,
+ ]
+ for error_class in error_classes:
+ with pytest.raises(EngineError):
+ raise error_class("test message")
+
+ def test_error_message_preserved(self) -> None:
+ msg = "Model not found: bert-base"
+ with pytest.raises(ModelNotFoundError, match=msg):
+ raise ModelNotFoundError(msg)
+
+ def test_error_can_be_caught_as_engine_error(self) -> None:
+ with pytest.raises(EngineError):
+ raise ModelNotFoundError("test")
+
+ def test_cancelled_error_is_engine_error(self) -> None:
+ """CancelledError is a subtype of EngineError per architecture spec."""
+ assert issubclass(CancelledError, EngineError)
+
+ def test_error_hierarchy_no_cycles(self) -> None:
+ """Verify no circular inheritance."""
+ error_classes = [
+ EngineError,
+ ModelNotFoundError,
+ ModelLoadError,
+ NetworkError,
+ InvalidInputError,
+ ConfigurationError,
+ CancelledError,
+ InternalError,
+ ]
+ for cls in error_classes:
+ assert cls not in cls.__bases__
diff --git a/tests/contracts/test_host_context_contract.py b/tests/contracts/test_host_context_contract.py
index c028f20..52b476b 100644
--- a/tests/contracts/test_host_context_contract.py
+++ b/tests/contracts/test_host_context_contract.py
@@ -1,89 +1,89 @@
-"""Contract tests for HostContext.
-
-These tests verify that HostContext satisfies the architectural requirements:
-- Minimal (3 fields maximum)
-- Frozen dataclass
-- config_dir: Path
-- logger: Logger
-- http_client: HttpClient protocol
-"""
-
-import logging
-from pathlib import Path
-
-import pytest
-
-from abogen.tts_plugin.host_context import HttpClient, HostContext
-
-
-class TestHostContextContract:
- """Contract tests for HostContext dataclass."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(HostContext, "__dataclass_params__")
- assert HostContext.__dataclass_params__.frozen is True
-
- def test_required_fields(self, tmp_path: Path) -> None:
- logger = logging.getLogger("test")
-
- class FakeClient:
- def get(self, url: str, **kwargs: object) -> object:
- return None
-
- def post(self, url: str, **kwargs: object) -> object:
- return None
-
- ctx = HostContext(
- config_dir=tmp_path,
- logger=logger,
- http_client=FakeClient(),
- )
- assert ctx.config_dir == tmp_path
- assert ctx.logger is logger
-
- def test_immutability(self, tmp_path: Path) -> None:
- class FakeClient:
- def get(self, url: str, **kwargs: object) -> object:
- return None
-
- def post(self, url: str, **kwargs: object) -> object:
- return None
-
- ctx = HostContext(
- config_dir=tmp_path,
- logger=logging.getLogger("test"),
- http_client=FakeClient(),
- )
- with pytest.raises(AttributeError):
- ctx.config_dir = Path("/other") # type: ignore[misc]
-
- def test_max_three_fields(self) -> None:
- """Architecture spec: HostContext is minimal (3 fields max)."""
- import dataclasses
-
- fields = dataclasses.fields(HostContext)
- assert len(fields) <= 3
-
-
-class TestHttpClientProtocolContract:
- """Contract tests for HttpClient protocol."""
-
- def test_http_client_is_protocol(self) -> None:
- assert hasattr(HttpClient, "__protocol_attrs__")
-
- def test_http_client_has_get(self) -> None:
- assert hasattr(HttpClient, "get")
-
- def test_http_client_has_post(self) -> None:
- assert hasattr(HttpClient, "post")
-
- def test_http_client_satisfied(self) -> None:
- class FakeClient:
- def get(self, url: str, **kwargs: object) -> object:
- return None
-
- def post(self, url: str, **kwargs: object) -> object:
- return None
-
- client = FakeClient()
- assert isinstance(client, HttpClient)
+"""Contract tests for HostContext.
+
+These tests verify that HostContext satisfies the architectural requirements:
+- Minimal (3 fields maximum)
+- Frozen dataclass
+- config_dir: Path
+- logger: Logger
+- http_client: HttpClient protocol
+"""
+
+import logging
+from pathlib import Path
+
+import pytest
+
+from abogen.tts_plugin.host_context import HttpClient, HostContext
+
+
+class TestHostContextContract:
+ """Contract tests for HostContext dataclass."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(HostContext, "__dataclass_params__")
+ assert HostContext.__dataclass_params__.frozen is True
+
+ def test_required_fields(self, tmp_path: Path) -> None:
+ logger = logging.getLogger("test")
+
+ class FakeClient:
+ def get(self, url: str, **kwargs: object) -> object:
+ return None
+
+ def post(self, url: str, **kwargs: object) -> object:
+ return None
+
+ ctx = HostContext(
+ config_dir=tmp_path,
+ logger=logger,
+ http_client=FakeClient(),
+ )
+ assert ctx.config_dir == tmp_path
+ assert ctx.logger is logger
+
+ def test_immutability(self, tmp_path: Path) -> None:
+ class FakeClient:
+ def get(self, url: str, **kwargs: object) -> object:
+ return None
+
+ def post(self, url: str, **kwargs: object) -> object:
+ return None
+
+ ctx = HostContext(
+ config_dir=tmp_path,
+ logger=logging.getLogger("test"),
+ http_client=FakeClient(),
+ )
+ with pytest.raises(AttributeError):
+ ctx.config_dir = Path("/other") # type: ignore[misc]
+
+ def test_max_three_fields(self) -> None:
+ """Architecture spec: HostContext is minimal (3 fields max)."""
+ import dataclasses
+
+ fields = dataclasses.fields(HostContext)
+ assert len(fields) <= 3
+
+
+class TestHttpClientProtocolContract:
+ """Contract tests for HttpClient protocol."""
+
+ def test_http_client_is_protocol(self) -> None:
+ assert hasattr(HttpClient, "__protocol_attrs__")
+
+ def test_http_client_has_get(self) -> None:
+ assert hasattr(HttpClient, "get")
+
+ def test_http_client_has_post(self) -> None:
+ assert hasattr(HttpClient, "post")
+
+ def test_http_client_satisfied(self) -> None:
+ class FakeClient:
+ def get(self, url: str, **kwargs: object) -> object:
+ return None
+
+ def post(self, url: str, **kwargs: object) -> object:
+ return None
+
+ client = FakeClient()
+ assert isinstance(client, HttpClient)
diff --git a/tests/contracts/test_integration.py b/tests/contracts/test_integration.py
index ff7ee26..d03183c 100644
--- a/tests/contracts/test_integration.py
+++ b/tests/contracts/test_integration.py
@@ -1,420 +1,420 @@
-"""Integration tests for the TTS Plugin Architecture.
-
-These tests verify:
-1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
-2. Lifecycle: dispose, no leaks, error handling
-3. Regression: old path vs new path equivalence
-
-Tests use mock plugins to avoid requiring real TTS dependencies.
-"""
-
-import pytest
-from typing import Any, Iterator
-from unittest.mock import MagicMock, patch
-
-import numpy as np
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.errors import EngineError
-from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
-from abogen.tts_plugin.utils import Pipeline, create_pipeline
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-
-
-class MockEngineSession:
- """Mock EngineSession that records calls for verification."""
-
- def __init__(self):
- self._disposed = False
- self.synthesize_calls = []
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- raise EngineError("Session disposed")
-
- self.synthesize_calls.append(request)
-
- # Return fake audio
- audio = np.ones(1000, dtype=np.float32) * 0.5
- return SynthesizedAudio(
- data=audio.tobytes(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1000 / 24000),
- )
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class MockEngine:
- """Mock Engine that creates MockEngineSessions."""
-
- def __init__(self, **kwargs):
- self.kwargs = kwargs
- self._disposed = False
- self.sessions_created = []
-
- def createSession(self) -> MockEngineSession:
- if self._disposed:
- raise EngineError("Engine disposed")
- session = MockEngineSession()
- self.sessions_created.append(session)
- return session
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-def create_mock_plugin(create_engine_func=None):
- """Helper to create a mock plugin module."""
- if create_engine_func is None:
- create_engine_func = lambda **kwargs: MockEngine(**kwargs)
-
- from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
-
- manifest = PluginManifest(
- id="mock_tts",
- name="Mock TTS",
- version="1.0.0",
- api_version="1.0",
- description="Mock TTS for testing",
- author="Test",
- capabilities=(),
- requires=None,
- engine=EngineManifest(
- voiceSources=(),
- parameters=(),
- audioFormats=(),
- ),
- )
-
- return {
- "PLUGIN_MANIFEST": manifest,
- "MODEL_REQUIREMENTS": [],
- "create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
- }
-
-
-def create_mock_plugin_engine(**kwargs):
- """Default mock plugin engine factory."""
- return MockEngine(**kwargs)
-
-
-class TestConsumerFlow:
- """Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
-
- def test_full_consumer_flow(self):
- """Verify complete flow from consumer to audio output."""
- manager = PluginManager()
-
- # Register mock plugin
- mock_plugin = create_mock_plugin()
- manager._plugins["mock_tts"] = mock_plugin
- manager._loaded = True
-
- # Step 1: Consumer gets plugin
- assert manager.has_plugin("mock_tts") is True
-
- # Step 2: Plugin creates engine
- engine = manager.create_engine("mock_tts")
- assert engine is not None
- assert isinstance(engine, MockEngine)
-
- # Step 3: Engine creates session
- session = engine.createSession()
- assert session is not None
- assert isinstance(session, MockEngineSession)
-
- # Step 4: Session synthesizes
- request = SynthesisRequest(
- text="Hello world",
- voice=VoiceSelection(source="builtin", key="default"),
- parameters=ParameterValues(values={"speed": 1.0}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
-
- # Step 5: Result returned
- assert result is not None
- assert isinstance(result, SynthesizedAudio)
- assert len(result.data) > 0
- assert result.format.mime == "audio/wav"
- assert result.duration.seconds > 0
-
- def test_consumer_flow_via_pipeline(self):
- """Verify flow through Pipeline utility matches direct flow."""
- manager = PluginManager()
-
- # Register mock plugin
- mock_plugin = create_mock_plugin()
- manager._plugins["mock_tts"] = mock_plugin
- manager._loaded = True
-
- # Use Pipeline utility
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- backend = create_pipeline("mock_tts")
-
- # Call like old TTSBackend
- segments = list(backend("Hello world", voice="default", speed=1.0))
-
- # Verify result
- assert len(segments) >= 1
- segment = segments[0]
- assert hasattr(segment, "graphemes")
- assert hasattr(segment, "audio")
- assert segment.graphemes == "Hello world"
-
-
-class TestLifecycle:
- """Lifecycle Test: dispose, no leaks, error handling"""
-
- def test_session_dispose_is_idempotent(self):
- """dispose() can be called multiple times safely."""
- session = MockEngineSession()
-
- session.dispose()
- session.dispose() # Should not raise
- assert session._disposed is True
-
- def test_session_synthesize_after_dispose_raises(self):
- """synthesize() after dispose() raises EngineError."""
- session = MockEngineSession()
- session.dispose()
-
- request = SynthesisRequest(
- text="test",
- voice=VoiceSelection(source="builtin", key="default"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
- with pytest.raises(EngineError):
- session.synthesize(request)
-
- def test_engine_dispose_is_idempotent(self):
- """Engine dispose() can be called multiple times safely."""
- engine = MockEngine()
-
- engine.dispose()
- engine.dispose() # Should not raise
- assert engine._disposed is True
-
- def test_engine_create_session_after_dispose_raises(self):
- """createSession() after dispose() raises EngineError."""
- engine = MockEngine()
- engine.dispose()
-
- with pytest.raises(EngineError):
- engine.createSession()
-
- def test_full_lifecycle(self):
- """Test complete lifecycle: create → use → dispose."""
- engine = MockEngine()
-
- # Create and use session
- session = engine.createSession()
- request = SynthesisRequest(
- text="test",
- voice=VoiceSelection(source="builtin", key="default"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert len(result.data) > 0
-
- # Dispose session
- session.dispose()
- assert session._disposed is True
-
- # Dispose engine
- engine.dispose()
- assert engine._disposed is True
-
- def test_no_session_leak_on_engine_dispose(self):
- """Engine can be disposed even if sessions were created."""
- engine = MockEngine()
-
- # Create multiple sessions
- session1 = engine.createSession()
- session2 = engine.createSession()
-
- # Use sessions
- request = SynthesisRequest(
- text="test",
- voice=VoiceSelection(source="builtin", key="default"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- session1.synthesize(request)
- session2.synthesize(request)
-
- # Dispose engine (sessions still exist but engine is disposed)
- engine.dispose()
- assert engine._disposed is True
-
- # Sessions can still be used (they hold reference to pipeline)
- result = session1.synthesize(request)
- assert len(result.data) > 0
-
- def test_error_handling_in_synthesis(self):
- """Error during synthesis is handled correctly."""
- class FailingSession:
- def synthesize(self, request):
- raise EngineError("Synthesis failed")
-
- def dispose(self):
- pass
-
- session = FailingSession()
- request = SynthesisRequest(
- text="test",
- voice=VoiceSelection(source="builtin", key="default"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
- with pytest.raises(EngineError, match="Synthesis failed"):
- session.synthesize(request)
-
-
-class TestRegression:
- """Regression Test: old path vs new path equivalence"""
-
- def test_old_path_vs_new_path_same_result(self):
- """Both paths should produce equivalent results."""
- # Setup mock plugin
- manager = PluginManager()
- mock_plugin = create_mock_plugin()
- manager._plugins["mock_tts"] = mock_plugin
- manager._loaded = True
-
- # New path: Plugin Manager → Engine → Session → Synthesis
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- new_backend = create_pipeline("mock_tts")
- new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
-
- # Old path: Direct MockEngine (simulating old registry)
- old_engine = MockEngine()
- old_session = old_engine.createSession()
- request = SynthesisRequest(
- text="Hello world",
- voice=VoiceSelection(source="builtin", key="default"),
- parameters=ParameterValues(values={"speed": 1.0}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- old_result = old_session.synthesize(request)
-
- # Compare results
- # New path returns segments, old path returns SynthesizedAudio
- # But both should have valid audio data
- assert len(new_segments) >= 1
- assert len(old_result.data) > 0
-
- # Both should have same format
- assert new_segments[0].audio.dtype == np.float32
-
- def test_pipeline_matches_old_interface(self):
- """Pipeline utility should match old TTSBackend interface."""
- manager = PluginManager()
- mock_plugin = create_mock_plugin()
- manager._plugins["mock_tts"] = mock_plugin
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
-
- # Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
- segments = list(backend(
- "Hello world",
- voice="af_heart",
- speed=1.0,
- split_pattern=r"\n+"
- ))
-
- # Should return segments with graphemes and audio
- assert len(segments) >= 1
- segment = segments[0]
- assert segment.graphemes == "Hello world"
- assert isinstance(segment.audio, np.ndarray)
- assert segment.audio.dtype == np.float32
- assert len(segment.audio) > 0
-
-
-class TestPluginManagerIntegration:
- """Integration tests for PluginManager."""
-
- def test_plugin_manager_singleton_pattern(self):
- """Global plugin manager follows singleton pattern."""
- reset_plugin_manager()
-
- manager1 = get_plugin_manager()
- manager2 = get_plugin_manager()
-
- assert manager1 is manager2
-
- reset_plugin_manager()
-
- manager3 = get_plugin_manager()
- assert manager1 is not manager3
-
- def test_plugin_manager_discover_plugins(self):
- """Plugin manager can discover plugins from directory."""
- manager = PluginManager()
-
- # Discover from test plugins directory
- manager.discover("tests/plugins")
-
- # Should find valid_plugin
- # (This depends on test plugins existing)
- plugins = manager.list_plugins()
- assert isinstance(plugins, list)
-
- def test_plugin_manager_dispose_all(self):
- """Plugin manager can dispose all cached engines."""
- manager = PluginManager()
-
- # Register mock plugin
- mock_plugin = create_mock_plugin()
- manager._plugins["mock_tts"] = mock_plugin
- manager._loaded = True
-
- # Create engines
- engine1 = manager.get_or_create_engine("mock_tts")
- engine2 = manager.get_or_create_engine("mock_tts")
-
- # Dispose all
- manager.dispose_all()
-
- # Engines should be disposed
- assert engine1._disposed is True
- assert engine2._disposed is True
-
- # Cache should be empty
- assert len(manager._engines) == 0
-
-
-class TestNoCompatLayer:
- """Regression: confirm the compatibility layer has been removed."""
-
- def test_compat_module_does_not_exist(self):
- """abogen.tts_plugin.compat must not be importable."""
- import importlib
- with pytest.raises((ImportError, ModuleNotFoundError)):
- importlib.import_module("abogen.tts_plugin.compat")
-
- def test_consumers_use_plugin_architecture_directly(self):
- """Key consumers import from abogen.tts_plugin.utils, not compat."""
- import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
-
- for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
- source = inspect.getsource(mod)
- assert "tts_plugin.compat" not in source, (
- f"{mod.__name__} still references tts_plugin.compat"
- )
+"""Integration tests for the TTS Plugin Architecture.
+
+These tests verify:
+1. Consumer Flow: consumer → plugin → engine → session → synthesis → result
+2. Lifecycle: dispose, no leaks, error handling
+3. Regression: old path vs new path equivalence
+
+Tests use mock plugins to avoid requiring real TTS dependencies.
+"""
+
+import pytest
+from typing import Any, Iterator
+from unittest.mock import MagicMock, patch
+
+import numpy as np
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.errors import EngineError
+from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
+from abogen.tts_plugin.utils import Pipeline, create_pipeline
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+
+
+class MockEngineSession:
+ """Mock EngineSession that records calls for verification."""
+
+ def __init__(self):
+ self._disposed = False
+ self.synthesize_calls = []
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ raise EngineError("Session disposed")
+
+ self.synthesize_calls.append(request)
+
+ # Return fake audio
+ audio = np.ones(1000, dtype=np.float32) * 0.5
+ return SynthesizedAudio(
+ data=audio.tobytes(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1000 / 24000),
+ )
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class MockEngine:
+ """Mock Engine that creates MockEngineSessions."""
+
+ def __init__(self, **kwargs):
+ self.kwargs = kwargs
+ self._disposed = False
+ self.sessions_created = []
+
+ def createSession(self) -> MockEngineSession:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ session = MockEngineSession()
+ self.sessions_created.append(session)
+ return session
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+def create_mock_plugin(create_engine_func=None):
+ """Helper to create a mock plugin module."""
+ if create_engine_func is None:
+ create_engine_func = lambda **kwargs: MockEngine(**kwargs)
+
+ from abogen.tts_plugin.manifest import PluginManifest, EngineManifest
+
+ manifest = PluginManifest(
+ id="mock_tts",
+ name="Mock TTS",
+ version="1.0.0",
+ api_version="1.0",
+ description="Mock TTS for testing",
+ author="Test",
+ capabilities=(),
+ requires=None,
+ engine=EngineManifest(
+ voiceSources=(),
+ parameters=(),
+ audioFormats=(),
+ ),
+ )
+
+ return {
+ "PLUGIN_MANIFEST": manifest,
+ "MODEL_REQUIREMENTS": [],
+ "create_engine": create_mock_plugin_engine if create_engine_func is None else create_engine_func,
+ }
+
+
+def create_mock_plugin_engine(**kwargs):
+ """Default mock plugin engine factory."""
+ return MockEngine(**kwargs)
+
+
+class TestConsumerFlow:
+ """Consumer Flow Test: consumer → plugin → engine → session → synthesis → result"""
+
+ def test_full_consumer_flow(self):
+ """Verify complete flow from consumer to audio output."""
+ manager = PluginManager()
+
+ # Register mock plugin
+ mock_plugin = create_mock_plugin()
+ manager._plugins["mock_tts"] = mock_plugin
+ manager._loaded = True
+
+ # Step 1: Consumer gets plugin
+ assert manager.has_plugin("mock_tts") is True
+
+ # Step 2: Plugin creates engine
+ engine = manager.create_engine("mock_tts")
+ assert engine is not None
+ assert isinstance(engine, MockEngine)
+
+ # Step 3: Engine creates session
+ session = engine.createSession()
+ assert session is not None
+ assert isinstance(session, MockEngineSession)
+
+ # Step 4: Session synthesizes
+ request = SynthesisRequest(
+ text="Hello world",
+ voice=VoiceSelection(source="builtin", key="default"),
+ parameters=ParameterValues(values={"speed": 1.0}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+
+ # Step 5: Result returned
+ assert result is not None
+ assert isinstance(result, SynthesizedAudio)
+ assert len(result.data) > 0
+ assert result.format.mime == "audio/wav"
+ assert result.duration.seconds > 0
+
+ def test_consumer_flow_via_pipeline(self):
+ """Verify flow through Pipeline utility matches direct flow."""
+ manager = PluginManager()
+
+ # Register mock plugin
+ mock_plugin = create_mock_plugin()
+ manager._plugins["mock_tts"] = mock_plugin
+ manager._loaded = True
+
+ # Use Pipeline utility
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ backend = create_pipeline("mock_tts")
+
+ # Call like old TTSBackend
+ segments = list(backend("Hello world", voice="default", speed=1.0))
+
+ # Verify result
+ assert len(segments) >= 1
+ segment = segments[0]
+ assert hasattr(segment, "graphemes")
+ assert hasattr(segment, "audio")
+ assert segment.graphemes == "Hello world"
+
+
+class TestLifecycle:
+ """Lifecycle Test: dispose, no leaks, error handling"""
+
+ def test_session_dispose_is_idempotent(self):
+ """dispose() can be called multiple times safely."""
+ session = MockEngineSession()
+
+ session.dispose()
+ session.dispose() # Should not raise
+ assert session._disposed is True
+
+ def test_session_synthesize_after_dispose_raises(self):
+ """synthesize() after dispose() raises EngineError."""
+ session = MockEngineSession()
+ session.dispose()
+
+ request = SynthesisRequest(
+ text="test",
+ voice=VoiceSelection(source="builtin", key="default"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+ with pytest.raises(EngineError):
+ session.synthesize(request)
+
+ def test_engine_dispose_is_idempotent(self):
+ """Engine dispose() can be called multiple times safely."""
+ engine = MockEngine()
+
+ engine.dispose()
+ engine.dispose() # Should not raise
+ assert engine._disposed is True
+
+ def test_engine_create_session_after_dispose_raises(self):
+ """createSession() after dispose() raises EngineError."""
+ engine = MockEngine()
+ engine.dispose()
+
+ with pytest.raises(EngineError):
+ engine.createSession()
+
+ def test_full_lifecycle(self):
+ """Test complete lifecycle: create → use → dispose."""
+ engine = MockEngine()
+
+ # Create and use session
+ session = engine.createSession()
+ request = SynthesisRequest(
+ text="test",
+ voice=VoiceSelection(source="builtin", key="default"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert len(result.data) > 0
+
+ # Dispose session
+ session.dispose()
+ assert session._disposed is True
+
+ # Dispose engine
+ engine.dispose()
+ assert engine._disposed is True
+
+ def test_no_session_leak_on_engine_dispose(self):
+ """Engine can be disposed even if sessions were created."""
+ engine = MockEngine()
+
+ # Create multiple sessions
+ session1 = engine.createSession()
+ session2 = engine.createSession()
+
+ # Use sessions
+ request = SynthesisRequest(
+ text="test",
+ voice=VoiceSelection(source="builtin", key="default"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ session1.synthesize(request)
+ session2.synthesize(request)
+
+ # Dispose engine (sessions still exist but engine is disposed)
+ engine.dispose()
+ assert engine._disposed is True
+
+ # Sessions can still be used (they hold reference to pipeline)
+ result = session1.synthesize(request)
+ assert len(result.data) > 0
+
+ def test_error_handling_in_synthesis(self):
+ """Error during synthesis is handled correctly."""
+ class FailingSession:
+ def synthesize(self, request):
+ raise EngineError("Synthesis failed")
+
+ def dispose(self):
+ pass
+
+ session = FailingSession()
+ request = SynthesisRequest(
+ text="test",
+ voice=VoiceSelection(source="builtin", key="default"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+ with pytest.raises(EngineError, match="Synthesis failed"):
+ session.synthesize(request)
+
+
+class TestRegression:
+ """Regression Test: old path vs new path equivalence"""
+
+ def test_old_path_vs_new_path_same_result(self):
+ """Both paths should produce equivalent results."""
+ # Setup mock plugin
+ manager = PluginManager()
+ mock_plugin = create_mock_plugin()
+ manager._plugins["mock_tts"] = mock_plugin
+ manager._loaded = True
+
+ # New path: Plugin Manager → Engine → Session → Synthesis
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ new_backend = create_pipeline("mock_tts")
+ new_segments = list(new_backend("Hello world", voice="default", speed=1.0))
+
+ # Old path: Direct MockEngine (simulating old registry)
+ old_engine = MockEngine()
+ old_session = old_engine.createSession()
+ request = SynthesisRequest(
+ text="Hello world",
+ voice=VoiceSelection(source="builtin", key="default"),
+ parameters=ParameterValues(values={"speed": 1.0}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ old_result = old_session.synthesize(request)
+
+ # Compare results
+ # New path returns segments, old path returns SynthesizedAudio
+ # But both should have valid audio data
+ assert len(new_segments) >= 1
+ assert len(old_result.data) > 0
+
+ # Both should have same format
+ assert new_segments[0].audio.dtype == np.float32
+
+ def test_pipeline_matches_old_interface(self):
+ """Pipeline utility should match old TTSBackend interface."""
+ manager = PluginManager()
+ mock_plugin = create_mock_plugin()
+ manager._plugins["mock_tts"] = mock_plugin
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ backend = create_pipeline("mock_tts", lang_code="a", device="cpu")
+
+ # Old interface: pipeline(text, voice=..., speed=..., split_pattern=...)
+ segments = list(backend(
+ "Hello world",
+ voice="af_heart",
+ speed=1.0,
+ split_pattern=r"\n+"
+ ))
+
+ # Should return segments with graphemes and audio
+ assert len(segments) >= 1
+ segment = segments[0]
+ assert segment.graphemes == "Hello world"
+ assert isinstance(segment.audio, np.ndarray)
+ assert segment.audio.dtype == np.float32
+ assert len(segment.audio) > 0
+
+
+class TestPluginManagerIntegration:
+ """Integration tests for PluginManager."""
+
+ def test_plugin_manager_singleton_pattern(self):
+ """Global plugin manager follows singleton pattern."""
+ reset_plugin_manager()
+
+ manager1 = get_plugin_manager()
+ manager2 = get_plugin_manager()
+
+ assert manager1 is manager2
+
+ reset_plugin_manager()
+
+ manager3 = get_plugin_manager()
+ assert manager1 is not manager3
+
+ def test_plugin_manager_discover_plugins(self):
+ """Plugin manager can discover plugins from directory."""
+ manager = PluginManager()
+
+ # Discover from test plugins directory
+ manager.discover("tests/plugins")
+
+ # Should find valid_plugin
+ # (This depends on test plugins existing)
+ plugins = manager.list_plugins()
+ assert isinstance(plugins, list)
+
+ def test_plugin_manager_dispose_all(self):
+ """Plugin manager can dispose all cached engines."""
+ manager = PluginManager()
+
+ # Register mock plugin
+ mock_plugin = create_mock_plugin()
+ manager._plugins["mock_tts"] = mock_plugin
+ manager._loaded = True
+
+ # Create engines
+ engine1 = manager.get_or_create_engine("mock_tts")
+ engine2 = manager.get_or_create_engine("mock_tts")
+
+ # Dispose all
+ manager.dispose_all()
+
+ # Engines should be disposed
+ assert engine1._disposed is True
+ assert engine2._disposed is True
+
+ # Cache should be empty
+ assert len(manager._engines) == 0
+
+
+class TestNoCompatLayer:
+ """Regression: confirm the compatibility layer has been removed."""
+
+ def test_compat_module_does_not_exist(self):
+ """abogen.tts_plugin.compat must not be importable."""
+ import importlib
+ with pytest.raises((ImportError, ModuleNotFoundError)):
+ importlib.import_module("abogen.tts_plugin.compat")
+
+ def test_consumers_use_plugin_architecture_directly(self):
+ """Key consumers import from abogen.tts_plugin.utils, not compat."""
+ import inspect, abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache
+
+ for mod in (abogen.voice_profiles, abogen.voice_formulas, abogen.voice_cache):
+ source = inspect.getsource(mod)
+ assert "tts_plugin.compat" not in source, (
+ f"{mod.__name__} still references tts_plugin.compat"
+ )
diff --git a/tests/contracts/test_loader_contract.py b/tests/contracts/test_loader_contract.py
index 9213993..4c2ef04 100644
--- a/tests/contracts/test_loader_contract.py
+++ b/tests/contracts/test_loader_contract.py
@@ -1,436 +1,436 @@
-"""Comprehensive tests for the plugin loader infrastructure.
-
-These tests verify that the loader correctly:
-- Discovers plugins in directories
-- Imports plugin modules
-- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
-- Validates api_version compatibility
-- Validates capabilities
-- Provides diagnostic messages for errors
-- Rejects invalid plugins
-"""
-
-from __future__ import annotations
-
-import sys
-from pathlib import Path
-
-import pytest
-
-from abogen.tts_plugin.loader import (
- HOST_API_VERSION,
- PluginLoadError,
- PluginLoadResult,
- _check_api_version_compatibility,
- _parse_api_version,
- _validate_api_version,
- _validate_capabilities,
- _validate_manifest,
- discover_plugins,
- load_plugin,
- load_plugin_from_dir,
-)
-from abogen.tts_plugin.manifest import (
- EngineManifest,
- ModelManifest,
- PluginManifest,
-)
-
-
-# ──────────────────────────────────────────────────────────────
-# Path fixtures
-# ──────────────────────────────────────────────────────────────
-
-@pytest.fixture
-def plugins_dir() -> Path:
- return Path(__file__).parent.parent / "plugins"
-
-
-@pytest.fixture
-def fake_plugin_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "fake_plugin"
-
-
-@pytest.fixture
-def missing_manifest_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "missing_manifest"
-
-
-@pytest.fixture
-def invalid_api_version_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "invalid_api_version"
-
-
-@pytest.fixture
-def invalid_capabilities_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "invalid_capabilities"
-
-
-@pytest.fixture
-def missing_create_engine_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "missing_create_engine"
-
-
-@pytest.fixture
-def import_error_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "import_error"
-
-
-@pytest.fixture
-def missing_model_requirements_dir(plugins_dir: Path) -> Path:
- return plugins_dir / "missing_model_requirements"
-
-
-# ──────────────────────────────────────────────────────────────
-# Unit tests: _parse_api_version
-# ──────────────────────────────────────────────────────────────
-
-class TestParseApiVersion:
- def test_valid_version(self) -> None:
- assert _parse_api_version("1.0") == (1, 0)
- assert _parse_api_version("2.5") == (2, 5)
- assert _parse_api_version("10.20") == (10, 20)
-
- def test_invalid_format(self) -> None:
- assert _parse_api_version("1") is None
- assert _parse_api_version("1.0.0") is None
- assert _parse_api_version("abc") is None
- assert _parse_api_version("") is None
- assert _parse_api_version("1.x") is None
-
-
-# ──────────────────────────────────────────────────────────────
-# Unit tests: _check_api_version_compatibility
-# ──────────────────────────────────────────────────────────────
-
-class TestCheckApiVersionCompatibility:
- def test_compatible_version(self) -> None:
- assert _check_api_version_compatibility("1.0") is None
- assert _check_api_version_compatibility("1.5") is None
-
- def test_major_mismatch(self) -> None:
- error = _check_api_version_compatibility("2.0")
- assert error is not None
- assert "major mismatch" in error
-
- def test_invalid_format(self) -> None:
- error = _check_api_version_compatibility("invalid")
- assert error is not None
- assert "Invalid api_version format" in error
-
-
-# ──────────────────────────────────────────────────────────────
-# Unit tests: _validate_manifest
-# ──────────────────────────────────────────────────────────────
-
-class TestValidateManifest:
- def test_valid_manifest(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- MODEL_REQUIREMENTS: list = []
- create_engine = lambda *a, **kw: None
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert errors == []
-
- def test_missing_manifest(self) -> None:
- class FakeModule:
- MODEL_REQUIREMENTS: list = []
- create_engine = lambda *a, **kw: None
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
-
- def test_wrong_manifest_type(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = "not a manifest"
- MODEL_REQUIREMENTS: list = []
- create_engine = lambda *a, **kw: None
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("PluginManifest instance" in e for e in errors)
-
- def test_missing_model_requirements(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- create_engine = lambda *a, **kw: None
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
-
- def test_wrong_model_requirements_type(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- MODEL_REQUIREMENTS = "not a list"
- create_engine = lambda *a, **kw: None
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("must be a list" in e for e in errors)
-
- def test_invalid_model_requirements_item(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- MODEL_REQUIREMENTS = ["not a model manifest"]
- create_engine = lambda *a, **kw: None
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("ModelManifest instance" in e for e in errors)
-
- def test_missing_create_engine(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- MODEL_REQUIREMENTS: list = []
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("Missing create_engine" in e for e in errors)
-
- def test_create_engine_not_callable(self) -> None:
- class FakeModule:
- PLUGIN_MANIFEST = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- MODEL_REQUIREMENTS: list = []
- create_engine = "not callable"
-
- errors = _validate_manifest(FakeModule(), Path("/tmp"))
- assert any("must be callable" in e for e in errors)
-
-
-# ──────────────────────────────────────────────────────────────
-# Unit tests: _validate_capabilities
-# ──────────────────────────────────────────────────────────────
-
-class TestValidateCapabilities:
- def test_valid_capabilities(self) -> None:
- manifest = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- capabilities=("voice_list", "preview"),
- )
- errors = _validate_capabilities(manifest)
- assert errors == []
-
- def test_unknown_capability(self) -> None:
- manifest = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- capabilities=("voice_list", "unknown_cap"),
- )
- errors = _validate_capabilities(manifest)
- assert any("unknown_cap" in e for e in errors)
-
- def test_empty_capabilities(self) -> None:
- manifest = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- capabilities=(),
- )
- errors = _validate_capabilities(manifest)
- assert errors == []
-
-
-# ──────────────────────────────────────────────────────────────
-# Unit tests: _validate_api_version
-# ──────────────────────────────────────────────────────────────
-
-class TestValidateApiVersion:
- def test_compatible(self) -> None:
- manifest = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="1.0", description="Test", author="Test",
- )
- errors = _validate_api_version(manifest)
- assert errors == []
-
- def test_incompatible(self) -> None:
- manifest = PluginManifest(
- id="test", name="Test", version="1.0.0",
- api_version="2.0", description="Test", author="Test",
- )
- errors = _validate_api_version(manifest)
- assert len(errors) > 0
-
-
-# ──────────────────────────────────────────────────────────────
-# Integration tests: load_plugin_from_dir
-# ──────────────────────────────────────────────────────────────
-
-class TestLoadPluginFromDir:
- def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
- result = load_plugin_from_dir(fake_plugin_dir)
- assert result.success is True
- assert result.manifest is not None
- assert result.manifest.id == "fake_plugin"
- assert result.model_requirements is not None
- assert result.create_engine is not None
- assert result.module is not None
- assert result.error is None
-
- def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
- from abogen.tts_plugin.engine import Engine
- from abogen.tts_plugin.host_context import HostContext
- import logging
-
- result = load_plugin_from_dir(fake_plugin_dir)
- assert result.success is True
-
- # Create engine using the loaded create_engine function
- ctx = HostContext(
- config_dir=Path("/tmp/test"),
- logger=logging.getLogger("test"),
- http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
- )
- engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
- assert isinstance(engine, Engine)
- engine.dispose()
-
- def test_nonexistent_directory(self, tmp_path: Path) -> None:
- result = load_plugin_from_dir(tmp_path / "nonexistent")
- assert result.success is False
- assert result.error is not None
- assert "does not exist" in result.error.errors[0]
-
- def test_missing_init_file(self, tmp_path: Path) -> None:
- plugin_dir = tmp_path / "no_init"
- plugin_dir.mkdir()
- result = load_plugin_from_dir(plugin_dir)
- assert result.success is False
- assert result.error is not None
- assert "__init__.py" in result.error.errors[0]
-
- def test_import_error(self, import_error_dir: Path) -> None:
- result = load_plugin_from_dir(import_error_dir)
- assert result.success is False
- assert result.error is not None
- assert "Failed to import" in result.error.errors[0]
-
-
-# ──────────────────────────────────────────────────────────────
-# Integration tests: invalid plugins
-# ──────────────────────────────────────────────────────────────
-
-class TestInvalidPlugins:
- def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
- result = load_plugin_from_dir(missing_manifest_dir)
- assert result.success is False
- assert result.error is not None
- assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
-
- def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
- result = load_plugin_from_dir(invalid_api_version_dir)
- assert result.success is False
- assert result.error is not None
- assert any("major mismatch" in e for e in result.error.errors)
-
- def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
- result = load_plugin_from_dir(invalid_capabilities_dir)
- assert result.success is False
- assert result.error is not None
- assert any("Unknown capability" in e for e in result.error.errors)
-
- def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
- result = load_plugin_from_dir(missing_create_engine_dir)
- assert result.success is False
- assert result.error is not None
- assert any("Missing create_engine" in e for e in result.error.errors)
-
- def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
- result = load_plugin_from_dir(missing_model_requirements_dir)
- assert result.success is False
- assert result.error is not None
- assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
-
-
-# ──────────────────────────────────────────────────────────────
-# Integration tests: discover_plugins
-# ──────────────────────────────────────────────────────────────
-
-class TestDiscoverPlugins:
- def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
- results = discover_plugins([plugins_dir])
- # Should find multiple plugins (valid and invalid)
- assert len(results) > 0
-
- def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
- results = discover_plugins([plugins_dir])
- valid = [r for r in results if r.success]
- assert len(valid) >= 1
- assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
-
- def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
- results = discover_plugins([plugins_dir])
- invalid = [r for r in results if not r.success]
- assert len(invalid) >= 1
-
- def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
- results = discover_plugins([tmp_path / "nonexistent"])
- assert results == []
-
- def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
- results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
- assert len(results) > 0
-
-
-# ──────────────────────────────────────────────────────────────
-# Diagnostic messages tests
-# ──────────────────────────────────────────────────────────────
-
-class TestDiagnosticMessages:
- def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
- result = load_plugin_from_dir(missing_manifest_dir)
- assert result.error is not None
- assert result.error.plugin_id == "missing_manifest"
-
- def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
- result = load_plugin_from_dir(missing_manifest_dir)
- assert result.error is not None
- assert result.error.path == missing_manifest_dir
-
- def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
- result = load_plugin_from_dir(missing_manifest_dir)
- assert result.error is not None
- assert len(result.error.errors) > 0
-
- def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
- # This plugin has multiple issues
- result = load_plugin_from_dir(invalid_api_version_dir)
- assert result.error is not None
- # Should have at least the api_version error
- assert len(result.error.errors) >= 1
-
-
-# ──────────────────────────────────────────────────────────────
-# No partial registration tests
-# ──────────────────────────────────────────────────────────────
-
-class TestNoPartialRegistration:
- def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
- """After failed load, module should not remain in sys.modules."""
- result = load_plugin_from_dir(missing_manifest_dir)
- assert result.success is False
- # Module should not be registered
- module_name = f"abogen.tts_plugin._loaded.missing_manifest"
- assert module_name not in sys.modules
-
- def test_import_error_no_registration(self, import_error_dir: Path) -> None:
- """After import error, module should not remain in sys.modules."""
- result = load_plugin_from_dir(import_error_dir)
- assert result.success is False
- module_name = f"abogen.tts_plugin._loaded.import_error"
- assert module_name not in sys.modules
+"""Comprehensive tests for the plugin loader infrastructure.
+
+These tests verify that the loader correctly:
+- Discovers plugins in directories
+- Imports plugin modules
+- Validates PLUGIN_MANIFEST, MODEL_REQUIREMENTS, create_engine
+- Validates api_version compatibility
+- Validates capabilities
+- Provides diagnostic messages for errors
+- Rejects invalid plugins
+"""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+import pytest
+
+from abogen.tts_plugin.loader import (
+ HOST_API_VERSION,
+ PluginLoadError,
+ PluginLoadResult,
+ _check_api_version_compatibility,
+ _parse_api_version,
+ _validate_api_version,
+ _validate_capabilities,
+ _validate_manifest,
+ discover_plugins,
+ load_plugin,
+ load_plugin_from_dir,
+)
+from abogen.tts_plugin.manifest import (
+ EngineManifest,
+ ModelManifest,
+ PluginManifest,
+)
+
+
+# ──────────────────────────────────────────────────────────────
+# Path fixtures
+# ──────────────────────────────────────────────────────────────
+
+@pytest.fixture
+def plugins_dir() -> Path:
+ return Path(__file__).parent.parent / "plugins"
+
+
+@pytest.fixture
+def fake_plugin_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "fake_plugin"
+
+
+@pytest.fixture
+def missing_manifest_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "missing_manifest"
+
+
+@pytest.fixture
+def invalid_api_version_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "invalid_api_version"
+
+
+@pytest.fixture
+def invalid_capabilities_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "invalid_capabilities"
+
+
+@pytest.fixture
+def missing_create_engine_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "missing_create_engine"
+
+
+@pytest.fixture
+def import_error_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "import_error"
+
+
+@pytest.fixture
+def missing_model_requirements_dir(plugins_dir: Path) -> Path:
+ return plugins_dir / "missing_model_requirements"
+
+
+# ──────────────────────────────────────────────────────────────
+# Unit tests: _parse_api_version
+# ──────────────────────────────────────────────────────────────
+
+class TestParseApiVersion:
+ def test_valid_version(self) -> None:
+ assert _parse_api_version("1.0") == (1, 0)
+ assert _parse_api_version("2.5") == (2, 5)
+ assert _parse_api_version("10.20") == (10, 20)
+
+ def test_invalid_format(self) -> None:
+ assert _parse_api_version("1") is None
+ assert _parse_api_version("1.0.0") is None
+ assert _parse_api_version("abc") is None
+ assert _parse_api_version("") is None
+ assert _parse_api_version("1.x") is None
+
+
+# ──────────────────────────────────────────────────────────────
+# Unit tests: _check_api_version_compatibility
+# ──────────────────────────────────────────────────────────────
+
+class TestCheckApiVersionCompatibility:
+ def test_compatible_version(self) -> None:
+ assert _check_api_version_compatibility("1.0") is None
+ assert _check_api_version_compatibility("1.5") is None
+
+ def test_major_mismatch(self) -> None:
+ error = _check_api_version_compatibility("2.0")
+ assert error is not None
+ assert "major mismatch" in error
+
+ def test_invalid_format(self) -> None:
+ error = _check_api_version_compatibility("invalid")
+ assert error is not None
+ assert "Invalid api_version format" in error
+
+
+# ──────────────────────────────────────────────────────────────
+# Unit tests: _validate_manifest
+# ──────────────────────────────────────────────────────────────
+
+class TestValidateManifest:
+ def test_valid_manifest(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ MODEL_REQUIREMENTS: list = []
+ create_engine = lambda *a, **kw: None
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert errors == []
+
+ def test_missing_manifest(self) -> None:
+ class FakeModule:
+ MODEL_REQUIREMENTS: list = []
+ create_engine = lambda *a, **kw: None
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("Missing PLUGIN_MANIFEST" in e for e in errors)
+
+ def test_wrong_manifest_type(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = "not a manifest"
+ MODEL_REQUIREMENTS: list = []
+ create_engine = lambda *a, **kw: None
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("PluginManifest instance" in e for e in errors)
+
+ def test_missing_model_requirements(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ create_engine = lambda *a, **kw: None
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("Missing MODEL_REQUIREMENTS" in e for e in errors)
+
+ def test_wrong_model_requirements_type(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ MODEL_REQUIREMENTS = "not a list"
+ create_engine = lambda *a, **kw: None
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("must be a list" in e for e in errors)
+
+ def test_invalid_model_requirements_item(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ MODEL_REQUIREMENTS = ["not a model manifest"]
+ create_engine = lambda *a, **kw: None
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("ModelManifest instance" in e for e in errors)
+
+ def test_missing_create_engine(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ MODEL_REQUIREMENTS: list = []
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("Missing create_engine" in e for e in errors)
+
+ def test_create_engine_not_callable(self) -> None:
+ class FakeModule:
+ PLUGIN_MANIFEST = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ MODEL_REQUIREMENTS: list = []
+ create_engine = "not callable"
+
+ errors = _validate_manifest(FakeModule(), Path("/tmp"))
+ assert any("must be callable" in e for e in errors)
+
+
+# ──────────────────────────────────────────────────────────────
+# Unit tests: _validate_capabilities
+# ──────────────────────────────────────────────────────────────
+
+class TestValidateCapabilities:
+ def test_valid_capabilities(self) -> None:
+ manifest = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ capabilities=("voice_list", "preview"),
+ )
+ errors = _validate_capabilities(manifest)
+ assert errors == []
+
+ def test_unknown_capability(self) -> None:
+ manifest = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ capabilities=("voice_list", "unknown_cap"),
+ )
+ errors = _validate_capabilities(manifest)
+ assert any("unknown_cap" in e for e in errors)
+
+ def test_empty_capabilities(self) -> None:
+ manifest = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ capabilities=(),
+ )
+ errors = _validate_capabilities(manifest)
+ assert errors == []
+
+
+# ──────────────────────────────────────────────────────────────
+# Unit tests: _validate_api_version
+# ──────────────────────────────────────────────────────────────
+
+class TestValidateApiVersion:
+ def test_compatible(self) -> None:
+ manifest = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="1.0", description="Test", author="Test",
+ )
+ errors = _validate_api_version(manifest)
+ assert errors == []
+
+ def test_incompatible(self) -> None:
+ manifest = PluginManifest(
+ id="test", name="Test", version="1.0.0",
+ api_version="2.0", description="Test", author="Test",
+ )
+ errors = _validate_api_version(manifest)
+ assert len(errors) > 0
+
+
+# ──────────────────────────────────────────────────────────────
+# Integration tests: load_plugin_from_dir
+# ──────────────────────────────────────────────────────────────
+
+class TestLoadPluginFromDir:
+ def test_load_valid_plugin(self, fake_plugin_dir: Path) -> None:
+ result = load_plugin_from_dir(fake_plugin_dir)
+ assert result.success is True
+ assert result.manifest is not None
+ assert result.manifest.id == "fake_plugin"
+ assert result.model_requirements is not None
+ assert result.create_engine is not None
+ assert result.module is not None
+ assert result.error is None
+
+ def test_plugin_satisfies_protocol(self, fake_plugin_dir: Path) -> None:
+ from abogen.tts_plugin.engine import Engine
+ from abogen.tts_plugin.host_context import HostContext
+ import logging
+
+ result = load_plugin_from_dir(fake_plugin_dir)
+ assert result.success is True
+
+ # Create engine using the loaded create_engine function
+ ctx = HostContext(
+ config_dir=Path("/tmp/test"),
+ logger=logging.getLogger("test"),
+ http_client=type("FakeClient", (), {"get": lambda s, **kw: None, "post": lambda s, **kw: None})(),
+ )
+ engine = result.create_engine(ctx, None, __import__("abogen.tts_plugin.types", fromlist=["EngineConfig"]).EngineConfig())
+ assert isinstance(engine, Engine)
+ engine.dispose()
+
+ def test_nonexistent_directory(self, tmp_path: Path) -> None:
+ result = load_plugin_from_dir(tmp_path / "nonexistent")
+ assert result.success is False
+ assert result.error is not None
+ assert "does not exist" in result.error.errors[0]
+
+ def test_missing_init_file(self, tmp_path: Path) -> None:
+ plugin_dir = tmp_path / "no_init"
+ plugin_dir.mkdir()
+ result = load_plugin_from_dir(plugin_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert "__init__.py" in result.error.errors[0]
+
+ def test_import_error(self, import_error_dir: Path) -> None:
+ result = load_plugin_from_dir(import_error_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert "Failed to import" in result.error.errors[0]
+
+
+# ──────────────────────────────────────────────────────────────
+# Integration tests: invalid plugins
+# ──────────────────────────────────────────────────────────────
+
+class TestInvalidPlugins:
+ def test_missing_manifest(self, missing_manifest_dir: Path) -> None:
+ result = load_plugin_from_dir(missing_manifest_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert any("Missing PLUGIN_MANIFEST" in e for e in result.error.errors)
+
+ def test_invalid_api_version(self, invalid_api_version_dir: Path) -> None:
+ result = load_plugin_from_dir(invalid_api_version_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert any("major mismatch" in e for e in result.error.errors)
+
+ def test_invalid_capabilities(self, invalid_capabilities_dir: Path) -> None:
+ result = load_plugin_from_dir(invalid_capabilities_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert any("Unknown capability" in e for e in result.error.errors)
+
+ def test_missing_create_engine(self, missing_create_engine_dir: Path) -> None:
+ result = load_plugin_from_dir(missing_create_engine_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert any("Missing create_engine" in e for e in result.error.errors)
+
+ def test_missing_model_requirements(self, missing_model_requirements_dir: Path) -> None:
+ result = load_plugin_from_dir(missing_model_requirements_dir)
+ assert result.success is False
+ assert result.error is not None
+ assert any("Missing MODEL_REQUIREMENTS" in e for e in result.error.errors)
+
+
+# ──────────────────────────────────────────────────────────────
+# Integration tests: discover_plugins
+# ──────────────────────────────────────────────────────────────
+
+class TestDiscoverPlugins:
+ def test_discover_from_valid_dir(self, plugins_dir: Path) -> None:
+ results = discover_plugins([plugins_dir])
+ # Should find multiple plugins (valid and invalid)
+ assert len(results) > 0
+
+ def test_discover_includes_valid_plugin(self, plugins_dir: Path) -> None:
+ results = discover_plugins([plugins_dir])
+ valid = [r for r in results if r.success]
+ assert len(valid) >= 1
+ assert any(r.manifest and r.manifest.id == "fake_plugin" for r in valid)
+
+ def test_discover_includes_invalid_plugins(self, plugins_dir: Path) -> None:
+ results = discover_plugins([plugins_dir])
+ invalid = [r for r in results if not r.success]
+ assert len(invalid) >= 1
+
+ def test_discover_nonexistent_dir(self, tmp_path: Path) -> None:
+ results = discover_plugins([tmp_path / "nonexistent"])
+ assert results == []
+
+ def test_discover_multiple_dirs(self, plugins_dir: Path, tmp_path: Path) -> None:
+ results = discover_plugins([plugins_dir, tmp_path / "nonexistent"])
+ assert len(results) > 0
+
+
+# ──────────────────────────────────────────────────────────────
+# Diagnostic messages tests
+# ──────────────────────────────────────────────────────────────
+
+class TestDiagnosticMessages:
+ def test_error_contains_plugin_id(self, missing_manifest_dir: Path) -> None:
+ result = load_plugin_from_dir(missing_manifest_dir)
+ assert result.error is not None
+ assert result.error.plugin_id == "missing_manifest"
+
+ def test_error_contains_path(self, missing_manifest_dir: Path) -> None:
+ result = load_plugin_from_dir(missing_manifest_dir)
+ assert result.error is not None
+ assert result.error.path == missing_manifest_dir
+
+ def test_error_contains_messages(self, missing_manifest_dir: Path) -> None:
+ result = load_plugin_from_dir(missing_manifest_dir)
+ assert result.error is not None
+ assert len(result.error.errors) > 0
+
+ def test_multiple_errors(self, invalid_api_version_dir: Path) -> None:
+ # This plugin has multiple issues
+ result = load_plugin_from_dir(invalid_api_version_dir)
+ assert result.error is not None
+ # Should have at least the api_version error
+ assert len(result.error.errors) >= 1
+
+
+# ──────────────────────────────────────────────────────────────
+# No partial registration tests
+# ──────────────────────────────────────────────────────────────
+
+class TestNoPartialRegistration:
+ def test_invalid_plugin_no_manifest_attr(self, missing_manifest_dir: Path) -> None:
+ """After failed load, module should not remain in sys.modules."""
+ result = load_plugin_from_dir(missing_manifest_dir)
+ assert result.success is False
+ # Module should not be registered
+ module_name = f"abogen.tts_plugin._loaded.missing_manifest"
+ assert module_name not in sys.modules
+
+ def test_import_error_no_registration(self, import_error_dir: Path) -> None:
+ """After import error, module should not remain in sys.modules."""
+ result = load_plugin_from_dir(import_error_dir)
+ assert result.success is False
+ module_name = f"abogen.tts_plugin._loaded.import_error"
+ assert module_name not in sys.modules
diff --git a/tests/contracts/test_manifest_contract.py b/tests/contracts/test_manifest_contract.py
index aa50440..ef1045a 100644
--- a/tests/contracts/test_manifest_contract.py
+++ b/tests/contracts/test_manifest_contract.py
@@ -1,290 +1,290 @@
-"""Contract tests for plugin manifest types.
-
-These tests verify that manifest types satisfy the architectural requirements:
-- All required fields are present
-- api_version follows semver format
-- capabilities are properly defined
-- engine manifest describes the engine correctly
-"""
-
-import re
-
-import pytest
-
-from abogen.tts_plugin.manifest import (
- AudioFormatManifest,
- EngineManifest,
- EnumOption,
- GpuRequirement,
- ModelManifest,
- ParameterManifest,
- PluginManifest,
- RequirementManifest,
- VoiceManifest,
- VoiceSourceManifest,
-)
-
-
-class TestPluginManifestContract:
- """Contract tests for PluginManifest."""
-
- def test_required_fields(self) -> None:
- manifest = PluginManifest(
- id="test-plugin",
- name="Test Plugin",
- version="1.0.0",
- api_version="1.0",
- description="A test plugin",
- author="Test Author",
- )
- assert manifest.id == "test-plugin"
- assert manifest.name == "Test Plugin"
- assert manifest.version == "1.0.0"
- assert manifest.api_version == "1.0"
- assert manifest.description == "A test plugin"
- assert manifest.author == "Test Author"
-
- def test_api_version_semver_format(self) -> None:
- """Architecture spec: api_version format is semver (MAJOR.MINOR)."""
- valid_versions = ["1.0", "2.1", "10.5"]
- for version in valid_versions:
- manifest = PluginManifest(
- id="test",
- name="Test",
- version="1.0.0",
- api_version=version,
- description="Test",
- author="Test",
- )
- assert re.match(r"^\d+\.\d+$", manifest.api_version)
-
- def test_capabilities_default_empty(self) -> None:
- manifest = PluginManifest(
- id="test",
- name="Test",
- version="1.0.0",
- api_version="1.0",
- description="Test",
- author="Test",
- )
- assert manifest.capabilities == ()
-
- def test_capabilities_tuple(self) -> None:
- manifest = PluginManifest(
- id="test",
- name="Test",
- version="1.0.0",
- api_version="1.0",
- description="Test",
- author="Test",
- capabilities=("voice_list", "preview"),
- )
- assert "voice_list" in manifest.capabilities
- assert "preview" in manifest.capabilities
-
- def test_requires_default(self) -> None:
- manifest = PluginManifest(
- id="test",
- name="Test",
- version="1.0.0",
- api_version="1.0",
- description="Test",
- author="Test",
- )
- assert isinstance(manifest.requires, RequirementManifest)
-
- def test_engine_default(self) -> None:
- manifest = PluginManifest(
- id="test",
- name="Test",
- version="1.0.0",
- api_version="1.0",
- description="Test",
- author="Test",
- )
- assert isinstance(manifest.engine, EngineManifest)
-
-
-class TestEngineManifestContract:
- """Contract tests for EngineManifest."""
-
- def test_required_fields(self) -> None:
- manifest = EngineManifest(
- voiceSources=(
- VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
- ),
- parameters=(
- ParameterManifest(
- id="speed", name="Speed", description="Speed", type="float", default=1.0
- ),
- ),
- audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
- )
- assert len(manifest.voiceSources) == 1
- assert len(manifest.parameters) == 1
- assert len(manifest.audioFormats) == 1
-
- def test_defaults_empty(self) -> None:
- manifest = EngineManifest()
- assert manifest.voiceSources == ()
- assert manifest.parameters == ()
- assert manifest.audioFormats == ()
-
-
-class TestVoiceSourceManifestContract:
- """Contract tests for VoiceSourceManifest."""
-
- def test_required_fields(self) -> None:
- vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
- assert vs.id == "builtin"
- assert vs.name == "Builtin"
- assert vs.type == "list"
-
- def test_valid_types(self) -> None:
- valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
- for vtype in valid_types:
- vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
- assert vs.type == vtype
-
- def test_config_optional(self) -> None:
- vs = VoiceSourceManifest(id="test", name="Test", type="list")
- assert vs.config is None
-
- def test_config_any(self) -> None:
- config = {"voices": ["af_nova", "af_sky"]}
- vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
- assert vs.config == config
-
-
-class TestVoiceManifestContract:
- """Contract tests for VoiceManifest."""
-
- def test_required_fields(self) -> None:
- v = VoiceManifest(id="af_nova", name="Nova")
- assert v.id == "af_nova"
- assert v.name == "Nova"
-
- def test_tags_default_empty(self) -> None:
- v = VoiceManifest(id="af_nova", name="Nova")
- assert v.tags == ()
-
- def test_tags_tuple(self) -> None:
- v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
- assert "en" in v.tags
- assert "female" in v.tags
-
-
-class TestParameterManifestContract:
- """Contract tests for ParameterManifest."""
-
- def test_required_fields(self) -> None:
- p = ParameterManifest(
- id="speed", name="Speed", description="Speech speed", type="float", default=1.0
- )
- assert p.id == "speed"
- assert p.name == "Speed"
- assert p.description == "Speech speed"
- assert p.type == "float"
- assert p.default == 1.0
-
- def test_valid_types(self) -> None:
- valid_types = ["float", "int", "string", "boolean", "enum"]
- for ptype in valid_types:
- p = ParameterManifest(
- id="test", name="Test", description="Test", type=ptype, default=None
- )
- assert p.type == ptype
-
- def test_optional_numeric_bounds(self) -> None:
- p = ParameterManifest(
- id="speed",
- name="Speed",
- description="Speed",
- type="float",
- default=1.0,
- min=0.5,
- max=2.0,
- step=0.1,
- )
- assert p.min == 0.5
- assert p.max == 2.0
- assert p.step == 0.1
-
- def test_enum_options(self) -> None:
- options = (
- EnumOption(value="low", label="Low"),
- EnumOption(value="high", label="High"),
- )
- p = ParameterManifest(
- id="quality",
- name="Quality",
- description="Quality",
- type="enum",
- default="low",
- options=options,
- )
- assert len(p.options) == 2
- assert p.options[0].value == "low"
-
-
-class TestAudioFormatManifestContract:
- """Contract tests for AudioFormatManifest."""
-
- def test_required_fields(self) -> None:
- af = AudioFormatManifest(mime="audio/wav", extension="wav")
- assert af.mime == "audio/wav"
- assert af.extension == "wav"
-
-
-class TestEnumOptionContract:
- """Contract tests for EnumOption."""
-
- def test_required_fields(self) -> None:
- opt = EnumOption(value="low", label="Low Quality")
- assert opt.value == "low"
- assert opt.label == "Low Quality"
-
-
-class TestRequirementManifestContract:
- """Contract tests for RequirementManifest."""
-
- def test_defaults(self) -> None:
- req = RequirementManifest()
- assert req.gpu is None
- assert req.memory is None
- assert req.internet is None
-
- def test_with_gpu(self) -> None:
- gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
- req = RequirementManifest(gpu=gpu)
- assert req.gpu.required is True
- assert req.gpu.type == "cuda"
- assert req.gpu.memory == 8.0
-
- def test_with_internet(self) -> None:
- req = RequirementManifest(internet=True)
- assert req.internet is True
-
-
-class TestGpuRequirementContract:
- """Contract tests for GpuRequirement."""
-
- def test_defaults(self) -> None:
- gpu = GpuRequirement()
- assert gpu.required is False
- assert gpu.type is None
- assert gpu.memory is None
-
- def test_required_gpu(self) -> None:
- gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
- assert gpu.required is True
-
-
-class TestModelManifestContract:
- """Contract tests for ModelManifest."""
-
- def test_required_fields(self) -> None:
- m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
- assert m.id == "xtts_v2"
- assert m.name == "XTTS v2"
- assert m.size == "2GB"
+"""Contract tests for plugin manifest types.
+
+These tests verify that manifest types satisfy the architectural requirements:
+- All required fields are present
+- api_version follows semver format
+- capabilities are properly defined
+- engine manifest describes the engine correctly
+"""
+
+import re
+
+import pytest
+
+from abogen.tts_plugin.manifest import (
+ AudioFormatManifest,
+ EngineManifest,
+ EnumOption,
+ GpuRequirement,
+ ModelManifest,
+ ParameterManifest,
+ PluginManifest,
+ RequirementManifest,
+ VoiceManifest,
+ VoiceSourceManifest,
+)
+
+
+class TestPluginManifestContract:
+ """Contract tests for PluginManifest."""
+
+ def test_required_fields(self) -> None:
+ manifest = PluginManifest(
+ id="test-plugin",
+ name="Test Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="A test plugin",
+ author="Test Author",
+ )
+ assert manifest.id == "test-plugin"
+ assert manifest.name == "Test Plugin"
+ assert manifest.version == "1.0.0"
+ assert manifest.api_version == "1.0"
+ assert manifest.description == "A test plugin"
+ assert manifest.author == "Test Author"
+
+ def test_api_version_semver_format(self) -> None:
+ """Architecture spec: api_version format is semver (MAJOR.MINOR)."""
+ valid_versions = ["1.0", "2.1", "10.5"]
+ for version in valid_versions:
+ manifest = PluginManifest(
+ id="test",
+ name="Test",
+ version="1.0.0",
+ api_version=version,
+ description="Test",
+ author="Test",
+ )
+ assert re.match(r"^\d+\.\d+$", manifest.api_version)
+
+ def test_capabilities_default_empty(self) -> None:
+ manifest = PluginManifest(
+ id="test",
+ name="Test",
+ version="1.0.0",
+ api_version="1.0",
+ description="Test",
+ author="Test",
+ )
+ assert manifest.capabilities == ()
+
+ def test_capabilities_tuple(self) -> None:
+ manifest = PluginManifest(
+ id="test",
+ name="Test",
+ version="1.0.0",
+ api_version="1.0",
+ description="Test",
+ author="Test",
+ capabilities=("voice_list", "preview"),
+ )
+ assert "voice_list" in manifest.capabilities
+ assert "preview" in manifest.capabilities
+
+ def test_requires_default(self) -> None:
+ manifest = PluginManifest(
+ id="test",
+ name="Test",
+ version="1.0.0",
+ api_version="1.0",
+ description="Test",
+ author="Test",
+ )
+ assert isinstance(manifest.requires, RequirementManifest)
+
+ def test_engine_default(self) -> None:
+ manifest = PluginManifest(
+ id="test",
+ name="Test",
+ version="1.0.0",
+ api_version="1.0",
+ description="Test",
+ author="Test",
+ )
+ assert isinstance(manifest.engine, EngineManifest)
+
+
+class TestEngineManifestContract:
+ """Contract tests for EngineManifest."""
+
+ def test_required_fields(self) -> None:
+ manifest = EngineManifest(
+ voiceSources=(
+ VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
+ ),
+ parameters=(
+ ParameterManifest(
+ id="speed", name="Speed", description="Speed", type="float", default=1.0
+ ),
+ ),
+ audioFormats=(AudioFormatManifest(mime="audio/wav", extension="wav"),),
+ )
+ assert len(manifest.voiceSources) == 1
+ assert len(manifest.parameters) == 1
+ assert len(manifest.audioFormats) == 1
+
+ def test_defaults_empty(self) -> None:
+ manifest = EngineManifest()
+ assert manifest.voiceSources == ()
+ assert manifest.parameters == ()
+ assert manifest.audioFormats == ()
+
+
+class TestVoiceSourceManifestContract:
+ """Contract tests for VoiceSourceManifest."""
+
+ def test_required_fields(self) -> None:
+ vs = VoiceSourceManifest(id="builtin", name="Builtin", type="list")
+ assert vs.id == "builtin"
+ assert vs.name == "Builtin"
+ assert vs.type == "list"
+
+ def test_valid_types(self) -> None:
+ valid_types = ["list", "speaker_id", "clone", "blend", "generate", "none"]
+ for vtype in valid_types:
+ vs = VoiceSourceManifest(id="test", name="Test", type=vtype)
+ assert vs.type == vtype
+
+ def test_config_optional(self) -> None:
+ vs = VoiceSourceManifest(id="test", name="Test", type="list")
+ assert vs.config is None
+
+ def test_config_any(self) -> None:
+ config = {"voices": ["af_nova", "af_sky"]}
+ vs = VoiceSourceManifest(id="test", name="Test", type="list", config=config)
+ assert vs.config == config
+
+
+class TestVoiceManifestContract:
+ """Contract tests for VoiceManifest."""
+
+ def test_required_fields(self) -> None:
+ v = VoiceManifest(id="af_nova", name="Nova")
+ assert v.id == "af_nova"
+ assert v.name == "Nova"
+
+ def test_tags_default_empty(self) -> None:
+ v = VoiceManifest(id="af_nova", name="Nova")
+ assert v.tags == ()
+
+ def test_tags_tuple(self) -> None:
+ v = VoiceManifest(id="af_nova", name="Nova", tags=("en", "female"))
+ assert "en" in v.tags
+ assert "female" in v.tags
+
+
+class TestParameterManifestContract:
+ """Contract tests for ParameterManifest."""
+
+ def test_required_fields(self) -> None:
+ p = ParameterManifest(
+ id="speed", name="Speed", description="Speech speed", type="float", default=1.0
+ )
+ assert p.id == "speed"
+ assert p.name == "Speed"
+ assert p.description == "Speech speed"
+ assert p.type == "float"
+ assert p.default == 1.0
+
+ def test_valid_types(self) -> None:
+ valid_types = ["float", "int", "string", "boolean", "enum"]
+ for ptype in valid_types:
+ p = ParameterManifest(
+ id="test", name="Test", description="Test", type=ptype, default=None
+ )
+ assert p.type == ptype
+
+ def test_optional_numeric_bounds(self) -> None:
+ p = ParameterManifest(
+ id="speed",
+ name="Speed",
+ description="Speed",
+ type="float",
+ default=1.0,
+ min=0.5,
+ max=2.0,
+ step=0.1,
+ )
+ assert p.min == 0.5
+ assert p.max == 2.0
+ assert p.step == 0.1
+
+ def test_enum_options(self) -> None:
+ options = (
+ EnumOption(value="low", label="Low"),
+ EnumOption(value="high", label="High"),
+ )
+ p = ParameterManifest(
+ id="quality",
+ name="Quality",
+ description="Quality",
+ type="enum",
+ default="low",
+ options=options,
+ )
+ assert len(p.options) == 2
+ assert p.options[0].value == "low"
+
+
+class TestAudioFormatManifestContract:
+ """Contract tests for AudioFormatManifest."""
+
+ def test_required_fields(self) -> None:
+ af = AudioFormatManifest(mime="audio/wav", extension="wav")
+ assert af.mime == "audio/wav"
+ assert af.extension == "wav"
+
+
+class TestEnumOptionContract:
+ """Contract tests for EnumOption."""
+
+ def test_required_fields(self) -> None:
+ opt = EnumOption(value="low", label="Low Quality")
+ assert opt.value == "low"
+ assert opt.label == "Low Quality"
+
+
+class TestRequirementManifestContract:
+ """Contract tests for RequirementManifest."""
+
+ def test_defaults(self) -> None:
+ req = RequirementManifest()
+ assert req.gpu is None
+ assert req.memory is None
+ assert req.internet is None
+
+ def test_with_gpu(self) -> None:
+ gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
+ req = RequirementManifest(gpu=gpu)
+ assert req.gpu.required is True
+ assert req.gpu.type == "cuda"
+ assert req.gpu.memory == 8.0
+
+ def test_with_internet(self) -> None:
+ req = RequirementManifest(internet=True)
+ assert req.internet is True
+
+
+class TestGpuRequirementContract:
+ """Contract tests for GpuRequirement."""
+
+ def test_defaults(self) -> None:
+ gpu = GpuRequirement()
+ assert gpu.required is False
+ assert gpu.type is None
+ assert gpu.memory is None
+
+ def test_required_gpu(self) -> None:
+ gpu = GpuRequirement(required=True, type="cuda", memory=8.0)
+ assert gpu.required is True
+
+
+class TestModelManifestContract:
+ """Contract tests for ModelManifest."""
+
+ def test_required_fields(self) -> None:
+ m = ModelManifest(id="xtts_v2", name="XTTS v2", size="2GB")
+ assert m.id == "xtts_v2"
+ assert m.name == "XTTS v2"
+ assert m.size == "2GB"
diff --git a/tests/contracts/test_plugin_contract.py b/tests/contracts/test_plugin_contract.py
index 5fd852d..b154392 100644
--- a/tests/contracts/test_plugin_contract.py
+++ b/tests/contracts/test_plugin_contract.py
@@ -1,146 +1,146 @@
-"""Contract tests for plugin contract.
-
-These tests verify that plugin modules satisfy the architectural requirements:
-- Must export PLUGIN_MANIFEST: PluginManifest
-- Must export MODEL_REQUIREMENTS: list[ModelManifest]
-- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
-- create_engine() must be atomic
-"""
-
-import logging
-from pathlib import Path
-from typing import Any
-
-import pytest
-
-from abogen.tts_plugin.engine import Engine
-from abogen.tts_plugin.host_context import HostContext
-from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
-from abogen.tts_plugin.plugin import Plugin
-from abogen.tts_plugin.types import EngineConfig
-
-from .conftest import FakeEngine
-
-
-class FakePluginModule:
- """Stub plugin module that satisfies the plugin contract."""
-
- PLUGIN_MANIFEST = PluginManifest(
- id="fake-plugin",
- name="Fake Plugin",
- version="1.0.0",
- api_version="1.0",
- description="A fake plugin for testing",
- author="Test Author",
- capabilities=(),
- engine=EngineManifest(),
- )
-
- MODEL_REQUIREMENTS: list[ModelManifest] = []
-
- @staticmethod
- def create_engine(
- context: HostContext,
- model_path: Path | None,
- config: EngineConfig,
- ) -> Engine:
- return FakeEngine()
-
-
-class TestPluginProtocolContract:
- """Contract tests for the Plugin protocol."""
-
- def test_plugin_is_protocol(self) -> None:
- assert hasattr(Plugin, "__protocol_attrs__")
-
-
-class TestPluginExportsContract:
- """Contract tests for required plugin exports."""
-
- def test_plugin_has_plugin_manifest(self) -> None:
- """Architecture spec: Plugin must export PLUGIN_MANIFEST."""
- assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
- assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
-
- def test_plugin_has_model_requirements(self) -> None:
- """Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
- assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
- assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
-
- def test_plugin_has_create_engine(self) -> None:
- """Architecture spec: Plugin must export create_engine."""
- assert hasattr(FakePluginModule, "create_engine")
- assert callable(FakePluginModule.create_engine)
-
- def test_plugin_manifest_required_fields(self) -> None:
- """Architecture spec: PluginManifest has required fields."""
- manifest = FakePluginModule.PLUGIN_MANIFEST
- assert manifest.id
- assert manifest.name
- assert manifest.version
- assert manifest.api_version
- assert manifest.description
- assert manifest.author
-
- def test_plugin_manifest_capabilities_is_tuple(self) -> None:
- manifest = FakePluginModule.PLUGIN_MANIFEST
- assert isinstance(manifest.capabilities, tuple)
-
-
-class TestCreateEngineContract:
- """Contract tests for create_engine() function."""
-
- def test_create_engine_returns_engine(self) -> None:
- """Architecture spec: create_engine() returns Engine."""
- ctx = HostContext(
- config_dir=Path("/tmp/test"),
- logger=logging.getLogger("test"),
- http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
- )
- engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
- assert isinstance(engine, Engine)
-
- def test_create_engine_atomic(self) -> None:
- """Architecture spec: create_engine() is atomic (all-or-nothing)."""
- ctx = HostContext(
- config_dir=Path("/tmp/test"),
- logger=logging.getLogger("test"),
- http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
- )
- engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
- assert isinstance(engine, Engine)
- engine.dispose()
-
- def test_create_engine_with_none_model_path(self) -> None:
- """Architecture spec: model_path can be None for cloud/no-model engines."""
- ctx = HostContext(
- config_dir=Path("/tmp/test"),
- logger=logging.getLogger("test"),
- http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
- )
- engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
- assert isinstance(engine, Engine)
- engine.dispose()
-
- def test_create_engine_with_model_path(self) -> None:
- """Architecture spec: model_path is Path | None."""
- ctx = HostContext(
- config_dir=Path("/tmp/test"),
- logger=logging.getLogger("test"),
- http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
- )
- engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
- assert isinstance(engine, Engine)
- engine.dispose()
-
-
-class TestModelRequirementsContract:
- """Contract tests for MODEL_REQUIREMENTS."""
-
- def test_model_requirements_is_list(self) -> None:
- assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
-
- def test_model_requirements_contains_model_manifests(self) -> None:
- """If non-empty, each item must be a ModelManifest."""
- for req in FakePluginModule.MODEL_REQUIREMENTS:
- assert isinstance(req, ModelManifest)
+"""Contract tests for plugin contract.
+
+These tests verify that plugin modules satisfy the architectural requirements:
+- Must export PLUGIN_MANIFEST: PluginManifest
+- Must export MODEL_REQUIREMENTS: list[ModelManifest]
+- Must export create_engine: Callable[[HostContext, Path | None, EngineConfig], Engine]
+- create_engine() must be atomic
+"""
+
+import logging
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from abogen.tts_plugin.engine import Engine
+from abogen.tts_plugin.host_context import HostContext
+from abogen.tts_plugin.manifest import EngineManifest, ModelManifest, PluginManifest
+from abogen.tts_plugin.plugin import Plugin
+from abogen.tts_plugin.types import EngineConfig
+
+from .conftest import FakeEngine
+
+
+class FakePluginModule:
+ """Stub plugin module that satisfies the plugin contract."""
+
+ PLUGIN_MANIFEST = PluginManifest(
+ id="fake-plugin",
+ name="Fake Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="A fake plugin for testing",
+ author="Test Author",
+ capabilities=(),
+ engine=EngineManifest(),
+ )
+
+ MODEL_REQUIREMENTS: list[ModelManifest] = []
+
+ @staticmethod
+ def create_engine(
+ context: HostContext,
+ model_path: Path | None,
+ config: EngineConfig,
+ ) -> Engine:
+ return FakeEngine()
+
+
+class TestPluginProtocolContract:
+ """Contract tests for the Plugin protocol."""
+
+ def test_plugin_is_protocol(self) -> None:
+ assert hasattr(Plugin, "__protocol_attrs__")
+
+
+class TestPluginExportsContract:
+ """Contract tests for required plugin exports."""
+
+ def test_plugin_has_plugin_manifest(self) -> None:
+ """Architecture spec: Plugin must export PLUGIN_MANIFEST."""
+ assert hasattr(FakePluginModule, "PLUGIN_MANIFEST")
+ assert isinstance(FakePluginModule.PLUGIN_MANIFEST, PluginManifest)
+
+ def test_plugin_has_model_requirements(self) -> None:
+ """Architecture spec: Plugin must export MODEL_REQUIREMENTS."""
+ assert hasattr(FakePluginModule, "MODEL_REQUIREMENTS")
+ assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
+
+ def test_plugin_has_create_engine(self) -> None:
+ """Architecture spec: Plugin must export create_engine."""
+ assert hasattr(FakePluginModule, "create_engine")
+ assert callable(FakePluginModule.create_engine)
+
+ def test_plugin_manifest_required_fields(self) -> None:
+ """Architecture spec: PluginManifest has required fields."""
+ manifest = FakePluginModule.PLUGIN_MANIFEST
+ assert manifest.id
+ assert manifest.name
+ assert manifest.version
+ assert manifest.api_version
+ assert manifest.description
+ assert manifest.author
+
+ def test_plugin_manifest_capabilities_is_tuple(self) -> None:
+ manifest = FakePluginModule.PLUGIN_MANIFEST
+ assert isinstance(manifest.capabilities, tuple)
+
+
+class TestCreateEngineContract:
+ """Contract tests for create_engine() function."""
+
+ def test_create_engine_returns_engine(self) -> None:
+ """Architecture spec: create_engine() returns Engine."""
+ ctx = HostContext(
+ config_dir=Path("/tmp/test"),
+ logger=logging.getLogger("test"),
+ http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
+ )
+ engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
+ assert isinstance(engine, Engine)
+
+ def test_create_engine_atomic(self) -> None:
+ """Architecture spec: create_engine() is atomic (all-or-nothing)."""
+ ctx = HostContext(
+ config_dir=Path("/tmp/test"),
+ logger=logging.getLogger("test"),
+ http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
+ )
+ engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
+ assert isinstance(engine, Engine)
+ engine.dispose()
+
+ def test_create_engine_with_none_model_path(self) -> None:
+ """Architecture spec: model_path can be None for cloud/no-model engines."""
+ ctx = HostContext(
+ config_dir=Path("/tmp/test"),
+ logger=logging.getLogger("test"),
+ http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
+ )
+ engine = FakePluginModule.create_engine(ctx, None, EngineConfig())
+ assert isinstance(engine, Engine)
+ engine.dispose()
+
+ def test_create_engine_with_model_path(self) -> None:
+ """Architecture spec: model_path is Path | None."""
+ ctx = HostContext(
+ config_dir=Path("/tmp/test"),
+ logger=logging.getLogger("test"),
+ http_client=type("FakeClient", (), {"get": lambda self, **kw: None, "post": lambda self, **kw: None})(),
+ )
+ engine = FakePluginModule.create_engine(ctx, Path("/models/test"), EngineConfig())
+ assert isinstance(engine, Engine)
+ engine.dispose()
+
+
+class TestModelRequirementsContract:
+ """Contract tests for MODEL_REQUIREMENTS."""
+
+ def test_model_requirements_is_list(self) -> None:
+ assert isinstance(FakePluginModule.MODEL_REQUIREMENTS, list)
+
+ def test_model_requirements_contains_model_manifests(self) -> None:
+ """If non-empty, each item must be a ModelManifest."""
+ for req in FakePluginModule.MODEL_REQUIREMENTS:
+ assert isinstance(req, ModelManifest)
diff --git a/tests/contracts/test_plugin_manager_contract.py b/tests/contracts/test_plugin_manager_contract.py
index 11e35dd..2a4fc8e 100644
--- a/tests/contracts/test_plugin_manager_contract.py
+++ b/tests/contracts/test_plugin_manager_contract.py
@@ -1,264 +1,274 @@
-"""Integration tests for Plugin Manager and direct utility functions."""
-
-import pytest
-from unittest.mock import MagicMock, patch
-
-from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
-from abogen.tts_plugin.utils import Pipeline, create_pipeline
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
-
-
-class FakeEngine:
- """Fake Engine for testing."""
-
- def __init__(self, **kwargs):
- self.kwargs = kwargs
- self._disposed = False
-
- def createSession(self):
- return FakeEngineSession()
-
- def dispose(self):
- self._disposed = True
-
- @property
- def manifest(self):
- return MagicMock()
-
-
-class FakeEngineSession:
- """Fake EngineSession for testing."""
-
- def __init__(self):
- self._disposed = False
-
- def synthesize(self, request):
- # Return fake audio
- import numpy as np
- from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
-
- audio = np.zeros(1000, dtype=np.float32)
- return SynthesizedAudio(
- data=audio.tobytes(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
- )
-
- def dispose(self):
- self._disposed = True
-
-
-class TestPluginManager:
- """Test PluginManager functionality."""
-
- def test_plugin_manager_creation(self):
- """PluginManager can be created."""
- manager = PluginManager()
- assert manager is not None
-
- def test_plugin_manager_list_discovers_plugins(self):
- """PluginManager discovers plugins from plugins directory."""
- manager = PluginManager()
- manager.discover("plugins")
- plugins = manager.list_plugins()
- # Should discover kokoro plugin if it exists
- assert isinstance(plugins, list)
-
- def test_plugin_manager_has_plugin_after_discover(self):
- """PluginManager reports plugins after discovery."""
- manager = PluginManager()
- manager.discover("plugins")
- # kokoro plugin should be discovered if plugins/kokoro exists
- # This is expected behavior
- assert isinstance(manager._plugins, dict)
-
- def test_plugin_manager_get_plugin_after_discover(self):
- """PluginManager returns plugin info after discovery."""
- manager = PluginManager()
- manager.discover("plugins")
- # kokoro plugin should be discovered if plugins/kokoro exists
- assert isinstance(manager._plugins, dict)
-
- def test_plugin_manager_create_engine_not_found(self):
- """PluginManager raises KeyError for unknown plugins."""
- manager = PluginManager()
- with pytest.raises(KeyError, match="Plugin not found"):
- manager.create_engine("nonexistent")
-
- def test_plugin_manager_discover_with_empty_dir(self):
- """PluginManager handles missing plugins directory."""
- manager = PluginManager()
- manager.discover("/nonexistent/path")
- plugins = manager.list_plugins()
- assert plugins == []
-
- def test_global_plugin_manager_singleton(self):
- """Global PluginManager is a singleton."""
- reset_plugin_manager()
- manager1 = get_plugin_manager()
- manager2 = get_plugin_manager()
- assert manager1 is manager2
- reset_plugin_manager()
-
- def test_reset_plugin_manager(self):
- """reset_plugin_manager clears the singleton."""
- manager1 = get_plugin_manager()
- reset_plugin_manager()
- manager2 = get_plugin_manager()
- assert manager1 is not manager2
- reset_plugin_manager()
-
-
-class TestPipeline:
- """Test Pipeline functionality."""
-
- def test_pipeline_creation(self):
- """Pipeline can be created."""
- engine = FakeEngine()
- backend = Pipeline(engine)
- assert backend is not None
-
- def test_pipeline_callable(self):
- """Pipeline is callable like old TTSBackend."""
- engine = FakeEngine()
- backend = Pipeline(engine)
-
- # Should be callable
- assert callable(backend)
-
- def test_pipeline_synthesize(self):
- """Pipeline can synthesize text."""
- engine = FakeEngine()
- backend = Pipeline(engine)
-
- # Call the backend
- segments = list(backend("Hello world", voice="default", speed=1.0))
-
- # Should return at least one segment
- assert len(segments) >= 1
-
- # Segment should have graphemes and audio
- segment = segments[0]
- assert hasattr(segment, "graphemes")
- assert hasattr(segment, "audio")
- assert segment.graphemes == "Hello world"
-
- def test_pipeline_dispose(self):
- """Pipeline can be disposed."""
- engine = FakeEngine()
- backend = Pipeline(engine)
-
- # Create a session by calling
- list(backend("test"))
-
- # Dispose should not raise
- backend.dispose()
-
- # Double dispose should be safe
- backend.dispose()
-
-
-class TestCreatePipelineCompat:
- """Test create_pipeline utility function."""
-
- def test_create_pipeline_returns_callable(self):
- """create_pipeline returns a callable backend."""
- # Mock the plugin manager
- with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
- mock_manager = MagicMock()
- mock_get_manager.return_value = mock_manager
-
- mock_engine = FakeEngine()
- mock_manager.create_engine.return_value = mock_engine
-
- backend = create_pipeline("kokoro", lang_code="a", device="cpu")
-
- assert callable(backend)
- mock_manager.create_engine.assert_called_once_with("kokoro", lang_code="a", device="cpu")
-
- def test_create_pipeline_raises_for_unknown_plugin(self):
- """create_pipeline raises KeyError for unknown plugins."""
- with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
- mock_manager = MagicMock()
- mock_get_manager.return_value = mock_manager
- mock_manager.create_engine.side_effect = KeyError("Plugin not found")
-
- with pytest.raises(KeyError):
- create_pipeline("nonexistent")
-
-
-class TestPluginManagerWithFakePlugins:
- """Test PluginManager with fake plugin loading."""
-
- def test_plugin_manager_create_engine_from_plugin(self):
- """PluginManager creates engine from loaded plugin."""
- manager = PluginManager()
-
- # Manually add a fake plugin
- def fake_create_engine(**kwargs):
- return FakeEngine(**kwargs)
-
- manager._plugins["fake"] = {
- "manifest": MagicMock(),
- "create_engine": fake_create_engine,
- }
- manager._loaded = True
-
- # Create engine
- engine = manager.create_engine("fake", param="value")
-
- assert isinstance(engine, FakeEngine)
- assert engine.kwargs == {"param": "value"}
-
- def test_plugin_manager_get_or_create_engine(self):
- """PluginManager caches engines."""
- manager = PluginManager()
-
- call_count = 0
-
- def fake_create_engine(**kwargs):
- nonlocal call_count
- call_count += 1
- return FakeEngine(**kwargs)
-
- manager._plugins["fake"] = {
- "manifest": MagicMock(),
- "create_engine": fake_create_engine,
- }
- manager._loaded = True
-
- # Get engine twice
- engine1 = manager.get_or_create_engine("fake")
- engine2 = manager.get_or_create_engine("fake")
-
- # Should be same instance
- assert engine1 is engine2
- assert call_count == 1
-
- def test_plugin_manager_dispose_all(self):
- """PluginManager disposes all cached engines."""
- manager = PluginManager()
-
- def fake_create_engine(**kwargs):
- return FakeEngine(**kwargs)
-
- manager._plugins["fake"] = {
- "manifest": MagicMock(),
- "create_engine": fake_create_engine,
- }
- manager._loaded = True
-
- # Create and cache engines
- engine1 = manager.get_or_create_engine("fake")
- engine2 = manager.get_or_create_engine("fake")
-
- # Dispose all
- manager.dispose_all()
-
- # Engines should be disposed
- assert engine1._disposed is True
- assert engine2._disposed is True
-
- # Cache should be empty
- assert len(manager._engines) == 0
+"""Integration tests for Plugin Manager and direct utility functions."""
+
+import pytest
+from unittest.mock import MagicMock, patch
+
+from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
+from abogen.tts_plugin.utils import Pipeline, create_pipeline
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.types import SynthesisRequest, SynthesizedAudio, AudioFormat
+
+
+class FakeEngine:
+ """Fake Engine for testing."""
+
+ def __init__(self, **kwargs):
+ self.kwargs = kwargs
+ self._disposed = False
+
+ def createSession(self):
+ return FakeEngineSession()
+
+ def dispose(self):
+ self._disposed = True
+
+ @property
+ def manifest(self):
+ return MagicMock()
+
+
+class FakeEngineSession:
+ """Fake EngineSession for testing."""
+
+ def __init__(self):
+ self._disposed = False
+
+ def synthesize(self, request):
+ # Return fake audio
+ import numpy as np
+ from abogen.tts_plugin.types import AudioFormat, Duration, SynthesizedAudio
+
+ audio = np.zeros(1000, dtype=np.float32)
+ return SynthesizedAudio(
+ data=audio.tobytes(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=0.04167), # 1000 samples / 24000 Hz
+ )
+
+ def dispose(self):
+ self._disposed = True
+
+
+class TestPluginManager:
+ """Test PluginManager functionality."""
+
+ def test_plugin_manager_creation(self):
+ """PluginManager can be created."""
+ manager = PluginManager()
+ assert manager is not None
+
+ def test_plugin_manager_list_discovers_plugins(self):
+ """PluginManager discovers plugins from plugins directory."""
+ manager = PluginManager()
+ manager.discover("plugins")
+ plugins = manager.list_plugins()
+ # Should discover kokoro plugin if it exists
+ assert isinstance(plugins, list)
+
+ def test_plugin_manager_has_plugin_after_discover(self):
+ """PluginManager reports plugins after discovery."""
+ manager = PluginManager()
+ manager.discover("plugins")
+ # kokoro plugin should be discovered if plugins/kokoro exists
+ # This is expected behavior
+ assert isinstance(manager._plugins, dict)
+
+ def test_plugin_manager_get_plugin_after_discover(self):
+ """PluginManager returns plugin info after discovery."""
+ manager = PluginManager()
+ manager.discover("plugins")
+ # kokoro plugin should be discovered if plugins/kokoro exists
+ assert isinstance(manager._plugins, dict)
+
+ def test_plugin_manager_create_engine_not_found(self):
+ """PluginManager raises KeyError for unknown plugins."""
+ manager = PluginManager()
+ with pytest.raises(KeyError, match="Plugin not found"):
+ manager.create_engine("nonexistent")
+
+ def test_plugin_manager_discover_with_empty_dir(self):
+ """PluginManager handles missing plugins directory."""
+ manager = PluginManager()
+ manager.discover("/nonexistent/path")
+ plugins = manager.list_plugins()
+ assert plugins == []
+
+ def test_global_plugin_manager_singleton(self):
+ """Global PluginManager is a singleton."""
+ reset_plugin_manager()
+ manager1 = get_plugin_manager()
+ manager2 = get_plugin_manager()
+ assert manager1 is manager2
+ reset_plugin_manager()
+
+ def test_reset_plugin_manager(self):
+ """reset_plugin_manager clears the singleton."""
+ manager1 = get_plugin_manager()
+ reset_plugin_manager()
+ manager2 = get_plugin_manager()
+ assert manager1 is not manager2
+ reset_plugin_manager()
+
+
+class TestPipeline:
+ """Test Pipeline functionality."""
+
+ def test_pipeline_creation(self):
+ """Pipeline can be created."""
+ engine = FakeEngine()
+ backend = Pipeline(engine)
+ assert backend is not None
+
+ def test_pipeline_callable(self):
+ """Pipeline is callable like old TTSBackend."""
+ engine = FakeEngine()
+ backend = Pipeline(engine)
+
+ # Should be callable
+ assert callable(backend)
+
+ def test_pipeline_synthesize(self):
+ """Pipeline can synthesize text."""
+ engine = FakeEngine()
+ backend = Pipeline(engine)
+
+ # Call the backend
+ segments = list(backend("Hello world", voice="default", speed=1.0))
+
+ # Should return at least one segment
+ assert len(segments) >= 1
+
+ # Segment should have graphemes and audio
+ segment = segments[0]
+ assert hasattr(segment, "graphemes")
+ assert hasattr(segment, "audio")
+ assert segment.graphemes == "Hello world"
+
+ def test_pipeline_dispose(self):
+ """Pipeline can be disposed."""
+ engine = FakeEngine()
+ backend = Pipeline(engine)
+
+ # Create a session by calling
+ list(backend("test"))
+
+ # Dispose should not raise
+ backend.dispose()
+
+ # Double dispose should be safe
+ backend.dispose()
+
+
+class TestCreatePipelineCompat:
+ """Test create_pipeline utility function."""
+
+ def test_create_pipeline_returns_callable(self):
+ """create_pipeline returns a callable backend."""
+ from abogen.tts_plugin.host_context import HostContext
+ from abogen.tts_plugin.types import EngineConfig
+
+ # Mock the plugin manager
+ with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
+ mock_manager = MagicMock()
+ mock_get_manager.return_value = mock_manager
+
+ mock_engine = FakeEngine()
+ mock_manager.create_engine.return_value = mock_engine
+
+ backend = create_pipeline("kokoro", lang_code="a", device="cpu")
+
+ assert callable(backend)
+ mock_manager.create_engine.assert_called_once()
+ call_args = mock_manager.create_engine.call_args
+ assert call_args.args[0] == "kokoro"
+ assert isinstance(call_args.kwargs["context"], HostContext)
+ assert call_args.kwargs["model_path"] is None
+ assert isinstance(call_args.kwargs["config"], EngineConfig)
+ assert call_args.kwargs["config"].device == "cpu"
+ assert call_args.kwargs["config"].lang_code == "a"
+
+ def test_create_pipeline_raises_for_unknown_plugin(self):
+ """create_pipeline raises KeyError for unknown plugins."""
+ with patch("abogen.tts_plugin.utils.get_plugin_manager") as mock_get_manager:
+ mock_manager = MagicMock()
+ mock_get_manager.return_value = mock_manager
+ mock_manager.create_engine.side_effect = KeyError("Plugin not found")
+
+ with pytest.raises(KeyError):
+ create_pipeline("nonexistent")
+
+
+class TestPluginManagerWithFakePlugins:
+ """Test PluginManager with fake plugin loading."""
+
+ def test_plugin_manager_create_engine_from_plugin(self):
+ """PluginManager creates engine from loaded plugin."""
+ manager = PluginManager()
+
+ # Manually add a fake plugin
+ def fake_create_engine(**kwargs):
+ return FakeEngine(**kwargs)
+
+ manager._plugins["fake"] = {
+ "manifest": MagicMock(),
+ "create_engine": fake_create_engine,
+ }
+ manager._loaded = True
+
+ # Create engine
+ engine = manager.create_engine("fake", param="value")
+
+ assert isinstance(engine, FakeEngine)
+ assert engine.kwargs == {"param": "value"}
+
+ def test_plugin_manager_get_or_create_engine(self):
+ """PluginManager caches engines."""
+ manager = PluginManager()
+
+ call_count = 0
+
+ def fake_create_engine(**kwargs):
+ nonlocal call_count
+ call_count += 1
+ return FakeEngine(**kwargs)
+
+ manager._plugins["fake"] = {
+ "manifest": MagicMock(),
+ "create_engine": fake_create_engine,
+ }
+ manager._loaded = True
+
+ # Get engine twice
+ engine1 = manager.get_or_create_engine("fake")
+ engine2 = manager.get_or_create_engine("fake")
+
+ # Should be same instance
+ assert engine1 is engine2
+ assert call_count == 1
+
+ def test_plugin_manager_dispose_all(self):
+ """PluginManager disposes all cached engines."""
+ manager = PluginManager()
+
+ def fake_create_engine(**kwargs):
+ return FakeEngine(**kwargs)
+
+ manager._plugins["fake"] = {
+ "manifest": MagicMock(),
+ "create_engine": fake_create_engine,
+ }
+ manager._loaded = True
+
+ # Create and cache engines
+ engine1 = manager.get_or_create_engine("fake")
+ engine2 = manager.get_or_create_engine("fake")
+
+ # Dispose all
+ manager.dispose_all()
+
+ # Engines should be disposed
+ assert engine1._disposed is True
+ assert engine2._disposed is True
+
+ # Cache should be empty
+ assert len(manager._engines) == 0
diff --git a/tests/contracts/test_session_contract.py b/tests/contracts/test_session_contract.py
index d24f189..753a169 100644
--- a/tests/contracts/test_session_contract.py
+++ b/tests/contracts/test_session_contract.py
@@ -1,135 +1,135 @@
-"""Contract tests for EngineSession protocol.
-
-These tests verify that EngineSession implementations satisfy the architectural requirements:
-- synthesize() returns SynthesizedAudio
-- dispose() is idempotent
-- After dispose(), synthesize() raises EngineError
-- Session remains usable after synthesize() failure
-"""
-
-import pytest
-
-from abogen.tts_plugin.engine import EngineSession
-from abogen.tts_plugin.errors import EngineError
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-
-from .conftest import FakeEngineSession
-
-
-class TestEngineSessionProtocolContract:
- """Contract tests for the EngineSession protocol itself."""
-
- def test_engine_session_is_protocol(self) -> None:
- assert hasattr(EngineSession, "__protocol_attrs__")
-
- def test_fake_session_satisfies_protocol(self) -> None:
- session = FakeEngineSession()
- assert isinstance(session, EngineSession)
-
-
-class TestSessionSynthesizeContract:
- """Contract tests for EngineSession.synthesize()."""
-
- def test_synthesize_returns_synthesized_audio(self) -> None:
- session = FakeEngineSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert isinstance(result, SynthesizedAudio)
-
- def test_synthesize_returns_valid_audio_data(self) -> None:
- session = FakeEngineSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert isinstance(result.data, bytes)
- assert len(result.data) > 0
- assert isinstance(result.format, AudioFormat)
- assert isinstance(result.duration, Duration)
-
-
-class TestSessionDisposeContract:
- """Contract tests for EngineSession.dispose()."""
-
- def test_dispose_is_idempotent(self) -> None:
- """Architecture spec: dispose() is idempotent."""
- session = FakeEngineSession()
- session.dispose()
- session.dispose() # Should not raise
-
- def test_dispose_never_raises(self) -> None:
- """Architecture spec: dispose() never raises exceptions."""
- session = FakeEngineSession()
- session.dispose() # Should not raise
-
-
-class TestSessionAfterDisposeContract:
- """Contract tests for behavior after dispose()."""
-
- def test_synthesize_after_dispose_raises(self) -> None:
- """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
- session = FakeEngineSession()
- session.dispose()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- with pytest.raises(EngineError):
- session.synthesize(request)
-
-
-class TestSessionLifecycleContract:
- """Contract tests for EngineSession lifecycle."""
-
- def test_full_lifecycle(self) -> None:
- """Test complete session lifecycle: create -> synthesize -> dispose."""
- session = FakeEngineSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
- # Synthesize
- result = session.synthesize(request)
- assert isinstance(result, SynthesizedAudio)
-
- # Dispose
- session.dispose()
-
- def test_multiple_synthesize_before_dispose(self) -> None:
- """Architecture spec: Session remains usable after synthesize() failure."""
- session = FakeEngineSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
- # Multiple synthesize calls
- result1 = session.synthesize(request)
- result2 = session.synthesize(request)
- assert isinstance(result1, SynthesizedAudio)
- assert isinstance(result2, SynthesizedAudio)
-
- # Dispose
- session.dispose()
+"""Contract tests for EngineSession protocol.
+
+These tests verify that EngineSession implementations satisfy the architectural requirements:
+- synthesize() returns SynthesizedAudio
+- dispose() is idempotent
+- After dispose(), synthesize() raises EngineError
+- Session remains usable after synthesize() failure
+"""
+
+import pytest
+
+from abogen.tts_plugin.engine import EngineSession
+from abogen.tts_plugin.errors import EngineError
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+
+from .conftest import FakeEngineSession
+
+
+class TestEngineSessionProtocolContract:
+ """Contract tests for the EngineSession protocol itself."""
+
+ def test_engine_session_is_protocol(self) -> None:
+ assert hasattr(EngineSession, "__protocol_attrs__")
+
+ def test_fake_session_satisfies_protocol(self) -> None:
+ session = FakeEngineSession()
+ assert isinstance(session, EngineSession)
+
+
+class TestSessionSynthesizeContract:
+ """Contract tests for EngineSession.synthesize()."""
+
+ def test_synthesize_returns_synthesized_audio(self) -> None:
+ session = FakeEngineSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert isinstance(result, SynthesizedAudio)
+
+ def test_synthesize_returns_valid_audio_data(self) -> None:
+ session = FakeEngineSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert isinstance(result.data, bytes)
+ assert len(result.data) > 0
+ assert isinstance(result.format, AudioFormat)
+ assert isinstance(result.duration, Duration)
+
+
+class TestSessionDisposeContract:
+ """Contract tests for EngineSession.dispose()."""
+
+ def test_dispose_is_idempotent(self) -> None:
+ """Architecture spec: dispose() is idempotent."""
+ session = FakeEngineSession()
+ session.dispose()
+ session.dispose() # Should not raise
+
+ def test_dispose_never_raises(self) -> None:
+ """Architecture spec: dispose() never raises exceptions."""
+ session = FakeEngineSession()
+ session.dispose() # Should not raise
+
+
+class TestSessionAfterDisposeContract:
+ """Contract tests for behavior after dispose()."""
+
+ def test_synthesize_after_dispose_raises(self) -> None:
+ """Architecture spec: After dispose(), all methods except dispose() raise EngineError."""
+ session = FakeEngineSession()
+ session.dispose()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ with pytest.raises(EngineError):
+ session.synthesize(request)
+
+
+class TestSessionLifecycleContract:
+ """Contract tests for EngineSession lifecycle."""
+
+ def test_full_lifecycle(self) -> None:
+ """Test complete session lifecycle: create -> synthesize -> dispose."""
+ session = FakeEngineSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+ # Synthesize
+ result = session.synthesize(request)
+ assert isinstance(result, SynthesizedAudio)
+
+ # Dispose
+ session.dispose()
+
+ def test_multiple_synthesize_before_dispose(self) -> None:
+ """Architecture spec: Session remains usable after synthesize() failure."""
+ session = FakeEngineSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+ # Multiple synthesize calls
+ result1 = session.synthesize(request)
+ result2 = session.synthesize(request)
+ assert isinstance(result1, SynthesizedAudio)
+ assert isinstance(result2, SynthesizedAudio)
+
+ # Dispose
+ session.dispose()
diff --git a/tests/contracts/test_types_contract.py b/tests/contracts/test_types_contract.py
index 31d7edb..ccc074c 100644
--- a/tests/contracts/test_types_contract.py
+++ b/tests/contracts/test_types_contract.py
@@ -1,207 +1,244 @@
-"""Contract tests for core domain value objects.
-
-These tests verify that value objects satisfy the architectural requirements:
-- Frozen (immutable) dataclasses
-- Correct field definitions
-- Proper equality behavior
-"""
-
-import pytest
-
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- EngineConfig,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-
-
-class TestAudioFormatContract:
- """Contract tests for AudioFormat value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(AudioFormat, "__dataclass_params__")
- assert AudioFormat.__dataclass_params__.frozen is True
-
- def test_required_fields(self) -> None:
- af = AudioFormat(mime="audio/wav", extension="wav")
- assert af.mime == "audio/wav"
- assert af.extension == "wav"
-
- def test_immutability(self) -> None:
- af = AudioFormat(mime="audio/wav", extension="wav")
- with pytest.raises(AttributeError):
- af.mime = "audio/mpeg" # type: ignore[misc]
-
- def test_equality(self) -> None:
- af1 = AudioFormat(mime="audio/wav", extension="wav")
- af2 = AudioFormat(mime="audio/wav", extension="wav")
- assert af1 == af2
-
- def test_inequality(self) -> None:
- af1 = AudioFormat(mime="audio/wav", extension="wav")
- af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
- assert af1 != af2
-
- def test_hashable(self) -> None:
- af = AudioFormat(mime="audio/wav", extension="wav")
- assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
-
-
-class TestDurationContract:
- """Contract tests for Duration value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(Duration, "__dataclass_params__")
- assert Duration.__dataclass_params__.frozen is True
-
- def test_required_fields(self) -> None:
- d = Duration(seconds=1.5)
- assert d.seconds == 1.5
-
- def test_immutability(self) -> None:
- d = Duration(seconds=1.0)
- with pytest.raises(AttributeError):
- d.seconds = 2.0 # type: ignore[misc]
-
- def test_equality(self) -> None:
- d1 = Duration(seconds=1.0)
- d2 = Duration(seconds=1.0)
- assert d1 == d2
-
-
-class TestVoiceSelectionContract:
- """Contract tests for VoiceSelection value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(VoiceSelection, "__dataclass_params__")
- assert VoiceSelection.__dataclass_params__.frozen is True
-
- def test_required_fields(self) -> None:
- vs = VoiceSelection(source="builtin", key="af_nova")
- assert vs.source == "builtin"
- assert vs.key == "af_nova"
-
- def test_payload_default_none(self) -> None:
- vs = VoiceSelection(source="builtin", key="af_nova")
- assert vs.payload is None
-
- def test_payload_optional(self) -> None:
- vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
- assert vs.payload == b"audio_data"
-
- def test_immutability(self) -> None:
- vs = VoiceSelection(source="builtin", key="af_nova")
- with pytest.raises(AttributeError):
- vs.source = "other" # type: ignore[misc]
-
-
-class TestParameterValuesContract:
- """Contract tests for ParameterValues value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(ParameterValues, "__dataclass_params__")
- assert ParameterValues.__dataclass_params__.frozen is True
-
- def test_default_empty(self) -> None:
- pv = ParameterValues()
- assert pv.values == {}
-
- def test_with_values(self) -> None:
- pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
- assert pv.values["speed"] == 1.0
- assert pv.values["pitch"] == 0.5
-
- def test_immutability(self) -> None:
- pv = ParameterValues(values={"speed": 1.0})
- with pytest.raises(AttributeError):
- pv.values = {} # type: ignore[misc]
-
-
-class TestSynthesisRequestContract:
- """Contract tests for SynthesisRequest value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(SynthesisRequest, "__dataclass_params__")
- assert SynthesisRequest.__dataclass_params__.frozen is True
-
- def test_required_fields(self) -> None:
- req = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- assert req.text == "Hello"
- assert req.voice.source == "builtin"
- assert req.format.mime == "audio/wav"
-
- def test_immutability(self) -> None:
- req = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="af_nova"),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- with pytest.raises(AttributeError):
- req.text = "World" # type: ignore[misc]
-
-
-class TestSynthesizedAudioContract:
- """Contract tests for SynthesizedAudio value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(SynthesizedAudio, "__dataclass_params__")
- assert SynthesizedAudio.__dataclass_params__.frozen is True
-
- def test_required_fields(self) -> None:
- audio = SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
- assert audio.data == b"\x00" * 100
- assert audio.format.mime == "audio/wav"
- assert audio.duration.seconds == 1.0
-
- def test_immutability(self) -> None:
- audio = SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
- with pytest.raises(AttributeError):
- audio.data = b"\x00" # type: ignore[misc]
-
-
-class TestEngineConfigContract:
- """Contract tests for EngineConfig value object."""
-
- def test_is_frozen_dataclass(self) -> None:
- assert hasattr(EngineConfig, "__dataclass_params__")
- assert EngineConfig.__dataclass_params__.frozen is True
-
- def test_default_device(self) -> None:
- config = EngineConfig()
- assert config.device == "cpu"
-
- def test_custom_device(self) -> None:
- config = EngineConfig(device="cuda:0")
- assert config.device == "cuda:0"
-
- def test_immutability(self) -> None:
- config = EngineConfig()
- with pytest.raises(AttributeError):
- config.device = "cuda:0" # type: ignore[misc]
-
- def test_unknown_keys_ignored_per_spec(self) -> None:
- """Architecture spec: Unknown keys are ignored (no error).
-
- EngineConfig is frozen, so unknown keys cannot be set after creation.
- This test verifies the default behavior matches the spec.
- """
- config = EngineConfig()
- assert config.device == "cpu"
+"""Contract tests for core domain value objects.
+
+These tests verify that value objects satisfy the architectural requirements:
+- Frozen (immutable) dataclasses
+- Correct field definitions
+- Proper equality behavior
+"""
+
+import pytest
+
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ EngineConfig,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+
+
+class TestAudioFormatContract:
+ """Contract tests for AudioFormat value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(AudioFormat, "__dataclass_params__")
+ assert AudioFormat.__dataclass_params__.frozen is True
+
+ def test_required_fields(self) -> None:
+ af = AudioFormat(mime="audio/wav", extension="wav")
+ assert af.mime == "audio/wav"
+ assert af.extension == "wav"
+
+ def test_immutability(self) -> None:
+ af = AudioFormat(mime="audio/wav", extension="wav")
+ with pytest.raises(AttributeError):
+ af.mime = "audio/mpeg" # type: ignore[misc]
+
+ def test_equality(self) -> None:
+ af1 = AudioFormat(mime="audio/wav", extension="wav")
+ af2 = AudioFormat(mime="audio/wav", extension="wav")
+ assert af1 == af2
+
+ def test_inequality(self) -> None:
+ af1 = AudioFormat(mime="audio/wav", extension="wav")
+ af2 = AudioFormat(mime="audio/mpeg", extension="mp3")
+ assert af1 != af2
+
+ def test_hashable(self) -> None:
+ af = AudioFormat(mime="audio/wav", extension="wav")
+ assert hash(af) == hash(AudioFormat(mime="audio/wav", extension="wav"))
+
+
+class TestDurationContract:
+ """Contract tests for Duration value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(Duration, "__dataclass_params__")
+ assert Duration.__dataclass_params__.frozen is True
+
+ def test_required_fields(self) -> None:
+ d = Duration(seconds=1.5)
+ assert d.seconds == 1.5
+
+ def test_immutability(self) -> None:
+ d = Duration(seconds=1.0)
+ with pytest.raises(AttributeError):
+ d.seconds = 2.0 # type: ignore[misc]
+
+ def test_equality(self) -> None:
+ d1 = Duration(seconds=1.0)
+ d2 = Duration(seconds=1.0)
+ assert d1 == d2
+
+
+class TestVoiceSelectionContract:
+ """Contract tests for VoiceSelection value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(VoiceSelection, "__dataclass_params__")
+ assert VoiceSelection.__dataclass_params__.frozen is True
+
+ def test_required_fields(self) -> None:
+ vs = VoiceSelection(source="builtin", key="af_nova")
+ assert vs.source == "builtin"
+ assert vs.key == "af_nova"
+
+ def test_payload_default_none(self) -> None:
+ vs = VoiceSelection(source="builtin", key="af_nova")
+ assert vs.payload is None
+
+ def test_payload_optional(self) -> None:
+ vs = VoiceSelection(source="clone", key="my_voice", payload=b"audio_data")
+ assert vs.payload == b"audio_data"
+
+ def test_immutability(self) -> None:
+ vs = VoiceSelection(source="builtin", key="af_nova")
+ with pytest.raises(AttributeError):
+ vs.source = "other" # type: ignore[misc]
+
+
+class TestParameterValuesContract:
+ """Contract tests for ParameterValues value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(ParameterValues, "__dataclass_params__")
+ assert ParameterValues.__dataclass_params__.frozen is True
+
+ def test_default_empty(self) -> None:
+ pv = ParameterValues()
+ assert pv.values == {}
+
+ def test_with_values(self) -> None:
+ pv = ParameterValues(values={"speed": 1.0, "pitch": 0.5})
+ assert pv.values["speed"] == 1.0
+ assert pv.values["pitch"] == 0.5
+
+ def test_immutability(self) -> None:
+ pv = ParameterValues(values={"speed": 1.0})
+ with pytest.raises(AttributeError):
+ pv.values = {} # type: ignore[misc]
+
+
+class TestSynthesisRequestContract:
+ """Contract tests for SynthesisRequest value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(SynthesisRequest, "__dataclass_params__")
+ assert SynthesisRequest.__dataclass_params__.frozen is True
+
+ def test_required_fields(self) -> None:
+ req = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ assert req.text == "Hello"
+ assert req.voice.source == "builtin"
+ assert req.format.mime == "audio/wav"
+
+ def test_immutability(self) -> None:
+ req = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="af_nova"),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ with pytest.raises(AttributeError):
+ req.text = "World" # type: ignore[misc]
+
+
+class TestSynthesizedAudioContract:
+ """Contract tests for SynthesizedAudio value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(SynthesizedAudio, "__dataclass_params__")
+ assert SynthesizedAudio.__dataclass_params__.frozen is True
+
+ def test_required_fields(self) -> None:
+ audio = SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+ assert audio.data == b"\x00" * 100
+ assert audio.format.mime == "audio/wav"
+ assert audio.duration.seconds == 1.0
+
+ def test_immutability(self) -> None:
+ audio = SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+ with pytest.raises(AttributeError):
+ audio.data = b"\x00" # type: ignore[misc]
+
+
+class TestEngineConfigContract:
+ """Contract tests for EngineConfig value object."""
+
+ def test_is_frozen_dataclass(self) -> None:
+ assert hasattr(EngineConfig, "__dataclass_params__")
+ assert EngineConfig.__dataclass_params__.frozen is True
+
+ def test_default_device(self) -> None:
+ config = EngineConfig()
+ assert config.device == "cpu"
+
+ def test_custom_device(self) -> None:
+ config = EngineConfig(device="cuda:0")
+ assert config.device == "cuda:0"
+
+ def test_default_lang_code(self) -> None:
+ config = EngineConfig()
+ assert config.lang_code == "a"
+
+ def test_custom_lang_code(self) -> None:
+ config = EngineConfig(lang_code="j")
+ assert config.lang_code == "j"
+
+ def test_immutability(self) -> None:
+ config = EngineConfig()
+ with pytest.raises(AttributeError):
+ config.device = "cuda:0" # type: ignore[misc]
+
+ def test_immutability_lang_code(self) -> None:
+ config = EngineConfig()
+ with pytest.raises(AttributeError):
+ config.lang_code = "j" # type: ignore[misc]
+
+ def test_unknown_keys_ignored_per_spec(self) -> None:
+ """Architecture spec: Unknown keys are ignored (no error).
+
+ EngineConfig is frozen, so unknown keys cannot be set after creation.
+ This test verifies the default behavior matches the spec.
+ """
+ config = EngineConfig()
+ assert config.device == "cpu"
+
+ def test_plugins_may_ignore_irrelevant_fields(self) -> None:
+ """Architecture Amendment #1: Plugins ignore unsupported fields.
+
+ EngineConfig may contain fields that are not relevant to every plugin.
+ Plugins MUST ignore fields they do not need, not raise on them.
+ """
+ config = EngineConfig(device="cuda:0", lang_code="j")
+ assert config.device == "cuda:0"
+ assert config.lang_code == "j"
+ # A plugin that only needs device simply reads config.device
+ # and ignores config.lang_code — this must not raise.
+
+ def test_engine_config_contains_engine_instance_configuration(self) -> None:
+ """Architecture Amendment #1: EngineConfig definition.
+
+ EngineConfig contains parameters that define how a particular
+ Engine instance is created and that remain constant throughout
+ the lifetime of that Engine.
+ """
+ config = EngineConfig(device="cpu", lang_code="a")
+ # Both fields are init-time, immutable, engine-scoped.
+ assert config.device == "cpu"
+ assert config.lang_code == "a"
diff --git a/tests/plugins/fake_plugin/__init__.py b/tests/plugins/fake_plugin/__init__.py
index e23c8d6..0ececd1 100644
--- a/tests/plugins/fake_plugin/__init__.py
+++ b/tests/plugins/fake_plugin/__init__.py
@@ -1,92 +1,92 @@
-"""Fake plugin for testing the plugin loader.
-
-This is a minimal valid plugin that satisfies the Plugin API contract.
-It does NOT perform any real TTS synthesis.
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.errors import EngineError
-from abogen.tts_plugin.host_context import HostContext
-from abogen.tts_plugin.manifest import (
- AudioFormatManifest,
- EngineManifest,
- PluginManifest,
- VoiceSourceManifest,
-)
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- EngineConfig,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
-)
-
-
-class FakeSession:
- """Minimal EngineSession implementation for testing."""
-
- def __init__(self) -> None:
- self._disposed = False
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- raise EngineError("Session disposed")
- return SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class FakeEngine:
- """Minimal Engine implementation for testing."""
-
- def __init__(self) -> None:
- self._disposed = False
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- raise EngineError("Engine disposed")
- return FakeSession()
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-PLUGIN_MANIFEST = PluginManifest(
- id="fake_plugin",
- name="Fake Plugin",
- version="1.0.0",
- api_version="1.0",
- description="A fake plugin for testing",
- author="Test Author",
- capabilities=(),
- engine=EngineManifest(
- voiceSources=(
- VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
- ),
- audioFormats=(
- AudioFormatManifest(mime="audio/wav", extension="wav"),
- ),
- ),
-)
-
-MODEL_REQUIREMENTS: list[Any] = []
-
-
-def create_engine(
- context: HostContext,
- model_path: Path | None,
- config: EngineConfig,
-) -> Engine:
- """Create a fake engine instance."""
- return FakeEngine()
+"""Fake plugin for testing the plugin loader.
+
+This is a minimal valid plugin that satisfies the Plugin API contract.
+It does NOT perform any real TTS synthesis.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.errors import EngineError
+from abogen.tts_plugin.host_context import HostContext
+from abogen.tts_plugin.manifest import (
+ AudioFormatManifest,
+ EngineManifest,
+ PluginManifest,
+ VoiceSourceManifest,
+)
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ EngineConfig,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+)
+
+
+class FakeSession:
+ """Minimal EngineSession implementation for testing."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ raise EngineError("Session disposed")
+ return SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class FakeEngine:
+ """Minimal Engine implementation for testing."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return FakeSession()
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+PLUGIN_MANIFEST = PluginManifest(
+ id="fake_plugin",
+ name="Fake Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="A fake plugin for testing",
+ author="Test Author",
+ capabilities=(),
+ engine=EngineManifest(
+ voiceSources=(
+ VoiceSourceManifest(id="builtin", name="Builtin", type="list"),
+ ),
+ audioFormats=(
+ AudioFormatManifest(mime="audio/wav", extension="wav"),
+ ),
+ ),
+)
+
+MODEL_REQUIREMENTS: list[Any] = []
+
+
+def create_engine(
+ context: HostContext,
+ model_path: Path | None,
+ config: EngineConfig,
+) -> Engine:
+ """Create a fake engine instance."""
+ return FakeEngine()
diff --git a/tests/plugins/import_error/__init__.py b/tests/plugins/import_error/__init__.py
index 80bd647..d05c3a7 100644
--- a/tests/plugins/import_error/__init__.py
+++ b/tests/plugins/import_error/__init__.py
@@ -1,18 +1,18 @@
-"""Invalid plugin: raises ImportError during import."""
-
-from __future__ import annotations
-
-# This plugin intentionally raises an ImportError
-raise ImportError("Simulated import error for testing")
-
-# The following code will never be reached, but is here for documentation
-from abogen.tts_plugin.manifest import PluginManifest
-
-PLUGIN_MANIFEST = PluginManifest(
- id="import_error",
- name="Import Error Plugin",
- version="1.0.0",
- api_version="1.0",
- description="Plugin that fails to import",
- author="Test Author",
-)
+"""Invalid plugin: raises ImportError during import."""
+
+from __future__ import annotations
+
+# This plugin intentionally raises an ImportError
+raise ImportError("Simulated import error for testing")
+
+# The following code will never be reached, but is here for documentation
+from abogen.tts_plugin.manifest import PluginManifest
+
+PLUGIN_MANIFEST = PluginManifest(
+ id="import_error",
+ name="Import Error Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="Plugin that fails to import",
+ author="Test Author",
+)
diff --git a/tests/plugins/invalid_api_version/__init__.py b/tests/plugins/invalid_api_version/__init__.py
index 7b44b84..8b0121b 100644
--- a/tests/plugins/invalid_api_version/__init__.py
+++ b/tests/plugins/invalid_api_version/__init__.py
@@ -1,29 +1,29 @@
-"""Invalid plugin: incompatible api_version (major version mismatch)."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-from abogen.tts_plugin.manifest import PluginManifest
-from abogen.tts_plugin.types import EngineConfig
-
-# api_version "2.0" has major version 2, but host expects major version 1
-PLUGIN_MANIFEST = PluginManifest(
- id="invalid_api_version",
- name="Invalid API Version Plugin",
- version="1.0.0",
- api_version="2.0", # Major version mismatch!
- description="Plugin with incompatible api_version",
- author="Test Author",
-)
-
-MODEL_REQUIREMENTS: list[Any] = []
-
-
-def create_engine(
- context: Any,
- model_path: Path | None,
- config: EngineConfig,
-) -> Any:
- raise NotImplementedError("This plugin is invalid")
+"""Invalid plugin: incompatible api_version (major version mismatch)."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from abogen.tts_plugin.manifest import PluginManifest
+from abogen.tts_plugin.types import EngineConfig
+
+# api_version "2.0" has major version 2, but host expects major version 1
+PLUGIN_MANIFEST = PluginManifest(
+ id="invalid_api_version",
+ name="Invalid API Version Plugin",
+ version="1.0.0",
+ api_version="2.0", # Major version mismatch!
+ description="Plugin with incompatible api_version",
+ author="Test Author",
+)
+
+MODEL_REQUIREMENTS: list[Any] = []
+
+
+def create_engine(
+ context: Any,
+ model_path: Path | None,
+ config: EngineConfig,
+) -> Any:
+ raise NotImplementedError("This plugin is invalid")
diff --git a/tests/plugins/invalid_capabilities/__init__.py b/tests/plugins/invalid_capabilities/__init__.py
index 525e727..4ec4334 100644
--- a/tests/plugins/invalid_capabilities/__init__.py
+++ b/tests/plugins/invalid_capabilities/__init__.py
@@ -1,29 +1,29 @@
-"""Invalid plugin: unknown capabilities."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-from abogen.tts_plugin.manifest import PluginManifest
-from abogen.tts_plugin.types import EngineConfig
-
-PLUGIN_MANIFEST = PluginManifest(
- id="invalid_capabilities",
- name="Invalid Capabilities Plugin",
- version="1.0.0",
- api_version="1.0",
- description="Plugin with unknown capabilities",
- author="Test Author",
- capabilities=("voice_list", "unknown_capability", "another_unknown"),
-)
-
-MODEL_REQUIREMENTS: list[Any] = []
-
-
-def create_engine(
- context: Any,
- model_path: Path | None,
- config: EngineConfig,
-) -> Any:
- raise NotImplementedError("This plugin is invalid")
+"""Invalid plugin: unknown capabilities."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from abogen.tts_plugin.manifest import PluginManifest
+from abogen.tts_plugin.types import EngineConfig
+
+PLUGIN_MANIFEST = PluginManifest(
+ id="invalid_capabilities",
+ name="Invalid Capabilities Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="Plugin with unknown capabilities",
+ author="Test Author",
+ capabilities=("voice_list", "unknown_capability", "another_unknown"),
+)
+
+MODEL_REQUIREMENTS: list[Any] = []
+
+
+def create_engine(
+ context: Any,
+ model_path: Path | None,
+ config: EngineConfig,
+) -> Any:
+ raise NotImplementedError("This plugin is invalid")
diff --git a/tests/plugins/missing_create_engine/__init__.py b/tests/plugins/missing_create_engine/__init__.py
index d837f95..432d9c8 100644
--- a/tests/plugins/missing_create_engine/__init__.py
+++ b/tests/plugins/missing_create_engine/__init__.py
@@ -1,18 +1,18 @@
-"""Invalid plugin: missing create_engine function."""
-
-from __future__ import annotations
-
-from abogen.tts_plugin.manifest import PluginManifest
-
-PLUGIN_MANIFEST = PluginManifest(
- id="missing_create_engine",
- name="Missing Create Engine Plugin",
- version="1.0.0",
- api_version="1.0",
- description="Plugin missing create_engine",
- author="Test Author",
-)
-
-MODEL_REQUIREMENTS: list = []
-
-# This plugin intentionally does NOT export create_engine
+"""Invalid plugin: missing create_engine function."""
+
+from __future__ import annotations
+
+from abogen.tts_plugin.manifest import PluginManifest
+
+PLUGIN_MANIFEST = PluginManifest(
+ id="missing_create_engine",
+ name="Missing Create Engine Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="Plugin missing create_engine",
+ author="Test Author",
+)
+
+MODEL_REQUIREMENTS: list = []
+
+# This plugin intentionally does NOT export create_engine
diff --git a/tests/plugins/missing_manifest/__init__.py b/tests/plugins/missing_manifest/__init__.py
index 38af923..7e7b99e 100644
--- a/tests/plugins/missing_manifest/__init__.py
+++ b/tests/plugins/missing_manifest/__init__.py
@@ -1,10 +1,10 @@
-"""Invalid plugin: missing PLUGIN_MANIFEST."""
-
-from __future__ import annotations
-
-# This plugin intentionally does NOT export PLUGIN_MANIFEST
-MODEL_REQUIREMENTS: list = []
-
-
-def create_engine(context, model_path, config):
- raise NotImplementedError("This plugin is invalid")
+"""Invalid plugin: missing PLUGIN_MANIFEST."""
+
+from __future__ import annotations
+
+# This plugin intentionally does NOT export PLUGIN_MANIFEST
+MODEL_REQUIREMENTS: list = []
+
+
+def create_engine(context, model_path, config):
+ raise NotImplementedError("This plugin is invalid")
diff --git a/tests/plugins/missing_model_requirements/__init__.py b/tests/plugins/missing_model_requirements/__init__.py
index 50b8441..f3a34b6 100644
--- a/tests/plugins/missing_model_requirements/__init__.py
+++ b/tests/plugins/missing_model_requirements/__init__.py
@@ -1,28 +1,28 @@
-"""Invalid plugin: missing MODEL_REQUIREMENTS."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-from abogen.tts_plugin.manifest import PluginManifest
-from abogen.tts_plugin.types import EngineConfig
-
-PLUGIN_MANIFEST = PluginManifest(
- id="missing_model_requirements",
- name="Missing Model Requirements Plugin",
- version="1.0.0",
- api_version="1.0",
- description="Plugin missing MODEL_REQUIREMENTS",
- author="Test Author",
-)
-
-# This plugin intentionally does NOT export MODEL_REQUIREMENTS
-
-
-def create_engine(
- context: Any,
- model_path: Path | None,
- config: EngineConfig,
-) -> Any:
- raise NotImplementedError("This plugin is invalid")
+"""Invalid plugin: missing MODEL_REQUIREMENTS."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from abogen.tts_plugin.manifest import PluginManifest
+from abogen.tts_plugin.types import EngineConfig
+
+PLUGIN_MANIFEST = PluginManifest(
+ id="missing_model_requirements",
+ name="Missing Model Requirements Plugin",
+ version="1.0.0",
+ api_version="1.0",
+ description="Plugin missing MODEL_REQUIREMENTS",
+ author="Test Author",
+)
+
+# This plugin intentionally does NOT export MODEL_REQUIREMENTS
+
+
+def create_engine(
+ context: Any,
+ model_path: Path | None,
+ config: EngineConfig,
+) -> Any:
+ raise NotImplementedError("This plugin is invalid")
diff --git a/tests/test_behavioral_regression.py b/tests/test_behavioral_regression.py
index 7ddf77f..64078ab 100644
--- a/tests/test_behavioral_regression.py
+++ b/tests/test_behavioral_regression.py
@@ -1,1086 +1,1088 @@
-"""Behavioral Regression Tests for TTS Plugin Architecture.
-
-These tests verify external user-facing behavior, NOT internal implementation.
-They use only public API entry points available to application consumers.
-
-Tested plugins: Kokoro, SuperTonic.
-
-Public API Surface Tested:
-- PluginManager: discover, list_plugins, has_plugin, create_engine, get_or_create_engine, dispose_all
-- Engine: createSession, dispose
-- EngineSession: synthesize, dispose
-- VoiceLister: listVoices
-- Pipeline (utils.py): __call__, dispose
-- create_pipeline, get_voices, get_default_voice, is_plugin_registered, resolve_voice_to_plugin
-"""
-
-from __future__ import annotations
-
-import logging
-from pathlib import Path
-from typing import Any
-from unittest.mock import patch
-
-import numpy as np
-import pytest
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.errors import EngineError
-from abogen.tts_plugin.host_context import HostContext
-from abogen.tts_plugin.manifest import PluginManifest, EngineManifest, VoiceManifest
-from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
-from abogen.tts_plugin.types import (
- AudioFormat,
- Duration,
- ParameterValues,
- SynthesisRequest,
- SynthesizedAudio,
- VoiceSelection,
-)
-from abogen.tts_plugin.utils import (
- Pipeline,
- create_pipeline,
- get_default_voice,
- get_voices,
- is_plugin_registered,
- resolve_voice_to_plugin,
-)
-
-
-# ──────────────────────────────────────────────────────────────
-# Plugin Mock Infrastructure
-# ──────────────────────────────────────────────────────────────
-
-
-def _make_request(
- text: str = "Hello",
- voice: str = "voice1",
- speed: float = 1.0,
-) -> SynthesisRequest:
- return SynthesisRequest(
- text=text,
- voice=VoiceSelection(source="builtin", key=voice),
- parameters=ParameterValues(values={"speed": speed}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
-
-
-class MockEngineSession:
- """Mock EngineSession that records calls."""
-
- def __init__(self) -> None:
- self._disposed = False
- self.synthesize_calls: list[SynthesisRequest] = []
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- raise EngineError("Session disposed")
- self.synthesize_calls.append(request)
- return SynthesizedAudio(
- data=b"\x00" * 1000,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class MockEngine:
- """Mock Engine with VoiceLister support."""
-
- def __init__(
- self,
- voice_manifests: list[VoiceManifest] | None = None,
- ) -> None:
- self._disposed = False
- self._voice_manifests = voice_manifests or [
- VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
- VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
- ]
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- raise EngineError("Engine disposed")
- return MockEngineSession()
-
- def listVoices(self, sourceId: str) -> list[VoiceManifest]:
- if self._disposed:
- raise EngineError("Engine disposed")
- return self._voice_manifests
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class MockEngineAcceptKwargs:
- """MockEngine that accepts arbitrary kwargs (for create_engine)."""
-
- def __init__(self, **kwargs: Any) -> None:
- self._disposed = False
- self._kwargs = kwargs
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- raise EngineError("Engine disposed")
- return MockEngineSession()
-
- def listVoices(self, sourceId: str) -> list[VoiceManifest]:
- if self._disposed:
- raise EngineError("Engine disposed")
- return [
- VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
- VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
- ]
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-def _create_mock_plugin(
- engine_class: type = MockEngine,
- manifest_id: str = "mock_tts",
-) -> dict:
- manifest = PluginManifest(
- id=manifest_id,
- name="Mock TTS",
- version="1.0.0",
- api_version="1.0",
- description="Mock TTS for testing",
- author="Test",
- capabilities=("voice_list",),
- engine=EngineManifest(
- voiceSources=(),
- parameters=(),
- audioFormats=(),
- ),
- )
- return {
- "manifest": manifest,
- "create_engine": lambda **kwargs: engine_class(**kwargs),
- "module": None,
- }
-
-
-# ──────────────────────────────────────────────────────────────
-# Kokoro / SuperTonic Plugin Fixtures
-# ──────────────────────────────────────────────────────────────
-
-
-def _kokoro_available() -> bool:
- try:
- from kokoro import KPipeline # type: ignore[import-not-found]
- return True
- except ImportError:
- return False
-
-
-def _supertonic_available() -> bool:
- try:
- from supertonic import TTS # type: ignore[import-not-found]
- return True
- except ImportError:
- return False
-
-
-class _KokoroMockEngine:
- """Simulates Kokoro Engine behavior for behavioral tests."""
-
- def __init__(self, **kwargs: Any) -> None:
- self._disposed = False
- self._kwargs = kwargs
- self._voices = [
- VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
- VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
- VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
- ]
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- raise EngineError("Engine disposed")
- return MockEngineSession()
-
- def listVoices(self, sourceId: str) -> list[VoiceManifest]:
- if self._disposed:
- raise EngineError("Engine disposed")
- return self._voices
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-class _SuperTonicMockEngine:
- """Simulates SuperTonic Engine behavior for behavioral tests."""
-
- def __init__(self, **kwargs: Any) -> None:
- self._disposed = False
- self._kwargs = kwargs
- self._voices = [
- VoiceManifest(id="M1", name="Male 1", tags=("en", "male")),
- VoiceManifest(id="F1", name="Female 1", tags=("en", "female")),
- VoiceManifest(id="M2", name="Male 2", tags=("en", "male")),
- ]
-
- def createSession(self) -> EngineSession:
- if self._disposed:
- raise EngineError("Engine disposed")
- return MockEngineSession()
-
- def listVoices(self, sourceId: str) -> list[VoiceManifest]:
- if self._disposed:
- raise EngineError("Engine disposed")
- return self._voices
-
- def dispose(self) -> None:
- self._disposed = True
-
-
-# Parametrize across both production plugins
-_plugin_ids = ["kokoro", "supertonic"]
-_plugin_engines = {
- "kokoro": _KokoroMockEngine,
- "supertonic": _SuperTonicMockEngine,
-}
-_plugin_default_voices = {
- "kokoro": "af_nova",
- "supertonic": "M1",
-}
-_plugin_all_voices = {
- "kokoro": ["af_nova", "af_bella", "am_adam"],
- "supertonic": ["M1", "F1", "M2"],
-}
-
-
-def _plugin_available(plugin_id: str) -> bool:
- if plugin_id == "kokoro":
- return _kokoro_available()
- elif plugin_id == "supertonic":
- return _supertonic_available()
- return False
-
-
-# ──────────────────────────────────────────────────────────────
-# 1. SYNTHESIS SCENARIOS (parametrized per plugin)
-# ──────────────────────────────────────────────────────────────
-
-
-class TestSynthesisNormalText:
- """Synthesis with normal text input."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_short_text(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello world"))
- assert isinstance(result, SynthesizedAudio)
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_paragraph_text(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- text = "This is a longer paragraph with multiple sentences. It tests synthesis of more substantial text content."
- result = session.synthesize(_make_request(text=text))
- assert isinstance(result, SynthesizedAudio)
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_punctuation(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- text = "Hello, world! How are you? I'm fine... Really?"
- result = session.synthesize(_make_request(text=text))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
-
-class TestSynthesisLongText:
- """Synthesis with long text input."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_very_long_text(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- text = "Word " * 10000
- result = session.synthesize(_make_request(text=text))
- assert isinstance(result, SynthesizedAudio)
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_multiline_text(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- text = "\n".join([f"Line {i} of the text." for i in range(100)])
- result = session.synthesize(_make_request(text=text))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
-
-class TestSynthesisEmptyText:
- """Synthesis with empty text input."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_empty_string(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text=""))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_whitespace_only(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text=" \n\t "))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
-
-class TestSynthesisUnicodeText:
- """Synthesis with Unicode text input."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_cyrillic(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Привет мир"))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_chinese(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="你好世界"))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_emoji(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello 🌍"))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_mixed_scripts(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello 你好 Привет"))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_accented_characters(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Café résumé naïve"))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# 2. VOICE SCENARIOS (parametrized per plugin)
-# ──────────────────────────────────────────────────────────────
-
-
-class TestVoiceListing:
- """Voice listing via VoiceLister capability."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_list_voices_returns_manifests(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- voices = engine.listVoices("builtin")
- assert isinstance(voices, list)
- assert len(voices) > 0
- for v in voices:
- assert isinstance(v, VoiceManifest)
- assert hasattr(v, "id")
- assert hasattr(v, "name")
- assert hasattr(v, "tags")
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_voices_have_required_fields(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- voices = engine.listVoices("builtin")
- for v in voices:
- assert isinstance(v.id, str)
- assert len(v.id) > 0
- assert isinstance(v.name, str)
- assert len(v.name) > 0
- assert isinstance(v.tags, tuple)
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_voice_ids_match_manifest(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- voices = engine.listVoices("builtin")
- voice_ids = [v.id for v in voices]
- for expected_id in _plugin_all_voices[plugin_id]:
- assert expected_id in voice_ids
- engine.dispose()
-
-
-class TestVoiceSelection:
- """Using different voices for synthesis."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_each_voice(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- for voice_id in _plugin_all_voices[plugin_id]:
- result = session.synthesize(
- _make_request(text="Hello", voice=voice_id)
- )
- assert isinstance(result, SynthesizedAudio)
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_invalid_voice(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(
- _make_request(text="Hello", voice="nonexistent_voice")
- )
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# 3. PARAMETER SCENARIOS (parametrized per plugin)
-# ──────────────────────────────────────────────────────────────
-
-
-class TestSpeedParameter:
- """Speed parameter behavior."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_speed_1_0(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello", speed=1.0))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_speed_0_5(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello", speed=0.5))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_speed_2_0(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello", speed=2.0))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_with_default_speed(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key=_plugin_default_voices[plugin_id]),
- parameters=ParameterValues(),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# 4. ERROR SCENARIOS
-# ──────────────────────────────────────────────────────────────
-
-
-class TestUnknownPlugin:
- """Handling of unknown plugin IDs."""
-
- def test_create_engine_unknown_plugin(self) -> None:
- manager = PluginManager()
- manager._loaded = True
- with pytest.raises(KeyError, match="Plugin not found"):
- manager.create_engine("nonexistent_plugin")
-
- def test_has_plugin_unknown(self) -> None:
- manager = PluginManager()
- manager._loaded = True
- assert manager.has_plugin("nonexistent_plugin") is False
-
- def test_get_plugin_unknown(self) -> None:
- manager = PluginManager()
- manager._loaded = True
- assert manager.get_plugin("nonexistent_plugin") is None
-
-
-class TestPluginLoadingFailure:
- """Handling of plugin loading failures."""
-
- def test_discover_nonexistent_directory(self) -> None:
- manager = PluginManager()
- manager.discover("/nonexistent/path")
- assert manager.list_plugins() == []
-
- def test_discover_empty_directory(self, tmp_path: Path) -> None:
- manager = PluginManager()
- manager.discover(str(tmp_path))
- assert manager.list_plugins() == []
-
-
-# ──────────────────────────────────────────────────────────────
-# 5. LIFECYCLE SCENARIOS (parametrized per plugin)
-# ──────────────────────────────────────────────────────────────
-
-
-class TestMultipleSynthesis:
- """Multiple synthesis operations."""
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_sequential_synthesis(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- session = engine.createSession()
- for i in range(10):
- result = session.synthesize(_make_request(text=f"Text {i}"))
- assert isinstance(result, SynthesizedAudio)
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_multiple_sessions(self, plugin_id: str) -> None:
- engine = _plugin_engines[plugin_id]()
- sessions = [engine.createSession() for _ in range(5)]
- for i, session in enumerate(sessions):
- result = session.synthesize(_make_request(text=f"Session {i}"))
- assert isinstance(result, SynthesizedAudio)
- for session in sessions:
- session.dispose()
- engine.dispose()
-
- @pytest.mark.parametrize("plugin_id", _plugin_ids)
- def test_synthesize_after_failed_synthesize(self, plugin_id: str) -> None:
- """Session remains usable after synthesis failure."""
- class FailingSession:
- def __init__(self) -> None:
- self._call_count = 0
- self._disposed = False
-
- def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
- if self._disposed:
- raise EngineError("Session disposed")
- self._call_count += 1
- if self._call_count == 1:
- raise EngineError("First call fails")
- return SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
-
- def dispose(self) -> None:
- self._disposed = True
-
- session = FailingSession()
- with pytest.raises(EngineError):
- session.synthesize(_make_request(text="Fail"))
- result = session.synthesize(_make_request(text="Succeed"))
- assert isinstance(result, SynthesizedAudio)
- session.dispose()
-
-
-class TestPipelineRecreation:
- """Pipeline creation and disposal."""
-
- def test_create_and_dispose_pipeline(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- pipeline = create_pipeline("mock_tts")
- assert isinstance(pipeline, Pipeline)
- result = list(pipeline("Hello", voice="voice1", speed=1.0))
- assert len(result) >= 1
- pipeline.dispose()
-
- def test_pipeline_dispose_idempotent(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- pipeline = create_pipeline("mock_tts")
- pipeline.dispose()
- pipeline.dispose() # Should not raise
-
-
-class TestResourceCleanup:
- """Resource cleanup and disposal."""
-
- def test_engine_dispose_is_idempotent(self) -> None:
- engine = MockEngine()
- engine.dispose()
- engine.dispose() # Should not raise
-
- def test_session_dispose_is_idempotent(self) -> None:
- session = MockEngineSession()
- session.dispose()
- session.dispose() # Should not raise
-
- def test_create_session_after_engine_dispose_raises(self) -> None:
- engine = MockEngine()
- engine.dispose()
- with pytest.raises(EngineError):
- engine.createSession()
-
- def test_synthesize_after_session_dispose_raises(self) -> None:
- session = MockEngineSession()
- session.dispose()
- with pytest.raises(EngineError):
- session.synthesize(_make_request())
-
- def test_dispose_all_engines(self) -> None:
- manager = PluginManager()
- mock_plugin = _create_mock_plugin()
- manager._plugins["mock_tts"] = mock_plugin
- manager._loaded = True
-
- engine1 = manager.get_or_create_engine("mock_tts")
- engine2 = manager.get_or_create_engine("mock_tts")
- manager.dispose_all()
-
- assert engine1._disposed is True
- assert engine2._disposed is True
- assert len(manager._engines) == 0
-
- def test_no_exception_on_normal_termination(self) -> None:
- """Full lifecycle completes without unexpected exceptions."""
- engine = MockEngine()
- session = engine.createSession()
- result = session.synthesize(_make_request(text="Hello"))
- assert len(result.data) > 0
- session.dispose()
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# 6. PLUGIN MANAGER SCENARIOS
-# ──────────────────────────────────────────────────────────────
-
-
-class TestPluginManagerDiscovery:
- """Plugin discovery and listing."""
-
- def test_discover_with_valid_plugins(self) -> None:
- manager = PluginManager()
- manager._plugins["plugin_a"] = _create_mock_plugin(manifest_id="plugin_a")
- manager._plugins["plugin_b"] = _create_mock_plugin(manifest_id="plugin_b")
- manager._loaded = True
-
- plugins = manager.list_plugins()
- assert len(plugins) == 2
- ids = [p.id for p in plugins]
- assert "plugin_a" in ids
- assert "plugin_b" in ids
-
- def test_has_plugin(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- assert manager.has_plugin("mock_tts") is True
- assert manager.has_plugin("other") is False
-
- def test_get_plugin_returns_info(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- info = manager.get_plugin("mock_tts")
- assert info is not None
- assert "manifest" in info
- assert "create_engine" in info
-
-
-class TestPluginManagerEngineCreation:
- """Engine creation via PluginManager."""
-
- def test_create_engine(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- engine = manager.create_engine("mock_tts")
- assert isinstance(engine, MockEngine)
- engine.dispose()
-
- def test_get_or_create_engine_caches(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- engine1 = manager.get_or_create_engine("mock_tts")
- engine2 = manager.get_or_create_engine("mock_tts")
- assert engine1 is engine2
-
- def test_create_engine_unknown_plugin_raises(self) -> None:
- manager = PluginManager()
- manager._loaded = True
- with pytest.raises(KeyError):
- manager.create_engine("nonexistent")
-
- def test_create_engine_with_kwargs(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = {
- "manifest": PluginManifest(
- id="mock_tts", name="Mock TTS", version="1.0.0",
- api_version="1.0", description="Mock TTS for testing",
- author="Test", capabilities=("voice_list",),
- engine=EngineManifest(voiceSources=(), parameters=(), audioFormats=()),
- ),
- "create_engine": lambda **kwargs: MockEngineAcceptKwargs(**kwargs),
- "module": None,
- }
- manager._loaded = True
-
- engine = manager.create_engine("mock_tts", device="cpu")
- assert isinstance(engine, MockEngineAcceptKwargs)
- assert engine._kwargs["device"] == "cpu"
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# 7. VOICE RESOLUTION SCENARIOS
-# ──────────────────────────────────────────────────────────────
-
-
-class TestVoiceResolution:
- """Voice-to-plugin resolution."""
-
- def test_resolve_empty_spec(self) -> None:
- assert resolve_voice_to_plugin("", fallback="kokoro") == "kokoro"
-
- def test_resolve_none_spec(self) -> None:
- assert resolve_voice_to_plugin(None, fallback="kokoro") == "kokoro"
-
- def test_resolve_formula_with_star(self) -> None:
- assert resolve_voice_to_plugin("voice1*0.7") == "kokoro"
-
- def test_resolve_formula_with_plus(self) -> None:
- assert resolve_voice_to_plugin("voice1*0.7+voice2*0.3") == "kokoro"
-
- def test_resolve_unknown_voice_returns_fallback(self) -> None:
- assert resolve_voice_to_plugin("unknown_voice") == "kokoro"
-
-
-class TestGetVoices:
- """Voice listing utility functions."""
-
- def test_get_voices_registered_plugin(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs)
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- voices = get_voices("mock_tts")
- assert isinstance(voices, tuple)
- assert len(voices) > 0
- assert all(isinstance(v, str) for v in voices)
-
- def test_get_voices_unregistered_plugin(self) -> None:
- manager = PluginManager()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- voices = get_voices("nonexistent")
- assert voices == ()
-
- def test_get_default_voice(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs)
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- voice = get_default_voice("mock_tts")
- assert isinstance(voice, str)
- assert len(voice) > 0
-
- def test_get_default_voice_unregistered(self) -> None:
- manager = PluginManager()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- voice = get_default_voice("nonexistent", fallback="default")
- assert voice == "default"
-
- def test_is_plugin_registered(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- assert is_plugin_registered("mock_tts") is True
- assert is_plugin_registered("nonexistent") is False
-
-
-# ──────────────────────────────────────────────────────────────
-# 8. ERROR HIERARCHY BEHAVIORAL
-# ──────────────────────────────────────────────────────────────
-
-
-class TestErrorHierarchyBehavioral:
- """Error hierarchy behavioral tests."""
-
- def test_all_errors_catchable_as_engine_error(self) -> None:
- from abogen.tts_plugin.errors import (
- CancelledError,
- ConfigurationError,
- InternalError,
- InvalidInputError,
- ModelLoadError,
- ModelNotFoundError,
- NetworkError,
- )
-
- error_classes = [
- ModelNotFoundError,
- ModelLoadError,
- NetworkError,
- InvalidInputError,
- ConfigurationError,
- CancelledError,
- InternalError,
- ]
- for error_class in error_classes:
- with pytest.raises(EngineError):
- raise error_class("test")
-
- def test_error_message_preserved(self) -> None:
- from abogen.tts_plugin.errors import InvalidInputError
-
- msg = "Model not found: bert-base"
- with pytest.raises(EngineError, match=msg):
- raise InvalidInputError(msg)
-
-
-# ──────────────────────────────────────────────────────────────
-# 9. VALUE OBJECT BEHAVIORAL
-# ──────────────────────────────────────────────────────────────
-
-
-class TestValueObjectsBehavioral:
- """Value object behavioral tests."""
-
- def test_synthesis_request_immutability(self) -> None:
- req = _make_request()
- with pytest.raises(AttributeError):
- req.text = "changed" # type: ignore[misc]
-
- def test_voice_selection_immutability(self) -> None:
- vs = VoiceSelection(source="builtin", key="voice1")
- with pytest.raises(AttributeError):
- vs.source = "changed" # type: ignore[misc]
-
- def test_audio_format_equality(self) -> None:
- af1 = AudioFormat(mime="audio/wav", extension="wav")
- af2 = AudioFormat(mime="audio/wav", extension="wav")
- assert af1 == af2
-
- def test_synthesized_audio_fields(self) -> None:
- audio = SynthesizedAudio(
- data=b"\x00" * 100,
- format=AudioFormat(mime="audio/wav", extension="wav"),
- duration=Duration(seconds=1.0),
- )
- assert audio.data == b"\x00" * 100
- assert audio.format.mime == "audio/wav"
- assert audio.duration.seconds == 1.0
-
- def test_engine_config_defaults(self) -> None:
- from abogen.tts_plugin.types import EngineConfig
-
- config = EngineConfig()
- assert config.device == "cpu"
-
- def test_parameter_values_defaults(self) -> None:
- pv = ParameterValues()
- assert pv.values == {}
-
-
-# ──────────────────────────────────────────────────────────────
-# 10. ENGINE DISPOSAL BEHAVIORAL
-# ──────────────────────────────────────────────────────────────
-
-
-class TestEngineDisposalBehavioral:
- """Engine disposal behavioral tests."""
-
- def test_dispose_prevents_new_sessions(self) -> None:
- engine = MockEngine()
- engine.dispose()
- with pytest.raises(EngineError):
- engine.createSession()
-
- def test_dispose_all_engines(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- engine1 = manager.get_or_create_engine("mock_tts")
- engine2 = manager.get_or_create_engine("mock_tts")
- manager.dispose_all()
-
- assert engine1._disposed is True
- assert engine2._disposed is True
- assert len(manager._engines) == 0
-
-
-# ──────────────────────────────────────────────────────────────
-# 11. SESSION DISPOSAL BEHAVIORAL
-# ──────────────────────────────────────────────────────────────
-
-
-class TestSessionDisposalBehavioral:
- """Session disposal behavioral tests."""
-
- def test_dispose_prevents_synthesis(self) -> None:
- session = MockEngineSession()
- session.dispose()
- with pytest.raises(EngineError):
- session.synthesize(_make_request())
-
-
-# ──────────────────────────────────────────────────────────────
-# 12. CONCURRENT ACCESS (SEQUENTIAL SIMULATION)
-# ──────────────────────────────────────────────────────────────
-
-
-class TestConcurrentAccess:
- """Simulated concurrent access patterns."""
-
- def test_multiple_engines_independent(self) -> None:
- engine1 = MockEngine()
- engine2 = MockEngine()
- session1 = engine1.createSession()
- session2 = engine2.createSession()
-
- result1 = session1.synthesize(_make_request(text="Engine 1"))
- result2 = session2.synthesize(_make_request(text="Engine 2"))
-
- assert len(result1.data) > 0
- assert len(result2.data) > 0
-
- session1.dispose()
- session2.dispose()
- engine1.dispose()
- engine2.dispose()
-
- def test_session_per_thread_simulation(self) -> None:
- engine = MockEngine()
- sessions = [engine.createSession() for _ in range(5)]
-
- for i, session in enumerate(sessions):
- result = session.synthesize(_make_request(text=f"Thread {i}"))
- assert len(result.data) > 0
-
- for session in sessions:
- session.dispose()
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# 13. PLUGIN MANAGER SINGLETON
-# ──────────────────────────────────────────────────────────────
-
-
-class TestPluginManagerSingleton:
- """PluginManager singleton behavior."""
-
- def test_singleton_pattern(self) -> None:
- reset_plugin_manager()
- manager1 = get_plugin_manager()
- manager2 = get_plugin_manager()
- assert manager1 is manager2
- reset_plugin_manager()
-
- def test_reset_creates_new_instance(self) -> None:
- reset_plugin_manager()
- manager1 = get_plugin_manager()
- reset_plugin_manager()
- manager2 = get_plugin_manager()
- assert manager1 is not manager2
- reset_plugin_manager()
-
-
-# ──────────────────────────────────────────────────────────────
-# 14. PIPELINE UTILITY BEHAVIORAL
-# ──────────────────────────────────────────────────────────────
-
-
-class TestPipelineUtility:
- """Pipeline utility behavioral tests."""
-
- def test_pipeline_callable(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- pipeline = create_pipeline("mock_tts")
- result = list(pipeline("Hello world", voice="voice1", speed=1.0))
- assert len(result) >= 1
- segment = result[0]
- assert segment.graphemes == "Hello world"
- assert isinstance(segment.audio, np.ndarray)
- assert segment.audio.dtype == np.float32
- pipeline.dispose()
-
- def test_pipeline_with_split_pattern(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- pipeline = create_pipeline("mock_tts")
- result = list(pipeline("Hello", voice="voice1", split_pattern=r"\n+"))
- assert len(result) >= 1
- pipeline.dispose()
-
- def test_pipeline_dispose(self) -> None:
- manager = PluginManager()
- manager._plugins["mock_tts"] = _create_mock_plugin()
- manager._loaded = True
-
- with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
- pipeline = create_pipeline("mock_tts")
- pipeline.dispose()
- assert pipeline._session is None
+"""Behavioral Regression Tests for TTS Plugin Architecture.
+
+These tests verify external user-facing behavior, NOT internal implementation.
+They use only public API entry points available to application consumers.
+
+Tested plugins: Kokoro, SuperTonic.
+
+Public API Surface Tested:
+- PluginManager: discover, list_plugins, has_plugin, create_engine, get_or_create_engine, dispose_all
+- Engine: createSession, dispose
+- EngineSession: synthesize, dispose
+- VoiceLister: listVoices
+- Pipeline (utils.py): __call__, dispose
+- create_pipeline, get_voices, get_default_voice, is_plugin_registered, resolve_voice_to_plugin
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.errors import EngineError
+from abogen.tts_plugin.host_context import HostContext
+from abogen.tts_plugin.manifest import PluginManifest, EngineManifest, VoiceManifest
+from abogen.tts_plugin.plugin_manager import PluginManager, get_plugin_manager, reset_plugin_manager
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ Duration,
+ ParameterValues,
+ SynthesisRequest,
+ SynthesizedAudio,
+ VoiceSelection,
+)
+from abogen.tts_plugin.utils import (
+ Pipeline,
+ create_pipeline,
+ get_default_voice,
+ get_voices,
+ is_plugin_registered,
+ resolve_voice_to_plugin,
+)
+
+
+# ──────────────────────────────────────────────────────────────
+# Plugin Mock Infrastructure
+# ──────────────────────────────────────────────────────────────
+
+
+def _make_request(
+ text: str = "Hello",
+ voice: str = "voice1",
+ speed: float = 1.0,
+) -> SynthesisRequest:
+ return SynthesisRequest(
+ text=text,
+ voice=VoiceSelection(source="builtin", key=voice),
+ parameters=ParameterValues(values={"speed": speed}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+
+
+class MockEngineSession:
+ """Mock EngineSession that records calls."""
+
+ def __init__(self) -> None:
+ self._disposed = False
+ self.synthesize_calls: list[SynthesisRequest] = []
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ raise EngineError("Session disposed")
+ self.synthesize_calls.append(request)
+ return SynthesizedAudio(
+ data=b"\x00" * 1000,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class MockEngine:
+ """Mock Engine with VoiceLister support."""
+
+ def __init__(
+ self,
+ voice_manifests: list[VoiceManifest] | None = None,
+ **kwargs: Any,
+ ) -> None:
+ self._disposed = False
+ self._voice_manifests = voice_manifests or [
+ VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
+ VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
+ ]
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return MockEngineSession()
+
+ def listVoices(self, sourceId: str) -> list[VoiceManifest]:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return self._voice_manifests
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class MockEngineAcceptKwargs:
+ """MockEngine that accepts arbitrary kwargs (for create_engine)."""
+
+ def __init__(self, **kwargs: Any) -> None:
+ self._disposed = False
+ self._kwargs = kwargs
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return MockEngineSession()
+
+ def listVoices(self, sourceId: str) -> list[VoiceManifest]:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return [
+ VoiceManifest(id="voice1", name="Voice 1", tags=("en",)),
+ VoiceManifest(id="voice2", name="Voice 2", tags=("es",)),
+ ]
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+def _create_mock_plugin(
+ engine_class: type = MockEngine,
+ manifest_id: str = "mock_tts",
+) -> dict:
+ manifest = PluginManifest(
+ id=manifest_id,
+ name="Mock TTS",
+ version="1.0.0",
+ api_version="1.0",
+ description="Mock TTS for testing",
+ author="Test",
+ capabilities=("voice_list",),
+ engine=EngineManifest(
+ voiceSources=(),
+ parameters=(),
+ audioFormats=(),
+ ),
+ )
+ return {
+ "manifest": manifest,
+ "create_engine": lambda **kwargs: engine_class(**kwargs),
+ "module": None,
+ }
+
+
+# ──────────────────────────────────────────────────────────────
+# Kokoro / SuperTonic Plugin Fixtures
+# ──────────────────────────────────────────────────────────────
+
+
+def _kokoro_available() -> bool:
+ try:
+ from kokoro import KPipeline # type: ignore[import-not-found]
+ return True
+ except ImportError:
+ return False
+
+
+def _supertonic_available() -> bool:
+ try:
+ from supertonic import TTS # type: ignore[import-not-found]
+ return True
+ except ImportError:
+ return False
+
+
+class _KokoroMockEngine:
+ """Simulates Kokoro Engine behavior for behavioral tests."""
+
+ def __init__(self, **kwargs: Any) -> None:
+ self._disposed = False
+ self._kwargs = kwargs
+ self._voices = [
+ VoiceManifest(id="af_nova", name="Nova", tags=("en", "female")),
+ VoiceManifest(id="af_bella", name="Bella", tags=("en", "female")),
+ VoiceManifest(id="am_adam", name="Adam", tags=("en", "male")),
+ ]
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return MockEngineSession()
+
+ def listVoices(self, sourceId: str) -> list[VoiceManifest]:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return self._voices
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+class _SuperTonicMockEngine:
+ """Simulates SuperTonic Engine behavior for behavioral tests."""
+
+ def __init__(self, **kwargs: Any) -> None:
+ self._disposed = False
+ self._kwargs = kwargs
+ self._voices = [
+ VoiceManifest(id="M1", name="Male 1", tags=("en", "male")),
+ VoiceManifest(id="F1", name="Female 1", tags=("en", "female")),
+ VoiceManifest(id="M2", name="Male 2", tags=("en", "male")),
+ ]
+
+ def createSession(self) -> EngineSession:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return MockEngineSession()
+
+ def listVoices(self, sourceId: str) -> list[VoiceManifest]:
+ if self._disposed:
+ raise EngineError("Engine disposed")
+ return self._voices
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+
+# Parametrize across both production plugins
+_plugin_ids = ["kokoro", "supertonic"]
+_plugin_engines = {
+ "kokoro": _KokoroMockEngine,
+ "supertonic": _SuperTonicMockEngine,
+}
+_plugin_default_voices = {
+ "kokoro": "af_nova",
+ "supertonic": "M1",
+}
+_plugin_all_voices = {
+ "kokoro": ["af_nova", "af_bella", "am_adam"],
+ "supertonic": ["M1", "F1", "M2"],
+}
+
+
+def _plugin_available(plugin_id: str) -> bool:
+ if plugin_id == "kokoro":
+ return _kokoro_available()
+ elif plugin_id == "supertonic":
+ return _supertonic_available()
+ return False
+
+
+# ──────────────────────────────────────────────────────────────
+# 1. SYNTHESIS SCENARIOS (parametrized per plugin)
+# ──────────────────────────────────────────────────────────────
+
+
+class TestSynthesisNormalText:
+ """Synthesis with normal text input."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_short_text(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello world"))
+ assert isinstance(result, SynthesizedAudio)
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_paragraph_text(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ text = "This is a longer paragraph with multiple sentences. It tests synthesis of more substantial text content."
+ result = session.synthesize(_make_request(text=text))
+ assert isinstance(result, SynthesizedAudio)
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_punctuation(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ text = "Hello, world! How are you? I'm fine... Really?"
+ result = session.synthesize(_make_request(text=text))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+
+class TestSynthesisLongText:
+ """Synthesis with long text input."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_very_long_text(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ text = "Word " * 10000
+ result = session.synthesize(_make_request(text=text))
+ assert isinstance(result, SynthesizedAudio)
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_multiline_text(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ text = "\n".join([f"Line {i} of the text." for i in range(100)])
+ result = session.synthesize(_make_request(text=text))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+
+class TestSynthesisEmptyText:
+ """Synthesis with empty text input."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_empty_string(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text=""))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_whitespace_only(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text=" \n\t "))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+
+class TestSynthesisUnicodeText:
+ """Synthesis with Unicode text input."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_cyrillic(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Привет мир"))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_chinese(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="你好世界"))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_emoji(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello 🌍"))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_mixed_scripts(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello 你好 Привет"))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_accented_characters(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Café résumé naïve"))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# 2. VOICE SCENARIOS (parametrized per plugin)
+# ──────────────────────────────────────────────────────────────
+
+
+class TestVoiceListing:
+ """Voice listing via VoiceLister capability."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_list_voices_returns_manifests(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ voices = engine.listVoices("builtin")
+ assert isinstance(voices, list)
+ assert len(voices) > 0
+ for v in voices:
+ assert isinstance(v, VoiceManifest)
+ assert hasattr(v, "id")
+ assert hasattr(v, "name")
+ assert hasattr(v, "tags")
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_voices_have_required_fields(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ voices = engine.listVoices("builtin")
+ for v in voices:
+ assert isinstance(v.id, str)
+ assert len(v.id) > 0
+ assert isinstance(v.name, str)
+ assert len(v.name) > 0
+ assert isinstance(v.tags, tuple)
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_voice_ids_match_manifest(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ voices = engine.listVoices("builtin")
+ voice_ids = [v.id for v in voices]
+ for expected_id in _plugin_all_voices[plugin_id]:
+ assert expected_id in voice_ids
+ engine.dispose()
+
+
+class TestVoiceSelection:
+ """Using different voices for synthesis."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_each_voice(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ for voice_id in _plugin_all_voices[plugin_id]:
+ result = session.synthesize(
+ _make_request(text="Hello", voice=voice_id)
+ )
+ assert isinstance(result, SynthesizedAudio)
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_invalid_voice(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(
+ _make_request(text="Hello", voice="nonexistent_voice")
+ )
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# 3. PARAMETER SCENARIOS (parametrized per plugin)
+# ──────────────────────────────────────────────────────────────
+
+
+class TestSpeedParameter:
+ """Speed parameter behavior."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_speed_1_0(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello", speed=1.0))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_speed_0_5(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello", speed=0.5))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_speed_2_0(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello", speed=2.0))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_with_default_speed(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key=_plugin_default_voices[plugin_id]),
+ parameters=ParameterValues(),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# 4. ERROR SCENARIOS
+# ──────────────────────────────────────────────────────────────
+
+
+class TestUnknownPlugin:
+ """Handling of unknown plugin IDs."""
+
+ def test_create_engine_unknown_plugin(self) -> None:
+ manager = PluginManager()
+ manager._loaded = True
+ with pytest.raises(KeyError, match="Plugin not found"):
+ manager.create_engine("nonexistent_plugin")
+
+ def test_has_plugin_unknown(self) -> None:
+ manager = PluginManager()
+ manager._loaded = True
+ assert manager.has_plugin("nonexistent_plugin") is False
+
+ def test_get_plugin_unknown(self) -> None:
+ manager = PluginManager()
+ manager._loaded = True
+ assert manager.get_plugin("nonexistent_plugin") is None
+
+
+class TestPluginLoadingFailure:
+ """Handling of plugin loading failures."""
+
+ def test_discover_nonexistent_directory(self) -> None:
+ manager = PluginManager()
+ manager.discover("/nonexistent/path")
+ assert manager.list_plugins() == []
+
+ def test_discover_empty_directory(self, tmp_path: Path) -> None:
+ manager = PluginManager()
+ manager.discover(str(tmp_path))
+ assert manager.list_plugins() == []
+
+
+# ──────────────────────────────────────────────────────────────
+# 5. LIFECYCLE SCENARIOS (parametrized per plugin)
+# ──────────────────────────────────────────────────────────────
+
+
+class TestMultipleSynthesis:
+ """Multiple synthesis operations."""
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_sequential_synthesis(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ session = engine.createSession()
+ for i in range(10):
+ result = session.synthesize(_make_request(text=f"Text {i}"))
+ assert isinstance(result, SynthesizedAudio)
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_multiple_sessions(self, plugin_id: str) -> None:
+ engine = _plugin_engines[plugin_id]()
+ sessions = [engine.createSession() for _ in range(5)]
+ for i, session in enumerate(sessions):
+ result = session.synthesize(_make_request(text=f"Session {i}"))
+ assert isinstance(result, SynthesizedAudio)
+ for session in sessions:
+ session.dispose()
+ engine.dispose()
+
+ @pytest.mark.parametrize("plugin_id", _plugin_ids)
+ def test_synthesize_after_failed_synthesize(self, plugin_id: str) -> None:
+ """Session remains usable after synthesis failure."""
+ class FailingSession:
+ def __init__(self) -> None:
+ self._call_count = 0
+ self._disposed = False
+
+ def synthesize(self, request: SynthesisRequest) -> SynthesizedAudio:
+ if self._disposed:
+ raise EngineError("Session disposed")
+ self._call_count += 1
+ if self._call_count == 1:
+ raise EngineError("First call fails")
+ return SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+
+ def dispose(self) -> None:
+ self._disposed = True
+
+ session = FailingSession()
+ with pytest.raises(EngineError):
+ session.synthesize(_make_request(text="Fail"))
+ result = session.synthesize(_make_request(text="Succeed"))
+ assert isinstance(result, SynthesizedAudio)
+ session.dispose()
+
+
+class TestPipelineRecreation:
+ """Pipeline creation and disposal."""
+
+ def test_create_and_dispose_pipeline(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ pipeline = create_pipeline("mock_tts")
+ assert isinstance(pipeline, Pipeline)
+ result = list(pipeline("Hello", voice="voice1", speed=1.0))
+ assert len(result) >= 1
+ pipeline.dispose()
+
+ def test_pipeline_dispose_idempotent(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ pipeline = create_pipeline("mock_tts")
+ pipeline.dispose()
+ pipeline.dispose() # Should not raise
+
+
+class TestResourceCleanup:
+ """Resource cleanup and disposal."""
+
+ def test_engine_dispose_is_idempotent(self) -> None:
+ engine = MockEngine()
+ engine.dispose()
+ engine.dispose() # Should not raise
+
+ def test_session_dispose_is_idempotent(self) -> None:
+ session = MockEngineSession()
+ session.dispose()
+ session.dispose() # Should not raise
+
+ def test_create_session_after_engine_dispose_raises(self) -> None:
+ engine = MockEngine()
+ engine.dispose()
+ with pytest.raises(EngineError):
+ engine.createSession()
+
+ def test_synthesize_after_session_dispose_raises(self) -> None:
+ session = MockEngineSession()
+ session.dispose()
+ with pytest.raises(EngineError):
+ session.synthesize(_make_request())
+
+ def test_dispose_all_engines(self) -> None:
+ manager = PluginManager()
+ mock_plugin = _create_mock_plugin()
+ manager._plugins["mock_tts"] = mock_plugin
+ manager._loaded = True
+
+ engine1 = manager.get_or_create_engine("mock_tts")
+ engine2 = manager.get_or_create_engine("mock_tts")
+ manager.dispose_all()
+
+ assert engine1._disposed is True
+ assert engine2._disposed is True
+ assert len(manager._engines) == 0
+
+ def test_no_exception_on_normal_termination(self) -> None:
+ """Full lifecycle completes without unexpected exceptions."""
+ engine = MockEngine()
+ session = engine.createSession()
+ result = session.synthesize(_make_request(text="Hello"))
+ assert len(result.data) > 0
+ session.dispose()
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# 6. PLUGIN MANAGER SCENARIOS
+# ──────────────────────────────────────────────────────────────
+
+
+class TestPluginManagerDiscovery:
+ """Plugin discovery and listing."""
+
+ def test_discover_with_valid_plugins(self) -> None:
+ manager = PluginManager()
+ manager._plugins["plugin_a"] = _create_mock_plugin(manifest_id="plugin_a")
+ manager._plugins["plugin_b"] = _create_mock_plugin(manifest_id="plugin_b")
+ manager._loaded = True
+
+ plugins = manager.list_plugins()
+ assert len(plugins) == 2
+ ids = [p.id for p in plugins]
+ assert "plugin_a" in ids
+ assert "plugin_b" in ids
+
+ def test_has_plugin(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ assert manager.has_plugin("mock_tts") is True
+ assert manager.has_plugin("other") is False
+
+ def test_get_plugin_returns_info(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ info = manager.get_plugin("mock_tts")
+ assert info is not None
+ assert "manifest" in info
+ assert "create_engine" in info
+
+
+class TestPluginManagerEngineCreation:
+ """Engine creation via PluginManager."""
+
+ def test_create_engine(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ engine = manager.create_engine("mock_tts")
+ assert isinstance(engine, MockEngine)
+ engine.dispose()
+
+ def test_get_or_create_engine_caches(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ engine1 = manager.get_or_create_engine("mock_tts")
+ engine2 = manager.get_or_create_engine("mock_tts")
+ assert engine1 is engine2
+
+ def test_create_engine_unknown_plugin_raises(self) -> None:
+ manager = PluginManager()
+ manager._loaded = True
+ with pytest.raises(KeyError):
+ manager.create_engine("nonexistent")
+
+ def test_create_engine_with_kwargs(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = {
+ "manifest": PluginManifest(
+ id="mock_tts", name="Mock TTS", version="1.0.0",
+ api_version="1.0", description="Mock TTS for testing",
+ author="Test", capabilities=("voice_list",),
+ engine=EngineManifest(voiceSources=(), parameters=(), audioFormats=()),
+ ),
+ "create_engine": lambda **kwargs: MockEngineAcceptKwargs(**kwargs),
+ "module": None,
+ }
+ manager._loaded = True
+
+ engine = manager.create_engine("mock_tts", device="cpu")
+ assert isinstance(engine, MockEngineAcceptKwargs)
+ assert engine._kwargs["device"] == "cpu"
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# 7. VOICE RESOLUTION SCENARIOS
+# ──────────────────────────────────────────────────────────────
+
+
+class TestVoiceResolution:
+ """Voice-to-plugin resolution."""
+
+ def test_resolve_empty_spec(self) -> None:
+ assert resolve_voice_to_plugin("", fallback="kokoro") == "kokoro"
+
+ def test_resolve_none_spec(self) -> None:
+ assert resolve_voice_to_plugin(None, fallback="kokoro") == "kokoro"
+
+ def test_resolve_formula_with_star(self) -> None:
+ assert resolve_voice_to_plugin("voice1*0.7") == "kokoro"
+
+ def test_resolve_formula_with_plus(self) -> None:
+ assert resolve_voice_to_plugin("voice1*0.7+voice2*0.3") == "kokoro"
+
+ def test_resolve_unknown_voice_returns_fallback(self) -> None:
+ assert resolve_voice_to_plugin("unknown_voice") == "kokoro"
+
+
+class TestGetVoices:
+ """Voice listing utility functions."""
+
+ def test_get_voices_registered_plugin(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs)
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ voices = get_voices("mock_tts")
+ assert isinstance(voices, tuple)
+ assert len(voices) > 0
+ assert all(isinstance(v, str) for v in voices)
+
+ def test_get_voices_unregistered_plugin(self) -> None:
+ manager = PluginManager()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ voices = get_voices("nonexistent")
+ assert voices == ()
+
+ def test_get_default_voice(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin(engine_class=MockEngineAcceptKwargs)
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ voice = get_default_voice("mock_tts")
+ assert isinstance(voice, str)
+ assert len(voice) > 0
+
+ def test_get_default_voice_unregistered(self) -> None:
+ manager = PluginManager()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ voice = get_default_voice("nonexistent", fallback="default")
+ assert voice == "default"
+
+ def test_is_plugin_registered(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ assert is_plugin_registered("mock_tts") is True
+ assert is_plugin_registered("nonexistent") is False
+
+
+# ──────────────────────────────────────────────────────────────
+# 8. ERROR HIERARCHY BEHAVIORAL
+# ──────────────────────────────────────────────────────────────
+
+
+class TestErrorHierarchyBehavioral:
+ """Error hierarchy behavioral tests."""
+
+ def test_all_errors_catchable_as_engine_error(self) -> None:
+ from abogen.tts_plugin.errors import (
+ CancelledError,
+ ConfigurationError,
+ InternalError,
+ InvalidInputError,
+ ModelLoadError,
+ ModelNotFoundError,
+ NetworkError,
+ )
+
+ error_classes = [
+ ModelNotFoundError,
+ ModelLoadError,
+ NetworkError,
+ InvalidInputError,
+ ConfigurationError,
+ CancelledError,
+ InternalError,
+ ]
+ for error_class in error_classes:
+ with pytest.raises(EngineError):
+ raise error_class("test")
+
+ def test_error_message_preserved(self) -> None:
+ from abogen.tts_plugin.errors import InvalidInputError
+
+ msg = "Model not found: bert-base"
+ with pytest.raises(EngineError, match=msg):
+ raise InvalidInputError(msg)
+
+
+# ──────────────────────────────────────────────────────────────
+# 9. VALUE OBJECT BEHAVIORAL
+# ──────────────────────────────────────────────────────────────
+
+
+class TestValueObjectsBehavioral:
+ """Value object behavioral tests."""
+
+ def test_synthesis_request_immutability(self) -> None:
+ req = _make_request()
+ with pytest.raises(AttributeError):
+ req.text = "changed" # type: ignore[misc]
+
+ def test_voice_selection_immutability(self) -> None:
+ vs = VoiceSelection(source="builtin", key="voice1")
+ with pytest.raises(AttributeError):
+ vs.source = "changed" # type: ignore[misc]
+
+ def test_audio_format_equality(self) -> None:
+ af1 = AudioFormat(mime="audio/wav", extension="wav")
+ af2 = AudioFormat(mime="audio/wav", extension="wav")
+ assert af1 == af2
+
+ def test_synthesized_audio_fields(self) -> None:
+ audio = SynthesizedAudio(
+ data=b"\x00" * 100,
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ duration=Duration(seconds=1.0),
+ )
+ assert audio.data == b"\x00" * 100
+ assert audio.format.mime == "audio/wav"
+ assert audio.duration.seconds == 1.0
+
+ def test_engine_config_defaults(self) -> None:
+ from abogen.tts_plugin.types import EngineConfig
+
+ config = EngineConfig()
+ assert config.device == "cpu"
+ assert config.lang_code == "a"
+
+ def test_parameter_values_defaults(self) -> None:
+ pv = ParameterValues()
+ assert pv.values == {}
+
+
+# ──────────────────────────────────────────────────────────────
+# 10. ENGINE DISPOSAL BEHAVIORAL
+# ──────────────────────────────────────────────────────────────
+
+
+class TestEngineDisposalBehavioral:
+ """Engine disposal behavioral tests."""
+
+ def test_dispose_prevents_new_sessions(self) -> None:
+ engine = MockEngine()
+ engine.dispose()
+ with pytest.raises(EngineError):
+ engine.createSession()
+
+ def test_dispose_all_engines(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ engine1 = manager.get_or_create_engine("mock_tts")
+ engine2 = manager.get_or_create_engine("mock_tts")
+ manager.dispose_all()
+
+ assert engine1._disposed is True
+ assert engine2._disposed is True
+ assert len(manager._engines) == 0
+
+
+# ──────────────────────────────────────────────────────────────
+# 11. SESSION DISPOSAL BEHAVIORAL
+# ──────────────────────────────────────────────────────────────
+
+
+class TestSessionDisposalBehavioral:
+ """Session disposal behavioral tests."""
+
+ def test_dispose_prevents_synthesis(self) -> None:
+ session = MockEngineSession()
+ session.dispose()
+ with pytest.raises(EngineError):
+ session.synthesize(_make_request())
+
+
+# ──────────────────────────────────────────────────────────────
+# 12. CONCURRENT ACCESS (SEQUENTIAL SIMULATION)
+# ──────────────────────────────────────────────────────────────
+
+
+class TestConcurrentAccess:
+ """Simulated concurrent access patterns."""
+
+ def test_multiple_engines_independent(self) -> None:
+ engine1 = MockEngine()
+ engine2 = MockEngine()
+ session1 = engine1.createSession()
+ session2 = engine2.createSession()
+
+ result1 = session1.synthesize(_make_request(text="Engine 1"))
+ result2 = session2.synthesize(_make_request(text="Engine 2"))
+
+ assert len(result1.data) > 0
+ assert len(result2.data) > 0
+
+ session1.dispose()
+ session2.dispose()
+ engine1.dispose()
+ engine2.dispose()
+
+ def test_session_per_thread_simulation(self) -> None:
+ engine = MockEngine()
+ sessions = [engine.createSession() for _ in range(5)]
+
+ for i, session in enumerate(sessions):
+ result = session.synthesize(_make_request(text=f"Thread {i}"))
+ assert len(result.data) > 0
+
+ for session in sessions:
+ session.dispose()
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# 13. PLUGIN MANAGER SINGLETON
+# ──────────────────────────────────────────────────────────────
+
+
+class TestPluginManagerSingleton:
+ """PluginManager singleton behavior."""
+
+ def test_singleton_pattern(self) -> None:
+ reset_plugin_manager()
+ manager1 = get_plugin_manager()
+ manager2 = get_plugin_manager()
+ assert manager1 is manager2
+ reset_plugin_manager()
+
+ def test_reset_creates_new_instance(self) -> None:
+ reset_plugin_manager()
+ manager1 = get_plugin_manager()
+ reset_plugin_manager()
+ manager2 = get_plugin_manager()
+ assert manager1 is not manager2
+ reset_plugin_manager()
+
+
+# ──────────────────────────────────────────────────────────────
+# 14. PIPELINE UTILITY BEHAVIORAL
+# ──────────────────────────────────────────────────────────────
+
+
+class TestPipelineUtility:
+ """Pipeline utility behavioral tests."""
+
+ def test_pipeline_callable(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ pipeline = create_pipeline("mock_tts")
+ result = list(pipeline("Hello world", voice="voice1", speed=1.0))
+ assert len(result) >= 1
+ segment = result[0]
+ assert segment.graphemes == "Hello world"
+ assert isinstance(segment.audio, np.ndarray)
+ assert segment.audio.dtype == np.float32
+ pipeline.dispose()
+
+ def test_pipeline_with_split_pattern(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ pipeline = create_pipeline("mock_tts")
+ result = list(pipeline("Hello", voice="voice1", split_pattern=r"\n+"))
+ assert len(result) >= 1
+ pipeline.dispose()
+
+ def test_pipeline_dispose(self) -> None:
+ manager = PluginManager()
+ manager._plugins["mock_tts"] = _create_mock_plugin()
+ manager._loaded = True
+
+ with patch("abogen.tts_plugin.utils.get_plugin_manager", return_value=manager):
+ pipeline = create_pipeline("mock_tts")
+ pipeline.dispose()
+ assert pipeline._session is None
diff --git a/tests/test_conversion_voice_resolution.py b/tests/test_conversion_voice_resolution.py
index 1f41900..7164378 100644
--- a/tests/test_conversion_voice_resolution.py
+++ b/tests/test_conversion_voice_resolution.py
@@ -1,52 +1,52 @@
-from types import SimpleNamespace
-from typing import cast
-
-from abogen.tts_plugin.utils import get_voices
-from abogen.webui.conversion_runner import (
- _chapter_voice_spec,
- _chunk_voice_spec,
- _collect_required_voice_ids,
-)
-from abogen.webui.service import Job
-
-
-def _sample_job(formula: str) -> Job:
- return cast(
- Job,
- SimpleNamespace(
- voice="__custom_mix",
- speakers={
- "narrator": {
- "resolved_voice": formula,
- }
- },
- chapters=[],
- chunks=[{}],
- ),
- )
-
-
-def test_chapter_voice_spec_uses_resolved_formula():
- formula = "af_nova*0.7+am_liam*0.3"
- job = _sample_job(formula)
-
- assert _chapter_voice_spec(job, None) == formula
-
-
-def test_chunk_voice_fallback_uses_resolved_formula():
- formula = "af_nova*0.7+am_liam*0.3"
- job = _sample_job(formula)
-
- result = _chunk_voice_spec(job, {}, "")
-
- assert result == formula
-
-
-def test_voice_collection_includes_formula_components():
- formula = "af_nova*0.7+am_liam*0.3"
- job = _sample_job(formula)
-
- voices = _collect_required_voice_ids(job)
-
- assert {"af_nova", "am_liam"}.issubset(voices)
- assert voices.issuperset(get_voices("kokoro"))
+from types import SimpleNamespace
+from typing import cast
+
+from abogen.tts_plugin.utils import get_voices
+from abogen.webui.conversion_runner import (
+ _chapter_voice_spec,
+ _chunk_voice_spec,
+ _collect_required_voice_ids,
+)
+from abogen.webui.service import Job
+
+
+def _sample_job(formula: str) -> Job:
+ return cast(
+ Job,
+ SimpleNamespace(
+ voice="__custom_mix",
+ speakers={
+ "narrator": {
+ "resolved_voice": formula,
+ }
+ },
+ chapters=[],
+ chunks=[{}],
+ ),
+ )
+
+
+def test_chapter_voice_spec_uses_resolved_formula():
+ formula = "af_nova*0.7+am_liam*0.3"
+ job = _sample_job(formula)
+
+ assert _chapter_voice_spec(job, None) == formula
+
+
+def test_chunk_voice_fallback_uses_resolved_formula():
+ formula = "af_nova*0.7+am_liam*0.3"
+ job = _sample_job(formula)
+
+ result = _chunk_voice_spec(job, {}, "")
+
+ assert result == formula
+
+
+def test_voice_collection_includes_formula_components():
+ formula = "af_nova*0.7+am_liam*0.3"
+ job = _sample_job(formula)
+
+ voices = _collect_required_voice_ids(job)
+
+ assert {"af_nova", "am_liam"}.issubset(voices)
+ assert voices.issuperset(get_voices("kokoro"))
diff --git a/tests/test_supertonic_plugin.py b/tests/test_supertonic_plugin.py
index 7a6cf3e..594f519 100644
--- a/tests/test_supertonic_plugin.py
+++ b/tests/test_supertonic_plugin.py
@@ -1,257 +1,265 @@
-"""Tests for the SuperTonic TTS Plugin.
-
-These tests verify that the SuperTonic plugin:
-- Loads correctly through the Plugin Loader
-- Has a valid manifest
-- Creates a valid Engine
-- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
-- Implements VoiceLister capability
-"""
-
-from __future__ import annotations
-
-import logging
-from pathlib import Path
-from typing import Any
-
-import pytest
-
-from abogen.tts_plugin.engine import Engine, EngineSession
-from abogen.tts_plugin.host_context import HostContext
-from abogen.tts_plugin.loader import load_plugin_from_dir
-from abogen.tts_plugin.manifest import PluginManifest
-from abogen.tts_plugin.types import (
- AudioFormat,
- EngineConfig,
- ParameterValues,
- SynthesisRequest,
- VoiceSelection,
-)
-
-from tests.contracts.engine_contract import EngineContractMixin
-
-
-# ──────────────────────────────────────────────────────────────
-# Helpers
-# ──────────────────────────────────────────────────────────────
-
-def _supertonic_available() -> bool:
- try:
- from supertonic import TTS # type: ignore[import-not-found]
- return True
- except ImportError:
- return False
-
-
-def _make_mock_engine() -> Any:
- from plugins.supertonic.engine import SuperTonicEngine
-
- class MockSegment:
- def __init__(self):
- import numpy as np
- self.audio = np.zeros(24000, dtype="float32")
-
- class MockPipeline:
- sample_rate = 24000
-
- def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
- return [MockSegment()]
-
- return SuperTonicEngine(MockPipeline())
-
-
-# ──────────────────────────────────────────────────────────────
-# Fixtures
-# ──────────────────────────────────────────────────────────────
-
-@pytest.fixture
-def supertonic_plugin_dir() -> Path:
- return Path(__file__).parent.parent / "plugins" / "supertonic"
-
-
-@pytest.fixture
-def host_context(tmp_path: Path) -> HostContext:
- class FakeHttpClient:
- def get(self, url: str, **kwargs: object) -> object:
- return None
- def post(self, url: str, **kwargs: object) -> object:
- return None
-
- return HostContext(
- config_dir=tmp_path,
- logger=logging.getLogger("test"),
- http_client=FakeHttpClient(),
- )
-
-
-@pytest.fixture
-def engine() -> Engine:
- return _make_mock_engine()
-
-
-# ──────────────────────────────────────────────────────────────
-# Plugin Loading Tests
-# ──────────────────────────────────────────────────────────────
-
-class TestSuperTonicPluginLoading:
-
- def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- assert result.manifest is not None
- assert result.create_engine is not None
-
- def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- manifest = result.manifest
- assert isinstance(manifest, PluginManifest)
- assert manifest.id == "supertonic"
- assert manifest.name == "SuperTonic"
- assert manifest.api_version == "1.0"
-
- def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- assert result.model_requirements is not None
- assert isinstance(result.model_requirements, tuple)
-
- def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- assert "voice_list" in result.manifest.capabilities
-
- def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- engine_manifest = result.manifest.engine
- assert len(engine_manifest.voiceSources) > 0
- assert len(engine_manifest.audioFormats) > 0
- assert len(engine_manifest.parameters) > 0
-
-
-# ──────────────────────────────────────────────────────────────
-# Engine Creation (real backend, skipped if not installed)
-# ──────────────────────────────────────────────────────────────
-
-class TestSuperTonicEngineCreation:
-
- @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
- def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- engine = result.create_engine(host_context, None, EngineConfig())
- assert isinstance(engine, Engine)
- engine.dispose()
-
- @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
- def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
- result = load_plugin_from_dir(supertonic_plugin_dir)
- assert result.success is True
- engine = result.create_engine(host_context, None, EngineConfig())
- assert isinstance(engine, Engine)
- engine.dispose()
-
-
-# ──────────────────────────────────────────────────────────────
-# Engine / Session Contract (inherited from base)
-# ──────────────────────────────────────────────────────────────
-
-class TestSuperTonicEngineContract(EngineContractMixin):
- """Every test from EngineContractMixin runs against SuperTonicEngine."""
-
- @pytest.fixture
- def default_voice(self) -> str:
- return "M1"
-
-
-# ──────────────────────────────────────────────────────────────
-# VoiceLister Tests
-# ──────────────────────────────────────────────────────────────
-
-class TestSuperTonicVoiceLister:
-
- def test_list_voices(self) -> None:
- engine = _make_mock_engine()
- voices = engine.listVoices("builtin")
- assert len(voices) == 10
- assert all(hasattr(v, "id") for v in voices)
- assert all(hasattr(v, "name") for v in voices)
- engine.dispose()
-
- def test_voices_have_tags(self) -> None:
- engine = _make_mock_engine()
- for voice in engine.listVoices("builtin"):
- assert isinstance(voice.tags, tuple)
- assert len(voice.tags) > 0
- engine.dispose()
-
- def test_male_voices_have_male_tag(self) -> None:
- engine = _make_mock_engine()
- for v in engine.listVoices("builtin"):
- if v.id.startswith("M"):
- assert "male" in v.tags
- engine.dispose()
-
- def test_female_voices_have_female_tag(self) -> None:
- engine = _make_mock_engine()
- for v in engine.listVoices("builtin"):
- if v.id.startswith("F"):
- assert "female" in v.tags
- engine.dispose()
-
- def test_list_voices_after_dispose_raises(self) -> None:
- from abogen.tts_plugin.errors import EngineError
- engine = _make_mock_engine()
- engine.dispose()
- with pytest.raises(EngineError):
- engine.listVoices("builtin")
-
-
-# ──────────────────────────────────────────────────────────────
-# SuperTonic-specific parameter tests
-# ──────────────────────────────────────────────────────────────
-
-class TestSuperTonicParameters:
-
- def test_speed_parameter(self) -> None:
- engine = _make_mock_engine()
- session = engine.createSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="M1"),
- parameters=ParameterValues(values={"speed": 1.5}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert isinstance(result.data, bytes)
- session.dispose()
- engine.dispose()
-
- def test_total_steps_parameter(self) -> None:
- engine = _make_mock_engine()
- session = engine.createSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="M1"),
- parameters=ParameterValues(values={"total_steps": 10}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert isinstance(result.data, bytes)
- session.dispose()
- engine.dispose()
-
- def test_default_parameters(self) -> None:
- engine = _make_mock_engine()
- session = engine.createSession()
- request = SynthesisRequest(
- text="Hello",
- voice=VoiceSelection(source="builtin", key="M1"),
- parameters=ParameterValues(values={}),
- format=AudioFormat(mime="audio/wav", extension="wav"),
- )
- result = session.synthesize(request)
- assert isinstance(result.data, bytes)
- session.dispose()
- engine.dispose()
+"""Tests for the SuperTonic TTS Plugin.
+
+These tests verify that the SuperTonic plugin:
+- Loads correctly through the Plugin Loader
+- Has a valid manifest
+- Creates a valid Engine
+- Satisfies the Engine/EngineSession contract (via EngineContractMixin)
+- Implements VoiceLister capability
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from abogen.tts_plugin.engine import Engine, EngineSession
+from abogen.tts_plugin.host_context import HostContext
+from abogen.tts_plugin.loader import load_plugin_from_dir
+from abogen.tts_plugin.manifest import PluginManifest
+from abogen.tts_plugin.types import (
+ AudioFormat,
+ EngineConfig,
+ ParameterValues,
+ SynthesisRequest,
+ VoiceSelection,
+)
+
+from tests.contracts.engine_contract import EngineContractMixin
+
+
+# ──────────────────────────────────────────────────────────────
+# Helpers
+# ──────────────────────────────────────────────────────────────
+
+def _supertonic_available() -> bool:
+ try:
+ from supertonic import TTS # type: ignore[import-not-found]
+ return True
+ except ImportError:
+ return False
+
+
+def _make_mock_engine() -> Any:
+ from plugins.supertonic.engine import SuperTonicEngine
+
+ class MockSegment:
+ def __init__(self):
+ import numpy as np
+ self.audio = np.zeros(24000, dtype="float32")
+
+ class MockPipeline:
+ sample_rate = 24000
+
+ def __call__(self, text, voice, speed, split_pattern=None, total_steps=None):
+ return [MockSegment()]
+
+ engine = SuperTonicEngine(MockPipeline())
+
+ # Override listVoices for testing (real engine reads from manifest)
+ from abogen.tts_plugin.manifest import VoiceManifest
+ engine.listVoices = lambda source_id: [
+ VoiceManifest(id="test_voice_1", name="Test Voice 1", tags=("male",)),
+ VoiceManifest(id="test_voice_2", name="Test Voice 2", tags=("female",)),
+ ]
+ return engine
+
+
+# ──────────────────────────────────────────────────────────────
+# Fixtures
+# ──────────────────────────────────────────────────────────────
+
+@pytest.fixture
+def supertonic_plugin_dir() -> Path:
+ return Path(__file__).parent.parent / "plugins" / "supertonic"
+
+
+@pytest.fixture
+def host_context(tmp_path: Path) -> HostContext:
+ class FakeHttpClient:
+ def get(self, url: str, **kwargs: object) -> object:
+ return None
+ def post(self, url: str, **kwargs: object) -> object:
+ return None
+
+ return HostContext(
+ config_dir=tmp_path,
+ logger=logging.getLogger("test"),
+ http_client=FakeHttpClient(),
+ )
+
+
+@pytest.fixture
+def engine() -> Engine:
+ return _make_mock_engine()
+
+
+# ──────────────────────────────────────────────────────────────
+# Plugin Loading Tests
+# ──────────────────────────────────────────────────────────────
+
+class TestSuperTonicPluginLoading:
+
+ def test_plugin_loads_successfully(self, supertonic_plugin_dir: Path) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ assert result.manifest is not None
+ assert result.create_engine is not None
+
+ def test_plugin_has_valid_manifest(self, supertonic_plugin_dir: Path) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ manifest = result.manifest
+ assert isinstance(manifest, PluginManifest)
+ assert manifest.id == "supertonic"
+ assert manifest.name == "SuperTonic"
+ assert manifest.api_version == "1.0"
+
+ def test_plugin_has_model_requirements(self, supertonic_plugin_dir: Path) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ assert result.model_requirements is not None
+ assert isinstance(result.model_requirements, tuple)
+
+ def test_plugin_manifest_capabilities(self, supertonic_plugin_dir: Path) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ assert "voice_list" in result.manifest.capabilities
+
+ def test_plugin_manifest_engine(self, supertonic_plugin_dir: Path) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ engine_manifest = result.manifest.engine
+ assert len(engine_manifest.voiceSources) > 0
+ assert len(engine_manifest.audioFormats) > 0
+ assert len(engine_manifest.parameters) > 0
+
+
+# ──────────────────────────────────────────────────────────────
+# Engine Creation (real backend, skipped if not installed)
+# ──────────────────────────────────────────────────────────────
+
+class TestSuperTonicEngineCreation:
+
+ @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
+ def test_create_engine(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ engine = result.create_engine(host_context, None, EngineConfig())
+ assert isinstance(engine, Engine)
+ engine.dispose()
+
+ @pytest.mark.skipif(not _supertonic_available(), reason="SuperTonic not installed")
+ def test_engine_satisfies_protocol(self, supertonic_plugin_dir: Path, host_context: HostContext) -> None:
+ result = load_plugin_from_dir(supertonic_plugin_dir)
+ assert result.success is True
+ engine = result.create_engine(host_context, None, EngineConfig())
+ assert isinstance(engine, Engine)
+ engine.dispose()
+
+
+# ──────────────────────────────────────────────────────────────
+# Engine / Session Contract (inherited from base)
+# ──────────────────────────────────────────────────────────────
+
+class TestSuperTonicEngineContract(EngineContractMixin):
+ """Every test from EngineContractMixin runs against SuperTonicEngine."""
+
+ @pytest.fixture
+ def default_voice(self) -> str:
+ return "M1"
+
+
+# ──────────────────────────────────────────────────────────────
+# VoiceLister Tests
+# ──────────────────────────────────────────────────────────────
+
+class TestSuperTonicVoiceLister:
+
+ def test_list_voices(self) -> None:
+ engine = _make_mock_engine()
+ voices = engine.listVoices("builtin")
+ assert len(voices) == 10
+ assert all(hasattr(v, "id") for v in voices)
+ assert all(hasattr(v, "name") for v in voices)
+ engine.dispose()
+
+ def test_voices_have_tags(self) -> None:
+ engine = _make_mock_engine()
+ for voice in engine.listVoices("builtin"):
+ assert isinstance(voice.tags, tuple)
+ assert len(voice.tags) > 0
+ engine.dispose()
+
+ def test_male_voices_have_male_tag(self) -> None:
+ engine = _make_mock_engine()
+ for v in engine.listVoices("builtin"):
+ if v.id.startswith("M"):
+ assert "male" in v.tags
+ engine.dispose()
+
+ def test_female_voices_have_female_tag(self) -> None:
+ engine = _make_mock_engine()
+ for v in engine.listVoices("builtin"):
+ if v.id.startswith("F"):
+ assert "female" in v.tags
+ engine.dispose()
+
+ def test_list_voices_after_dispose_raises(self) -> None:
+ from abogen.tts_plugin.errors import EngineError
+ engine = _make_mock_engine()
+ engine.dispose()
+ with pytest.raises(EngineError):
+ engine.listVoices("builtin")
+
+
+# ──────────────────────────────────────────────────────────────
+# SuperTonic-specific parameter tests
+# ──────────────────────────────────────────────────────────────
+
+class TestSuperTonicParameters:
+
+ def test_speed_parameter(self) -> None:
+ engine = _make_mock_engine()
+ session = engine.createSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="M1"),
+ parameters=ParameterValues(values={"speed": 1.5}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert isinstance(result.data, bytes)
+ session.dispose()
+ engine.dispose()
+
+ def test_total_steps_parameter(self) -> None:
+ engine = _make_mock_engine()
+ session = engine.createSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="M1"),
+ parameters=ParameterValues(values={"total_steps": 10}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert isinstance(result.data, bytes)
+ session.dispose()
+ engine.dispose()
+
+ def test_default_parameters(self) -> None:
+ engine = _make_mock_engine()
+ session = engine.createSession()
+ request = SynthesisRequest(
+ text="Hello",
+ voice=VoiceSelection(source="builtin", key="M1"),
+ parameters=ParameterValues(values={}),
+ format=AudioFormat(mime="audio/wav", extension="wav"),
+ )
+ result = session.synthesize(request)
+ assert isinstance(result.data, bytes)
+ session.dispose()
+ engine.dispose()
diff --git a/tests/test_voice_cache.py b/tests/test_voice_cache.py
index 55ade39..b926254 100644
--- a/tests/test_voice_cache.py
+++ b/tests/test_voice_cache.py
@@ -1,69 +1,69 @@
-from types import SimpleNamespace
-from typing import cast
-
-import pytest
-
-from abogen.tts_plugin.utils import get_voices
-from abogen.voice_cache import (
- LocalEntryNotFoundError,
- _CACHED_VOICES,
- ensure_voice_assets,
-)
-from abogen.webui.conversion_runner import _collect_required_voice_ids
-from abogen.webui.service import Job
-
-
-@pytest.fixture(autouse=True)
-def clear_voice_cache():
- _CACHED_VOICES.clear()
- yield
- _CACHED_VOICES.clear()
-
-
-def test_ensure_voice_assets_downloads_missing(monkeypatch):
- recorded = []
-
- cached = set()
-
- def fake_download(**kwargs):
- filename = kwargs["filename"]
- if kwargs.get("local_files_only"):
- if filename in cached:
- return f"/tmp/{filename}"
- raise LocalEntryNotFoundError(f"{filename} missing")
-
- recorded.append(filename)
- cached.add(filename)
- return f"/tmp/{filename}"
-
- monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
-
- downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
-
- assert downloaded == {"af_nova", "am_liam"}
- assert errors == {}
- assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
-
- recorded.clear()
- downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
-
- assert downloaded_again == set()
- assert errors_again == {}
- assert recorded == []
-
-
-def test_collect_required_voice_ids_includes_all():
- job = SimpleNamespace(
- voice="af_nova",
- chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
- chunks=[{"voice": "am_michael"}],
- speakers={
- "hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
- "narrator": {"voice": "af_nova"},
- },
- )
-
- voices = _collect_required_voice_ids(cast(Job, job))
-
- assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
- assert voices.issuperset(get_voices("kokoro"))
+from types import SimpleNamespace
+from typing import cast
+
+import pytest
+
+from abogen.tts_plugin.utils import get_voices
+from abogen.voice_cache import (
+ LocalEntryNotFoundError,
+ _CACHED_VOICES,
+ ensure_voice_assets,
+)
+from abogen.webui.conversion_runner import _collect_required_voice_ids
+from abogen.webui.service import Job
+
+
+@pytest.fixture(autouse=True)
+def clear_voice_cache():
+ _CACHED_VOICES.clear()
+ yield
+ _CACHED_VOICES.clear()
+
+
+def test_ensure_voice_assets_downloads_missing(monkeypatch):
+ recorded = []
+
+ cached = set()
+
+ def fake_download(**kwargs):
+ filename = kwargs["filename"]
+ if kwargs.get("local_files_only"):
+ if filename in cached:
+ return f"/tmp/{filename}"
+ raise LocalEntryNotFoundError(f"{filename} missing")
+
+ recorded.append(filename)
+ cached.add(filename)
+ return f"/tmp/{filename}"
+
+ monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
+
+ downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
+
+ assert downloaded == {"af_nova", "am_liam"}
+ assert errors == {}
+ assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
+
+ recorded.clear()
+ downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
+
+ assert downloaded_again == set()
+ assert errors_again == {}
+ assert recorded == []
+
+
+def test_collect_required_voice_ids_includes_all():
+ job = SimpleNamespace(
+ voice="af_nova",
+ chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
+ chunks=[{"voice": "am_michael"}],
+ speakers={
+ "hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
+ "narrator": {"voice": "af_nova"},
+ },
+ )
+
+ voices = _collect_required_voice_ids(cast(Job, job))
+
+ assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
+ assert voices.issuperset(get_voices("kokoro"))