From e2b2f610a60a5808b7a7771f71df5d64e11e2781 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 22 Dec 2025 05:51:21 -0800 Subject: [PATCH] feat: Additive merge of webui branch with PyQt GUI support - Add abogen/pyqt/ package with full PyQt6 desktop GUI - Restore PyQt GUI files (gui.py, book_handler.py, queue_manager_gui.py, voice_formula_gui.py, conversion.py) - Add new shared modules from webui: book_parser.py, subtitle_utils.py, spacy_utils.py - Add Linux libraries (libxcb-cursor) for Qt platform plugin support - Add new epub parsing tests from webui branch - Update pyproject.toml with dual entry points: - abogen: PyQt6 desktop GUI - abogen-web: Flask Web UI - Add PyQt6 to dependencies - Re-export PyQt classes from root modules for backwards compatibility - Merge CHANGELOG.md entries (1.2.0-1.2.5 from webui) - Update README.md with dual interface documentation Implements #26 - shared core with separate UI folders --- CHANGELOG.md | 57 + README.md | 18 +- abogen/book_handler.py | 12 +- abogen/book_parser.py | 917 ++++ abogen/conversion.py | 17 +- abogen/gui.py | 16 +- abogen/libs/libxcb-cursor-amd64.so.0 | Bin 0 -> 24080 bytes abogen/libs/libxcb-cursor-arm64.so.0 | Bin 0 -> 23912 bytes abogen/predownload_gui.py | 590 +++ abogen/pyqt/__init__.py | 7 + abogen/pyqt/book_handler.py | 1654 +++++++ abogen/pyqt/conversion.py | 2477 ++++++++++ abogen/pyqt/gui.py | 4049 +++++++++++++++++ abogen/pyqt/main.py | 187 + abogen/pyqt/predownload_gui.py | 590 +++ abogen/pyqt/queue_manager_gui.py | 860 ++++ abogen/pyqt/queued_item.py | 21 + abogen/pyqt/voice_formula_gui.py | 1521 +++++++ abogen/queue_manager_gui.py | 12 +- abogen/spacy_utils.py | 161 + abogen/subtitle_utils.py | 459 ++ abogen/voice_formula_gui.py | 16 +- build_pypi.py | 90 + pyproject.toml | 6 +- tests/test_book_handler_regression.py | 84 + tests/test_book_parser.py | 210 + tests/test_epub_content_slicing.py | 199 + tests/test_epub_heuristic_nav.py | 117 + tests/test_epub_html_nav_parsing.py | 184 + .../test_epub_missing_file_error_handling.py | 100 + tests/test_epub_ncx_parsing.py | 140 + tests/test_epub_standard_nav.py | 130 + 32 files changed, 14863 insertions(+), 38 deletions(-) create mode 100644 abogen/book_parser.py create mode 100644 abogen/libs/libxcb-cursor-amd64.so.0 create mode 100644 abogen/libs/libxcb-cursor-arm64.so.0 create mode 100644 abogen/predownload_gui.py create mode 100644 abogen/pyqt/__init__.py create mode 100644 abogen/pyqt/book_handler.py create mode 100644 abogen/pyqt/conversion.py create mode 100644 abogen/pyqt/gui.py create mode 100644 abogen/pyqt/main.py create mode 100644 abogen/pyqt/predownload_gui.py create mode 100644 abogen/pyqt/queue_manager_gui.py create mode 100644 abogen/pyqt/queued_item.py create mode 100644 abogen/pyqt/voice_formula_gui.py create mode 100644 abogen/spacy_utils.py create mode 100644 abogen/subtitle_utils.py create mode 100644 build_pypi.py create mode 100644 tests/test_book_handler_regression.py create mode 100644 tests/test_book_parser.py create mode 100644 tests/test_epub_content_slicing.py create mode 100644 tests/test_epub_heuristic_nav.py create mode 100644 tests/test_epub_html_nav_parsing.py create mode 100644 tests/test_epub_missing_file_error_handling.py create mode 100644 tests/test_epub_ncx_parsing.py create mode 100644 tests/test_epub_standard_nav.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a228d79..0cf61ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,63 @@ # Unreleased - Added an EPUB 3 packaging pipeline that builds media-overlay EPUBs from generated audio and chunk metadata. - Persisted chunk timing metadata in job artifacts and exercised the exporter with automated tests. +- Added Flask-based Web UI (`abogen-web`) for Docker and headless server deployments. +- Reorganized codebase to support both PyQt6 desktop GUI and Web UI from a shared core. +- Added Supertonic TTS engine support with GPU acceleration. +- Added entity analysis and pronunciation override system for proper nouns. +- Added speaker/role assignment for multi-voice "theatrical" audiobooks. +- Added Calibre OPDS and Audiobookshelf integration. + +# 1.2.5 +- Added new option: `Override item settings with current selection` in the queue manager. When enabled, all items in the queue will be processed using the current global settings selected in the main GUI, overriding their individual settings. When disabled, each item will retain its own specific settings. +- Fixed `Error "Could not load the Qt platform plugin "xcb"` error that occurred in some Linux distributions due to missing `libxcb-cursor0` library by conditionally loading the bundled library when the system version is unavailable, issue mentioned by @bmcgonag in #101. +- Fixed the `No module named pip` error that occurred for users who installed Abogen via the [**uv**](https://github.com/astral-sh/uv) installer. +- Fixed defaults for `replace_single_newlines` not being applied correctly in some cases. +- Fixed `Save chapters separately for queued epubs is ignored`, issue mentioned by @dymas-cz in #109. +- Fixed incorrect sentence segmentation when using spaCy, where text would erroneously split after opening parentheses. +- Improvements in code and documentation. + +# 1.2.4 +- **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using audio duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.) +- New option: **"Use spaCy for sentence segmentation"** You can now use [spaCy](https://spacy.io/) to automatically detect sentence boundaries and produce cleaner, more readable subtitles. +- New option: **Pre-download models and voices for offline use** You can now pre-download all required Kokoro models, voices, and spaCy language models. +- Added support for `.` separator in timestamps (e.g. `HH:MM:SS.ms`) for timestamp-based text files. +- Optimized regex compilation and eliminated busy-wait loops. +- Possibly fixed `Silent truncation of long paragraphs` issue mentioned in #91 by @xklzlxr. +- Fixed unused regex patterns and variable naming conventions. +- Improvements in code and documentation. + +# 1.2.3 +- Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files. + +# 1.2.2 +- **You can now voice your subtitle files!** Simply add `.srt`, `.ass` or `.vtt` files to generate timed audio. +- New option: **"Use silent gaps between subtitles"**: Prevents unnecessary audio speed-up by letting speech continue into the silent gaps between subtitles. +- New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed (TTS Regeneration or FFmpeg Time-stretch). +- Added support for embedding cover images in M4B files. +- Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows. +- Potential fix for `CUDA GPU is not available` issue. +- Improvements in code and documentation. + +# 1.2.1 +- Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support. +- Added tooltip indicators in queue manager to display book handler options. +- Added `Open processed file` and `Open input file` options for items in the queue manager. +- Added loading gif animation to book handler window. +- Fixed light theme slider colors in voice mixer for better visibility (for non-Windows users). +- Fixed subtitle word-count splitting logic for more accurate segmentation. +- Improvements in code and documentation. + +# 1.2.0 +- Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94. +- Added a loading indicator to the book handler window for better user experience during book preprocessing. +- Fixed `cannot access local variable 'is_narrow'` error when subtitle format `SRT` was selected, mentioned by @Kinasa0096 in #88. +- Fixed folder and filename sanitization to properly handle OS-specific illegal characters. +- Fixed `/` and `\` path display by normalizing paths. +- Fixed book reprocessing issue where books were being processed every time the chapters window was opened. +- Fixed taskbar icon not appearing correctly in Windows. +- Fixed "Go to folder" button not opening the chapter output directory when only separate chapters were generated. +- Improvements in code and documentation. # 1.1.9 - Fixed the issue where spaces were deleted before punctuation marks while generating subtitles. diff --git a/README.md b/README.md index 99cc48e..3d2b812 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,17 @@ Abogen is a web-first text-to-speech workstation. Drop in an EPUB, PDF, Markdown - LLM-assisted text normalization with live previews and configurable prompts - Runs well in Docker, ships a REST-style JSON API, and works across macOS, Linux, and Windows +## Interfaces + +Abogen offers **two interfaces** to suit different workflows: + +| Command | Interface | Best for | +|---------|-----------|----------| +| `abogen` | PyQt6 Desktop GUI | Local desktop use on Windows/macOS/Linux | +| `abogen-web` | Flask Web UI | Docker, headless servers, remote access | + +Both interfaces share the same core processing engine and produce identical output. + ## Quick start Abogen supports Python 3.10–3.12. @@ -19,11 +30,16 @@ source .venv/bin/activate # On Windows use: .venv\Scripts\activate pip install abogen ``` -### Launch the web app +### Launch the desktop app (PyQt6) ```bash abogen ``` +### Launch the web app (Flask) +```bash +abogen-web +``` + Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically. > **Tip:** Keep the terminal open while the server is running. Use `Ctrl+C` to stop it. diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 4935302..33342dd 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -1,9 +1,11 @@ -"""Legacy PyQt-based chapter selection dialog has been removed.""" +"""Backwards-compatible re-export of the PyQt book handler. + +The actual implementation lives in abogen.pyqt.book_handler. +""" from __future__ import annotations +from abogen.pyqt.book_handler import * # noqa: F401, F403 +from abogen.pyqt.book_handler import HandlerDialog -def __getattr__(name: str): - raise AttributeError( - "The PyQt chapter selection dialog was removed. Use the web interface instead." - ) +__all__ = ["HandlerDialog"] diff --git a/abogen/book_parser.py b/abogen/book_parser.py new file mode 100644 index 0000000..d81d9c8 --- /dev/null +++ b/abogen/book_parser.py @@ -0,0 +1,917 @@ +import os +import re +import logging +import textwrap +import urllib.parse +from abc import ABC, abstractmethod + +import ebooklib +from ebooklib import epub +from bs4 import BeautifulSoup, NavigableString +import fitz # PyMuPDF +import markdown + +from abogen.utils import detect_encoding +from abogen.subtitle_utils import clean_text, calculate_text_length + +# Pre-compile frequently used regex patterns +_BRACKETED_NUMBERS_PATTERN = re.compile(r"\[\s*\d+\s*\]") +_STANDALONE_PAGE_NUMBERS_PATTERN = re.compile(r"^\s*\d+\s*$", re.MULTILINE) +_PAGE_NUMBERS_AT_END_PATTERN = re.compile(r"\s+\d+\s*$", re.MULTILINE) +_PAGE_NUMBERS_WITH_DASH_PATTERN = re.compile(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", re.MULTILINE) + + +class BaseBookParser(ABC): + """ + Abstract base class for parsing different book formats. + """ + + def __init__(self, book_path): + self.book_path = os.path.normpath(os.path.abspath(book_path)) + self.content_texts = {} + self.content_lengths = {} + self.book_metadata = {} + # Unified structure for navigation: list of dicts + # { 'title': str, 'src': str, 'children': [], 'has_content': bool } + self.processed_nav_structure = [] + self.load() + + @abstractmethod + def load(self): + """Load the book file.""" + pass + + @abstractmethod + def process_content(self, replace_single_newlines=True): + """Process the book content to extract text and structure.""" + pass + + @property + @abstractmethod + def file_type(self): + """Return the type of the file (pdf, epub, markdown).""" + pass + + def get_chapters(self): + """Return a list of chapter IDs and Names.""" + chapters = [] + if self.processed_nav_structure: + + def flatten_nav(nodes): + for node in nodes: + if node.get("has_content"): + chapters.append((node["src"], node["title"])) + if node.get("children"): + flatten_nav(node["children"]) + + flatten_nav(self.processed_nav_structure) + else: + # Fallback for simple content without nav structure + for ch_id, content in self.content_texts.items(): + # This could be improved, but serves as a generic fallback + chapters.append((ch_id, ch_id)) + return chapters + + def get_formatted_text(self): + """ + Returns the full text of the book formatted with chapter markers. + """ + chapters = self.get_chapters() + full_text = [] + + for chapter_id, chapter_name in chapters: + text = self.content_texts.get(chapter_id, "") + if text: + full_text.append(f"\n<>\n") + full_text.append(text) + + return "\n".join(full_text) + + def get_metadata(self): + """Return extracted metadata.""" + return self.book_metadata + + +class PdfParser(BaseBookParser): + def __init__(self, book_path): + self.pdf_doc = None + super().__init__(book_path) + + @property + def file_type(self): + return "pdf" + + def load(self): + try: + self.pdf_doc = fitz.open(self.book_path) + except Exception as e: + logging.error(f"Error loading PDF {self.book_path}: {e}") + raise + + def process_content(self, replace_single_newlines=True): + if not self.pdf_doc: + self.load() + + for page_num in range(len(self.pdf_doc)): + text = clean_text(self.pdf_doc[page_num].get_text()) + + # Clean up common PDF artifacts: + # - Remove bracketed numbers often used for citations [1] + text = _BRACKETED_NUMBERS_PATTERN.sub("", text) + # - Remove standalone page numbers often found in headers/footers + text = _STANDALONE_PAGE_NUMBERS_PATTERN.sub("", text) + # - Remove page numbers at end of lines + text = _PAGE_NUMBERS_AT_END_PATTERN.sub("", text) + # - Remove page numbers with dashes - 4 - + text = _PAGE_NUMBERS_WITH_DASH_PATTERN.sub("", text) + + page_id = f"page_{page_num + 1}" + self.content_texts[page_id] = text + self.content_lengths[page_id] = calculate_text_length(text) + + return self.content_texts, self.content_lengths + + def _extract_book_metadata(self): + # PDF metadata extraction can be added here if needed + # For now, base class metadata is empty dict + pass + + def get_chapters(self): + # PDF specific implementation because it doesn't use nav structure + chapters = [] + if self.pdf_doc: + for i in range(len(self.pdf_doc)): + chapters.append((f"page_{i+1}", f"Page {i+1}")) + return chapters + + +class MarkdownParser(BaseBookParser): + def __init__(self, book_path): + self.markdown_text = None + super().__init__(book_path) + + @property + def file_type(self): + return "markdown" + + def load(self): + try: + encoding = detect_encoding(self.book_path) + with open(self.book_path, "r", encoding=encoding, errors="replace") as f: + self.markdown_text = f.read() + except Exception as e: + logging.error(f"Error reading markdown file: {e}") + self.markdown_text = "" + + def process_content(self, replace_single_newlines=True): + if self.markdown_text is None: + self.load() + + self._process_markdown_content() + return self.content_texts, self.content_lengths + + def _convert_markdown_toc_to_nav(self, toc_tokens): + nav_nodes = [] + for token in toc_tokens: + node = { + "title": token["name"], + "src": token["id"], + "children": self._convert_markdown_toc_to_nav( + token.get("children", []) + ), + "has_content": True, + } + nav_nodes.append(node) + return nav_nodes + + def _process_markdown_content(self): + if not self.markdown_text: + return + + original_text = textwrap.dedent(self.markdown_text) + md = markdown.Markdown(extensions=["toc", "fenced_code"]) + html = md.convert(original_text) + markdown_toc = md.toc_tokens + + # Convert markdown TOC tokens to our unified navigation structure + self.processed_nav_structure = self._convert_markdown_toc_to_nav(markdown_toc) + + cleaned_full_text = clean_text(original_text) + + # If no TOC found, treat as single chapter + if not self.processed_nav_structure: + chapter_id = "markdown_content" + self.content_texts[chapter_id] = cleaned_full_text + self.content_lengths[chapter_id] = calculate_text_length(cleaned_full_text) + return + + soup = BeautifulSoup(html, "html.parser") + + all_headers = [] + + def flatten_nav_internal(nodes): + for node in nodes: + all_headers.append(node) + if node.get("children"): + flatten_nav_internal(node["children"]) + + flatten_nav_internal(self.processed_nav_structure) + + header_positions = [] + for node in all_headers: + header_id = node["src"] + id_pattern = f'id="{header_id}"' + pos = html.find(id_pattern) + if pos != -1: + tag_start = html.rfind("<", 0, pos) + header_positions.append( + {"id": header_id, "start": tag_start, "name": node["title"]} + ) + header_positions.sort(key=lambda x: x["start"]) + + for i, header_pos in enumerate(header_positions): + header_id = header_pos["id"] + header_name = header_pos["name"] + content_start = header_pos["start"] + + content_end = ( + header_positions[i + 1]["start"] + if i + 1 < len(header_positions) + else len(html) + ) + section_html = html[content_start:content_end] + section_soup = BeautifulSoup(section_html, "html.parser") + + header_tag = section_soup.find(attrs={"id": header_id}) + if header_tag: + header_tag.decompose() + + section_text = clean_text(section_soup.get_text()).strip() + chapter_id = header_id + if section_text: + full_content = f"{header_name}\n\n{section_text}" + self.content_texts[chapter_id] = full_content + self.content_lengths[chapter_id] = calculate_text_length(full_content) + else: + self.content_texts[chapter_id] = header_name + self.content_lengths[chapter_id] = calculate_text_length(header_name) + + def get_chapters(self): + chapters = super().get_chapters() + if not chapters and "markdown_content" in self.content_texts: + chapters.append(("markdown_content", "Content")) + return chapters + + +class EpubParser(BaseBookParser): + def __init__(self, book_path): + self.book = None + self.doc_content = {} + super().__init__(book_path) + + @property + def file_type(self): + return "epub" + + def load(self): + try: + self.book = epub.read_epub(self.book_path) + except KeyError as e: + # TODO: should we just patch the ebooklib pre-emptively to avoid the need to catch this exception? + logging.warning(f"EPUB missing referenced file: {e}. Attempting to patch.") + # Patch ebooklib to skip missing files + import types + from ebooklib import epub as _epub_module + + reader_class = _epub_module.EpubReader + orig_read_file = reader_class.read_file + + def safe_read_file(self, name): + try: + return orig_read_file(self, name) + except KeyError: + logging.warning(f"Missing file in EPUB: {name}. Returning empty bytes.") + return b"" + + reader_class.read_file = safe_read_file + try: + self.book = epub.read_epub(self.book_path) + finally: + reader_class.read_file = orig_read_file + + def process_content(self, replace_single_newlines=True): + if not self.book: + self.load() + + self.book_metadata = self._extract_book_metadata() + try: + nav_item, nav_type = self._identify_nav_item() + self._execute_nav_parsing_logic(nav_item, nav_type) + except Exception as e: + logging.warning(f"EPUB nav processing failed: {e}. Falling back to spine.") + self._process_epub_content_spine_fallback() + + return self.content_texts, self.content_lengths + + def _extract_book_metadata(self): + metadata = {} + if not self.book: + return metadata + + try: + metadata["title"] = self.book.get_metadata("DC", "title")[0][0] + except Exception: + metadata["title"] = os.path.splitext(os.path.basename(self.book_path))[0] + + try: + metadata["author"] = self.book.get_metadata("DC", "creator")[0][0] + except Exception: + metadata["author"] = "Unknown Author" + + try: + metadata["language"] = self.book.get_metadata("DC", "language")[0][0] + except Exception: + metadata["language"] = "en" + + return metadata + + def _find_doc_key(self, base_href, doc_order, doc_order_decoded): + candidates = [ + base_href, + urllib.parse.unquote(base_href), + ] + base_name = os.path.basename(base_href).lower() + for k in list(doc_order.keys()) + list(doc_order_decoded.keys()): + if os.path.basename(k).lower() == base_name: + candidates.append(k) + for candidate in candidates: + if candidate in doc_order: + return candidate, doc_order[candidate] + elif candidate in doc_order_decoded: + return candidate, doc_order_decoded[candidate] + return None, None + + def _find_position_robust(self, doc_href, fragment_id): + if doc_href not in self.doc_content: + logging.warning(f"Document '{doc_href}' not found in cached content.") + return 0 + html_content = self.doc_content[doc_href] + if not fragment_id: + return 0 + + try: + temp_soup = BeautifulSoup(f"
{html_content}
", "html.parser") + target_element = temp_soup.find(id=fragment_id) + if target_element: + tag_str = str(target_element) + pos = html_content.find(tag_str[: min(len(tag_str), 200)]) + if pos != -1: + return pos + except Exception as e: + logging.warning(f"BeautifulSoup failed to find id='{fragment_id}': {e}") + + safe_fragment_id = re.escape(fragment_id) + id_name_pattern = re.compile( + f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']", re.IGNORECASE + ) + match = id_name_pattern.search(html_content) + if match: + return match.start() + + id_match_str = f'id="{fragment_id}"' + name_match_str = f'name="{fragment_id}"' + id_pos = html_content.find(id_match_str) + name_pos = html_content.find(name_match_str) + + pos = -1 + if id_pos != -1 and name_pos != -1: + pos = min(id_pos, name_pos) + elif id_pos != -1: + pos = id_pos + elif name_pos != -1: + pos = name_pos + + if pos != -1: + tag_start_pos = html_content.rfind("<", 0, pos) + final_pos = tag_start_pos if tag_start_pos != -1 else 0 + return final_pos + + logging.warning( + f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0." + ) + return 0 + + def _parse_ncx_navpoint( + self, + nav_point, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + """ + Recursive parsing of NCX navigation nodes. + + Logic tested by: tests/test_epub_ncx_parsing.py + """ + nav_label = nav_point.find("navLabel") + content = nav_point.find("content") + title = ( + nav_label.find("text").get_text(strip=True) + if nav_label and nav_label.find("text") + else "Untitled Section" + ) + src = content["src"] if content and "src" in content.attrs else None + + current_entry_node = {"title": title, "src": src, "children": []} + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + if not doc_key: + current_entry_node["has_content"] = False + else: + position = find_position_func(doc_key, fragment) + entry_data = { + "src": src, + "title": title, + "doc_href": doc_key, + "position": position, + "doc_order": doc_idx, + } + ordered_entries.append(entry_data) + current_entry_node["has_content"] = True + else: + current_entry_node["has_content"] = False + + child_navpoints = nav_point.find_all("navPoint", recursive=False) + if child_navpoints: + for child_np in child_navpoints: + self._parse_ncx_navpoint( + child_np, + ordered_entries, + doc_order, + doc_order_decoded, + current_entry_node["children"], + find_position_func, + ) + + if title and ( + current_entry_node.get("has_content", False) or current_entry_node["children"] + ): + tree_structure_list.append(current_entry_node) + + def _extract_nav_li_title(self, li_element, link_element=None, span_element=None): + """Helper to extract title from a nav
  • element, handling various structures.""" + title = "Untitled Section" + + if link_element: + title = link_element.get_text(strip=True) or title + elif span_element: + title = span_element.get_text(strip=True) or title + + # Fallback to direct text if title is empty or default + # If we used link/span but got empty string, we try fallback. + # If we didn't use link/span, we try fallback. + if not title.strip() or title == "Untitled Section": + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + if li_text: + title = li_text + + # Second fallback: if we have a span but title is still empty, try span text again + # (covered by logic above mostly, but mirroring original logic's intense fallback) + if (not title.strip() or title == "Untitled Section") and span_text: + title = span_text.get_text(strip=True) or title + + return title + + def _parse_html_nav_li( + self, + li_element, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + """ + Recursive parsing of HTML5 Navigation (li) nodes. + + Logic tested by: tests/test_epub_html_nav_parsing.py + """ + link = li_element.find("a", recursive=False) + span_text = li_element.find("span", recursive=False) + src = None + current_entry_node = {"children": []} + + if link and "href" in link.attrs: + src = link["href"] + + title = self._extract_nav_li_title(li_element, link, span_text) + + current_entry_node["title"] = title + current_entry_node["src"] = src + + doc_key = None + doc_idx = None + position = 0 + fragment = None + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + if doc_key is not None: + position = find_position_func(doc_key, fragment) + entry_data = { + "src": src, + "title": title, + "doc_href": doc_key, + "position": position, + "doc_order": doc_idx, + } + ordered_entries.append(entry_data) + current_entry_node["has_content"] = True + else: + current_entry_node["has_content"] = False + else: + current_entry_node["has_content"] = False + + for child_ol in li_element.find_all("ol", recursive=False): + for child_li in child_ol.find_all("li", recursive=False): + self._parse_html_nav_li( + child_li, + ordered_entries, + doc_order, + doc_order_decoded, + current_entry_node["children"], + find_position_func, + ) + tree_structure_list.append(current_entry_node) + + def _identify_nav_item(self): + """Identify the navigation item (HTML Nav or NCX) and its type.""" + nav_item = None + nav_type = None + + # 1. Check ITEM_NAVIGATION + nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) + + # 1.1 Support for EPUB 3 EpubNav which might be ITEM_DOCUMENT (9) but with properties=['nav'] + if not nav_items: + # Look in ITEM_DOCUMENT for items with 'nav' property + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + if hasattr(item, 'get_type') and item.get_type() == ebooklib.ITEM_DOCUMENT: + # Check properties - ebooklib stores opf properties in list + # Some versions use item.properties, some need checking + props = getattr(item, 'properties', []) + if 'nav' in props: + nav_items.append(item) + + if nav_items: + nav_item = next( + ( + item + for item in nav_items + if "nav" in item.get_name().lower() + and item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) or next( + ( + item + for item in nav_items + if item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if nav_item: + nav_type = "html" + + # 2. NCX in NAV + if not nav_item and nav_items: + ncx_in_nav = next( + (item for item in nav_items if item.get_name().lower().endswith(".ncx")), + None, + ) + if ncx_in_nav: + nav_item = ncx_in_nav + nav_type = "ncx" + + # 3. ITEM_NCX or Fallback + # If no explicit navigation item found, try to find a standard NCX file + if not nav_item: + ncx_constant = getattr(epub, "ITEM_NCX", None) + if ncx_constant is not None: + ncx_items = list(self.book.get_items_of_type(ncx_constant)) + if ncx_items: + nav_item = ncx_items[0] + nav_type = "ncx" + + # 4. Heuristic Search + # Scan documents for something that looks like a TOC if standard methods fail + if not nav_item: + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + if " idx_next: + docs_between = [ + spine_docs[k] + for k in range(idx_current + 1, len(spine_docs)) + ] + docs_between.extend( + [spine_docs[k] for k in range(0, idx_next)] + ) + except ValueError: + pass + + for doc_href in docs_between: + slice_html += self.doc_content.get(doc_href, "") + next_doc_html = self.doc_content.get(next_doc, "") + slice_html += next_doc_html[:next_pos] + else: + slice_html = current_doc_html[start_slice_pos:] + try: + idx_current = spine_docs.index(current_doc) + for doc_idx in range(idx_current + 1, len(spine_docs)): + slice_html += self.doc_content.get(spine_docs[doc_idx], "") + except ValueError: + pass + + if not slice_html.strip() and current_doc_html: + slice_html = current_doc_html + + if slice_html.strip(): + slice_soup = BeautifulSoup(slice_html, "html.parser") + for tag in slice_soup.find_all(["p", "div"]): + tag.append("\n\n") + + for ol in slice_soup.find_all("ol"): + start = int(ol.get("start", 1)) + for idx, li in enumerate(ol.find_all("li", recursive=False)): + number_text = f"{start + idx}) " + if li.string: + li.string.replace_with(number_text + li.string) + else: + li.insert(0, NavigableString(number_text)) + + for tag in slice_soup.find_all(["sup", "sub"]): + tag.decompose() + + text = clean_text(slice_soup.get_text()).strip() + if text: + self.content_texts[current_src] = text + self.content_lengths[current_src] = calculate_text_length(text) + else: + self.content_texts[current_src] = "" + self.content_lengths[current_src] = 0 + else: + self.content_texts[current_src] = "" + self.content_lengths[current_src] = 0 + + if ordered_nav_entries: + first_entry = ordered_nav_entries[0] + first_doc_href = first_entry["doc_href"] + first_pos = first_entry["position"] + first_doc_order = first_entry["doc_order"] + prefix_html = "" + + for doc_idx in range(first_doc_order): + if doc_idx < len(spine_docs): + intermediate_doc_href = spine_docs[doc_idx] + prefix_html += self.doc_content.get(intermediate_doc_href, "") + + first_doc_html = self.doc_content.get(first_doc_href, "") + prefix_html += first_doc_html[:first_pos] + + if prefix_html.strip(): + prefix_soup = BeautifulSoup(prefix_html, "html.parser") + for tag in prefix_soup.find_all(["sup", "sub"]): + tag.decompose() + prefix_text = clean_text(prefix_soup.get_text()).strip() + + if prefix_text: + prefix_chapter_src = "internal:prefix_content" + self.content_texts[prefix_chapter_src] = prefix_text + self.content_lengths[prefix_chapter_src] = len(prefix_text) + self.processed_nav_structure.insert( + 0, + { + "src": prefix_chapter_src, + "title": "Introduction", + "children": [], + "has_content": True, + }, + ) + + def _process_epub_content_spine_fallback(self): + """ + Process EPUB content using the spine (linear reading order) + when navigation processing fails. + """ + logging.info("Using spine fallback for EPUB processing.") + self.doc_content = {} + spine_docs = [] + for spine_item_tuple in self.book.spine: + item_id = spine_item_tuple[0] + item = self.book.get_item_with_id(item_id) + if item: + spine_docs.append(item.get_name()) + else: + logging.warning(f"Spine item with id '{item_id}' not found.") + + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href in spine_docs: + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + self.doc_content[href] = html_content + except Exception: + self.doc_content[href] = "" + + self.content_texts = {} + self.content_lengths = {} + for i, doc_href in enumerate(spine_docs): + html_content = self.doc_content.get(doc_href, "") + if html_content: + soup = BeautifulSoup(html_content, "html.parser") + + # Handle ordered lists + for ol in soup.find_all("ol"): + start = int(ol.get("start", 1)) + for idx, li in enumerate(ol.find_all("li", recursive=False)): + number_text = f"{start + idx}) " + if li.string: + li.string.replace_with(number_text + li.string) + else: + li.insert(0, NavigableString(number_text)) + + # Remove sup/sub + for tag in soup.find_all(["sup", "sub"]): + tag.decompose() + + text = clean_text(soup.get_text()).strip() + if text: + self.content_texts[doc_href] = text + self.content_lengths[doc_href] = calculate_text_length(text) + + def get_chapters(self): + chapters = super().get_chapters() + if not chapters: + # Use spine order fallback if no Nav structure + if self.book: + for spine_item_tuple in self.book.spine: + item_id = spine_item_tuple[0] + item = self.book.get_item_with_id(item_id) + if item: + href = item.get_name() + if href in self.content_texts: + chapters.append((href, href)) + return chapters + + +def get_book_parser(book_path, file_type=None): + """ + Factory function to get the appropriate parser instance. + """ + book_path = os.path.normpath(os.path.abspath(book_path)) + + if not file_type: + if book_path.lower().endswith(".pdf"): + file_type = "pdf" + elif book_path.lower().endswith((".md", ".markdown")): + file_type = "markdown" + else: + file_type = "epub" + + if file_type == "pdf": + return PdfParser(book_path) + elif file_type == "markdown": + return MarkdownParser(book_path) + elif file_type == "epub": + return EpubParser(book_path) + else: + raise ValueError(f"Unsupported file type: {file_type}") diff --git a/abogen/conversion.py b/abogen/conversion.py index c496c1e..f615349 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -1,9 +1,16 @@ -"""Legacy PyQt conversion helpers removed.""" +"""Backwards-compatible re-export of conversion module. + +The PyQt-based implementation lives in abogen.pyqt.conversion. +The web-based implementation is in abogen.webui.conversion_runner. +""" from __future__ import annotations +# Re-export PyQt conversion classes for backwards compatibility +from abogen.pyqt.conversion import ( # noqa: F401 + ConversionThread, + VoicePreviewThread, + PlayAudioThread, +) -def __getattr__(name: str): # pragma: no cover - compatibility shim - raise AttributeError( - "The PyQt-based conversion helpers were removed. Use the web service pipeline instead." - ) +__all__ = ["ConversionThread", "VoicePreviewThread", "PlayAudioThread"] diff --git a/abogen/gui.py b/abogen/gui.py index 702c030..a023b17 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1,15 +1,11 @@ -"""Legacy PyQt GUI module removed in favor of the web interface.""" +"""Backwards-compatible re-export of the PyQt GUI. + +The actual implementation lives in abogen.pyqt.gui. +""" from __future__ import annotations - -class abogen: # pragma: no cover - legacy entry point - """Placeholder for the removed PyQt GUI class.""" - - def __init__(self, *_args, **_kwargs): - raise RuntimeError( - "The PyQt desktop interface has been removed. Please use the web UI instead." - ) - +from abogen.pyqt.gui import * # noqa: F401, F403 +from abogen.pyqt.gui import abogen __all__ = ["abogen"] diff --git a/abogen/libs/libxcb-cursor-amd64.so.0 b/abogen/libs/libxcb-cursor-amd64.so.0 new file mode 100644 index 0000000000000000000000000000000000000000..1acddcbfee4ae0574c198330ac7dfd4b685accfb GIT binary patch literal 24080 zcmeHveRx#Wx$mA3BtXnWMUCHs4w_gD2_Q`bbOtiGqr)KzXsp;_$V|v+zMRZJR4AcI zknK1&w~wdI?e*S^9*>3AbEO`OQK=4!1gt%cAJy1;Y^11r8x&NE(W1G(ckOp&Z??>R z&Ut$8bN_Mkta;b(x8C<%Yp<`p_6mD#2m}}9xLk_7#wk}Sgv!n_86PL~+{h6KpHiVr z!0*}0SweTbmGq6+$|{qRQc*mDpgc}O=vyoxq(?-?WS+@qSW3Gb&}6rl?e?;rl!c^# zOG;^;3y#8GzE!96k`XQ`Ntdpg_Yx6`_xc6=hK!iBBBivu4t7+||L9f7iq~^~+3H2* z`;(GyN8H;K_!x(9^*=0EN_yWtf5z0xdj}UjFzL&CyWT5XdaQ!7p!8V?vi_ACrLZth z$s>-g*$B@powhjt68AOl{qayrkSF*#2JX}o@E4+dC*n6|(Vv8ZoJgPQ!*c@vZQxEh z0pAPmM1JPuhEK%*AWJ#-X5nwoQch}z6ZxN(CErK0=$B^Uzs{2HeOdIkWvPdsX7SUS zrJU7S{GXOZ|F2p6-tiljG`?){!p4^7=(6yt#;BQg;*?NbdpJ}dYYsQY?u;t! zb*n;k$#`2!JXF`xoQSq3Le0^&N?o|Iv87I=#-q*1BovDXv^tsyK`GkY7DG`Y;Y4_h zZjHCJM&pS)m}rj1!-=T0LsE079yxqNJrs|&HcF#JNO}pa2{$IAp~h(Q>O_NrY@^L< zl(s}X(b9;Dh{og1Eg=*!oIp8*PcG-}$!PqJ(3)tR3i!>0)>vHv<%lc%@-8|*y!54FbHo5HO^PvF(&-PV~lwp3;~J=PRn4dbNQ0rW$Kt(q@8=5jnk6+m_AL9Im&pf(deIfxnTbs<}A5R zJS`SGS%J>m%oPcotGv&6iooNP!;F(>$0kw^Ujox5*V%G?tnmDo;q|oSGyERVah*rl zNnYYD4&HO6P*b)$_-V{%9K81`Q-8q0m$LqlgP+6vVF!N|^UC$dm$QobLI=N$d5?pS zGGFH4o0#`G_;%)N9DEn^D;@k#nQwLQ13t4JIvo5jS-;1@KhFFX2mcK7+a3Ii%x4_@ z0P_P5{!QkG9Q+XThaLQf%qvTeum8U?U+Cb+al3mQ{He^BIrt*xeGcBke2s%IVSc59 zuVB8_!C%RIhl8(SzQ@6T!u%Eoe-rcD9sDZhGY-Cq`2h!i7xP07zJvM04nD=avh4W! z|1tB04*tI}?{V<|%zT-H|0VN22mcuJH4gqM=2trS-ORT-_&+e;;o#q4zQ@78$NUxt z|2gy99lXNhE92nZ%nvyDbC@4;@E0(D*uj@FuPpZ{bjf+L(kGx&=-_?KdmMZ<^JNZx z3G+S&e>3wn4nD&CN(X-{^Q{j4cIG=A{GH7AIQR|BZ*lP5%x`z_A9A~79Q=K(Kj7dW zV1CHKKg9fD2fvMZ$KG9#UR2`|S`+Xq*A_&0Y`lGK zQf%XS=^*UOY`om_qJ# zf3l5lwebZuzTL*VZG4A~FSPNSZTu-VzQ@L&YU6ut{Ao6Ri;bURX41+rKI3>8-Kpd&tV&XfsG%r@g5tm z@OqE>)KnXvXX7ul@r5@2A{$?1v5haY@t4^63L9T+<9#;1#Kx;O z{!$xXW8=SL<7;jFbQ{0Y#$RUR8*F^3jc>K_Gi-djjh|`b)y{)?YHIwizw1%d?o1+Q zbU^Lgo%ez=dinF9MyKt@uNx1?(P@Mm$kG@Z#Wn3|!Zg(w0|Gxzn5GybBk(T>)6`;Y z7x*Ev!0?(-1Yb3;Yma8k)uyfqzDrhNRIW z@Q(@8P&7IOzK1XkL8DdR9}=daXRH+XPQocEajEI~m2*AHay31^98#?uQMLCHXJKgE13elVC*LwURA1`8piAiA@&b(t z3&Z}4E1;OZ{8NbKYRT8IS9g6qPSp;pyY#D6*B;Cx_Ymbb zqyFXoWzW_^Z~0PH`|3FwzSY|6m;@>!YASacRj;a5d5g<4`aP&6+;_E>>vebC0!>wW zLDh==sgu;yclSn0P2E*Spldp2wDO}*)4ZbhqIBIE_kBg+b`9k|U3uSMDn8$@ z4eZi$htJ^p7nI{UN{+ks=nuGn#suTA>Bo{k7}v%PSKZB9#!5#<5^S?^+;qj zrTkS=yA(;dF?Ux5Dk4>r*Zr!y`(hEz@BDIt`|h(5@BA{?y{QNhG$ei%BOkvaf3xN` zp-^gio!6tLPrns&O|_P((!MCqJWI-YEvl|_1eNrU$UKOu_NZE;w`h2R`vRr9v)|=+ z@0c)eQsPJ#3b6tfi_xplU7qA&H0Qrza(s#=@SyhfXOFnMwjm1o zMWSh*XZmrIeuijzoG|?wqKmad#?=@yC_gF$t%NSyub=V-S|;HwG^_PrQ7&qSg@u$h zje3u^u*moqGz!(f-~HsGDfRx!MQ7Ce-HR5T;eIj`%lz^e=rH=}#>Yab1AZ0%o`AS< zW3rhaI-E!U`3QMCr2if{!mG!)ZEXBf@6_++^vBHfV&hs{I#quP)vt%4W#n*qs3Yr3 z*lw+9S7w|BJN*l=WAhKDgTJY%4Icd>(xuGKHX~*FDQ2Wl&!dR;=bMeu&tW+>6^j0n zUi8({&XK~kt2@6Q_Y^(#F_HG-X_H*k`LWBr<3?8%I)i)1?C-jFOeuscTj1W2tGHeY zYP(zm>8g{C&3_O^Ym3p*{FpJ+y4)YCu7S}ZIX}Y1I5JJ=&m8_2v`_h;RBiBPn(WN^ zSWTa|tqc?T7*5TqC`i7gcJ6Vh=!*KwD0_FNkE&jE2L^>PwHNF*!fyEYJbr;s!2~<} zoP|HZ^gH~RgX`V;Rgx7ZQ*wbzieTlzth4(|4|EXB>e|0JQek4 z+-qSM;dK@k{u?bU{I9UE@E^3W@ITMOVajj1h5dxjweW+KU%rK@J&j|s|EJFj6Fz8R zKjF76JeBav7IqQdY2kM%zeg>+OWePOA0&LQg*OsjXW=m6Mhp81udwh`!a)nW2+yExe2HxfXt~95~;?8wnpfX3j@w|1jZ$7WNZ<+rm=`zieR_;hh$Km+IqD3-2QQ zfQ26`PHP^lRTUHf^LhTH z^lX>Wf^x%;hI$`2+u>ve;?rk7B0CI_;m|UB)r?g)r6n0u+V?l!b1Ok z3mYZS@3OE)_6ZBe2uCbjO?a7wO9)q4cp~B178d@!7T!nsO|kGc!nqcnh<;!k;dzMa zpZp!L@IJz?TX-Ac-4@meKVjh*;fF0;P56Ebmk{o<@I=B13ybncEG){u%)+AlRTkFh zer8)ZM%ZiNYH|M-E)n-{;fd3se}v~Dx__#V0~X##_;m|!BfQ(f8sR4_93%X&g{ukQ zZ{ZTcT^625IALLf>LX&|eT0`;cpKp=3u}aDTR2A8YvF3bQ!HFUIM>1x3Df%>&AW8} zR6hqSypQnf7T!j9w}myrPgpoc_+blI6TaWVC4{>yJdtq1!bUN0#KQXsFSGDA!c`X5 z2+y`~jIh_j)r6;5xP)*n@NX~&j2a%qj+>8{sv8d$dKAU>X5Hn@1A#X}dbL#XVuSsH zkd7)IRv^ zk>sFz$1=PPrx)gReCbJ^jQ67qX7@sX#F36K=O_C*+Fc$sUF%8~nD2L;FSt79dJ>T@oRb?r-@KU|A>oVGV!)78%h zI)_~D?hP6ethBrC$Qwu6>Ej z@Vz~>-$9!l*yrdxSljQSCQ`L^-Xc|-;>BCxpRqHChMB@!9?QHcz1{`A#j2L@<~>FG zlJpK4pjY)&70f!@eTuv5CW(Woc6Bke;$6g~tGpG#bi(Tklz&9q5`{fu@3Aioi#EgN ziM#7fREM^!s^kmWwWvTA3PpSr@poMH19#VzPza`iXh^(G7vhn79g10=S*(qiZ`|+0 znRw%-{g_}X->X6yIgc-g9@J+Fb)g&7KG&a=>djCmm%j3&*xA`4suakt?bc^~I6CU@ zJT~fc-~Zy`?vIi_U)M*8xxz0Teu&P=ulwY%UOGHaI@C|Y1SnirYcJ~W8z!jG-=CccZw8TY~5#Ey!$mDeqf|#yXf+mG)!n;`9RCz~b~0m#7mp<@1T{tx zi(V8%+htq;2R-DrLVrU?!JZK7(9}Gye&bQ9o@L(R?u?dm#{;&G)%|m-#`OHfr(g=` zffcD0iFsgw&)wBby_dS7>~`H6KX+hrKH01w8``bwPZXG%TDxl0iN+zj^!O{Fwpi;A zYChGt7QFriOvc`qX?szQnQHBb*bczJsY=~g(;w)gy0A@SfxYFK{=iP^w}I|glXEcz zVF2pwe-)Le1^SX})YRg=sJMJFzC^{{iCqCT9UZ(`ZhFz4C3@vrPcWVAQ@f8Q&IxLt z1hxIT56=`d2KES((>mApjV6ohdoWQp6{61z4Vtsg8BUcv7^{kPO%J9n2E3vjO@*T;GB z*FpEGrWfy2)5|l+8oQACzhl&QV=wHGd-JOp3i?+cz`8E56Zxs>+!xX5&`E|4t0lW> zZWxCh;2c#8Y!0U9b(1&!ze68|+^Ge&253w1A7~co4{V`C|8L+?=@0Z0=np(#ibqMH zmV?^*9s_qoTOHWvO$8qCS57eZ*N}OY_P+kB_px7RquzTKQ%#v&(zhqTA){K=q@K$sH^gQP(%Yz9`j+WrWO{Z zl3T$e9ZBRrPhSWhl=U-0F2+EZtL<5!RT(4x)QyD;v}MLI?2#?WLy4}=Pc7f7KaVoP z4cYV~O4H19u&qvQn49KuQIR`L-fA8QT||R{g&Xk^%*mJe;?hG zKeZ$m<@b-So#KA-u=~kZ+>h;g3N?$}+MF8wEjSBwAE?&$d_Uywet{+!%UwL(|yZfg|6svl({!t9NMXHlZ_~&~6Ux=!y^%?yS z2dFdcbR|Nach$HOH&KoclHPLaDnacFZ4c%AG1Sl-s3*LDer@&u(Y^~)i9;h~Mdd+N za4l^3!1x&yt@VbQ3T&=Qc}4FsHXx}#AbX6l0zF!vj9e}C9t`l;^tDvX%0c)2nVT^d z(VHv%NXhwe+dC`7v2daJJI&WDm z{v#$PuG#MHW-y(4p}YGPL_p1(PybWFefJH3!Srlj^{)4GsPR{!P(kfeRojCMX}0$1 zOW5g|WEu3%qsh_Nz0ch>nS2_qRI)dH*w;*AwK%2%T)H8*(> zkHsg&-%4=TW^(dfe(&u5$lcYA@k&d9^^ksTz49#ml}**K9@7J8STQZ{QnhhZ>^yhZ z4N#+5!4oJy8Ymyd3Lp>qFu*)Ld>;GtW7D#_b5DL%PT22 z9^vPTCsA_4H=f<*{xSJ0{|H+mv~Uilz4Vw@s!MiP?>dlUKEKg?>x!q~?+)kcqeJMT zmG31#q})FR81B&aJdI{msLtP|;?OFs`wZNku?B@eV`{$>e;3s|kxFHxzq@zRU)oQ6 zO)I^$O`*Yqj|WN<4bi6P7;2+W3u@~VrIFT{QoC^Z^-GsrA6i=dy@1`*VNB3Nwor`6 zTM_C*a(Rf?BB%%z2*n742r_*y;(d&_LiQqTMrdcQ0df$l!MzB52xPYvvKL`9W8&Ho zZ$PMJPKB&MC`Kq^E)Q~q{pu{qb`Y;4B=@qUcpu_h5qc3eGv5x`fKbbr^i;$v5Q-6s zn9pNL=_6PJ>j;Agdl7_hmRliv5jG>VGuObf7E0%ZUjDc>DN}~y&Uheaa$4ZMc9nc&U^!8ErQCJ@~uE9MkqqaLm+MhulOV>=!g#@ z>_zBfek;pf$ju1t2o22DvQ!}}I9|-M2r`f3Be*9WVG#XM=C#)rC$0~8D|5Y&n;q%W z&Mwu%#8-;xhg3l_NHvI^y=Zk>Zd1XRq=9F_ZI6zse(1#!vB& zpE>^gvmP1mzUVBam@+L^&YD^LpUwFHjXr(nroaKdrB3#N|qlBkQfw6!Th ztu+x>1Z)V$$Zvb7tu+=8H!3JDKC?2FsH8$$l51 z%=!zrMI&bZD9qYsB`FJ07mL@?O{@wv9AC^@`Oy`=)N7-!_qh1bOtx~oDcr2Avbo0h zZ*?v8_4xX%DY;s~7j1R;04^y@i_a0`O=hX&-L}TVchtej>SWWZI}~c4L{y2k*EOJq zqDl;(Ix1_!iMj>_>G5b|W0(p`H^2p`Z;U463K|xlJ}NMZ%oM-nR1;`&B@z##(=}3y zE0I_jpJn0$I?+eWls5X-PHBooB8^cl6B1fcS7cq?+z^e&@I@YaZB$X0EDnqAi7Fxt)wu1K^#oNP?c$W?wYqm*u}?TQ)6w)l*;hHyMO z;~RweeID!7mX}{YX9hk@n}L|A3qP)N#?6gDPDa@J#^`7V70XJ%(1*H zS9_uBocsxSWa|Y-uO}YlJw&<%g^LP`u5nLZo7bUSb@tp#XL&CKE9~(?s0A(YtzKc=mpY^e?+(CmY!%-$BkxT)3r{{t{n5Dl za?|7QJ*ld5LQZ57npNBj)!h;3(^>-?eOz}-MBN1e>D-LzNV_FlS*ym%dOel(*_;!W zty9u>4SZVz-`2plHSm9-2551POP=l_?G=jJ){zkw^Vp5+>rn^^vY4 zz_Om@8kU<_{)FWtEO)Tn!}1N5@3TD0@+9tvXRy4Orn^^vYGFJ(g^cC7DD+NGmpl(ej%RreitPXL{1Z7oNm$5v5g!TvMlIM<8m{5`Djabe zX{;~L58-K8k>`e7#_96Bkg1I2IU(~H%kx1lW-QMInZa0|2O{Ttc@79qlZrh5L+r!A z<|!^O?Xl1$&-;+`uRQ1D8Z%ju=X=QKi#*q34yViWJQym9&y1Ba;bvUP_#DQ?jPWO6 zaq(kJDP=65Zxn5QG< zPR1Ti-@y1<#@&po8Ow7t@Lxd1b)V0amzzR)fH6-u%0rCjbNV*M^Z_qjkNJd9&NGGb zYsT{VxRWt`a7@=;pAchb6TZP%K0o&}E-}@VLyUR)S1K!_<>k?t^)t(7RaVqRDrV2B ztSbxGhb!tTW=AidRd)HT`np*ar4ek{t|k|`zkTK&rL?U92a6@btCZ5_mPE94b#tefk33lL+I=vgfwh8hsl6rt=PXUjWx&ci)iU; z?6sHDL3~OnPK1j=sYM*KR~l^y)yKn4(NIGKP9!o-Y1=w{hiM_>$RXSmt3z2@666_$ zqvQHYSK%)iR3w}oh&yeJHQ&Y+_IHZOu&23JjyKx>rAtaVFPf}*Ca#Q=q<(?_eb^%H z<$6p?Ij-e+lJmawLF10fAnoOPP0B)^$mn@RMU(cje~^FjE$!v{PfEGogNnH1JSj2F zyR`O__Hw-?Wg{!f@=HA_14yT}oWycHCuI%WJIl|svJ4SQllF4`I3}raaLN3g_y2vQ zk^KTv!X?+EQp)u!+0z{0v~K`Mbt0d?a=k949H&qb7v*KY>HzHaK5iH(hvbd1QK3UN zJM2BYaF9}#m(9k?FZs0&dwCAEludGCb>u7kt#{bV_Y*1Qx<%?c@4u7nTf2pu(W>$3Dozby?nor=XJ|-x#fLJJK2xou%-Hw_VS#h z!6hJZ6`Nx0SK3Rt8!2{s`TnQy!0n(!+a>L!{2kKl_HupRah+*&SQeapcvwpD8Y1LN zmcR7|k4S30L5Q*bZXz=;87Dc2;3%R-eDmG6aOi;^yx64u`o1VuJj;gJoN4bnZ4YVkwDm2w(rmOCKi4hT8v zx;O$J=}Pe=aQr(UvTAqdu@D~Va$J7y`!HO|>HTM)3=y#ua;zg!{vyIo=?b$<8>d3f ziQl?=@29Uc{qoFrU%2<*J}!Lv8#mYg?k5)tiyJrz4qu5M%bE6VqKu4GN@b3pBK%(2 zJby#_g2`Y0@OPi?H^~HloHU+#bFL0_(o~AvCoQwWb=aMGbyRv1+_iWv57#x~KMHQj zHTW)Y*V6Lw`? z^uLin508u2%I__R@50ai%ub+xQUZQM0{t~6pGGB1tQ5{8Km6Q}UnZqd|7;UllL@>F zMf`r3#p{+s=s>v6uB)_k& zuEB4{4+iRytf!{ZL{)*X2V#NxPz{W$^o4z+bYrlgF%S&z5JY_-=nDs=9D?dSTae~e z(LKRHW1SQVd!!c6c3)jnz*85fuL@Tak`2^vr%*T;Zm5I3fncz{!2=V0Vc22n6n1WF z3Ium}wg-Z2;MD<*HU2Q{2yXR-Lsy3ivH#E{r0xqf25aiWTe!g3CVzDhtUtV?G2p3S zt4wlhqgk-Q%BIGvQ&R0vk`tL|3fI(etyOZx)K{WLgw3W#SyfwnHFc(DTa|7J1_EpW z%8s^h!P6F$Ux=xCKdjnnRqhslT|?-KHc{8$s}$|S(i;qL6;{>lXslLwPQcSx)4bJZ z<;=uYR&()NGD@gmM0m|sUloL#tcD=+J1XQ4Hw7(|t_-nqs1~EJVNAeMTp2(v8?5GJ zF+z7eTFUlosrJ>^Y`rRTB0c1#lIlW%KrPkPRG|0VYMFH_S5QHyp_Z>_7TZ)(2P=R*@(Q8*gT30W3c;_L3Y+r7rKv|x!`oDl|%(MS3zTC!l>sEfNZG5YU z-)!SM1m9xgdj)^U#t#a8($>&g*ILne36acB=`~=?-hKxjjtAbwT+JmzS+iCi~4G@@drfwLpHuu@Etb3 zTkyR$zF+WzHhxg>Cv5x)!4KQ`A;FK>__KmfExWS(X|+|4IW|63@C$8xhTw~Ayi4#U zHh!_-%WZs#;HzzXx!{{^yjSooHojT#hirU{;5%&mA;I_B_zuAj+W2n4pRn=$f*-c= z#{@rO<4*`a_5Lf%e^~H2HvX*O7ut9d?XbwkX9&K;#^(sW+{U{EUv1+T3%=RL7YV+_ z#_NJVWaBppzQe|Q1>bAq8wEdTD_55PA;IU^_zuA@ zwDG-yFS7B2f-kZ0Cj{TS(Ms2W`Byz}>sNU7oN7?v$J5wzOyR{;Z3doD_`ev7PD2X6 zOyOyhWse4X#1_d6O;vcYR51Arg&(Kr$x(QDR>#3Eg;(ugsPGdMJ&P4SP2r0aK3(B; zg`cSKB?>=D;WsJ#WQ8wRczO26!Cr+|&o8PKK2y=tsPI_|->mR_E-0Uf!cUVV+!lqO zuJ8vGK3m}rDZF|F*sAa|l=vMAKU3km6=)T{7ImH5>PU#Ren z3cp<8n-zYA!e8EbwDYo)j9(Bvb$RD&9sN$vZ#noZ_ycgJe6cQ;acZ^U(sF;B>vFy= zf8BTmFh$d285)^kCQbpKqu>d^ZUs*Qu266$a8$w5f!h^46L>(uHvpef@El;xF_!*~ zz;hIw5A0TO0dR$amjXu>yaKpg!M6bqDEKddPbt_9tZ8HE-vvBJ!CwY;EBIdE3I&$} zM-}`P;C2On6?j0wUjsg+;0j>PIhOtw;5iEZ8(_DB>wzm290ZOkcsp>rf*%GRQ1Bzb zrxY9o){@53|2yD03jPMLTfu)1T%q890FEm7o51Y~ej0c{!T%HZl!DuVwdAq%p9h|! z;4Wadg8vz~Lcu=(jw<*i;C2Q75O_eruL7U4a0GpWhS$Y(qMmQX+K)D`{Xx)|h-k2oD&jW#-5$>8i?HeCSrj`5xxAruF>>mCO$hf+puQpJw65Ky)OY@i z_`wSZub80srBF+0GHm<687V!jMX+OylybaJ5$B?l^0y-|{d4qHkyKjF{0yY`0eGyJ zj#W;gKEFQCIO$Bv9}senBK)K?J70rs9KQ+p=YGcka&Ry{AMZt%_s!K~ zL(c53jo@TjdJ6IId5m21jlTttBcmhU>3Xc+0e2?iPoW;S!?gAI5`<%p-L_#0l~LX7 z&3QjFN`14x6Qo^j&(NjHH!-zMm2q!GT<>kvhc}v~FQOh^o~6g0LfN*j)?<4#9qq&M zb4L~(IsZ;^=Y?!NHj>MAHE7n=18fKBC_`F*4)s=HrEiwiT7x&Lt)5NWTEI~ZUb+~!3%ZK{88#Z&9w4)AAJ7?sh zyt_1%2Zy`8^NiNl9mZ*d;ipNRSl=9b<#s)`YVSM)Yno#}T0}AZb;MnX zx_9cUau?DzwC6Hz_ugE+h;e^BUN$s=3LhRv2G-+Ki{UcwN~N+tqpj(X^Ty-Li^p-B znAP5uWAMI7{|38+}B7KMZ{v*L$S6(e0*fW#v zz^jp6?PxQBHy=VgkCRq$8N9~!WLow-4?S$pTGpRQu`UinyJTCR6=7fEu;~;#jIdVJ z$G3p{YS(tY61ubVE^xAc_SGhVOYZa`Tpdri-%^Gi+i&X8V_#<%#c+eIA{*@p^*CrC}%9RXT0c;U=Z)tI%J&DI+55_(a86vka?Pa~L0}*zGWQ+#Qp581JbV zYmO5t)->ZS6}vU#n2MvC@e36XXvUz5HK*}o6}z1V`kj)#)A*5!G3WQHSW7Z`RP0VN zx>X!aGG0*eK$6j+VlCNtPQ~tIqgBPxWaHZ^9!NG0saQ)fo>H+p#WzZRdF=D zfed3<#o83(0~Ncc7(*(KPBGq7@xT=0go?FH<1H1tGY!lyW9daRjbEsEAk!FBv6f~0 z7?{UBInJM-OfkHZ@A~^om_IRYug19Dl0;=6Voij3u{#&z_G=o}GZ>@yrs%8kFh)l; ztiitQEt)szFPe)ndLPDS*Pyq^M@cv;a-&a`(H(cf3W*^3BSBGmq zVVvGONnbSw`qn}Z{{K?i$EXXehcbCRbbj*K+LSpJ%leVyw+rKUF2?UXF@8r<$QVF= zS}(_!W1i|`S=LF-xtryBSLz6%Og>fH0Ltu)iczL)*Jylqrg2)uTBh;7icx33S261J z9TmH$7;marn_~Q0#b`5cs2FYPXDW7Q7_X~X%P@|r7;W|y6=P0%S;d&MUQ}_^VSHc3 zn3KM%V$ETEN5z=acs(&%9#N<9tco$GJfmXGX&h8B=AfM1QKe$cNq!Y$&hn_(ooqa) zVlCNtK*g97H>((P=0+8}lZ^E$){=~MD#o0=M#UJjR;$=O*|=TB+GJy;iZLcGQ!&QO zMJjerGHzC}Hpy6^VvNaiRg5t^SH_M&bAF+>V!ya-D_Cf6nGO!nl9M{vSrw3y^kHNeb&!d$F2H9ixejY#g(p-nZWBigkH#Kqde=SRFlQ}qT~oa3%{kbkMQGa| z?B%>SU>^s6KlX9C=P_qoj+c2cUxY9gGv6lndrCZBSD*cJ{NN(!FaJ5MPBHD$nQQ>Zyer5<< zCH97XtmS#%E#)I@*HkKEdmYU)GaZ-Xg{+TttxlqCm@ms(FU6PdhAi(TrqeVD4cV*7GOW_u^&; zbWkh>dA8=^mpVUmI=gtUH1e^tlh0L9p2t4RqF5`=Rbn`MnfJ*$uD@f4v46%nOJ)T1 z+q#kWl#%0yiTd&=*%)%>bR|QlK7|TfafU!HJ?72A*$@1j=1cEuoxC^az4+{TdhFXc zx0nT;Kb)%>JLjetFR#%I>`{-gofN^k9Ooo#pS9nf-8CJ$&HW(FULTrBJxAlIoqMpy zUOnR2){652{9l;-4`IUx*pr?z_tLyipV{>u%8kzk`Vr^wIz5KEIL7A(A7W3wuRqyv z4<#FU*eCND!*68V2~^g)UPqoIr7wWPxl=ylKfr%9g_fMpes<>j8vT1jdv+$yVG3C< z>tsD{Jq2Zv-Sw)_<;D2{%VOPXwK4eLL0Btf-iC{Lp=VbL{mWCC&(7=_i7!8gdfg4( ztuu>nJBdB!VVo75f~{PRC(&M?f*!WVDwnLTW1J4rzh@;yo|bZ6w4n>h)WdD$M+oP3 z^V0inl>b_kKh6%(uG+_~>rBF$>LZldqd2eNvYLxJ<2*dUI#FhOaGt`l(@^KCd<1>j zfi{6Nt^D?lEMuJ)wI$P$&$4h<^zoX`%R7H=o$H}KvY`)lXLFtp!G8w+A>?WIWt>|e ze=f8k?;qnUIh{Y4ZS+ceKLCfe(M@`?!TGs_z8Y~@ZEN;Ciq*?-SF+KA@HUi{%SAEQ zC-Eb!^DTrkXX+So8Z7UEoYze2%C@OPUiJ%&Uzz3^0@^RmBD;Bv`wac#qTkucZ83&( zi_dXx5#LL{*~@2VyQkcwZPNRED33Pu544}-?V5E~xE6VB-A{e6r5K&i(kJe@$l$XC|NH%tV%{17{*Hqu=;Y2B&bYcpB|(lGVQQS@&yb zH?8~iKK8f4&-p?B?dEtKKQF>I!<>Xx2adH`cXcOyEhW`s?D{a_=G4Ckw;bof9j(&d9 zIX$1#|32RJ(4>)-Xs7ufA-%RIB7K}r-r2u>Gp~eKs4yo&IbG5+vb zY3mcn8_~A!rBfM?7c*f`4t`v=M|V%Y=}xroPMkNj?biETHQRf0$x(|vK^g02`yPWX z%b%0~9Q^y7I`T3t|Isvkha)Lc>c^U$!~Y=SB_R!rd!-!bq%$Y~3Gg`I>ucRjeUWi+ zccT3wK9>uhA0vM0X^cUq5SP=thf$59Id3-LY?$zT=l7F3TNF7kMXWBUFzNIwtd(}}S*ihMi147to(YWXAV zhuj*3bKW_R zTu*H~x!pKwU;8v(W{yYDISu801H#>qAv|(l%IAjXF!wzUK9{o23}MWd=ZrkBUf%hV zx$e1ud5zEWBj{f&!|y51VLl~{kC;pI|GdC@f6?aD3{B&9g!dH~8_=GPW6sRS*mf{? zHU;~0sH}b5rkkkt@zU0N|2gO(s?7tJr(>OwjWCphN7uKtz|ZduzJ)XQ9=szd+tv5X z5Y|jzjKmM}+6m=`by7}#+j@%qQs~yv_PIQcpzP5P3VGabUrGj_xt5^(P)}Vvmw$Mh z-nTEc@0pJH(IA)W6!fFRq^&=Sz3nV$|HZLaQS#q55f}5MF-?W=jYkjDPm$4td=+yFENK3}S7-Wu5aSiLM9R1;b zv!7Dm9%tnEyU0_&Mll|*VRz40VH2PC^O-7cGL_E2?}xCJ&-L+tP^NMopF$f5A#b@f zwYGB{mA!}d(E2x_-Zm|3e=g0eJ&C!|mBj5JyB6ogWqEi9fpH%5e@_1YKzckMaXm5S z_bpsEtzr&u+kfv($&|I9szbf2Q9mc4zZJT;uJ*tm!F!`9WS8%Dp-yu0pK(sxe-eGN zR_MW5e_6D)sds3%{(~m8$KqW_o`Ie-9m!PY-ZgF3DvS^JpMT^{epmDj*y3H|7~%Tl zF`M=Mm8?(99Wt+6r!v1CBY1~_xJMz2`Yi3k`=lPczv1(C{9l}@92e^ro^!fhLL3?1 zszoyQp#9#4Hp}yQu~WSN!FC31t<+*hYHs$kRw~w>I@mG3uMcz6s3sux-Cb|C^fvvpp-`hu-m9%Ws zzi8CQac(xzf-AT&4Ojc-nlW?f`Ty#Z{GSuK!O~05Pfea$oV+yoh8fQ%Po6siZ5+=+ znz3}@e>LI%JMsDc6`K?c*WqSQm3eoTaYchK%x<`$(G$jf8+JoAm1dtd`5-Qhi150= zmM|_{1#uy)@&b>l7FboO#=A;&*dJ^NVI(pI z8pCE@Siq#KeKnG(XlV9?8f${SI#V4X+qg6& zHQDlwJAc8g2siVi6ezC3`F8k)a)ehkZLQ$K=cW{f{efnGH7aPderj;>5W)12FYL!T z79n~tP*>+;jvsp!BOhE$qdD197ii)(i?)huiD*x_AGZ{k-6f`c7)=>^fcNFAz6Y$>6d=B6+jvt9TZ1@!${-q7SYr}ZoZ9bDMhhHd| zPrAVJ`(N@dd_d<#$g+Gbj3z6>ygbPZiiaga9YMZ?aB=yF~aJf#vs1)(TuA!e#%K z-!GB5|=?E>o}yinjx0*hgiyaMNo zaQS_Z+XRk?@C7nlw>)xuKOn;8_}D7&d=bA};FSXR3oP&S%I|#SiE#OS57De>NW{NU zgr607k-&H@VLo#FPZc;zgl7oMXLS6y1YT@GS}d>}7fS^GvIs91Sk50=q1liUeLSur4s}ubIy#fu{-V6__vg@zbcA4$1kqSzysE zC?fC;Ry1l6_%8%LAh4V-4+%V7gm(y>DR8gAay}gt_$Cp4LSQ*>u3YY~3{?0keEz~f zz!zA#tZU%q9O(uWS}tCRm?83$aVqd245!mR77)HhoTV1j)YsrI ze?N~3@R_z6gf*Do!Yc?=d$t69c++df;Ztkh4l9oILzQo9jUS2PjRF5DFjZRO6?j#{ z2H;D7$QQm2Rx66<|JdPijK@|@XgVMU4(ZBqP$o1wPY8nB!3C^Mq`X{@Nmuq`*?;6X zFZFRhV;iKrT(3#DQ4Cz1F6)r;vOTbV=B2z`|4CP__YlQ=pAJ_LZ0pCIMRMWqnWsl$ZJcnvi!3g>pSA zUAcZ`c^-4^^3~wDOyqnk*Xz=i{TNZq$6o(_u&TU$o#T>*3wb$DN|#^ysPgjr(9%tp z1B;NC_6vyr$8e>*++RpnrX%Cq?SELv%ks|<6M=LO*%GutirVBmimjBSYfsOPA4hba zOJp(0{gC{=vpqeDrF#gnycUx3a=+BF0VEz7&q8BZ^3NfF>t4#seSrK9wo4R@v|Gx_ za{V4;x&EYlx7a^*Zv=^l`)yLma3)Qck+BAWW5)>)yzHme2`l zxX>Zxq{`ReVNTj#F20jg{uL{RJ-*Ex`YEE>IhK_2g_fGpzczEOuzfTVJdca?-&RTZ H?P>jQi=Ypj literal 0 HcmV?d00001 diff --git a/abogen/predownload_gui.py b/abogen/predownload_gui.py new file mode 100644 index 0000000..47138f1 --- /dev/null +++ b/abogen/predownload_gui.py @@ -0,0 +1,590 @@ +""" +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, VOICES_INTERNAL +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 = VOICES_INTERNAL + 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 VOICES_INTERNAL: + 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(VOICES_INTERNAL) + 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/__init__.py b/abogen/pyqt/__init__.py new file mode 100644 index 0000000..cc5cb14 --- /dev/null +++ b/abogen/pyqt/__init__.py @@ -0,0 +1,7 @@ +"""PyQt6 Desktop GUI for abogen. + +This package contains the traditional PyQt6-based desktop interface. +For the web-based interface, see abogen.webui. +""" + +from __future__ import annotations diff --git a/abogen/pyqt/book_handler.py b/abogen/pyqt/book_handler.py new file mode 100644 index 0000000..4fa63ba --- /dev/null +++ b/abogen/pyqt/book_handler.py @@ -0,0 +1,1654 @@ +import re +import base64 +from bs4 import BeautifulSoup, NavigableString +from PyQt6.QtGui import QMovie +from PyQt6.QtWidgets import ( + QDialog, + QTreeWidget, + QTreeWidgetItem, + QTextEdit, + QPushButton, + QVBoxLayout, + QHBoxLayout, + QDialogButtonBox, + QSplitter, + QWidget, + QCheckBox, + QTreeWidgetItemIterator, + QLabel, + QMenu, +) +from PyQt6.QtCore import ( + Qt, + QThread, + pyqtSignal, + QSize, +) +from abogen.utils import ( + detect_encoding, + get_resource_path, +) +from abogen.book_parser import get_book_parser + +from abogen.subtitle_utils import ( + clean_text, + calculate_text_length, +) + +import os +import logging +import urllib.parse +import textwrap + +# Setup logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + +_HTML_TAG_PATTERN = re.compile(r"<[^>]+>") +_LEADING_DASH_PATTERN = re.compile(r"^\s*[-–—]\s*") +_LEADING_SIMPLE_DASH_PATTERN = re.compile(r"^\s*-\s*") + + +class HandlerDialog(QDialog): + # Class variables to remember checkbox states between dialog instances + _save_chapters_separately = False + _merge_chapters_at_end = True + _save_as_project = False # New class variable for save_as_project option + + # Cache for processed book content to avoid reprocessing + # Key: (book_path, modification_time, file_type) + # Value: dict with content_texts, content_lengths, doc_content (for epub), markdown_toc (for markdown) + _content_cache = {} + + class _LoaderThread(QThread): + """Minimal QThread that runs a callable and emits an error string on exception.""" + + error = pyqtSignal(str) + + def __init__(self, target_callable): + super().__init__() + self._target = target_callable + + def run(self): + try: + self._target() + except Exception as e: + self.error.emit(str(e)) + + @classmethod + def clear_content_cache(cls, book_path=None): + """Clear the content cache. If book_path is provided, only clear that book's cache.""" + if book_path is None: + cls._content_cache.clear() + logging.info("Cleared all content cache") + else: + keys_to_remove = [ + key for key in cls._content_cache.keys() if key[0] == book_path + ] + for key in keys_to_remove: + del cls._content_cache[key] + if keys_to_remove: + logging.info(f"Cleared content cache for {os.path.basename(book_path)}") + + def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): + super().__init__(parent) + + # Normalize path + book_path = os.path.normpath(os.path.abspath(book_path)) + self.book_path = book_path + + # Initialize Parser + try: + # Factory handles file type detection if file_type is None + self.parser = get_book_parser(book_path, file_type=file_type) + # Parser loads automatically in init now + except Exception as e: + logging.error(f"Failed to initialize parser for {book_path}: {e}") + raise + + # Extract book name from file path + book_name = os.path.splitext(os.path.basename(book_path))[0] + + # Set window title based on file type and book name + item_type = "Chapters" if self.parser.file_type in ["epub", "markdown"] else "Pages" + self.setWindowTitle(f"Select {item_type} - {book_name}") + self.resize(1200, 900) + self._block_signals = False # Flag to prevent recursive signals + # Configure window: remove help button and allow resizing + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.setWindowModality(Qt.WindowModality.NonModal) + # Initialize save chapters flags from class variables + self.save_chapters_separately = HandlerDialog._save_chapters_separately + self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end + self.save_as_project = HandlerDialog._save_as_project + + # Initialize metadata dict; will be populated in _preprocess_content by the background loader + self.book_metadata = {} + + # Initialize UI elements that are used in other methods + self.save_chapters_checkbox = None + self.merge_chapters_checkbox = None + + # Build treeview + self.treeWidget = QTreeWidget(self) + self.treeWidget.setHeaderHidden(True) + self.treeWidget.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection) + self.treeWidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.treeWidget.customContextMenuRequested.connect(self.on_tree_context_menu) + + # Initialize checked_chapters set + self.checked_chapters = set(checked_chapters) if checked_chapters else set() + + # For storing content and lengths (will be filled by background loader) + self.content_texts = {} + self.content_lengths = {} + # Also maintain refs for structure + self.processed_nav_structure = [] + + # Add a placeholder "Information" item so the tree isn't empty immediately + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) + info_item.setData(0, Qt.ItemDataRole.UserRole, "info:bookinfo") + info_item.setFlags(info_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + font = info_item.font(0) + font.setBold(True) + info_item.setFont(0, font) + + # Setup UI now so dialog appears immediately + self._setup_ui() + + # Create a centered loading overlay and show it while background load runs + self._create_loading_overlay() + # Hide the main UI so only the overlay is visible initially + if getattr(self, "splitter", None) is not None: + self.splitter.setVisible(False) + self._show_loading_overlay("Loading...") + + # Start background loading of book content so the dialog opens immediately + self._start_background_load() + + # Hide expand/collapse decoration if there are no parent items + has_parents = False + for i in range(self.treeWidget.topLevelItemCount()): + if self.treeWidget.topLevelItem(i).childCount() > 0: + has_parents = True + break + self.treeWidget.setRootIsDecorated(has_parents) + + def _create_loading_overlay(self): + """Create a centered loading indicator with a GIF on the left and text on the right. + + The indicator is added to the dialog's main layout above the splitter so + when the splitter is hidden only the indicator is visible. + """ + try: + # Container to hold gif + text and allow centering via stretches + container = QWidget(self) + container.setVisible(False) + h = QHBoxLayout(container) + h.setContentsMargins(0, 8, 0, 8) + h.setSpacing(10) + + # Left: GIF label (animated) + gif_label = QLabel(container) + gif_label.setVisible(False) + + loading_gif_path = get_resource_path("abogen.assets", "loading.gif") + movie = None + if loading_gif_path: + try: + movie = QMovie(loading_gif_path) + # Make GIF smaller so it doesn't dominate the text + movie.setScaledSize(QSize(25, 25)) + gif_label.setMovie(movie) + gif_label.setFixedSize(25, 25) + gif_label.setVisible(True) + except Exception: + movie = None + + # Right: Text label + text_label = QLabel(container) + text_label.setStyleSheet("font-size: 14pt;") + + # Add stretches to center the content horizontally + h.addStretch(1) + h.addWidget(gif_label, 0, Qt.AlignmentFlag.AlignVCenter) + h.addWidget(text_label, 0, Qt.AlignmentFlag.AlignVCenter) + h.addStretch(1) + + # Insert at top of main layout if present, otherwise keep as child + try: + layout = self.layout() + if layout is not None: + layout.insertWidget(0, container) + except Exception: + pass + + # Store refs + self._loading_container = container + self._loading_gif_label = gif_label + self._loading_text_label = text_label + self._loading_movie = movie + except Exception: + self._loading_container = None + self._loading_gif_label = None + self._loading_text_label = None + self._loading_movie = None + + def _show_loading_overlay(self, text: str): + container = getattr(self, "_loading_container", None) + text_lbl = getattr(self, "_loading_text_label", None) + movie = getattr(self, "_loading_movie", None) + gif_lbl = getattr(self, "_loading_gif_label", None) + if container is None or text_lbl is None: + return + text_lbl.setText(text) + if movie is not None and gif_lbl is not None: + try: + movie.start() + gif_lbl.setVisible(True) + except Exception: + pass + container.setVisible(True) + + def _hide_loading_overlay(self): + container = getattr(self, "_loading_container", None) + movie = getattr(self, "_loading_movie", None) + if container is None: + return + if movie is not None: + try: + movie.stop() + except Exception: + pass + container.setVisible(False) + + def _start_background_load(self): + """Start a QThread that runs the preprocessing in background.""" + # Start a minimal QThread which executes _preprocess_content + self._loader_thread = HandlerDialog._LoaderThread(self._preprocess_content) + self._loader_thread.finished.connect(self._on_load_finished) + self._loader_thread.error.connect(self._on_load_error) + # ensure thread instance is deleted when done + self._loader_thread.finished.connect(self._loader_thread.deleteLater) + self._loader_thread.start() + + def _on_load_error(self, err_msg): + logging.error(f"Error loading book in background: {err_msg}") + if getattr(self, "previewEdit", None) is not None: + self.previewEdit.setPlainText(f"Error loading book: {err_msg}") + if getattr(self, "splitter", None) is not None: + self.splitter.setVisible(True) + self._hide_loading_overlay() + + def _on_load_finished(self): + """Called in the main thread when background loading finished.""" + # Build the tree now that content_texts/content_lengths/etc. are ready + try: + # Rebuild tree based on file type + self._build_tree() + + # Run auto-check if no provided checks are relevant + if not self._are_provided_checks_relevant(): + self._run_auto_check() + + # Connect signals (after tree exists) + self.treeWidget.currentItemChanged.connect(self.update_preview) + self.treeWidget.itemChanged.connect(self.handle_item_check) + self.treeWidget.itemChanged.connect( + lambda _: self._update_checkbox_states() + ) + self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) + + # Expand and select first item + self.treeWidget.expandAll() + if self.treeWidget.topLevelItemCount() > 0: + self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) + self.treeWidget.setFocus() + + # Update checkbox states + self._update_checkbox_states() + + # Update preview for the current selection + current = self.treeWidget.currentItem() + self.update_preview(current) + + except Exception as e: + logging.error(f"Error finalizing book load: {e}") + # Show the main UI and hide loading text + if getattr(self, "splitter", None) is not None: + self.splitter.setVisible(True) + self._hide_loading_overlay() + + def _preprocess_content(self): + """Pre-process content from the document""" + # Create cache key from file path, modification time, file type, and replace_single_newlines setting + try: + mod_time = os.path.getmtime(self.book_path) + except Exception: + mod_time = 0 + + # Include replace_single_newlines in cache key since it affects text cleaning + from abogen.utils import load_config + + cfg = load_config() + replace_single_newlines = cfg.get("replace_single_newlines", True) + + cache_key = (self.book_path, mod_time, self.parser.file_type, replace_single_newlines) + + # Check if content is already cached + if cache_key in HandlerDialog._content_cache: + cached_data = HandlerDialog._content_cache[cache_key] + self.content_texts = cached_data["content_texts"] + self.content_lengths = cached_data["content_lengths"] + if "processed_nav_structure" in cached_data: + self.processed_nav_structure = cached_data["processed_nav_structure"] + if "book_metadata" in cached_data: + self.book_metadata = cached_data["book_metadata"] + + # Apply to parser so it stays in sync if used elsewhere + self.parser.content_texts = self.content_texts + self.parser.content_lengths = self.content_lengths + self.parser.processed_nav_structure = self.processed_nav_structure + self.parser.book_metadata = self.book_metadata + + logging.info(f"Using cached content for {os.path.basename(self.book_path)}") + return + + # Process content if not cached + try: + self.parser.process_content(replace_single_newlines=replace_single_newlines) + self.content_texts = self.parser.content_texts + self.content_lengths = self.parser.content_lengths + self.processed_nav_structure = self.parser.processed_nav_structure + self.book_metadata = self.parser.get_metadata() + except Exception as e: + logging.error(f"Error processing content: {e}", exc_info=True) + # Handle empty/failure case + self.content_texts = {} + self.content_lengths = {} + + # Cache the processed content + cache_data = { + "content_texts": self.content_texts, + "content_lengths": self.content_lengths, + "processed_nav_structure": self.processed_nav_structure, + "book_metadata": self.book_metadata, + } + + HandlerDialog._content_cache[cache_key] = cache_data + logging.info(f"Cached content for {os.path.basename(self.book_path)}") + + + def _build_tree(self): + self.treeWidget.clear() + + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) + info_item.setData(0, Qt.ItemDataRole.UserRole, "info:bookinfo") + info_item.setFlags(info_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + font = info_item.font(0) + font.setBold(True) + info_item.setFont(0, font) + + # TODO look into why we need this here and not just rely on logic in PDFParser + if self.parser.file_type == "pdf": + self._build_pdf_tree() + elif self.processed_nav_structure: + self._build_tree_from_nav( + self.processed_nav_structure, self.treeWidget + ) + else: + # If no structure found but content exists (rare fallback), list flat + for ch_id, ch_len in self.content_lengths.items(): + # Simple flat list + item = QTreeWidgetItem(self.treeWidget, [ch_id]) + item.setData(0, Qt.ItemDataRole.UserRole, ch_id) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + if self.content_texts.get(ch_id): + item.setCheckState(0, Qt.CheckState.Checked if ch_id in self.checked_chapters else Qt.CheckState.Unchecked) + + has_parents = False + iterator = QTreeWidgetItemIterator( + self.treeWidget, QTreeWidgetItemIterator.IteratorFlag.HasChildren + ) + if iterator.value(): + has_parents = True + self.treeWidget.setRootIsDecorated(has_parents) + + def _update_checkbox_states(self): + """Update the checkbox states based on the current checked chapters.""" + for i in range(self.treeWidget.topLevelItemCount()): + item = self.treeWidget.topLevelItem(i) + self._update_item_checkbox_state(item) + + def _build_tree_from_nav( + self, nav_nodes, parent_item, seen_content_hashes=None + ): + if seen_content_hashes is None: + seen_content_hashes = set() + for node in nav_nodes: + title = node.get("title", "Unknown") + src = node.get("src") + children = node.get("children", []) + + item = QTreeWidgetItem(parent_item, [title]) + item.setData(0, Qt.ItemDataRole.UserRole, src) + + is_empty = ( + src + and (src in self.content_texts) + and (not self.content_texts[src].strip()) + ) + is_duplicate = False + if src and src in self.content_texts and self.content_texts[src].strip(): + content_hash = hash(self.content_texts[src]) + if content_hash in seen_content_hashes: + is_duplicate = True + else: + seen_content_hashes.add(content_hash) + + if src and not is_empty and not is_duplicate: + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + is_checked = src in self.checked_chapters + item.setCheckState( + 0, Qt.CheckState.Checked if is_checked else Qt.CheckState.Unchecked + ) + elif is_duplicate: + # Mark as duplicate and remove checkbox + item.setText(0, f"{title} (Duplicate)") + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + elif children: + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState(0, Qt.CheckState.Unchecked) + else: + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + + if children: + self._build_tree_from_nav(children, item, seen_content_hashes) + + + # TODO look into why we need this here and not just rely on logic in PDFParser + def _build_pdf_tree(self): + pdf_doc = getattr(self.parser, "pdf_doc", None) + if not pdf_doc: + return + + outline = pdf_doc.get_toc() + self.has_pdf_bookmarks = bool(outline) + self.bookmark_items_map = {} + + if not outline: + self._build_pdf_pages_tree() + return + + bookmark_pages = [] + page_to_bookmark = {} + next_page_boundaries = {} + added_pages = set() + + def extract_page_numbers(entries): + for entry in entries: + if len(entry) >= 3: + _, title, page = entry[:3] + page_num = ( + page - 1 + if isinstance(page, int) + else pdf_doc.resolve_link(page)[0] + ) + bookmark_pages.append((page_num, title)) + + if len(entry) > 3 and isinstance(entry[3], list): + extract_page_numbers(entry[3]) + + extract_page_numbers(outline) + bookmark_pages.sort() + + for i, (page_num, title) in enumerate(bookmark_pages): + if i < len(bookmark_pages) - 1: + next_page_boundaries[page_num] = bookmark_pages[i + 1][0] + page_to_bookmark[page_num] = title + + def build_outline_tree(entries, parent_item): + for entry in entries: + if len(entry) >= 3: + entry_level, title, page = entry[:3] + page_num = ( + page - 1 + if isinstance(page, int) + else pdf_doc.resolve_link(page)[0] + ) + page_id = f"page_{page_num + 1}" + # attach chapters on same page under original + if page_num in self.bookmark_items_map: + orig = self.bookmark_items_map[page_num] + child = QTreeWidgetItem(orig, [f"{title} (Same page)"]) + child.setData(0, Qt.ItemDataRole.UserRole, page_id) + child.setFlags(child.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + continue + bookmark_item = QTreeWidgetItem(parent_item, [title]) + bookmark_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if this chapter has content + if self.content_lengths.get(page_id, 0) > 0: + bookmark_item.setFlags( + bookmark_item.flags() | Qt.ItemFlag.ItemIsUserCheckable + ) + bookmark_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + bookmark_item.setFlags( + bookmark_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable + ) + # map for uncategorized pages + self.bookmark_items_map[page_num] = bookmark_item + + added_pages.add(page_num) + + next_page = next_page_boundaries.get(page_num, len(pdf_doc)) + for sub_page_num in range(page_num + 1, next_page): + if ( + sub_page_num in page_to_bookmark + or sub_page_num in added_pages + ): + continue + + page_id = f"page_{sub_page_num + 1}" + page_title = f"Page {sub_page_num + 1}" + + page_text = self.content_texts.get(page_id, "").strip() + if page_text: + first_line = page_text.split("\n", 1)[0].strip() + if first_line and len(first_line) < 100: + page_title += f" - {first_line}" + + page_item = QTreeWidgetItem(bookmark_item, [page_title]) + page_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if this sub-page has content + if self.content_lengths.get(page_id, 0) > 0: + page_item.setFlags( + page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable + ) + page_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + page_item.setFlags( + page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable + ) + + added_pages.add(sub_page_num) + + build_outline_tree(outline, self.treeWidget) + + covered_pages = set(added_pages) + # attach any pages without direct bookmarks under nearest preceding chapter + uncategorized_pages = [ + i for i in range(len(pdf_doc)) if i not in covered_pages + ] + for page_num in uncategorized_pages: + # find nearest previous bookmark + prev_nums = [n for n in sorted(self.bookmark_items_map) if n < page_num] + parent_item = ( + self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget + ) + page_id = f"page_{page_num + 1}" + title = f"Page {page_num + 1}" + text = self.content_texts.get(page_id, "").strip() + if text: + first = text.split("\n", 1)[0].strip() + if first and len(first) < 100: + title += f" - {first}" + page_item = QTreeWidgetItem(parent_item, [title]) + page_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if uncategorized page has content + if self.content_lengths.get(page_id, 0) > 0: + page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + page_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + + def _build_pdf_pages_tree(self): + pdf_doc = getattr(self.parser, "pdf_doc", None) + if not pdf_doc: + return + + pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) + pages_item.setFlags(pages_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + font = pages_item.font(0) + font.setBold(True) + pages_item.setFont(0, font) + + for page_num in range(len(pdf_doc)): + page_id = f"page_{page_num + 1}" + page_title = f"Page {page_num + 1}" + + page_text = self.content_texts.get(page_id, "").strip() + if page_text: + first_line = page_text.split("\n", 1)[0].strip() + if first_line and len(first_line) < 100: + page_title += f" - {first_line}" + + page_item = QTreeWidgetItem(pages_item, [page_title]) + page_item.setData(0, Qt.ItemDataRole.UserRole, page_id) + # only allow checking if standalone page has content + if self.content_lengths.get(page_id, 0) > 0: + page_item.setFlags(page_item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + page_item.setCheckState( + 0, + ( + Qt.CheckState.Checked + if page_id in self.checked_chapters + else Qt.CheckState.Unchecked + ), + ) + else: + page_item.setFlags(page_item.flags() & ~Qt.ItemFlag.ItemIsUserCheckable) + + def _are_provided_checks_relevant(self): + if not self.checked_chapters: + return False + + all_identifiers = set() + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier: + all_identifiers.add(identifier) + iterator += 1 + + return bool(self.checked_chapters.intersection(all_identifiers)) + + def _setup_ui(self): + self.previewEdit = QTextEdit(self) + self.previewEdit.setReadOnly(True) + self.previewEdit.setMinimumWidth(300) + self.previewEdit.setStyleSheet("QTextEdit { border: none; }") + + self.previewInfoLabel = QLabel( + '*Note: You can modify the content later using the "Edit" button in the input box or by accessing the temporary files directory through settings (if not saved in a project folder).', + self, + ) + self.previewInfoLabel.setWordWrap(True) + self.previewInfoLabel.setStyleSheet( + "QLabel { color: #666; font-style: italic; }" + ) + + previewLayout = QVBoxLayout() + previewLayout.setContentsMargins(0, 0, 0, 0) + previewLayout.addWidget(self.previewEdit, 1) + previewLayout.addWidget(self.previewInfoLabel, 0) + + rightWidget = QWidget() + rightWidget.setLayout(previewLayout) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + item_type = "chapters" if self.parser.file_type in ["epub", "markdown"] else "pages" + + self.auto_select_btn = QPushButton(f"Auto-select {item_type}", self) + self.auto_select_btn.clicked.connect(self.auto_select_chapters) + self.auto_select_btn.setToolTip(f"Automatically select main {item_type}") + + buttons_layout = QVBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.setSpacing(10) + + auto_select_layout = QHBoxLayout() + auto_select_layout.addWidget(self.auto_select_btn) + buttons_layout.addLayout(auto_select_layout) + + select_layout = QHBoxLayout() + self.select_all_btn = QPushButton("Select all", self) + self.select_all_btn.clicked.connect(self.select_all_chapters) + self.deselect_all_btn = QPushButton("Clear all", self) + self.deselect_all_btn.clicked.connect(self.deselect_all_chapters) + select_layout.addWidget(self.select_all_btn) + select_layout.addWidget(self.deselect_all_btn) + buttons_layout.addLayout(select_layout) + + parent_layout = QHBoxLayout() + self.select_parents_btn = QPushButton("Select parents", self) + self.select_parents_btn.clicked.connect(self.select_parent_chapters) + self.deselect_parents_btn = QPushButton("Unselect parents", self) + self.deselect_parents_btn.clicked.connect(self.deselect_parent_chapters) + parent_layout.addWidget(self.select_parents_btn) + parent_layout.addWidget(self.deselect_parents_btn) + buttons_layout.addLayout(parent_layout) + + expand_layout = QHBoxLayout() + self.expand_all_btn = QPushButton("Expand All", self) + self.expand_all_btn.clicked.connect(self.treeWidget.expandAll) + self.collapse_all_btn = QPushButton("Collapse All", self) + self.collapse_all_btn.clicked.connect(self.treeWidget.collapseAll) + expand_layout.addWidget(self.expand_all_btn) + expand_layout.addWidget(self.collapse_all_btn) + buttons_layout.addLayout(expand_layout) + + leftLayout = QVBoxLayout() + leftLayout.setContentsMargins(0, 0, 5, 0) + leftLayout.addLayout(buttons_layout) + leftLayout.addWidget(self.treeWidget) + + checkbox_text = ( + "Save each chapter separately" + if self.parser.file_type in ["epub", "markdown"] + else "Save each page separately" + ) + self.save_chapters_checkbox = QCheckBox(checkbox_text, self) + self.save_chapters_checkbox.setChecked(self.save_chapters_separately) + self.save_chapters_checkbox.stateChanged.connect(self.on_save_chapters_changed) + leftLayout.addWidget(self.save_chapters_checkbox) + self.merge_chapters_checkbox = QCheckBox( + "Create a merged version at the end", self + ) + self.merge_chapters_checkbox.setChecked(self.merge_chapters_at_end) + self.merge_chapters_checkbox.stateChanged.connect( + self.on_merge_chapters_changed + ) + leftLayout.addWidget(self.merge_chapters_checkbox) + + self.save_as_project_checkbox = QCheckBox( + "Save in a project folder with metadata", self + ) + self.save_as_project_checkbox.setToolTip( + "Save the converted item in a project folder with metadata files. " + "(Useful if you want to work with converted items in the future.)" + ) + self.save_as_project_checkbox.setChecked(self.save_as_project) + self.save_as_project_checkbox.stateChanged.connect( + self.on_save_as_project_changed + ) + leftLayout.addWidget(self.save_as_project_checkbox) + + leftLayout.addWidget(buttons) + + leftWidget = QWidget() + leftWidget.setLayout(leftLayout) + + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.splitter.addWidget(leftWidget) + self.splitter.addWidget(rightWidget) + self.splitter.setSizes([280, 420]) + + mainLayout = QVBoxLayout(self) + mainLayout.addWidget(self.splitter) + self.setLayout(mainLayout) + + def _update_checkbox_states(self): + if ( + not hasattr(self, "save_chapters_checkbox") + or not self.save_chapters_checkbox + ): + return + + if ( + self.parser.file_type == "pdf" + and hasattr(self, "has_pdf_bookmarks") + and not self.has_pdf_bookmarks + ): + self.save_chapters_checkbox.setEnabled(False) + self.merge_chapters_checkbox.setEnabled(False) + return + + checked_count = 0 + + if self.parser.file_type in ["epub", "markdown"]: + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if ( + item.flags() & Qt.ItemFlag.ItemIsUserCheckable + and item.checkState(0) == Qt.CheckState.Checked + ): + checked_count += 1 + if checked_count >= 2: + break + iterator += 1 + + else: + parent_groups = set() + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if ( + item.flags() & Qt.ItemFlag.ItemIsUserCheckable + and item.checkState(0) == Qt.CheckState.Checked + ): + parent = item.parent() + if parent and parent != self.treeWidget.invisibleRootItem(): + parent_groups.add(id(parent)) + else: + parent_groups.add(id(item)) + iterator += 1 + + checked_count = len(parent_groups) + + min_groups_required = 2 + self.save_chapters_checkbox.setEnabled(checked_count >= min_groups_required) + + self.merge_chapters_checkbox.setEnabled( + self.save_chapters_checkbox.isEnabled() + and self.save_chapters_checkbox.isChecked() + ) + + def select_all_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def deselect_all_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState(0, Qt.CheckState.Unchecked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def select_parent_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable and item.childCount() > 0: + item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def deselect_parent_chapters(self): + self._block_signals = True + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable and item.childCount() > 0: + item.setCheckState(0, Qt.CheckState.Unchecked) + iterator += 1 + self._block_signals = False + self._update_checked_set_from_tree() + + def auto_select_chapters(self): + self._run_auto_check() + + def _run_auto_check(self): + self._block_signals = True + + if self.parser.file_type == "epub": + self._run_epub_auto_check() + elif self.parser.file_type == "markdown": + self._run_markdown_auto_check() + else: + self._run_pdf_auto_check() + + self._block_signals = False + self._update_checked_set_from_tree() + + def _run_epub_auto_check(self): + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): + iterator += 1 + continue + + src = item.data(0, Qt.ItemDataRole.UserRole) + + has_significant_content = src and self.content_lengths.get(src, 0) > 1000 + is_parent = item.childCount() > 0 + + if has_significant_content or is_parent: + item.setCheckState(0, Qt.CheckState.Checked) + if is_parent: + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: + child_src = child.data(0, Qt.ItemDataRole.UserRole) + child_has_content = ( + child_src and self.content_lengths.get(child_src, 0) > 0 + ) + child_is_parent = child.childCount() > 0 + if child_has_content or child_is_parent: + child.setCheckState(0, Qt.CheckState.Checked) + else: + item.setCheckState(0, Qt.CheckState.Unchecked) + + iterator += 1 + + def _run_markdown_auto_check(self): + """Auto-select markdown chapters with significant content""" + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): + iterator += 1 + continue + + identifier = item.data(0, Qt.ItemDataRole.UserRole) + + # Select chapters with content > 500 characters or parent items + has_significant_content = ( + identifier and self.content_lengths.get(identifier, 0) > 500 + ) + is_parent = item.childCount() > 0 + + if has_significant_content or is_parent: + item.setCheckState(0, Qt.CheckState.Checked) + # Also check children if this is a parent + if is_parent: + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: + child_identifier = child.data(0, Qt.ItemDataRole.UserRole) + child_has_content = ( + child_identifier + and self.content_lengths.get(child_identifier, 0) > 0 + ) + child_is_parent = child.childCount() > 0 + if child_has_content or child_is_parent: + child.setCheckState(0, Qt.CheckState.Checked) + else: + item.setCheckState(0, Qt.CheckState.Unchecked) + + iterator += 1 + + def _run_pdf_auto_check(self): + if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks: + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState(0, Qt.CheckState.Checked) + iterator += 1 + return + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable): + iterator += 1 + continue + + identifier = item.data(0, Qt.ItemDataRole.UserRole) + + if not identifier: + iterator += 1 + continue + + if ( + not identifier.startswith("page_") + or self.content_lengths.get(identifier, 0) > 0 + ): + item.setCheckState(0, Qt.CheckState.Checked) + + iterator += 1 + + def _update_checked_set_from_tree(self): + self.checked_chapters.clear() + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier: + self.checked_chapters.add(identifier) + iterator += 1 + if hasattr(self, "save_chapters_checkbox") and self.save_chapters_checkbox: + self._update_checkbox_states() + + def handle_item_check(self, item): + if self._block_signals: + return + + self._block_signals = True + + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemFlag.ItemIsUserCheckable: + child.setCheckState(0, item.checkState(0)) + + self._block_signals = False + self._update_checked_set_from_tree() + + def handle_item_double_click(self, item, column=0): + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable and item.childCount() == 0: + rect = self.treeWidget.visualItemRect(item) + checkbox_width = 20 + + mouse_pos = self.treeWidget.mapFromGlobal(self.treeWidget.cursor().pos()) + + if mouse_pos.x() > rect.x() + checkbox_width: + new_state = ( + Qt.CheckState.Unchecked + if item.checkState(0) == Qt.CheckState.Checked + else Qt.CheckState.Checked + ) + item.setCheckState(0, new_state) + + def update_preview(self, current): + if not current: + self.previewEdit.clear() + return + + identifier = current.data(0, Qt.ItemDataRole.UserRole) + + if identifier == "info:bookinfo": + self._display_book_info() + return + + text = None + if self.parser.file_type == "epub": + text = self.content_texts.get(identifier) + else: + text = self.content_texts.get(identifier) + + if text is None: + title = current.text(0) + self.previewEdit.setPlainText( + f"{title}\n\n(No content available for this item)" + ) + elif not text.strip(): + title = current.text(0) + self.previewEdit.setPlainText(f"{title}\n\n(This item is empty)") + else: + # Apply clean_text to preview so replace_single_newlines setting is respected + cleaned_text = clean_text(text) + self.previewEdit.setPlainText(cleaned_text) + + def _display_book_info(self): + self.previewEdit.clear() + html_content = "" + + cover_image = self.book_metadata.get("cover_image") + if cover_image: + try: + image_data = base64.b64encode(cover_image).decode("utf-8") + + image_type = "jpeg" + if cover_image.startswith(b"\x89PNG"): + image_type = "png" + elif cover_image.startswith(b"GIF"): + image_type = "gif" + + html_content += ( + f"
    " + ) + html_content += ( + f"Error displaying cover image: {str(e)}

    " + + title = self.book_metadata.get("title") + if title: + html_content += ( + f"

    {title}

    " + ) + + authors = self.book_metadata.get("authors") + if authors: + authors_text = ", ".join(authors) + html_content += f"

    By {authors_text}

    " + + publisher = self.book_metadata.get("publisher") + pub_year = self.book_metadata.get("publication_year") + + if publisher or pub_year: + pub_info = [] + if publisher: + pub_info.append(f"Published by {publisher}") + if pub_year: + pub_info.append(f"Year: {pub_year}") + html_content += f"

    {' | '.join(pub_info)}

    " + + html_content += "
    " + + description = self.book_metadata.get("description") + if description: + # Use pre-compiled pattern for better performance + desc = _HTML_TAG_PATTERN.sub("", description) + html_content += f"

    Description:

    {desc}

    " + + if self.parser.file_type == "pdf": + # Access pdf_doc from parser if available + pdf_doc = getattr(self.parser, "pdf_doc", None) + page_count = len(pdf_doc) if pdf_doc else 0 + html_content += f"

    File type: PDF
    Page count: {page_count}

    " + + html_content += "" + self.previewEdit.setHtml(html_content) + + def _extract_book_metadata(self): + metadata = { + "title": None, + "authors": [], + "description": None, + "cover_image": None, + "publisher": None, + "publication_year": None, + } + + if self.parser.file_type == "epub": + try: + title_items = self.book.get_metadata("DC", "title") + if title_items and len(title_items) > 0: + metadata["title"] = title_items[0][0] + except Exception as e: + logging.warning(f"Error extracting title metadata: {e}") + + try: + author_items = self.book.get_metadata("DC", "creator") + if author_items: + metadata["authors"] = [ + author[0] for author in author_items if len(author) > 0 + ] + except Exception as e: + logging.warning(f"Error extracting author metadata: {e}") + + try: + desc_items = self.book.get_metadata("DC", "description") + if desc_items and len(desc_items) > 0: + metadata["description"] = desc_items[0][0] + except Exception as e: + logging.warning(f"Error extracting description metadata: {e}") + + try: + publisher_items = self.book.get_metadata("DC", "publisher") + if publisher_items and len(publisher_items) > 0: + metadata["publisher"] = publisher_items[0][0] + except Exception as e: + logging.warning(f"Error extracting publisher metadata: {e}") + + # Try to extract publication year + try: + date_items = self.book.get_metadata("DC", "date") + if date_items and len(date_items) > 0: + date_str = date_items[0][0] + # Try to extract just the year from the date string + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + if year_match: + metadata["publication_year"] = year_match.group(0) + else: + metadata["publication_year"] = date_str + except Exception as e: + logging.warning(f"Error extracting publication date metadata: {e}") + + for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): + metadata["cover_image"] = item.get_content() + break + + if not metadata["cover_image"]: + for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE): + if "cover" in item.get_name().lower(): + metadata["cover_image"] = item.get_content() + break + elif self.parser.file_type == "markdown": + # Extract metadata from markdown frontmatter or first heading + if self.markdown_text: + # Try to extract YAML frontmatter + frontmatter_match = re.match( + r"^---\s*\n(.*?)\n---\s*\n", self.markdown_text, re.DOTALL + ) + if frontmatter_match: + try: + frontmatter = frontmatter_match.group(1) + # Simple YAML-like parsing for common fields + title_match = re.search( + r"^title:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) + if title_match: + metadata["title"] = ( + title_match.group(1).strip().strip("\"'") + ) + + author_match = re.search( + r"^author:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) + if author_match: + metadata["authors"] = [ + author_match.group(1).strip().strip("\"'") + ] + + desc_match = re.search( + r"^description:\s*(.+)$", + frontmatter, + re.MULTILINE | re.IGNORECASE, + ) + if desc_match: + metadata["description"] = ( + desc_match.group(1).strip().strip("\"'") + ) + + date_match = re.search( + r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE + ) + if date_match: + date_str = date_match.group(1).strip().strip("\"'") + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + if year_match: + metadata["publication_year"] = year_match.group(0) + except Exception as e: + logging.warning(f"Error parsing markdown frontmatter: {e}") + + # Fallback: use first H1 header as title if no frontmatter title + if not metadata["title"] and self.markdown_toc: + # Find the first level 1 header + first_h1 = next( + (h for h in self.markdown_toc if h["level"] == 1), None + ) + if first_h1: + metadata["title"] = first_h1["name"] + else: + pdf_info = self.pdf_doc.metadata + if pdf_info: + metadata["title"] = pdf_info.get("title", None) + + author = pdf_info.get("author", None) + if author: + metadata["authors"] = [author] + + metadata["description"] = pdf_info.get("subject", None) + + keywords = pdf_info.get("keywords", None) + if keywords: + if metadata["description"]: + metadata["description"] += f"\n\nKeywords: {keywords}" + else: + metadata["description"] = f"Keywords: {keywords}" + + metadata["publisher"] = pdf_info.get("creator", None) + + # Try to extract publication date from PDF metadata + if "creationDate" in pdf_info: + date_str = pdf_info["creationDate"] + year_match = re.search(r"D:(\d{4})", date_str) + if year_match: + metadata["publication_year"] = year_match.group(1) + elif "modDate" in pdf_info: + date_str = pdf_info["modDate"] + year_match = re.search(r"D:(\d{4})", date_str) + if year_match: + metadata["publication_year"] = year_match.group(1) + + if len(self.pdf_doc) > 0: + try: + pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2)) + metadata["cover_image"] = pix.tobytes("png") + except Exception: + pass + + return metadata + + def get_selected_text(self): + # If a background loader thread is running, wait for it to finish to + # preserve compatibility with callers that expect content to be ready + # when they create a HandlerDialog and immediately request selected text. + try: + if ( + hasattr(self, "_loader_thread") + and getattr(self, "_loader_thread") is not None + ): + # Wait for thread to finish (blocks until done) + if self._loader_thread.isRunning(): + self._loader_thread.wait() + except Exception: + pass + + if self.parser.file_type == "epub": + return self._get_epub_selected_text() + elif self.parser.file_type == "markdown": + return self._get_markdown_selected_text() + else: + return self._get_pdf_selected_text() + + def _format_metadata_tags(self): + """Format metadata tags for insertion at the beginning of the text""" + import datetime + from abogen.utils import get_user_cache_path + + metadata = self.book_metadata + filename = os.path.splitext(os.path.basename(self.book_path))[0] + current_year = str(datetime.datetime.now().year) + + # Get values with fallbacks + title = metadata.get("title") or filename + authors = metadata.get("authors") or ["Unknown"] + authors_text = ", ".join(authors) + album_artist = authors_text or "Unknown" + year = ( + metadata.get("publication_year") or current_year + ) # Use publication year if available + + # Count chapters/pages + total_chapters = len(self.checked_chapters) + chapter_text = ( + f"{total_chapters} {'Chapters' if self.parser.file_type == 'epub' else 'Pages'}" + ) + + # Handle cover image + cover_tag = "" + if metadata.get("cover_image"): + try: + import uuid + + cache_dir = get_user_cache_path() + cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg") + cover_path = os.path.normpath(cover_path) + with open(cover_path, "wb") as f: + f.write(metadata["cover_image"]) + cover_tag = f"<>" + except Exception as e: + logging.warning(f"Failed to save cover image: {e}") + + # Format metadata tags + metadata_tags = [ + f"<>", + f"<>", + f"<>", + f"<>", + f"<>", + f"<>", + f"<>", + ] + + if cover_tag: + metadata_tags.append(cover_tag) + + return "\n".join(metadata_tags) + + def _get_markdown_selected_text(self): + """Get selected text from markdown chapters""" + all_checked_identifiers = set() + chapter_texts = [] + + # Add metadata tags at the beginning + metadata_tags = self._format_metadata_tags() + + item_order_counter = 0 + ordered_checked_items = [] + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + item_order_counter += 1 + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) + iterator += 1 + + ordered_checked_items.sort(key=lambda x: x[0]) + + for order, item, identifier in ordered_checked_items: + text = self.content_texts.get(identifier) + if text and text.strip(): + title = item.text(0) + # Remove leading dashes from title using pre-compiled pattern + title = _LEADING_DASH_PATTERN.sub("", title).strip() + marker = f"<>" + chapter_texts.append(marker + "\n" + text) + + full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers + + def _get_epub_selected_text(self): + all_checked_identifiers = set() + chapter_texts = [] + + # Add metadata tags at the beginning + metadata_tags = self._format_metadata_tags() + + item_order_counter = 0 + ordered_checked_items = [] + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + item_order_counter += 1 + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) + iterator += 1 + + ordered_checked_items.sort(key=lambda x: x[0]) + + for order, item, identifier in ordered_checked_items: + text = self.content_texts.get(identifier) + if text and text.strip(): + title = item.text(0) + # Use pre-compiled pattern for better performance + title = _LEADING_DASH_PATTERN.sub("", title).strip() + marker = f"<>" + chapter_texts.append(marker + "\n" + text) + + full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers + + def _get_pdf_selected_text(self): + all_checked_identifiers = set() + included_text_ids = set() + section_titles = [] + all_content = [] + + # Add metadata tags at the beginning + metadata_tags = self._format_metadata_tags() + + pdf_has_no_bookmarks = ( + hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks + ) + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.checkState(0) == Qt.CheckState.Checked: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if identifier: + all_checked_identifiers.add(identifier) + iterator += 1 + + if pdf_has_no_bookmarks: + sorted_page_ids = sorted( + [id for id in all_checked_identifiers if id.startswith("page_")], + key=lambda x: int(x.split("_")[1]) if x.split("_")[1].isdigit() else 0, + ) + for page_id in sorted_page_ids: + if page_id not in included_text_ids: + text = self.content_texts.get(page_id, "") + if text: + all_content.append(text) + included_text_ids.add(page_id) + return ( + metadata_tags + "\n\n" + "\n\n".join(all_content), + all_checked_identifiers, + ) + + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.childCount() > 0: + parent_checked = item.checkState(0) == Qt.CheckState.Checked + parent_id = item.data(0, Qt.ItemDataRole.UserRole) + parent_title = item.text(0) + checked_children = [] + for i in range(item.childCount()): + child = item.child(i) + child_id = child.data(0, Qt.ItemDataRole.UserRole) + if ( + child.checkState(0) == Qt.CheckState.Checked + and child_id + and child_id not in included_text_ids + ): + checked_children.append((child, child_id)) + if parent_checked and parent_id and parent_id not in included_text_ids: + combined_text = self.content_texts.get(parent_id, "") + for child, child_id in checked_children: + child_text = self.content_texts.get(child_id, "") + if child_text: + combined_text += "\n\n" + child_text + included_text_ids.add(child_id) + if combined_text.strip(): + # Use pre-compiled pattern for better performance + title = _LEADING_SIMPLE_DASH_PATTERN.sub( + "", parent_title + ).strip() + marker = f"<>" + section_titles.append((title, marker + "\n" + combined_text)) + included_text_ids.add(parent_id) + elif not parent_checked and checked_children: + # Use pre-compiled pattern for better performance + title = _LEADING_SIMPLE_DASH_PATTERN.sub("", parent_title).strip() + marker = f"<>" + for idx, (child, child_id) in enumerate(checked_children): + text = self.content_texts.get(child_id, "") + if text: + if idx == 0: + section_titles.append((title, marker + "\n" + text)) + else: + section_titles.append((title, text)) + included_text_ids.add(child_id) + elif item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + identifier = item.data(0, Qt.ItemDataRole.UserRole) + if ( + identifier + and identifier not in included_text_ids + and item.checkState(0) == Qt.CheckState.Checked + ): + text = self.content_texts.get(identifier, "") + if text: + title = item.text(0) + # Use pre-compiled pattern for better performance + title = _LEADING_SIMPLE_DASH_PATTERN.sub("", title).strip() + marker = f"<>" + section_titles.append((title, marker + "\n" + text)) + included_text_ids.add(identifier) + iterator += 1 + + return ( + metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]), + all_checked_identifiers, + ) + + def on_save_chapters_changed(self, state): + self.save_chapters_separately = bool(state) + self.merge_chapters_checkbox.setEnabled(self.save_chapters_separately) + HandlerDialog._save_chapters_separately = self.save_chapters_separately + + def on_merge_chapters_changed(self, state): + self.merge_chapters_at_end = bool(state) + HandlerDialog._merge_chapters_at_end = self.merge_chapters_at_end + + def on_save_as_project_changed(self, state): + self.save_as_project = bool(state) + HandlerDialog._save_as_project = self.save_as_project + + def get_save_chapters_separately(self): + return ( + self.save_chapters_separately + if self.save_chapters_checkbox.isEnabled() + else False + ) + + def get_merge_chapters_at_end(self): + return self.merge_chapters_at_end + + def get_save_as_project(self): + return self.save_as_project + + def check_selected_items(self): + self.set_selected_items_checked(True) + + def uncheck_selected_items(self): + self.set_selected_items_checked(False) + + def set_selected_items_checked(self, state: bool): + print(f"Checking selected items: {state}") + self.treeWidget.blockSignals(True) + for item in self.treeWidget.selectedItems(): + if item.flags() & Qt.ItemFlag.ItemIsUserCheckable: + item.setCheckState( + 0, Qt.CheckState.Checked if state else Qt.CheckState.Unchecked + ) + self.treeWidget.blockSignals(False) + self._update_checked_set_from_tree() + + def on_tree_context_menu(self, pos): + item = self.treeWidget.itemAt(pos) + # multi-select context menu + if self.treeWidget.selectedItems() and len(self.treeWidget.selectedItems()) > 1: + menu = QMenu(self) + action = menu.addAction("Select") + action.triggered.connect(self.check_selected_items) + action = menu.addAction("Clear") + action.triggered.connect(self.uncheck_selected_items) + menu.exec(self.treeWidget.mapToGlobal(pos)) + return + + if ( + not item + or item.childCount() == 0 + or not (item.flags() & Qt.ItemFlag.ItemIsUserCheckable) + ): + return + + menu = QMenu(self) + checked = item.checkState(0) == Qt.CheckState.Checked + text = "Unselect only this" if checked else "Select only this" + action = menu.addAction(text) + + def do_toggle(): + self.treeWidget.blockSignals(True) + new_state = Qt.CheckState.Unchecked if checked else Qt.CheckState.Checked + item.setCheckState(0, new_state) + self.treeWidget.blockSignals(False) + self._update_checked_set_from_tree() + + action.triggered.connect(do_toggle) + menu.exec(self.treeWidget.mapToGlobal(pos)) + + diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py new file mode 100644 index 0000000..2195b8a --- /dev/null +++ b/abogen/pyqt/conversion.py @@ -0,0 +1,2477 @@ +import os +import re +import time +import hashlib # For generating unique cache filenames +from platformdirs import user_desktop_dir +from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer +from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox +import soundfile as sf +from abogen.utils import ( + create_process, + get_user_cache_path, + detect_encoding, +) +from abogen.constants import ( + LANGUAGE_DESCRIPTIONS, + SAMPLE_VOICE_TEXTS, + COLORS, + CHAPTER_OPTIONS_COUNTDOWN, + SUBTITLE_FORMATS, + SUPPORTED_SOUND_FORMATS, + SUPPORTED_SUBTITLE_FORMATS, +) +from abogen.voice_formulas import get_new_voice +import abogen.hf_tracker as hf_tracker +import static_ffmpeg +import threading # for efficient waiting +import subprocess +import platform + +# Configuration constants +_USER_RESPONSE_TIMEOUT = ( + 0.1 # Timeout in seconds for checking user response/cancellation +) + +from abogen.subtitle_utils import ( + clean_text, + parse_srt_file, + parse_vtt_file, + detect_timestamps_in_text, + parse_timestamp_text_file, + parse_ass_file, + get_sample_voice_text, + sanitize_name_for_os, + _CHAPTER_MARKER_SEARCH_PATTERN, +) + +class CountdownDialog(QDialog): + """Base dialog with auto-accept countdown functionality""" + + def __init__(self, title, countdown_seconds, parent=None): + super().__init__(parent) + self.setWindowTitle(title) + self.setMinimumWidth(350) + self.setWindowFlags( + self.windowFlags() + & ~Qt.WindowType.WindowCloseButtonHint + & ~Qt.WindowType.WindowContextHelpButtonHint + ) + + self.countdown_seconds = countdown_seconds + self.layout = QVBoxLayout(self) + self._timer = None + self._button_box = None + + def add_countdown_and_buttons(self): + """Add countdown label and OK button - call this after adding custom content""" + self.countdown_label = QLabel( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") + self.layout.addWidget(self.countdown_label) + + self._button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) + self._button_box.accepted.connect(self.accept) + self.layout.addWidget(self._button_box) + + self._timer = QTimer(self) + self._timer.timeout.connect(self._on_timer_tick) + self._timer.start(1000) + + def _on_timer_tick(self): + self.countdown_seconds -= 1 + if self.countdown_seconds > 0: + self.countdown_label.setText( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + else: + self._timer.stop() + self._button_box.accepted.emit() + + def closeEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if event.key() == Qt.Key.Key_Escape: + event.ignore() + else: + super().keyPressEvent(event) + + +class ChapterOptionsDialog(CountdownDialog): + def __init__(self, chapter_count, parent=None): + super().__init__("Chapter Options", CHAPTER_OPTIONS_COUNTDOWN, parent) + + self.layout.addWidget( + QLabel(f"Detected {chapter_count} chapters in the text file.") + ) + self.layout.addWidget(QLabel("How would you like to process these chapters?")) + + self.save_separately_checkbox = QCheckBox("Save each chapter separately") + self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end") + + self.save_separately_checkbox.setChecked(False) + self.merge_at_end_checkbox.setChecked(True) + + self.save_separately_checkbox.stateChanged.connect( + self.update_merge_checkbox_state + ) + + self.layout.addWidget(self.save_separately_checkbox) + self.layout.addWidget(self.merge_at_end_checkbox) + + self.add_countdown_and_buttons() + self.update_merge_checkbox_state() + + def update_merge_checkbox_state(self): + self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked()) + + def get_options(self): + return { + "save_chapters_separately": self.save_separately_checkbox.isChecked(), + "merge_chapters_at_end": self.merge_at_end_checkbox.isChecked() + and self.merge_at_end_checkbox.isEnabled(), + } + + +class TimestampDetectionDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Timestamps Detected") + self.setMinimumWidth(350) + self.use_timestamps_result = True + self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN + + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("This file contains timestamps in HH:MM:SS format.")) + layout.addWidget( + QLabel("Do you want to use these timestamps for precise audio timing?") + ) + + yes_label = QLabel( + "• Yes: Generate audio that matches each timestamp (subtitle mode will be ignored)" + ) + yes_label.setStyleSheet(f"color: {COLORS['BLUE_BORDER_HOVER']};") + layout.addWidget(yes_label) + + no_label = QLabel("• No: Ignore timestamps and process as regular text") + no_label.setStyleSheet(f"color: {COLORS['ORANGE']};") + layout.addWidget(no_label) + + # Countdown label + self.countdown_label = QLabel( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") + layout.addWidget(self.countdown_label) + + button_box = QDialogButtonBox() + yes_button = button_box.addButton("Yes", QDialogButtonBox.ButtonRole.AcceptRole) + no_button = button_box.addButton("No", QDialogButtonBox.ButtonRole.RejectRole) + + yes_button.clicked.connect(lambda: self._set_result(True)) + no_button.clicked.connect(lambda: self._set_result(False)) + + layout.addWidget(button_box) + + # Timer for countdown + self._timer = QTimer(self) + self._timer.timeout.connect(self._on_timer_tick) + self._timer.start(1000) + + def _on_timer_tick(self): + self.countdown_seconds -= 1 + if self.countdown_seconds > 0: + self.countdown_label.setText( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) + else: + self._timer.stop() + self._set_result(True) + + def _set_result(self, use_timestamps): + if self._timer: + self._timer.stop() + self.use_timestamps_result = use_timestamps + self.accept() + + def use_timestamps(self): + return self.use_timestamps_result + + +class ConversionThread(QThread): + progress_updated = pyqtSignal(int, str) # Add str for ETR + conversion_finished = pyqtSignal(object, object) # Pass output path as second arg + log_updated = pyqtSignal(object) # Updated signal for log updates + chapters_detected = pyqtSignal(int) # Signal for chapter detection + + # Punctuation constants for unified handling across languages + PUNCTUATION_SENTENCE = ".!?।。!?" + PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、," + PUNCTUATION_COMMAS = ",,、" + + def _get_split_pattern(self, lang_code, subtitle_mode): + """ + Get the appropriate split pattern based on language and subtitle mode. + + Args: + lang_code: Language code (a, b, e, f, etc.) + subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.) + + Returns: + Split pattern string + """ + # For English, always use newline splitting only + if lang_code in ["a", "b"]: + return "\n" + + # Determine spacing pattern based on language + spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+" + + # For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer + # punctuation-based splitting instead of plain newline splitting. + if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]: + return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern) + + if subtitle_mode == "Line": + return "\n" + elif subtitle_mode == "Sentence": + return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern) + elif subtitle_mode == "Sentence + Comma": + return r"(?<=[{}]){}|\n+".format( + self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern + ) + else: + return r"\n+" # Default to line breaks + + def __init__( + self, + file_name, + lang_code, + speed, + voice, + save_option, + output_folder, + subtitle_mode, + output_format, + np_module, + kpipeline_class, + start_time, + total_char_count, + use_gpu=True, + from_queue=False, + save_base_path=None, + ): # Add use_gpu parameter + super().__init__() + self._chapter_options_event = threading.Event() + self._timestamp_response_event = threading.Event() + self.np = np_module + self.KPipeline = kpipeline_class + self.file_name = file_name + self.lang_code = lang_code + self.speed = speed + self.voice = voice + self.save_option = save_option + self.output_folder = output_folder + self.subtitle_mode = subtitle_mode + self.cancel_requested = False + self.should_cancel = False + self.process = None + self.output_format = output_format + self.from_queue = from_queue + self.start_time = start_time # Store start_time + self.total_char_count = total_char_count # Use passed total character count + self.processed_char_count = 0 # Initialize processed character count + self.display_path = None # Add variable for display path + self.save_base_path = save_base_path # Store the save base path + self.is_direct_text = ( + False # Flag to indicate if input is from textbox rather than file + ) + self.chapter_options_set = False + self.waiting_for_user_input = False + self.use_gpu = use_gpu # Store the GPU setting + self.max_subtitle_words = 50 # Default value, will be overridden from GUI + self.silence_duration = 2.0 # Default value, will be overridden from GUI + self.use_spacy_segmentation = True # Default, will be overridden from GUI + # Set split pattern based on language and subtitle mode + self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode) + + def _stream_audio_in_chunks( + self, segments, process_func, progress_prefix="Processing" + ): + """ + Process audio segments in memory-efficient chunks + + Args: + segments: List of audio segments to process + process_func: Function that takes (segment_bytes, is_last) and processes a chunk + progress_prefix: Prefix for progress messages + + Returns: + Total samples processed + """ + # Calculate total size for progress reporting + total_samples = sum(len(segment) for segment in segments) + samples_processed = 0 + + self.log_updated.emit((f"\n{progress_prefix} segments...", "grey")) + + # Stream each segment individually + for i, segment in enumerate(segments): + try: + # Handle both NumPy arrays and PyTorch tensors + if hasattr(segment, "astype"): + segment_bytes = segment.astype("float32").tobytes() + else: + segment_bytes = segment.cpu().numpy().astype("float32").tobytes() + is_last = i == len(segments) - 1 + + # Update progress periodically - skip if there's only one segment + if (i % 20 == 0 or is_last) and len(segments) > 1: + progress_percent = int((samples_processed / total_samples) * 100) + self.log_updated.emit( + f"{progress_prefix} segment {i+1}/{len(segments)} ({progress_percent}% complete)" + ) + + # Process this segment + process_func(segment_bytes, is_last) + + # Update samples processed + samples_processed += len(segment) + + # Clear segment bytes from memory + del segment_bytes + except Exception as e: + self.log_updated.emit( + (f"Error processing segment {i}: {str(e)}", "red") + ) + raise + + return samples_processed + + def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" + ) + try: + hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg)) + # Show configuration + self.log_updated.emit("Configuration:") + + # Determine input file and processing file + if getattr(self, "from_queue", False): + input_file = self.save_base_path or self.file_name + processing_file = self.file_name + else: + input_file = self.display_path if self.display_path else self.file_name + processing_file = self.file_name + + # Normalize paths for consistent display (fixes Windows path separator issues) + input_file = os.path.normpath(input_file) if input_file else input_file + processing_file = ( + os.path.normpath(processing_file) + if processing_file + else processing_file + ) + + self.log_updated.emit(f"- Input File: {input_file}") + if input_file != processing_file: + self.log_updated.emit(f"- Processing File: {processing_file}") + + # Use file_name for logs if from_queue, otherwise use display_path if available + if getattr(self, "from_queue", False): + base_path = ( + self.save_base_path or self.file_name + ) # Use save_base_path if available + else: + base_path = self.display_path if self.display_path else self.file_name + + # Use file size string passed from GUI + if hasattr(self, "file_size_str"): + self.log_updated.emit(f"- File size: {self.file_size_str}") + + self.log_updated.emit(f"- Total characters: {int(self.total_char_count):,}") + + self.log_updated.emit( + f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})" + ) + self.log_updated.emit(f"- Voice: {self.voice}") + self.log_updated.emit(f"- Speed: {self.speed}") + self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}") + self.log_updated.emit(f"- Output format: {self.output_format}") + self.log_updated.emit( + f"- Subtitle format: {next((label for value, label in SUBTITLE_FORMATS if value == getattr(self, 'subtitle_format', 'srt')), getattr(self, 'subtitle_format', 'srt'))}" + ) + self.log_updated.emit( + f"- Use spaCy for sentence segmentation: {'Yes' if getattr(self, 'use_spacy_segmentation', False) else 'No'}" + ) + self.log_updated.emit(f"- Save option: {self.save_option}") + if self.replace_single_newlines: + self.log_updated.emit(f"- Replace single newlines: Yes") + + # Check if input is a subtitle file for additional configuration + is_subtitle_input = False + if not self.is_direct_text and self.file_name: + file_ext = os.path.splitext(self.file_name)[1].lower() + if file_ext in [".srt", ".ass", ".vtt"]: + is_subtitle_input = True + + # Display subtitle-specific options if processing subtitle file + if is_subtitle_input: + if getattr(self, "use_silent_gaps", False): + self.log_updated.emit("- Use silent gaps: Yes") + speed_method = getattr(self, "subtitle_speed_method", "tts") + method_label = ( + "TTS Regeneration" + if speed_method == "tts" + else "FFmpeg Time-stretch" + ) + self.log_updated.emit(f"- Speed adjustment method: {method_label}") + + # Display save_chapters_separately flag if it's set + if hasattr(self, "save_chapters_separately"): + self.log_updated.emit( + ( + f"- Save chapters separately: {'Yes' if self.save_chapters_separately else 'No'}" + ) + ) + # Display merge_chapters_at_end flag if save_chapters_separately is True + if self.save_chapters_separately: + merge_at_end = getattr(self, "merge_chapters_at_end", True) + self.log_updated.emit( + f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}" + ) + # Display the separate chapters format if it's set + separate_format = getattr(self, "separate_chapters_format", "wav") + self.log_updated.emit( + f"- Separate chapters format: {separate_format}" + ) + + # If merge_at_end is True, display the silence duration + if getattr(self, "merge_chapters_at_end", True): + self.log_updated.emit( + f"- Silence between chapters: {self.silence_duration} seconds" + ) + + if self.save_option == "Choose output folder": + self.log_updated.emit( + f"- Output folder: {self.output_folder or os.getcwd()}" + ) + + self.log_updated.emit(("\nInitializing TTS pipeline...", "grey")) + + # Set device based on use_gpu setting and platform + if self.use_gpu: + if platform.system() == "Darwin" and platform.processor() == "arm": + device = "mps" # Use MPS for Apple Silicon + else: + device = "cuda" # Use CUDA for other platforms + else: + device = "cpu" + + tts = self.KPipeline( + lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device + ) + + # Check if the input is a subtitle file or timestamp text file + is_subtitle_file = False + is_timestamp_text = False + if not self.is_direct_text and self.file_name: + file_ext = os.path.splitext(self.file_name)[1].lower() + if file_ext in [".srt", ".ass", ".vtt"]: + is_subtitle_file = True + self.log_updated.emit( + f"\nDetected subtitle file format: {file_ext}" + ) + elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name): + is_timestamp_text = True + self.log_updated.emit( + ("\nDetected timestamps in text file", "grey") + ) + # Signal to ask user (-1 indicates timestamp detection) + self.chapters_detected.emit(-1) + # Wait for user response using event with timeout for responsive cancellation + while not self._timestamp_response_event.wait( + timeout=_USER_RESPONSE_TIMEOUT + ): + if self.cancel_requested: + self.conversion_finished.emit("Cancelled", None) + return + # Check cancellation one more time after event is set + if self.cancel_requested: + self.conversion_finished.emit("Cancelled", None) + return + if not self._timestamp_response: + is_timestamp_text = False + delattr(self, "_timestamp_response") + self._timestamp_response_event.clear() + + # Process subtitle files separately + if is_subtitle_file or is_timestamp_text: + self._process_subtitle_file(tts, base_path, is_timestamp_text) + return + + if self.is_direct_text: + text = self.file_name # Treat file_name as direct text input + else: + encoding = detect_encoding(self.file_name) + with open( + self.file_name, "r", encoding=encoding, errors="replace" + ) as file: + text = file.read() + + # Clean up text using utility function + text = clean_text(text) + + + # --- Chapter splitting logic --- + # Use pre-compiled pattern for better performance + chapter_splits = list(_CHAPTER_MARKER_SEARCH_PATTERN.finditer(text)) + chapters = [] + if chapter_splits: + # prepend Introduction for content before first marker + first_start = chapter_splits[0].start() + if first_start > 0: + intro_text = text[:first_start].strip() + if intro_text: + chapters.append(("Introduction", intro_text)) + for idx, match in enumerate(chapter_splits): + start = match.end() + end = ( + chapter_splits[idx + 1].start() + if idx + 1 < len(chapter_splits) + else len(text) + ) + chapter_name = match.group(1).strip() + chapter_text = text[start:end].strip() + chapters.append((chapter_name, chapter_text)) + else: + chapters = [("text", text)] + total_chapters = len(chapters) + + # For text files with chapters, prompt user for options if not already set + is_txt_file = not self.is_direct_text and ( + self.file_name.lower().endswith(".txt") + or (self.display_path and self.display_path.lower().endswith(".txt")) + ) + + if ( + is_txt_file + and total_chapters > 1 + and ( + not hasattr(self, "save_chapters_separately") + or not hasattr(self, "merge_chapters_at_end") + ) + and not self.chapter_options_set + ): + + # Emit signal to main thread and wait + self.chapters_detected.emit(total_chapters) + self._chapter_options_event.wait() + if self.cancel_requested: + self.conversion_finished.emit("Cancelled", None) + return + self.chapter_options_set = True + + # Log all detected chapters at the beginning + if total_chapters > 1: + chapter_list = "\n".join( + [f"{i+1}) {c[0]}" for i, c in enumerate(chapters)] + ) + self.log_updated.emit( + (f"\nDetected chapters ({total_chapters}):\n" + chapter_list) + ) + else: + self.log_updated.emit((f"\nProcessing {chapters[0][0]}...", "grey")) + + # If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file + save_chapters_separately = getattr(self, "save_chapters_separately", False) + merge_chapters_at_end = getattr(self, "merge_chapters_at_end", True) + + # Ensure merge_chapters_at_end is True if not saving chapters separately + if not save_chapters_separately: + merge_chapters_at_end = True + + chapters_out_dir = None + suffix = "" + + # Use file_name for logs if from_queue, otherwise use display_path if available + if getattr(self, "from_queue", False): + base_path = ( + self.save_base_path or self.file_name + ) # Use save_base_path if available + else: + base_path = self.display_path if self.display_path else self.file_name + + base_name = os.path.splitext(os.path.basename(base_path))[0] + # Sanitize base_name for folder/file creation based on OS + sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) + + if self.save_option == "Save to Desktop": + parent_dir = user_desktop_dir() + elif self.save_option == "Save next to input file": + parent_dir = os.path.dirname(base_path) + else: + parent_dir = self.output_folder or os.getcwd() + # Ensure the output folder exists, error if it doesn't + if not os.path.exists(parent_dir): + self.log_updated.emit( + ( + f"Output folder does not exist: {parent_dir}", + "red", + ) + ) + # Find a unique suffix for both folder and merged file, always + counter = 1 + allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) + while True: + suffix = f"_{counter}" if counter > 1 else "" + chapters_out_dir_candidate = os.path.join( + parent_dir, f"{sanitized_base_name}{suffix}_chapters" + ) + # Only check for files with allowed extensions (extension without dot, case-insensitive) + # Use generator expression to avoid processing all files upfront + file_parts = ( + os.path.splitext(fname) for fname in os.listdir(parent_dir) + ) + clash = any( + name == f"{sanitized_base_name}{suffix}" + and ext[1:].lower() in allowed_exts + for name, ext in file_parts + ) + if not os.path.exists(chapters_out_dir_candidate) and not clash: + break + counter += 1 + if save_chapters_separately and total_chapters > 1: + separate_chapters_format = getattr( + self, "separate_chapters_format", "wav" + ) + chapters_out_dir = chapters_out_dir_candidate + os.makedirs(chapters_out_dir, exist_ok=True) + self.log_updated.emit( + (f"\nChapters output folder: {chapters_out_dir}", "grey") + ) + + # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True + if merge_chapters_at_end: + out_dir = parent_dir + base_filepath_no_ext = os.path.join( + out_dir, f"{sanitized_base_name}{suffix}" + ) + merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" + subtitle_entries = [] + current_time = 0.0 + rate = 24000 + subtitle_mode = self.subtitle_mode + self.etr_start_time = time.time() + self.processed_char_count = 0 + current_segment = 0 + chapters_time = [ + {"chapter": chapter[0], "start": 0.0, "end": 0.0} + for chapter in chapters + ] + # SRT numbering fix: use a global counter + merged_srt_index = 1 # SRT numbering for merged file + # Prepare output file/ffmpeg process for merged output + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file = sf.SoundFile( + merged_out_path, + "w", + samplerate=24000, + channels=1, + format=self.output_format, + ) + ffmpeg_proc = None + elif self.output_format == "m4b": + # Real-time M4B generation using FFmpeg pipe + static_ffmpeg.add_paths() + merged_out_file = None + ffmpeg_proc = None + metadata_options, cover_path = ( + self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + ) + # Prepare ffmpeg command for m4b output + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + "24000", + "-ac", + "1", + "-i", + "pipe:0", + ] + if cover_path and os.path.exists(cover_path): + cmd.extend( + [ + "-i", + cover_path, + "-map", + "0:a", + "-map", + "1", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ] + ) + cmd.extend( + [ + "-c:a", + "aac", + "-q:a", + "2", + "-movflags", + "+faststart+use_metadata_tags", + ] + ) + cmd += metadata_options + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + elif self.output_format == "opus": + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + "24000", + "-ac", + "1", + "-i", + "pipe:0", + ] + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + merged_out_file = None + else: + self.log_updated.emit( + (f"Unsupported output format: {self.output_format}", "red") + ) + self.conversion_finished.emit( + ("Audio generation failed.", "red"), None + ) + return + # Open merged subtitle file for incremental writing if needed + merged_subtitle_file = None + if self.subtitle_mode != "Disabled": + subtitle_format = getattr(self, "subtitle_format", "srt") + file_extension = "ass" if "ass" in subtitle_format else "srt" + merged_subtitle_path = ( + os.path.splitext(merged_out_path)[0] + f".{file_extension}" + ) + # Default subtitle layout flags/strings so they exist regardless + # of whether ASS-specific handling runs. This prevents runtime + # errors when non-ASS formats (like SRT) are selected. + is_centered = False + is_narrow = False + merged_subtitle_margin = "" + merged_subtitle_alignment_tag = "" + if "ass" in subtitle_format: + merged_subtitle_file = open( + merged_subtitle_path, + "w", + encoding="utf-8", + errors="replace", + ) + # Minimal ASS header + merged_subtitle_file.write("[Script Info]\n") + merged_subtitle_file.write("Title: Generated by Abogen\n") + merged_subtitle_file.write("ScriptType: v4.00+\n\n") + # Add style definitions for karaoke highlighting + if self.subtitle_mode == "Sentence + Highlighting": + merged_subtitle_file.write("[V4+ Styles]\n") + merged_subtitle_file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + merged_subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + merged_subtitle_file.write("[Events]\n") + merged_subtitle_file.write( + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + # Set margin/alignment for ASS + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", + ) + is_narrow = subtitle_format in ( + "ass_narrow", + "ass_centered_narrow", + ) + merged_subtitle_margin = "90" if is_narrow else "" + merged_subtitle_alignment_tag = ( + f"{{\\an5}}" if is_centered else "" + ) + else: + merged_subtitle_file = open( + merged_subtitle_path, + "w", + encoding="utf-8", + errors="replace", + ) + else: + merged_subtitle_path = None + merged_subtitle_file = None + else: + # If not merging, set merged_out_file and related variables to None + merged_out_file = None + ffmpeg_proc = None + merged_out_path = None + subtitle_entries = [] + current_time = 0.0 + rate = 24000 + subtitle_mode = self.subtitle_mode + self.etr_start_time = time.time() + self.processed_char_count = 0 + current_segment = 0 + chapters_time = [ + {"chapter": chapter[0], "start": 0.0, "end": 0.0} + for chapter in chapters + ] + srt_index = 1 # SRT numbering fix for chapter-only mode + # Instead of processing the whole text, process by chapter + for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): + chapter_out_path = None + chapter_out_file = None + chapter_ffmpeg_proc = None + chapter_subtitle_file = None + chapter_subtitle_path = None + if total_chapters > 1: + self.log_updated.emit( + ( + f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}", + "blue", + ) + ) + chapter_subtitle_entries = [] + chapter_current_time = 0.0 + # Set chapter start time before processing + chapter_time = chapters_time[chapter_idx - 1] + if merge_chapters_at_end: + chapter_time["start"] = current_time + + # Check if the voice is a formula and load it if necessary + if "*" in self.voice: + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + else: + loaded_voice = self.voice + # Prepare per-chapter output file if needed + if save_chapters_separately and total_chapters > 1: + # First pass: keep alphanumeric, spaces, hyphens, and underscores + sanitized = re.sub(r"[^\w\s\-]", "", chapter_name) + # Replace multiple spaces/hyphens with single underscore + sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_") + # Apply OS-specific sanitization + sanitized = sanitize_name_for_os(sanitized, is_folder=False) + # Limit length (leaving room for the chapter number prefix) + MAX_LEN = 80 + if len(sanitized) > MAX_LEN: + pos = sanitized[:MAX_LEN].rfind("_") + sanitized = sanitized[: pos if pos > 0 else MAX_LEN].rstrip("_") + chapter_filename = f"{chapter_idx:02d}_{sanitized}" + chapter_out_path = os.path.join( + chapters_out_dir, + f"{chapter_filename}.{separate_chapters_format}", + ) + if separate_chapters_format in ["wav", "mp3", "flac"]: + chapter_out_file = sf.SoundFile( + chapter_out_path, + "w", + samplerate=24000, + channels=1, + format=separate_chapters_format, + ) + chapter_ffmpeg_proc = None + elif separate_chapters_format == "opus": + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + "24000", + "-ac", + "1", + "-i", + "pipe:0", + ] + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + cmd.append(chapter_out_path) + chapter_ffmpeg_proc = create_process( + cmd, stdin=subprocess.PIPE, text=False + ) + chapter_out_file = None + else: + self.log_updated.emit( + ( + f"Unsupported chapter format: {separate_chapters_format}", + "red", + ) + ) + continue + # Open chapter subtitle file for incremental writing if needed + chapter_subtitle_file = None + chapter_srt_index = ( + 1 # Initialize SRT numbering for this chapter file + ) + if self.subtitle_mode != "Disabled": + subtitle_format = getattr(self, "subtitle_format", "srt") + file_extension = "ass" if "ass" in subtitle_format else "srt" + chapter_subtitle_path = os.path.join( + chapters_out_dir, f"{chapter_filename}.{file_extension}" + ) + # Ensure these variables exist even when not using ASS so + # later code can safely reference them. + is_centered = False + is_narrow = False + chapter_subtitle_margin = "" + chapter_subtitle_alignment_tag = "" + # Open the chapter subtitle file for writing for both SRT and ASS + chapter_subtitle_file = open( + chapter_subtitle_path, + "w", + encoding="utf-8", + errors="replace", + ) + if "ass" in subtitle_format: + # Minimal ASS header + chapter_subtitle_file.write("[Script Info]\n") + chapter_subtitle_file.write("Title: Generated by Abogen\n") + chapter_subtitle_file.write("ScriptType: v4.00+\n\n") + + # Add style definitions for karaoke highlighting + if self.subtitle_mode == "Sentence + Highlighting": + chapter_subtitle_file.write("[V4+ Styles]\n") + chapter_subtitle_file.write( + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + chapter_subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + + chapter_subtitle_file.write("[Events]\n") + chapter_subtitle_file.write( + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", + ) + is_narrow = subtitle_format in ( + "ass_narrow", + "ass_centered_narrow", + ) + chapter_subtitle_margin = "90" if is_narrow else "" + chapter_subtitle_alignment_tag = ( + f"{{\\an5}}" if is_centered else "" + ) + else: + chapter_subtitle_file = None + else: + chapter_subtitle_path = None + chapter_subtitle_file = None + + # Determine if spaCy segmentation should be used for PRE-TTS segmentation + # Only non-English languages use spaCy for pre-segmentation + # English uses spaCy only for subtitle generation (post-TTS) + # spaCy is disabled when subtitle mode is "Disabled" or "Line" + # spaCy is also disabled when input is a subtitle file + is_subtitle_input = ( + not self.is_direct_text + and self.file_name + and os.path.splitext(self.file_name)[1].lower() + in [".srt", ".ass", ".vtt"] + ) + use_spacy = ( + getattr(self, "use_spacy_segmentation", False) + and self.subtitle_mode not in ["Disabled", "Line"] + and not is_subtitle_input + ) + spacy_sentences = None + active_split_pattern = self.split_pattern + spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+" + + # Pre-load spaCy model for English if it will be needed for subtitle generation + if ( + use_spacy + and self.lang_code in ["a", "b"] + and self.subtitle_mode in ["Sentence", "Sentence + Comma"] + ): + from abogen.spacy_utils import get_spacy_model + + nlp = get_spacy_model( + self.lang_code, + log_callback=lambda msg: self.log_updated.emit(msg), + ) + if nlp: + self.log_updated.emit( + ( + "\nUsing spaCy for sentence segmentation (only for subtitles)...", + "grey", + ) + ) + + if use_spacy and self.lang_code not in ["a", "b"]: + # Non-English: use spaCy for pre-TTS segmentation + self.log_updated.emit( + ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") + ) + from abogen.spacy_utils import segment_sentences + + spacy_sentences = segment_sentences( + chapter_text, + self.lang_code, + log_callback=lambda msg: self.log_updated.emit(msg), + ) + if spacy_sentences: + self.log_updated.emit( + ( + f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...", + "grey", + ) + ) + # For Sentence + Comma mode, still split on commas within spaCy sentences + if self.subtitle_mode == "Sentence + Comma": + active_split_pattern = r"(?<=[{}]){}|\n+".format( + self.PUNCTUATION_COMMAS, spacing_pattern + ) + else: + active_split_pattern = ( + "\n" # Use newline splitting for Sentence mode + ) + else: + self.log_updated.emit( + ("\nspaCy: Fallback to default segmentation...", "grey") + ) + + # Process text - either as spaCy sentences or as single text + text_segments = spacy_sentences if spacy_sentences else [chapter_text] + + # Print active split pattern used by the TTS engine once for this batch + try: + print(f"Using split pattern: {active_split_pattern!r}") + except Exception: + # Print must never break processing + print("Using split pattern: (unprintable)") + + for text_segment in text_segments: + for result in tts( + text_segment, + voice=loaded_voice, + speed=self.speed, + split_pattern=active_split_pattern, + ): + # Print the result for debugging + # print(f"Result: {result}") + if self.cancel_requested: + if chapter_out_file: + chapter_out_file.close() + if merged_out_file: + merged_out_file.close() + self.conversion_finished.emit("Cancelled", None) + return + current_segment += 1 + grapheme_len = len(result.graphemes) + self.processed_char_count += grapheme_len + # Log progress with both character counts and the graphemes content + self.log_updated.emit( + f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" + ) + + chunk_dur = len(result.audio) / rate + chunk_start = current_time + # Write audio directly to merged file ONLY if merging + if merge_chapters_at_end and merged_out_file: + merged_out_file.write(result.audio) + elif merge_chapters_at_end and ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = ( + result.audio.numpy().astype("float32").tobytes() + ) + else: + audio_bytes = result.audio.astype("float32").tobytes() + ffmpeg_proc.stdin.write(audio_bytes) + if chapter_out_file: + chapter_out_file.write(result.audio) + elif chapter_ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = ( + result.audio.numpy().astype("float32").tobytes() + ) + else: + audio_bytes = result.audio.astype("float32").tobytes() + chapter_ffmpeg_proc.stdin.write(audio_bytes) + # Subtitle logic + if self.subtitle_mode != "Disabled": + tokens_list = getattr(result, "tokens", []) + + # Fallback for languages without token support (non-English) + # Create a single token representing the entire segment duration + if not tokens_list and result.graphemes: + + class FakeToken: + def __init__(self, text, start, end): + self.text = text + self.start_ts = start + self.end_ts = end + self.whitespace = "" + + tokens_list = [ + FakeToken(result.graphemes, 0, chunk_dur) + ] + + tokens_with_timestamps = [] + chapter_tokens_with_timestamps = [] + + # Process every token, regardless of text or timestamps + for tok in tokens_list: + tokens_with_timestamps.append( + { + "start": chunk_start + (tok.start_ts or 0), + "end": chunk_start + (tok.end_ts or 0), + "text": tok.text, + "whitespace": tok.whitespace, + } + ) + if chapter_out_file or chapter_ffmpeg_proc: + chapter_tokens_with_timestamps.append( + { + "start": chapter_current_time + + (tok.start_ts or 0), + "end": chapter_current_time + + (tok.end_ts or 0), + "text": tok.text, + "whitespace": tok.whitespace, + } + ) + # Process tokens according to subtitle mode + # Global subtitle processing ONLY if merging + if merge_chapters_at_end: + # Incremental subtitle writing for merged output + new_entries = [] + self._process_subtitle_tokens( + tokens_with_timestamps, + new_entries, + self.max_subtitle_words, + fallback_end_time=chunk_start + chunk_dur, + ) + if merged_subtitle_file: + subtitle_format = getattr( + self, "subtitle_format", "srt" + ) + if "ass" in subtitle_format: + for start, end, text in new_entries: + start_time = self._ass_time(start) + end_time = self._ass_time(end) + # Use karaoke effect for highlighting mode + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) + merged_subtitle_file.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" + ) + else: + for entry in new_entries: + start, end, text = entry + merged_subtitle_file.write( + f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" + ) + merged_srt_index += 1 + # Per-chapter subtitle processing for both file and ffmpeg_proc + if chapter_out_file or chapter_ffmpeg_proc: + new_chapter_entries = [] + self._process_subtitle_tokens( + chapter_tokens_with_timestamps, + new_chapter_entries, + self.max_subtitle_words, + fallback_end_time=chapter_current_time + chunk_dur, + ) + if chapter_subtitle_file: + subtitle_format = getattr( + self, "subtitle_format", "srt" + ) + if "ass" in subtitle_format: + for start, end, text in new_chapter_entries: + start_time = self._ass_time(start) + end_time = self._ass_time(end) + # Use karaoke effect for highlighting mode + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) + chapter_subtitle_file.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" + ) + else: + for entry in new_chapter_entries: + start, end, text = entry + chapter_subtitle_file.write( + f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" + ) + chapter_srt_index += 1 + if merge_chapters_at_end: + current_time += chunk_dur + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + else: + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + # Calculate percentage based on characters processed + percent = min( + int( + self.processed_char_count / self.total_char_count * 100 + ), + 99, + ) + + # Calculate ETR based on characters processed + etr_str = "Processing..." + chars_done = self.processed_char_count + elapsed = time.time() - self.etr_start_time + + # Calculate ETR if enough data is available + if ( + chars_done > 0 and elapsed > 0.5 + ): # Check elapsed > 0.5 to avoid instability + avg_time_per_char = elapsed / chars_done + remaining = ( + self.total_char_count - self.processed_char_count + ) + if remaining > 0: + secs = avg_time_per_char * remaining + h = int(secs // 3600) + m = int((secs % 3600) // 60) + s = int(secs % 60) + etr_str = f"{h:02d}:{m:02d}:{s:02d}" + + # Update progress more frequently (after each result) + self.progress_updated.emit(percent, etr_str) + + # Add silence between chapters for merged output (except after the last chapter) + if merge_chapters_at_end and chapter_idx < total_chapters: + silence_samples = int( + self.silence_duration * 24000 + ) # Silence duration at 24,000 Hz + silence_audio = self.np.zeros(silence_samples, dtype="float32") + silence_bytes = silence_audio.tobytes() + + if merged_out_file: + merged_out_file.write(silence_audio) + elif ffmpeg_proc: + ffmpeg_proc.stdin.write(silence_bytes) + + # Update timing for the silence + current_time += self.silence_duration + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += self.silence_duration + + # Set chapter end time after processing + if merge_chapters_at_end: + chapter_time["end"] = current_time + # Finalize chapter file for ffmpeg formats + if chapter_out_file or chapter_ffmpeg_proc: + self.log_updated.emit(("\nProcessing chapter audio...", "grey")) + if chapter_ffmpeg_proc: + chapter_ffmpeg_proc.stdin.close() + chapter_ffmpeg_proc.wait() + if chapter_out_file: + chapter_out_file.close() + # Close chapter subtitle file if open + if chapter_subtitle_file: + chapter_subtitle_file.close() + if ( + save_chapters_separately + and total_chapters > 1 + and self.subtitle_mode != "Disabled" + and chapter_subtitle_path + ): + self.log_updated.emit( + ( + f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nChapter subtitle saved to: {chapter_subtitle_path}", + "green", + ) + ) + elif chapter_out_path: + self.log_updated.emit( + ( + f"\nChapter {chapter_idx} saved to: {chapter_out_path}", + "green", + ) + ) + # Finalize merged output file ONLY if merging + if merge_chapters_at_end: + self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file.close() + elif self.output_format == "m4b": + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + # Add chapters via fast post-processing + if total_chapters > 1: + chapters_info_path = f"{base_filepath_no_ext}_chapters.txt" + with open(chapters_info_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + for chapter in chapters_time: + chapter_title = chapter["chapter"].replace("=", "\\=") + f.write(f"[CHAPTER]\n") + f.write(f"TIMEBASE=1/1000\n") + f.write(f"START={int(chapter['start']*1000)}\n") + f.write(f"END={int(chapter['end']*1000)}\n") + f.write(f"title={chapter_title}\n\n") + # Fast mux chapters into m4b (write to temp file, then replace original) + static_ffmpeg.add_paths() + orig_path = merged_out_path + root, ext = os.path.splitext(orig_path) + tmp_path = root + ".tmp" + ext + metadata_options, cover_path = ( + self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + ) + cmd = [ + "ffmpeg", + "-y", + "-i", + orig_path, + "-i", + chapters_info_path, + ] + if cover_path and os.path.exists(cover_path): + cmd.extend( + [ + "-i", + cover_path, + "-map", + "0:a", + "-map", + "2", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ] + ) + else: + cmd.extend(["-map", "0:a"]) + + cmd.extend( + [ + "-map_metadata", + "1", + "-map_chapters", + "1", + "-c:a", + "copy", + ] + ) + cmd += metadata_options + cmd.append(tmp_path) + proc = create_process(cmd) + proc.wait() + os.replace(tmp_path, orig_path) + os.remove(chapters_info_path) + elif self.output_format in ["opus"]: + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + self.progress_updated.emit(100, "00:00:00") + # Close merged subtitle file if open + if merged_subtitle_file: + merged_subtitle_file.close() + # Subtitle and final message logic + if merge_chapters_at_end: + if self.subtitle_mode != "Disabled": + self.conversion_finished.emit( + ( + f"\nAudio saved to: {merged_out_path}\n\nSubtitle saved to: {merged_subtitle_path}", + "green", + ), + merged_out_path, + ) + else: + self.conversion_finished.emit( + (f"\nAudio saved to: {merged_out_path}", "green"), + merged_out_path, + ) + else: + # If not merging, report the folder that holds the chapter files + self.progress_updated.emit(100, "00:00:00") + chapters_dir = os.path.abspath(chapters_out_dir or parent_dir) + self.conversion_finished.emit( + (f"\nAll chapters saved to: {chapters_dir}", "green"), + chapters_dir, + ) + except Exception as e: + # Cleanup ffmpeg subprocesses on error + try: + if "ffmpeg_proc" in locals() and ffmpeg_proc: + ffmpeg_proc.stdin.close() + ffmpeg_proc.terminate() + ffmpeg_proc.wait() + except Exception: + pass + try: + if "chapter_ffmpeg_proc" in locals() and chapter_ffmpeg_proc: + chapter_ffmpeg_proc.stdin.close() + chapter_ffmpeg_proc.terminate() + chapter_ffmpeg_proc.wait() + except Exception: + pass + self.log_updated.emit((f"Error occurred: {str(e)}", "red")) + self.conversion_finished.emit(("Audio generation failed.", "red"), None) + + def _process_subtitle_file(self, tts, base_path, is_timestamp_text=False): + """Process subtitle files with precise timing and generate output subtitles.""" + try: + # Parse subtitle file + if is_timestamp_text: + subtitles = parse_timestamp_text_file(self.file_name) + else: + file_ext = os.path.splitext(self.file_name)[1].lower() + if file_ext == ".srt": + subtitles = parse_srt_file(self.file_name) + elif file_ext == ".vtt": + subtitles = parse_vtt_file(self.file_name) + else: + subtitles = parse_ass_file(self.file_name) + + if not subtitles: + self.log_updated.emit(("No valid subtitle entries found.", "red")) + self.conversion_finished.emit( + ("No subtitle entries to process.", "red"), None + ) + return + + self.log_updated.emit( + (f"\nFound {len(subtitles)} subtitle entries", "grey") + ) + + # Setup output paths + base_name = os.path.splitext(os.path.basename(base_path))[0] + sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) + parent_dir = ( + user_desktop_dir() + if self.save_option == "Save to Desktop" + else ( + os.path.dirname(base_path) + if self.save_option == "Save next to input file" + else self.output_folder or os.getcwd() + ) + ) + + if not os.path.exists(parent_dir): + self.log_updated.emit( + (f"Output folder does not exist: {parent_dir}", "red") + ) + return + + # Find unique filename + counter = 1 + allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) + while True: + suffix = f"_{counter}" if counter > 1 else "" + # Use generator expression to avoid processing all files upfront + file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir)) + if not any( + name == f"{sanitized_base_name}{suffix}" + and ext[1:].lower() in allowed_exts + for name, ext in file_parts + ): + break + counter += 1 + + base_filepath_no_ext = os.path.join( + parent_dir, f"{sanitized_base_name}{suffix}" + ) + merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" + rate = 24000 + + # Setup audio output + merged_out_file, ffmpeg_proc = None, None + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file = sf.SoundFile( + merged_out_path, + "w", + samplerate=rate, + channels=1, + format=self.output_format, + ) + else: + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", + "-y", + "-thread_queue_size", + "32768", + "-f", + "f32le", + "-ar", + str(rate), + "-ac", + "1", + "-i", + "pipe:0", + ] + if self.output_format == "m4b": + metadata_options, cover_path = ( + self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + ) + if cover_path and os.path.exists(cover_path): + cmd.extend( + [ + "-i", + cover_path, + "-map", + "0:a", + "-map", + "1", + "-c:v", + "copy", + "-disposition:v", + "attached_pic", + ] + ) + cmd.extend( + [ + "-c:a", + "aac", + "-q:a", + "2", + "-movflags", + "+faststart+use_metadata_tags", + ] + ) + cmd.extend(metadata_options) + elif self.output_format == "opus": + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + else: + self.log_updated.emit( + (f"Unsupported output format: {self.output_format}", "red") + ) + return + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + + # Always generate subtitles for subtitle input files + subtitle_file, subtitle_path = None, None + subtitle_format = getattr(self, "subtitle_format", "srt") + file_extension = "ass" if "ass" in subtitle_format else "srt" + subtitle_path = f"{base_filepath_no_ext}.{file_extension}" + subtitle_file = open(subtitle_path, "w", encoding="utf-8", errors="replace") + + if "ass" in subtitle_format: + # Write ASS header + subtitle_file.write( + "[Script Info]\nTitle: Generated by Abogen\nScriptType: v4.00+\n\n" + ) + if self.subtitle_mode == "Sentence + Highlighting": + subtitle_file.write( + "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" + ) + subtitle_file.write( + "Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n" + ) + subtitle_file.write( + "[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + + is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow") + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", + ) + margin = "90" if is_narrow else "" + alignment = "{\\an5}" if is_centered else "" + + # Load voice + loaded_voice = ( + get_new_voice(tts, self.voice, self.use_gpu) + if "*" in self.voice + else self.voice + ) + + # Calculate initial audio buffer size from timed subtitles only + max_end_time = max( + (end for _, end, _ in subtitles if end is not None), default=0 + ) + audio_buffer = self.np.zeros( + int(max_end_time * rate) + rate, dtype="float32" + ) + + # Process each subtitle and mix into buffer + self.etr_start_time = time.time() + srt_index = 1 + + for idx, (start_time, end_time, text) in enumerate(subtitles, 1): + if self.cancel_requested: + if subtitle_file: + subtitle_file.close() + self.conversion_finished.emit("Cancelled", None) + return + + # Process text and timing + replace_nl = getattr(self, "replace_single_newlines", True) + processed_text = text.replace("\n", " ") if replace_nl else text + use_gaps = getattr(self, "use_silent_gaps", False) + next_start = ( + subtitles[idx][0] + if (use_gaps and idx < len(subtitles)) + else float("inf") + ) + subtitle_duration = None if end_time is None else end_time - start_time + + h1, m1, s1 = ( + int(start_time // 3600), + int(start_time % 3600 // 60), + int(start_time % 60), + ) + ms1 = int((start_time - int(start_time)) * 1000) + is_last = ( + is_timestamp_text + or (use_gaps and idx == len(subtitles)) + or end_time is None + ) + if is_last: + time_str = ( + f"{h1:02d}:{m1:02d}:{s1:02d}" + + (f",{ms1:03d}" if ms1 > 0 else "") + + " - AUTO" + ) + else: + h2, m2, s2 = ( + int(end_time // 3600), + int(end_time % 3600 // 60), + int(end_time % 60), + ) + ms2 = int((end_time - int(end_time)) * 1000) + time_str = ( + f"{h1:02d}:{m1:02d}:{s1:02d}" + + (f",{ms1:03d}" if ms1 > 0 else "") + + " - " + + f"{h2:02d}:{m2:02d}:{s2:02d}" + + (f",{ms2:03d}" if ms2 > 0 else "") + ) + self.log_updated.emit( + f"\n[{idx}/{len(subtitles)}] {time_str}: {processed_text}" + ) + + # Generate TTS audio + tts_results = [ + r + for r in tts( + processed_text, + voice=loaded_voice, + speed=self.speed, + split_pattern=None, + ) + if not self.cancel_requested + ] + audio_chunks = [r.audio for r in tts_results] + + if self.cancel_requested: + if subtitle_file: + subtitle_file.close() + self.conversion_finished.emit("Cancelled", None) + return + + # Concatenate audio and determine duration + full_audio = ( + self.np.concatenate( + [a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks] + ) + if audio_chunks + else self.np.zeros( + int((subtitle_duration or 0) * rate), dtype="float32" + ) + ) + audio_duration = len(full_audio) / rate + + # Use actual audio length for timing + if is_timestamp_text: + end_time = start_time + audio_duration + subtitle_duration = audio_duration + elif use_gaps: + end_time = min(start_time + audio_duration, next_start) + subtitle_duration = end_time - start_time + elif subtitle_duration is None: + subtitle_duration = audio_duration + end_time = start_time + audio_duration + + # Speed up if needed + speedup_threshold = ( + next_start - start_time if use_gaps else subtitle_duration + ) + if audio_duration > speedup_threshold: + speed_factor = audio_duration / speedup_threshold + + if getattr(self, "subtitle_speed_method", "tts") == "ffmpeg": + # FFmpeg time-stretch (faster processing) + self.log_updated.emit( + (f" -> FFmpeg time-stretch: {speed_factor:.2f}x", "grey") + ) + + static_ffmpeg.add_paths() + num_stages = max( + 1, + int( + self.np.ceil( + self.np.log(speed_factor) / self.np.log(2.0) + ) + ), + ) + tempo = speed_factor ** (1.0 / num_stages) + filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages) + + speed_proc = subprocess.Popen( + [ + "ffmpeg", + "-y", + "-f", + "f32le", + "-ar", + str(rate), + "-ac", + "1", + "-i", + "pipe:0", + "-filter:a", + filter_str, + "-f", + "f32le", + "-ar", + str(rate), + "-ac", + "1", + "pipe:1", + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + full_audio = self.np.frombuffer( + speed_proc.communicate(input=full_audio.tobytes())[0], + dtype="float32", + ) + audio_duration = len(full_audio) / rate + else: + # TTS regeneration (better quality) + new_speed = self.speed * speed_factor + self.log_updated.emit( + (f" -> Regenerating at {new_speed:.2f}x speed", "grey") + ) + + tts_results = [ + r + for r in tts( + processed_text, + voice=loaded_voice, + speed=new_speed, + split_pattern=None, + ) + if not self.cancel_requested + ] + audio_chunks = [r.audio for r in tts_results] + + full_audio = ( + self.np.concatenate( + [ + a.numpy() if hasattr(a, "numpy") else a + for a in audio_chunks + ] + ) + if audio_chunks + else self.np.zeros( + int(subtitle_duration * rate), dtype="float32" + ) + ) + audio_duration = len(full_audio) / rate + + # Adjust duration after potential speed changes + if use_gaps: + end_time = min(start_time + audio_duration, next_start) + subtitle_duration = end_time - start_time + elif subtitle_duration is None: + subtitle_duration = audio_duration + end_time = start_time + audio_duration + + # Pad or trim to subtitle duration + target_samples = int(subtitle_duration * rate) + if len(full_audio) < target_samples: + full_audio = self.np.concatenate( + [ + full_audio, + self.np.zeros( + target_samples - len(full_audio), dtype="float32" + ), + ] + ) + elif len(full_audio) > target_samples: + full_audio = full_audio[:target_samples] + + # Mix audio into buffer at the correct position (handles overlaps) + start_sample = int(start_time * rate) + end_sample = start_sample + len(full_audio) + if end_sample > len(audio_buffer): + # Extend buffer if needed + audio_buffer = self.np.concatenate( + [ + audio_buffer, + self.np.zeros( + end_sample - len(audio_buffer), dtype="float32" + ), + ] + ) + + # Mix (add) the audio - this handles overlaps by combining them + audio_buffer[start_sample:end_sample] += full_audio + + # Write subtitle + if subtitle_file: + if "ass" in subtitle_format: + effect = ( + "karaoke" + if self.subtitle_mode == "Sentence + Highlighting" + else "" + ) + ass_text = ( + processed_text + if replace_nl + else processed_text.replace("\n", "\\N") + ) + subtitle_file.write( + f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n" + ) + else: + subtitle_file.write( + f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n" + ) + srt_index += 1 + + # Update progress + percent = min(int(idx / len(subtitles) * 100), 99) + elapsed = time.time() - self.etr_start_time + etr_str = ( + "Processing..." + if elapsed <= 0.5 + else f"{int(elapsed*(len(subtitles)-idx)/idx)//3600:02d}:{(int(elapsed*(len(subtitles)-idx)/idx)%3600)//60:02d}:{int(elapsed*(len(subtitles)-idx)/idx)%60:02d}" + ) + self.progress_updated.emit(percent, etr_str) + + # Normalize audio buffer to prevent clipping from mixed overlaps + max_amplitude = self.np.abs(audio_buffer).max() + if max_amplitude > 1.0: + self.log_updated.emit( + f"\n -> Normalizing audio (peak: {max_amplitude:.2f})" + ) + audio_buffer = audio_buffer / max_amplitude + + # Write the complete audio buffer + self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) + if merged_out_file: + merged_out_file.write(audio_buffer) + merged_out_file.close() + elif ffmpeg_proc: + ffmpeg_proc.stdin.write(audio_buffer.astype("float32").tobytes()) + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + + if subtitle_file: + subtitle_file.close() + + self.progress_updated.emit(100, "00:00:00") + result_msg = f"\nAudio saved to: {merged_out_path}" + ( + f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else "" + ) + self.conversion_finished.emit((result_msg, "green"), merged_out_path) + + except Exception as e: + try: + if "ffmpeg_proc" in locals() and ffmpeg_proc: + ffmpeg_proc.stdin.close() + ffmpeg_proc.terminate() + ffmpeg_proc.wait() + if "subtitle_file" in locals() and subtitle_file: + subtitle_file.close() + except: + pass + self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red")) + self.conversion_finished.emit(("Audio generation failed.", "red"), None) + + def set_chapter_options(self, options): + """Set chapter options from the dialog and resume processing""" + self.save_chapters_separately = options["save_chapters_separately"] + self.merge_chapters_at_end = options["merge_chapters_at_end"] + self.waiting_for_user_input = False + self._chapter_options_event.set() + + def set_timestamp_response(self, treat_as_subtitle): + """Set whether to treat timestamp text file as subtitle.""" + self._timestamp_response = treat_as_subtitle + self._timestamp_response_event.set() + + def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self): + """Extract metadata tags from text content and add them to ffmpeg command""" + metadata_options = [] + + # Get the input text (either direct or from file) + text = "" + if self.is_direct_text: + text = self.file_name + else: + try: + encoding = detect_encoding(self.file_name) + with open( + self.file_name, "r", encoding=encoding, errors="replace" + ) as file: + text = file.read() + except Exception as e: + self.log_updated.emit( + f"Warning: Could not read file for metadata extraction: {e}" + ) + return [] + + # Extract metadata tags using regex + title_match = re.search(r"<]*)>>", text) + artist_match = re.search(r"<]*)>>", text) + album_match = re.search(r"<]*)>>", text) + year_match = re.search(r"<]*)>>", text) + album_artist_match = re.search(r"<]*)>>", text) + composer_match = re.search(r"<]*)>>", text) + genre_match = re.search(r"<]*)>>", text) + cover_match = re.search(r"<]*)>>", text) + cover_path = cover_match.group(1) if cover_match else None + + # Use display path or filename as fallback for title + + # Use file_name for logs if from_queue, otherwise use display_path if available + if getattr(self, "from_queue", False): + filename = os.path.splitext(os.path.basename(self.file_name))[0] + else: + filename = os.path.splitext( + os.path.basename( + self.display_path if self.display_path else self.file_name + ) + )[0] + + if title_match: + metadata_options.extend(["-metadata", f"title={title_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"title={filename}"]) + + # Add artist metadata + if artist_match: + metadata_options.extend(["-metadata", f"artist={artist_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"artist=Unknown"]) + + # Add album metadata + if album_match: + metadata_options.extend(["-metadata", f"album={album_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"album={filename}"]) + + # Add year metadata + if year_match: + metadata_options.extend(["-metadata", f"date={year_match.group(1)}"]) + else: + # Use current year if year is not specified + import datetime + + current_year = datetime.datetime.now().year + metadata_options.extend(["-metadata", f"date={current_year}"]) + + # Add album artist metadata + if album_artist_match: + metadata_options.extend( + ["-metadata", f"album_artist={album_artist_match.group(1)}"] + ) + else: + metadata_options.extend(["-metadata", f"album_artist=Unknown"]) + + # Add composer metadata + if composer_match: + metadata_options.extend( + ["-metadata", f"composer={composer_match.group(1)}"] + ) + else: + metadata_options.extend(["-metadata", f"composer=Narrator"]) + + # Add genre metadata + if genre_match: + metadata_options.extend(["-metadata", f"genre={genre_match.group(1)}"]) + else: + metadata_options.extend(["-metadata", f"genre=Audiobook"]) + + # Add these to ffmpeg command + return metadata_options, cover_path + + def _srt_time(self, t): + """Helper function to format time for SRT files""" + h = int(t // 3600) + m = int((t % 3600) // 60) + s = int(t % 60) + ms = int((t - int(t)) * 1000) + return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" + + def _ass_time(self, t): + """Helper function to format time for ASS files""" + h = int(t // 3600) + m = int((t % 3600) // 60) + s = int(t % 60) + cs = int((t - int(t)) * 100) # Centiseconds for ASS format + return f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}" + + def _process_subtitle_tokens( + self, + tokens_with_timestamps, + subtitle_entries, + max_subtitle_words, + fallback_end_time=None, + ): + """Helper function to process subtitle tokens according to the subtitle mode""" + if not tokens_with_timestamps: + return + + processed_tokens = tokens_with_timestamps # Use tokens directly + + # For English with spaCy enabled and sentence-based modes, use spaCy for sentence boundaries + # spaCy is disabled when subtitle mode is "Disabled" or "Line" + use_spacy_for_english = ( + getattr(self, "use_spacy_segmentation", False) + and self.subtitle_mode not in ["Disabled", "Line"] + and self.lang_code in ["a", "b"] + and self.subtitle_mode in ["Sentence", "Sentence + Comma"] + ) + # Use processed_tokens instead of tokens_with_timestamps for the rest of the method + if self.subtitle_mode == "Sentence + Highlighting": + # Sentence-based processing with karaoke highlighting + # Use punctuation without comma + separator = r"[{}]".format(self.PUNCTUATION_SENTENCE) + current_sentence = [] + word_count = 0 + + for token in processed_tokens: # Updated to use processed_tokens + current_sentence.append(token) + word_count += 1 + + # Split sentences based on separator or word count + if ( + re.search(separator, token["text"]) and token["whitespace"] == " " + ) or word_count >= max_subtitle_words: + if current_sentence: + # Create karaoke subtitle entry for this sentence + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Generate karaoke text with background highlighting + karaoke_text = "" + for t in current_sentence: + # Calculate duration in centiseconds + duration = ( + t["end"] - t["start"] + if t["end"] and t["start"] + else 0.5 + ) + duration_cs = int(duration * 100) + # Add karaoke effect - relies on style's SecondaryColour for highlighting + karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" + + subtitle_entries.append( + (start_time, end_time, karaoke_text.strip()) + ) + current_sentence = [] + word_count = 0 + + # Add any remaining tokens as a sentence + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Generate karaoke text for remaining tokens + karaoke_text = "" + for t in current_sentence: + duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5 + duration_cs = int(duration * 100) + karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}" + subtitle_entries.append((start_time, end_time, karaoke_text.strip())) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + + elif self.subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]: + # Check if we should use spaCy for English sentence boundaries + if use_spacy_for_english and self.subtitle_mode != "Line": + # Use spaCy for English sentence boundary detection (model already loaded) + from abogen.spacy_utils import get_spacy_model + + nlp = get_spacy_model( + self.lang_code + ) # No log_callback since model is already loaded + if nlp: + # Build full text and track character positions to token indices + full_text = "" + char_to_token = [] # Maps character index to token index + for idx, token in enumerate(processed_tokens): + start_char = len(full_text) + text_part = token["text"] + (token.get("whitespace", "") or "") + full_text += text_part + char_to_token.extend([idx] * len(text_part)) + + # Get sentence boundaries from spaCy + doc = nlp(full_text) + sentence_boundaries = [sent.end_char for sent in doc.sents] + + # For "Sentence + Comma" mode, also split on commas + if self.subtitle_mode == "Sentence + Comma": + comma_positions = [ + i + 1 for i, c in enumerate(full_text) if c == "," + ] + sentence_boundaries = sorted( + set(sentence_boundaries + comma_positions) + ) + + # Group tokens by sentence boundaries + current_sentence = [] + word_count = 0 + current_char_pos = 0 + boundary_idx = 0 + + for idx, token in enumerate(processed_tokens): + current_sentence.append(token) + word_count += 1 + text_len = len(token["text"]) + len( + token.get("whitespace", "") or "" + ) + current_char_pos += text_len + + # Check if we've hit a sentence boundary or max words + at_boundary = ( + boundary_idx < len(sentence_boundaries) + and current_char_pos >= sentence_boundaries[boundary_idx] + ) + if at_boundary or word_count >= max_subtitle_words: + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + sentence_text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_sentence + ) + subtitle_entries.append( + (start_time, end_time, sentence_text.strip()) + ) + current_sentence = [] + word_count = 0 + if at_boundary: + boundary_idx += 1 + + # Add remaining tokens + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + sentence_text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_sentence + ) + subtitle_entries.append( + (start_time, end_time, sentence_text.strip()) + ) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + return # Exit early, spaCy processing complete + + # Default regex-based processing (non-English or spaCy unavailable) + # Define separator pattern based on mode + if self.subtitle_mode == "Line": + separator = r"\n" + elif self.subtitle_mode == "Sentence": + # Use punctuation without comma + separator = r"[{}]".format(self.PUNCTUATION_SENTENCE) + else: # Sentence + Comma + # Use punctuation with comma + separator = r"[{}]".format(self.PUNCTUATION_SENTENCE_COMMA) + current_sentence = [] + word_count = 0 + + for token in processed_tokens: # Updated to use processed_tokens + current_sentence.append(token) + word_count += 1 + + # Split sentences based on separator or word count + if ( + re.search(separator, token["text"]) and token["whitespace"] == " " + ) or word_count >= max_subtitle_words: + if current_sentence: + # Create subtitle entry for this sentence + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Simplified text joining logic + sentence_text = "" + for t in current_sentence: + sentence_text += t["text"] + (t.get("whitespace", "") or "") + + subtitle_entries.append( + (start_time, end_time, sentence_text.strip()) + ) + current_sentence = [] + word_count = 0 + + # Add any remaining tokens as a sentence + if current_sentence: + start_time = current_sentence[0]["start"] + end_time = current_sentence[-1]["end"] + + # Simplified text joining logic + sentence_text = "" + for t in current_sentence: + sentence_text += t["text"] + (t.get("whitespace", "") or "") + subtitle_entries.append((start_time, end_time, sentence_text.strip())) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + + else: + # Word count-based grouping - simply count spaces and split after N spaces + try: + word_count = int(self.subtitle_mode.split()[0]) + word_count = min(word_count, max_subtitle_words) + except (ValueError, IndexError): + word_count = 1 + + current_group = [] + space_count = 0 + + for token in processed_tokens: + current_group.append(token) + + # Count spaces after tokens (in the whitespace field) + if token.get("whitespace", "") == " ": + space_count += 1 + + # Split after counting N spaces + if space_count >= word_count: + text = "".join( + t["text"] + (t.get("whitespace", "") or "") + for t in current_group + ) + subtitle_entries.append( + ( + current_group[0]["start"], + current_group[-1]["end"], + text.strip(), + ) + ) + current_group = [] + space_count = 0 + + # Add any remaining tokens + if current_group: + text = "".join( + t["text"] + (t.get("whitespace", "") or "") for t in current_group + ) + subtitle_entries.append( + (current_group[0]["start"], current_group[-1]["end"], text.strip()) + ) + + # Fallback for last entry + if subtitle_entries and fallback_end_time is not None: + last_entry = subtitle_entries[-1] + start, end, text = last_entry + if end is None or end <= start or end <= 0: + subtitle_entries[-1] = (start, fallback_end_time, text) + + def cancel(self): + self.cancel_requested = True + self.should_cancel = True + self.waiting_for_user_input = False + # Terminate subprocess if running + if self.process: + try: + self.process.terminate() + except Exception: + pass + # Terminate ffmpeg subprocesses if running + try: + if hasattr(self, "ffmpeg_proc") and self.ffmpeg_proc: + self.ffmpeg_proc.stdin.close() + self.ffmpeg_proc.terminate() + self.ffmpeg_proc.wait() + except Exception: + pass + try: + if hasattr(self, "chapter_ffmpeg_proc") and self.chapter_ffmpeg_proc: + self.chapter_ffmpeg_proc.stdin.close() + self.chapter_ffmpeg_proc.terminate() + self.chapter_ffmpeg_proc.wait() + except Exception: + pass + + +class VoicePreviewThread(QThread): + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__( + self, + np_module, + kpipeline_class, + lang_code, + voice, + speed, + use_gpu=False, + parent=None, + ): + super().__init__(parent) + self.np_module = np_module + self.kpipeline_class = kpipeline_class + self.lang_code = lang_code + self.voice = voice + self.speed = speed + self.use_gpu = use_gpu + + # Cache location for preview audio + self.cache_dir = get_user_cache_path("preview_cache") + + # Calculate cache path + self.cache_path = self._get_cache_path() + + def _get_cache_path(self): + """Generate a unique filename for the voice with its parameters""" + # For a voice formula, use a hash of the formula + if "*" in self.voice: + voice_id = ( + f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}" + ) + else: + voice_id = self.voice + + # Create a unique filename based on voice_id, language, and speed + filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav" + return os.path.join(self.cache_dir, filename) + + def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" + ) + + # Generate the preview and save to cache + try: + + # Set device based on use_gpu setting and platform + if self.use_gpu: + if platform.system() == "Darwin" and platform.processor() == "arm": + device = "mps" # Use MPS for Apple Silicon + else: + device = "cuda" # Use CUDA for other platforms + else: + device = "cpu" + + tts = self.kpipeline_class( + lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device + ) + # Enable voice formula support for preview + if "*" in self.voice: + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + else: + loaded_voice = self.voice + sample_text = get_sample_voice_text(self.lang_code) + audio_segments = [] + for result in tts( + sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None + ): + audio_segments.append(result.audio) + if audio_segments: + audio = self.np_module.concatenate(audio_segments) + # Save directly to the cache path + sf.write(self.cache_path, audio, 24000) + self.temp_wav = self.cache_path + self.finished.emit() + except Exception as e: + self.error.emit(f"Voice preview error: {str(e)}") + + +class PlayAudioThread(QThread): + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, wav_path, parent=None): + super().__init__(parent) + self.wav_path = wav_path + self.is_canceled = False + + def run(self): + try: + import pygame + import time as _time + + pygame.mixer.init() + pygame.mixer.music.load(self.wav_path) + pygame.mixer.music.play() + # Wait until playback is finished or canceled + while pygame.mixer.music.get_busy() and not self.is_canceled: + _time.sleep(0.2) + + # Make sure to clean up regardless of how we exited the loop + try: + pygame.mixer.music.stop() + pygame.mixer.music.unload() + pygame.mixer.quit() # Quit the mixer + except Exception: + # Ignore any errors during cleanup + pass + + self.finished.emit() + except Exception as e: + # Handle initialization errors separately to give better error messages + if "mixer not initialized" in str(e): + self.error.emit( + "Audio playback error: The audio system was not properly initialized" + ) + else: + self.error.emit(f"Audio playback error: {str(e)}") + + def stop(self): + """Safely stop playback""" + self.is_canceled = True + # Try to stop pygame if it's running, but catch all exceptions + try: + import pygame + + if pygame.mixer.get_init(): + if pygame.mixer.music.get_busy(): + pygame.mixer.music.stop() + pygame.mixer.music.unload() + except Exception: + # Ignore all errors when stopping since mixer might not be initialized + pass diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py new file mode 100644 index 0000000..2916645 --- /dev/null +++ b/abogen/pyqt/gui.py @@ -0,0 +1,4049 @@ +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.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread +from abogen.pyqt.book_handler import HandlerDialog +from abogen.constants import ( + PROGRAM_NAME, + VERSION, + GITHUB_URL, + PROGRAM_DESCRIPTION, + LANGUAGE_DESCRIPTIONS, + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + COLORS, + SUBTITLE_FORMATS, +) +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'
    {name}
    Size: {size_str}
    Characters: {format_num(char_count)}' + ) + # Set fixed width to force wrapping + self.setWordWrap(True) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" + ) + self.clear_btn.show() + is_document = window.selected_file_type in ["epub", "pdf", "md", "markdown"] + self.chapters_btn.setVisible(is_document) + if is_document: + chapter_count = len(window.selected_chapters) + file_type = window.selected_file_type + # Adjust button text based on file type + if file_type == "epub" or file_type == "md" or file_type == "markdown": + self.chapters_btn.setText(f"Chapters ({chapter_count})") + else: # PDF - always use Pages + self.chapters_btn.setText(f"Pages ({chapter_count})") + + # Hide textbox and show edit only for .txt, .srt, .ass, .vtt files + self.textbox_btn.hide() + # Show edit button for txt/subtitle files directly + # Or for epub/pdf files that have generated a temp txt file + should_show_edit = file_path.lower().endswith((".txt", ".srt", ".ass", ".vtt")) + + # For epub/pdf files, show edit if we have a selected_file (temp txt) + if ( + window.selected_file_type + in ["epub", "pdf", "md", "markdown", "md", "markdown"] + and window.selected_file + ): + should_show_edit = True + + self.edit_btn.setVisible(should_show_edit) + self.go_to_folder_btn.show() + + # Disable subtitle generation for subtitle input files + is_subtitle_input = file_path.lower().endswith((".srt", ".ass", ".vtt")) + if hasattr(window, "subtitle_combo"): + window.subtitle_combo.setEnabled(not is_subtitle_input) + + # Enable add to queue button only when file is accepted (input box is green) + self.resizeEvent(None) + if hasattr(window, "btn_add_to_queue"): + window.btn_add_to_queue.setEnabled(True) + + self.chapters_btn.adjustSize() + # Reset the input_box_cleared_by_queue flag after setting file info + if hasattr(window, "input_box_cleared_by_queue"): + window.input_box_cleared_by_queue = False + + def set_error(self, message): + self.setText(message) + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" + ) + # Show textbox button in error state as well + self.textbox_btn.show() + # Disable add to queue button on error + if hasattr(self.window(), "btn_add_to_queue"): + self.window().btn_add_to_queue.setEnabled(False) + + def clear_input(self): + self.window().selected_file = None + self.window().displayed_file_path = ( + None # Reset the displayed file path when clearing input + ) + # Reset book handler attributes + self.window().save_chapters_separately = None + self.window().merge_chapters_at_end = None + 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.clear_btn.hide() + self.chapters_btn.hide() + self.chapters_btn.setText("Chapters") # Reset text + # Show textbox and hide edit when input is cleared + self.textbox_btn.show() + self.edit_btn.hide() + self.go_to_folder_btn.hide() + + # Re-enable subtitle and replace newlines controls when cleared + window = self.window() + if hasattr(window, "subtitle_combo"): + # Only enable if language supports it + current_lang = getattr(window, "selected_lang", "a") + window.subtitle_combo.setEnabled( + current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + if hasattr(window, "replace_newlines_combo"): + window.replace_newlines_combo.setEnabled(True) + + # Disable add to queue button when input is cleared + if hasattr(window, "btn_add_to_queue"): + window.btn_add_to_queue.setEnabled(False) + # Reset the input_box_cleared_by_queue flag after setting file info + if hasattr(self.window(), "input_box_cleared_by_queue"): + self.window().input_box_cleared_by_queue = True + + def _human_readable_size(self, size, decimal_places=2): + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size < 1024.0: + return f"{size:.{decimal_places}f} {unit}" + size /= 1024.0 + return f"{size:.{decimal_places}f} PB" + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.window().open_file_dialog() + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + if urls: + ext = urls[0].toLocalFile().lower() + if ( + ext.endswith(".txt") + or ext.endswith(".epub") + or ext.endswith(".pdf") + or ext.endswith((".md", ".markdown")) + or ext.endswith((".srt", ".ass", ".vtt")) + ): + event.acceptProposedAction() + # Set hover style based on current state + if self.styleSheet().find(self.STYLE_ACTIVE) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }} {self.STYLE_ACTIVE_HOVER}" + ) + elif self.styleSheet().find(self.STYLE_ERROR) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }} {self.STYLE_ERROR_HOVER}" + ) + else: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }} {self.STYLE_DEFAULT_HOVER}" + ) + return + event.ignore() + + def dragLeaveEvent(self, event): + # Restore the style based on current state + if self.styleSheet().find(self.STYLE_ACTIVE) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" + ) + elif self.styleSheet().find(self.STYLE_ERROR) != -1: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" + ) + else: + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) + event.accept() + + def dropEvent(self, event): + if event.mimeData().hasUrls(): + urls = event.mimeData().urls() + if not urls: + event.ignore() + return + file_path = urls[0].toLocalFile() + win = self.window() + if file_path.lower().endswith(".txt"): + win.selected_file, win.selected_file_type = file_path, "txt" + win.displayed_file_path = ( + file_path # Set the displayed file path for text files + ) + self.set_file_info(file_path) + event.acceptProposedAction() + elif ( + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) + or file_path.lower().endswith((".srt", ".ass", ".vtt")) + ): + # Determine file type + if file_path.lower().endswith(".epub"): + file_type = "epub" + elif file_path.lower().endswith(".pdf"): + file_type = "pdf" + elif file_path.lower().endswith((".srt", ".ass", ".vtt")): + # For subtitle files, treat them like txt files (direct processing) + win.selected_file, win.selected_file_type = file_path, "txt" + win.displayed_file_path = file_path + self.set_file_info(file_path) + event.acceptProposedAction() + return + else: + file_type = "markdown" + + # Just store the file path but don't set the file info yet + win.selected_file_type = file_type + win.selected_book_path = file_path + win.open_book_file( + file_path # This will handle the dialog and setting file info + ) + event.acceptProposedAction() + else: + self.set_error( + "Please drop a .txt, .epub, .pdf, .md, .srt, .ass, or .vtt file." + ) + event.ignore() + else: + event.ignore() + + def on_chapters_clicked(self): + win = self.window() + if ( + win.selected_file_type in ["epub", "pdf", "md", "markdown"] + and win.selected_book_path + ): + # Call open_book_file which shows the dialog and updates selected_chapters + if win.open_book_file(win.selected_book_path): + # Refresh the info label and button text after dialog closes + self.set_file_info(win.selected_book_path) + + def on_textbox_clicked(self): + self.window().open_textbox_dialog() + + def on_edit_clicked(self): + win = self.window() + # For PDFs and EPUBs, use the temporary text file + if ( + win.selected_file_type in ["epub", "pdf", "md", "markdown"] + and win.selected_file + ): + # Use the temporary .txt file that was generated + win.open_textbox_dialog(win.selected_file) + else: + # For regular txt files + win.open_textbox_dialog() + + def on_go_to_folder_clicked(self): + win = self.window() + # win.selected_file holds the path to the text that is converted. + file_to_check = win.selected_file + + # If this is a converted document (epub/pdf/markdown) that was written to the + # user's cache directory, show a menu letting the user jump to either the + # processed (cached .txt) file or the original input file (epub/pdf/md). + try: + cache_dir = get_user_cache_path() + except Exception: + cache_dir = None + + is_cached_doc = False + if ( + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) + and cache_dir + ): + # Consider it cached when the file is under the cache directory and is a .txt + if file_to_check.endswith(".txt") and os.path.commonpath( + [os.path.abspath(file_to_check), os.path.abspath(cache_dir)] + ) == os.path.abspath(cache_dir): + # Only treat as document-cache when original type was a document + if getattr(win, "selected_file_type", None) in [ + "epub", + "pdf", + "md", + "markdown", + ]: + is_cached_doc = True + + if is_cached_doc: + menu = QMenu(self) + act_processed = QAction("Go to processed file", self) + + def open_processed(): + folder_path = os.path.dirname(file_to_check) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) + + act_processed.triggered.connect(open_processed) + menu.addAction(act_processed) + + act_input = QAction("Go to input file", self) + # Prefer displayed_file_path (original input path) then selected_book_path + input_path = getattr(win, "displayed_file_path", None) or getattr( + win, "selected_book_path", None + ) + if input_path and os.path.exists(input_path): + + def open_input(): + folder_path = os.path.dirname(input_path) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) + + act_input.triggered.connect(open_input) + else: + act_input.setEnabled(False) + + menu.addAction(act_input) + # Show the menu anchored to the button + menu.exec( + self.go_to_folder_btn.mapToGlobal( + QPoint(0, self.go_to_folder_btn.height()) + ) + ) + else: + if ( + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) + ): + folder_path = os.path.dirname(file_to_check) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) + else: + QMessageBox.warning(win, "Error", "Converted file not found.") + + +class TextboxDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Enter Text") + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.resize(700, 500) + + layout = QVBoxLayout(self) + + # Instructions + instructions = QLabel( + "Enter or paste the text you want to convert to audio:", self + ) + layout.addWidget(instructions) + + # Text edit area + self.text_edit = QTextEdit(self) + self.text_edit.setAcceptRichText(False) + self.text_edit.setPlaceholderText("Type or paste your text here...") + layout.addWidget(self.text_edit) + + # Character count label + self.char_count_label = QLabel("Characters: 0", self) + layout.addWidget(self.char_count_label) + + # Connect text changed signal to update character count + self.text_edit.textChanged.connect(self.update_char_count) + + # Buttons + button_layout = QHBoxLayout() + + self.save_as_button = QPushButton("Save as text", self) + self.save_as_button.clicked.connect(self.save_as_text) + self.save_as_button.setToolTip("Save the current text to a file") + + self.insert_chapter_btn = QPushButton("Insert Chapter Marker", self) + self.insert_chapter_btn.setToolTip("Insert a chapter marker at the cursor") + self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker) + button_layout.addWidget(self.insert_chapter_btn) + + self.cancel_button = QPushButton("Cancel", self) + self.cancel_button.clicked.connect(self.reject) + + self.ok_button = QPushButton("OK", self) + self.ok_button.setDefault(True) + self.ok_button.clicked.connect(self.handle_ok) + + button_layout.addWidget(self.save_as_button) + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.ok_button) + layout.addLayout(button_layout) + + # Store the original text to detect changes + self.original_text = "" + + def update_char_count(self): + text = self.text_edit.toPlainText() + count = calculate_text_length(text) + self.char_count_label.setText(f"Characters: {count:,}") + + def get_text(self): + return self.text_edit.toPlainText() + + def handle_ok(self): + text = self.text_edit.toPlainText() + # Check if text is empty based on character count + if calculate_text_length(text) == 0: + QMessageBox.warning(self, "Textbox Error", "Text cannot be empty.") + return + + # If the text hasn't changed, treat as cancel + if text == self.original_text: + self.reject() + else: + # Check if we need to warn about overwriting a non-temporary file + if hasattr(self, "is_non_cache_file") and self.is_non_cache_file: + msg_box = QMessageBox(self) + msg_box.setIcon(QMessageBox.Icon.Warning) + msg_box.setWindowTitle("File Overwrite Warning") + msg_box.setText( + f"You are about to overwrite the original file:\n{self.non_cache_file_path}" + ) + msg_box.setInformativeText("Do you want to continue?") + msg_box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + msg_box.setDefaultButton(QMessageBox.StandardButton.No) + + if msg_box.exec() != QMessageBox.StandardButton.Yes: + # User canceled, don't close the dialog + return + + self.accept() + + def save_as_text(self): + """Save the text content to a file chosen by the user""" + try: + text = self.text_edit.toPlainText() + if not text.strip(): + QMessageBox.warning(self, "Save Error", "There is no text to save.") + return + + # Get default filename from original file if editing + initial_path = "" + if hasattr(self, "non_cache_file_path") and self.non_cache_file_path: + initial_path = self.non_cache_file_path + + # For EPUB and PDF files, use the displayed_file_path from the main window + # This gives a better filename instead of the cache file path + main_window = self.parent() + if ( + hasattr(main_window, "displayed_file_path") + and main_window.displayed_file_path + ): + if main_window.selected_file_type in ["epub", "pdf", "md", "markdown"]: + # Use the base name of the displayed file but change extension to .txt + base_name = os.path.splitext(main_window.displayed_file_path)[0] + initial_path = base_name + ".txt" + + file_path, _ = QFileDialog.getSaveFileName( + self, "Save Text As", initial_path, "Text Files (*.txt);;All Files (*)" + ) + + if file_path: + # Add .txt extension if not specified and no other extension exists + if not os.path.splitext(file_path)[1]: + file_path += ".txt" + + with open(file_path, "w", encoding="utf-8") as f: + f.write(text) + + except Exception as e: + QMessageBox.critical(self, "Save Error", f"Could not save file:\n{e}") + + def insert_chapter_marker(self): + # Insert a fixed chapter marker without prompting + cursor = self.text_edit.textCursor() + cursor.insertText("\n<>\n") + self.text_edit.setTextCursor(cursor) + self.update_char_count() + self.text_edit.setFocus() + + +def migrate_subtitle_format(config): + """Convert old subtitle_format values to new internal keys.""" + old_to_new = { + "srt": "srt", + "ass (wide)": "ass_wide", + "ass (narrow)": "ass_narrow", + "ass (centered wide)": "ass_centered_wide", + "ass (centered narrow)": "ass_centered_narrow", + } + val = config.get("subtitle_format") + if val in old_to_new: + config["subtitle_format"] = old_to_new[val] + save_config(config) + + +class abogen(QWidget): + def __init__(self): + super().__init__() + self.config = load_config() + self.apply_theme(self.config.get("theme", "system")) + migrate_subtitle_format(self.config) + self.check_updates = self.config.get("check_updates", True) + self.save_option = self.config.get("save_option", "Save next to input file") + self.selected_output_folder = self.config.get("selected_output_folder", None) + self.selected_file = self.selected_file_type = self.selected_book_path = None + self.displayed_file_path = ( + None # Add new variable to track the displayed file path + ) + # Max log lines + self.log_window_max_lines = self.config.get("log_window_max_lines", 2000) + self.selected_chapters = set() + self.last_opened_book_path = None # Track the last opened book path + self.last_output_path = None + self.char_count = 0 + self._char_count_cache = {} + # Only one of selected_profile_name or selected_voice should be set + self.selected_profile_name = self.config.get("selected_profile_name") + self.selected_voice = None + self.selected_lang = None + self.mixed_voice_state = None + if self.selected_profile_name: + self.selected_voice = None + self.selected_lang = None + else: + self.selected_voice = self.config.get("selected_voice", "af_heart") + self.selected_lang = self.selected_voice[0] if self.selected_voice else None + self.is_converting = False + self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") + self.max_subtitle_words = self.config.get( + "max_subtitle_words", 50 + ) # Default max words per subtitle + self.silence_duration = self.config.get( + "silence_duration", 2.0 + ) # Default silence duration + self.selected_format = self.config.get("selected_format", "wav") + self.separate_chapters_format = self.config.get( + "separate_chapters_format", "wav" + ) # Format for individual chapter files + self.use_gpu = self.config.get( + "use_gpu", True # Load GPU setting with default True + ) + self.replace_single_newlines = self.config.get("replace_single_newlines", True) + self.use_silent_gaps = self.config.get("use_silent_gaps", True) + self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts") + self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True) + self._pending_close_event = None + self.gpu_ok = False # Initialize GPU availability status + + # Create thread-safe logging mechanism + self.log_signal = ThreadSafeLogSignal() + self.log_signal.log_signal.connect(self._update_log_main_thread) + + # Create warning signal emitter + self.warning_signal_emitter = ShowWarningSignalEmitter() + self.warning_signal_emitter.show_warning_signal.connect( + self.show_model_download_warning + ) + hf_tracker.set_show_warning_signal_emitter(self.warning_signal_emitter) + + # Set application icon + icon_path = get_resource_path("abogen.assets", "icon.ico") + if icon_path: + self.setWindowIcon(QIcon(icon_path)) + # Set taskbar icon for Windows + if platform.system() == "Windows": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("abogen") + + # Queued items list + self.queued_items = [] + self.current_queue_index = 0 + + self.initUI() + self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) + self.update_speed_label() + # Set initial selection: prefer profile, else voice + idx = -1 + if self.selected_profile_name: + idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") + elif self.selected_voice: + idx = self.voice_combo.findData(self.selected_voice) + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + # If a profile is selected at startup, load voices and language + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + if self.save_option == "Choose output folder" and self.selected_output_folder: + self.save_path_label.setText(self.selected_output_folder) + self.save_path_row_widget.show() + self.subtitle_combo.setCurrentText(self.subtitle_mode) + # Enable/disable subtitle options based on selected language (profile or voice) + enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) + self.subtitle_format_combo.setEnabled(enable) + # loading gif for preview button + loading_gif_path = get_resource_path("abogen.assets", "loading.gif") + if loading_gif_path: + self.loading_movie = QMovie(loading_gif_path) + self.loading_movie.frameChanged.connect( + lambda: self.btn_preview.setIcon( + QIcon(self.loading_movie.currentPixmap()) + ) + ) + + # Check for updates at startup if enabled + if self.check_updates: + QTimer.singleShot(1000, self.check_for_updates_startup) + + # Set hf_tracker callbacks + hf_tracker.set_log_callback(self.update_log) + + def initUI(self): + self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}") + screen = QApplication.primaryScreen().geometry() + width, height = 500, 800 + x = (screen.width() - width) // 2 + # If desired height is larger than screen, fit to screen height + if height > screen.height() - 65: + height = screen.height() - 100 # Leave a margin for window borders + y = max((screen.height() - height) // 2, 0) + self.setGeometry(x, y, width, height) + outer_layout = QVBoxLayout() + outer_layout.setContentsMargins(15, 15, 15, 15) + container = QWidget(self) + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(15) + self.input_box = InputBox(self) + container_layout.addWidget(self.input_box, 1) + # Manage queue button, start queue button + self.queue_row_widget = QWidget(self) # Make queue_row a QWidget + queue_row = QHBoxLayout(self.queue_row_widget) + queue_row.setContentsMargins(0, 0, 0, 0) + self.btn_add_to_queue = QPushButton("Add to Queue", self) + self.btn_add_to_queue.setFixedHeight(40) + self.btn_add_to_queue.setEnabled(False) + self.btn_add_to_queue.clicked.connect(self.add_to_queue) + queue_row.addWidget(self.btn_add_to_queue) + self.btn_manage_queue = QPushButton("Manage Queue", self) + self.btn_manage_queue.setFixedHeight(40) + self.btn_manage_queue.setEnabled(True) + self.btn_manage_queue.clicked.connect(self.manage_queue) + queue_row.addWidget(self.btn_manage_queue) + self.btn_clear_queue = QPushButton("Clear Queue", self) + self.btn_clear_queue.setFixedHeight(40) + self.btn_clear_queue.setEnabled(False) + self.btn_clear_queue.clicked.connect(self.clear_queue) + queue_row.addWidget(self.btn_clear_queue) + container_layout.addWidget(self.queue_row_widget) + self.log_text = QTextEdit(self) + self.log_text.setReadOnly(True) + self.log_text.setUndoRedoEnabled(False) + self.log_text.setFrameStyle(QFrame.Shape.NoFrame) + self.log_text.setStyleSheet("QTextEdit { border: none; }") + self.log_text.hide() + container_layout.addWidget(self.log_text, 1) + controls_layout = QVBoxLayout() + controls_layout.setContentsMargins(0, 10, 0, 0) + controls_layout.setSpacing(15) + # Speed controls + speed_layout = QVBoxLayout() + speed_layout.setSpacing(2) + speed_layout.addWidget(QLabel("Speed:", self)) + self.speed_slider = QSlider(Qt.Orientation.Horizontal, self) + self.speed_slider.setMinimum(10) + self.speed_slider.setMaximum(200) + self.speed_slider.setValue(100) + self.speed_slider.setTickPosition(QSlider.TickPosition.TicksBelow) + self.speed_slider.setTickInterval(5) + self.speed_slider.setSingleStep(5) + speed_layout.addWidget(self.speed_slider) + self.speed_label = QLabel("1.0", self) + speed_layout.addWidget(self.speed_label) + controls_layout.addLayout(speed_layout) + self.speed_slider.valueChanged.connect(self.update_speed_label) + # Voice selection + voice_layout = QHBoxLayout() + voice_layout.setSpacing(7) + voice_label = QLabel("Select voice:", self) + voice_layout.addWidget(voice_label) + self.voice_combo = QComboBox(self) + self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed) + self.voice_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.voice_combo.setToolTip( + "The first character represents the language:\n" + '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female' + ) + self.voice_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + voice_layout.addWidget(self.voice_combo) + # Voice formula button + self.btn_voice_formula_mixer = QPushButton(self) + mixer_icon_path = get_resource_path("abogen.assets", "voice_mixer.png") + self.btn_voice_formula_mixer.setIcon(QIcon(mixer_icon_path)) + self.btn_voice_formula_mixer.setToolTip("Mix and match voices") + self.btn_voice_formula_mixer.setFixedSize(40, 36) + self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }") + self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog) + voice_layout.addWidget(self.btn_voice_formula_mixer) + + # Play/Stop icons + def make_icon(color, shape): + pix = QPixmap(20, 20) + pix.fill(Qt.GlobalColor.transparent) + p = QPainter(pix) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setBrush(QColor(*color)) + p.setPen(Qt.PenStyle.NoPen) + if shape == "play": + pts = [ + pix.rect().topLeft() + QPoint(4, 2), + pix.rect().bottomLeft() + QPoint(4, -2), + pix.rect().center() + QPoint(6, 0), + ] + p.drawPolygon(QPolygon(pts)) + else: + p.drawRect(5, 5, 10, 10) + p.end() + return QIcon(pix) + + self.play_icon = make_icon((40, 160, 40), "play") + self.stop_icon = make_icon((200, 60, 60), "stop") + self.btn_preview = QPushButton(self) + self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setIconSize(QPixmap(20, 20).size()) + self.btn_preview.setToolTip("Preview selected voice") + self.btn_preview.setFixedSize(40, 36) + self.btn_preview.setStyleSheet("QPushButton { padding: 6px 12px; }") + self.btn_preview.clicked.connect(self.preview_voice) + voice_layout.addWidget(self.btn_preview) + self.preview_playing = False + self.play_audio_thread = None # Keep track of audio playing thread + controls_layout.addLayout(voice_layout) + + # Generate subtitles + subtitle_layout = QHBoxLayout() + subtitle_layout.setSpacing(7) + subtitle_label = QLabel("Generate subtitles:", self) + subtitle_layout.addWidget(subtitle_label) + self.subtitle_combo = QComboBox(self) + self.subtitle_combo.setToolTip( + "Choose how subtitles will be generated:\n" + "Disabled: No subtitles will be generated.\n" + "Line: Subtitles will be generated for each line.\n" + "Sentence: Subtitles will be generated for each sentence.\n" + "Sentence + Comma: Subtitles will be generated for each sentence and comma.\n" + "Sentence + Highlighting: Subtitles with word-by-word karaoke highlighting.\n" + "1+ word: Subtitles will be generated for each word(s).\n\n" + "Supported languages for subtitle generation:\n" + + "\n".join( + f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}' + for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + ) + subtitle_options = [ + "Disabled", + "Line", + "Sentence", + "Sentence + Comma", + "Sentence + Highlighting", + ] + [f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)] + self.subtitle_combo.addItems(subtitle_options) + self.subtitle_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.subtitle_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.subtitle_combo.setCurrentText(self.subtitle_mode) + self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) + subtitle_layout.addWidget(self.subtitle_combo) + controls_layout.addLayout(subtitle_layout) + + # Output voice format + format_layout = QHBoxLayout() + format_layout.setSpacing(7) + format_label = QLabel("Output voice format:", self) + format_layout.addWidget(format_label) + self.format_combo = QComboBox(self) + self.format_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.format_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + # Add items with display labels and underlying keys + for key, label in [ + ("wav", "wav"), + ("flac", "flac"), + ("mp3", "mp3"), + ("opus", "opus (best compression)"), + ("m4b", "m4b (with chapters)"), + ]: + self.format_combo.addItem(label, key) + # Initialize selection by matching saved key + idx = self.format_combo.findData(self.selected_format) + if idx >= 0: + self.format_combo.setCurrentIndex(idx) + # Map selection back to key on change + self.format_combo.currentIndexChanged.connect( + lambda i: self.on_format_changed(self.format_combo.itemData(i)) + ) + format_layout.addWidget(self.format_combo) + controls_layout.addLayout(format_layout) + + # Output subtitle format + subtitle_format_layout = QHBoxLayout() + subtitle_format_layout.setSpacing(7) + subtitle_format_label = QLabel("Output subtitle format:", self) + subtitle_format_layout.addWidget(subtitle_format_label) + self.subtitle_format_combo = QComboBox(self) + self.subtitle_format_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.subtitle_format_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + for value, text in SUBTITLE_FORMATS: + self.subtitle_format_combo.addItem(text, value) + subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") + idx = self.subtitle_format_combo.findData(subtitle_format) + if idx >= 0: + self.subtitle_format_combo.setCurrentIndex(idx) + self.subtitle_format_combo.currentIndexChanged.connect( + lambda i: self.set_subtitle_format(self.subtitle_format_combo.itemData(i)) + ) + subtitle_format_layout.addWidget(self.subtitle_format_combo) + # If subtitle mode requires highlighting, SRT is not supported. Disable SRT item + # and auto-switch to a compatible ASS format if SRT is currently selected. + try: + if ( + hasattr(self, "subtitle_mode") + and self.subtitle_mode == "Sentence + Highlighting" + ): + idx_srt = self.subtitle_format_combo.findData("srt") + if idx_srt >= 0: + item = self.subtitle_format_combo.model().item(idx_srt) + if item is not None: + item.setEnabled(False) + # If current selection is SRT, switch to centered narrow ASS + if self.subtitle_format_combo.currentData() == "srt": + new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") + if new_idx >= 0: + self.subtitle_format_combo.setCurrentIndex(new_idx) + # Persist the change + self.set_subtitle_format( + self.subtitle_format_combo.itemData(new_idx) + ) + except Exception: + # Fail-safe: don't crash UI if model manipulation isn't supported on some platforms + pass + + # Enable/disable subtitle options based on selected language (profile or voice) + self.update_subtitle_options_availability() + + controls_layout.addLayout(subtitle_format_layout) + + # Replace single newlines dropdown (acts like checkbox) + replace_newlines_layout = QHBoxLayout() + replace_newlines_layout.setSpacing(7) + replace_newlines_label = QLabel("Replace single newlines:", self) + replace_newlines_layout.addWidget(replace_newlines_label) + self.replace_newlines_combo = QComboBox(self) + self.replace_newlines_combo.addItems(["Disabled", "Enabled"]) + self.replace_newlines_combo.setToolTip( + "Replace single newlines in the input text with spaces before processing." + ) + self.replace_newlines_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.replace_newlines_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + # Set initial value based on config + self.replace_newlines_combo.setCurrentIndex( + 1 if self.replace_single_newlines else 0 + ) + self.replace_newlines_combo.currentIndexChanged.connect( + lambda idx: self.toggle_replace_single_newlines(idx == 1) + ) + replace_newlines_layout.addWidget(self.replace_newlines_combo) + controls_layout.addLayout(replace_newlines_layout) + + # Save location + save_layout = QHBoxLayout() + save_layout.setSpacing(7) + save_label = QLabel("Save location:", self) + save_layout.addWidget(save_label) + self.save_combo = QComboBox(self) + save_options = [ + "Save next to input file", + "Save to Desktop", + "Choose output folder", + ] + self.save_combo.addItems(save_options) + self.save_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.save_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.save_combo.setCurrentText(self.save_option) + self.save_combo.currentTextChanged.connect(self.on_save_option_changed) + save_layout.addWidget(self.save_combo) + controls_layout.addLayout(save_layout) + + # Save path label + self.save_path_row_widget = QWidget(self) + save_path_row = QHBoxLayout(self.save_path_row_widget) + save_path_row.setSpacing(7) + save_path_row.setContentsMargins(0, 0, 0, 0) + selected_folder_label = QLabel("Selected folder:", self.save_path_row_widget) + save_path_row.addWidget(selected_folder_label) + self.save_path_label = QLabel("", self.save_path_row_widget) + self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}") + self.save_path_label.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + save_path_row.addWidget(self.save_path_label) + self.save_path_row_widget.hide() # Hide the whole row by default + controls_layout.addWidget(self.save_path_row_widget) + + # GPU Acceleration Checkbox with Settings button + gpu_layout = QHBoxLayout() + gpu_checkbox_layout = QVBoxLayout() + self.gpu_checkbox = QCheckBox("Use GPU Acceleration (if available)", self) + self.gpu_checkbox.setChecked(self.use_gpu) + self.gpu_checkbox.setToolTip( + "Uncheck to force using CPU even if a compatible GPU is detected." + ) + self.gpu_checkbox.stateChanged.connect(self.on_gpu_setting_changed) + gpu_checkbox_layout.addWidget(self.gpu_checkbox) + gpu_layout.addLayout(gpu_checkbox_layout) + + # Set initial enabled state for subtitle format combo + if self.subtitle_mode == "Disabled": + self.subtitle_format_combo.setEnabled(False) + else: + self.subtitle_format_combo.setEnabled(True) + + # Settings button with icon + settings_icon_path = get_resource_path("abogen.assets", "settings.svg") + self.settings_btn = QPushButton(self) + if settings_icon_path and os.path.exists(settings_icon_path): + self.settings_btn.setIcon(QIcon(settings_icon_path)) + else: + # Fallback text if icon not found + self.settings_btn.setText("⚙") + self.settings_btn.setToolTip("Settings") + self.settings_btn.setFixedSize(36, 36) + self.settings_btn.clicked.connect(self.show_settings_menu) + gpu_layout.addWidget(self.settings_btn) + + controls_layout.addLayout(gpu_layout) + + # Start button + self.btn_start = QPushButton("Start", self) + self.btn_start.setFixedHeight(60) + self.btn_start.clicked.connect(self.start_conversion) + controls_layout.addWidget(self.btn_start) + # Add controls to a container widget + self.controls_widget = QWidget() + self.controls_widget.setLayout(controls_layout) + self.controls_widget.setSizePolicy( + QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed + ) + container_layout.addWidget(self.controls_widget) + # Progress bar + self.progress_bar = QProgressBar(self) + self.progress_bar.setValue(0) + self.progress_bar.hide() + container_layout.addWidget(self.progress_bar) + # ETR Label + self.etr_label = QLabel("Estimated time remaining: Calculating...", self) + self.etr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.etr_label.hide() + container_layout.addWidget(self.etr_label) + # Cancel button + self.btn_cancel = QPushButton("Cancel", self) + self.btn_cancel.setFixedHeight(60) + self.btn_cancel.clicked.connect(self.cancel_conversion) + self.btn_cancel.hide() + container_layout.addWidget(self.btn_cancel) + # Finish buttons + self.finish_widget = QWidget() + finish_layout = QVBoxLayout() + finish_layout.setContentsMargins(0, 0, 0, 0) + finish_layout.setSpacing(10) + self.open_file_btn = None # Store reference to open file button + + # Create buttons with their functions + finish_buttons = [ + ("Open file", self.open_file, "Open the output file."), + ( + "Go to folder", + self.go_to_file, + "Open the folder containing the output file.", + ), + ("New Conversion", self.reset_ui, "Start a new conversion."), + ("Go back", self.go_back_ui, "Return to the previous screen."), + ] + + for text, func, tip in finish_buttons: + btn = QPushButton(text, self) + btn.setFixedHeight(35) + btn.setToolTip(tip) + btn.clicked.connect(func) + finish_layout.addWidget(btn) + # Identify the Open file button by its function reference + if func == self.open_file: + self.open_file_btn = btn # Save reference to the open file button + + self.finish_widget.setLayout(finish_layout) + self.finish_widget.hide() + container_layout.addWidget(self.finish_widget) + outer_layout.addWidget(container) + self.setLayout(outer_layout) + self.populate_profiles_in_voice_combo() + + # Initialize flag to track if input box was cleared by queue + self.input_box_cleared_by_queue = False + + def open_file_dialog(self): + if self.is_converting: + return + try: + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select File", + "", + "Supported Files (*.txt *.epub *.pdf *.md *.srt *.ass *.vtt)", + ) + if not file_path: + return + if ( + file_path.lower().endswith(".epub") + or file_path.lower().endswith(".pdf") + or file_path.lower().endswith((".md", ".markdown")) + ): + # Determine file type + if file_path.lower().endswith(".epub"): + self.selected_file_type = "epub" + elif file_path.lower().endswith(".pdf"): + self.selected_file_type = "pdf" + else: + self.selected_file_type = "markdown" + + self.selected_book_path = file_path + # Don't set file info immediately, open_book_file will handle it after dialog is accepted + if not self.open_book_file(file_path): + return + elif file_path.lower().endswith((".srt", ".ass", ".vtt")): + # Handle subtitle files like text files + self.selected_file, self.selected_file_type = file_path, "txt" + self.displayed_file_path = file_path + self.input_box.set_file_info(file_path) + else: + self.selected_file, self.selected_file_type = file_path, "txt" + self.displayed_file_path = ( + file_path # Set the displayed file path for text files + ) + self.input_box.set_file_info(file_path) + except Exception as e: + self._show_error_message_box( + "File Dialog Error", f"Could not open file dialog:\n{e}" + ) + + def open_book_file(self, book_path): + # Clear selected chapters if this is a different book than the last one + if ( + not hasattr(self, "last_opened_book_path") + or self.last_opened_book_path != book_path + ): + self.selected_chapters = set() + self.last_opened_book_path = book_path + + # HandlerDialog uses internal caching to avoid reprocessing the same book + dialog = HandlerDialog( + book_path, + file_type=getattr(self, "selected_file_type", None), + checked_chapters=self.selected_chapters, + parent=self, + ) + dialog.setWindowModality(Qt.WindowModality.NonModal) + dialog.setModal(False) + dialog.show() # We'll handle the dialog result asynchronously + + def on_dialog_finished(result): + if result != QDialog.DialogCode.Accepted: + return False + chapters_text, all_checked_hrefs = dialog.get_selected_text() + if not all_checked_hrefs: + # Determine file type for error message + if book_path.lower().endswith(".pdf"): + file_type = "pdf" + item_type = "pages" + elif book_path.lower().endswith((".md", ".markdown")): + file_type = "markdown" + item_type = "chapters" + else: + file_type = "epub" + item_type = "chapters" + + error_msg = f"No {item_type} selected." + self._show_error_message_box(f"{file_type.upper()} Error", error_msg) + return False + self.selected_chapters = all_checked_hrefs + self.save_chapters_separately = dialog.get_save_chapters_separately() + self.merge_chapters_at_end = dialog.get_merge_chapters_at_end() + self.save_as_project = dialog.get_save_as_project() + + # Store if the PDF has bookmarks for button text display + if book_path.lower().endswith(".pdf"): + self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False) + + cleaned_text = clean_text(chapters_text) + computed_char_count = calculate_text_length(cleaned_text) + self.char_count = computed_char_count + if isinstance(getattr(self, "_char_count_cache", None), dict): + self._char_count_cache[book_path] = computed_char_count + + # Use "abogen" prefix for cache files + # Extract base name without extension + base_name = os.path.splitext(os.path.basename(book_path))[0] + + if self.save_as_project: + # Get project directory from user + project_dir = QFileDialog.getExistingDirectory( + self, "Select Project Folder", "", QFileDialog.Option.ShowDirsOnly + ) + if not project_dir: + # User cancelled, fallback to cache + self.save_as_project = False + cache_dir = get_user_cache_path() + else: + # Create project folder structure + project_name = f"{base_name}_project" + project_dir = os.path.join(project_dir, project_name) + cache_dir = os.path.join(project_dir, "text") + os.makedirs(cache_dir, exist_ok=True) + + # Save metadata if available + meta_dir = os.path.join(project_dir, "metadata") + os.makedirs( + meta_dir, exist_ok=True + ) # Save book metadata if available + if hasattr(dialog, "book_metadata"): + meta_path = os.path.join(meta_dir, "book_info.txt") + with open(meta_path, "w", encoding="utf-8") as f: + # Clean HTML tags from metadata + title = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("title", "Unknown")), + ) + publisher = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("publisher", "Unknown")), + ) + authors = [ + re.sub(r"<[^>]+>", "", str(author)) + for author in dialog.book_metadata.get( + "authors", ["Unknown"] + ) + ] + publication_year = re.sub( + r"<[^>]+>", + "", + str( + dialog.book_metadata.get( + "publication_year", "Unknown" + ) + ), + ) + + f.write(f"Title: {title}\n") + f.write(f"Authors: {', '.join(authors)}\n") + f.write(f"Publisher: {publisher}\n") + f.write(f"Publication Year: {publication_year}\n") + if dialog.book_metadata.get("description"): + description = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("description")), + ) + f.write(f"\nDescription:\n{description}\n") + + # Save cover image if available + if dialog.book_metadata.get("cover_image"): + cover_path = os.path.join(meta_dir, "cover.png") + with open(cover_path, "wb") as f: + f.write(dialog.book_metadata["cover_image"]) + else: + cache_dir = get_user_cache_path() + + fd, tmp = tempfile.mkstemp( + prefix=f"{base_name}_", suffix=".txt", dir=cache_dir + ) + os.close(fd) + with open(tmp, "w", encoding="utf-8") as f: + f.write(chapters_text) + self.selected_file = tmp + self.selected_book_path = book_path + self.displayed_file_path = book_path + if isinstance(getattr(self, "_char_count_cache", None), dict): + self._char_count_cache[tmp] = computed_char_count + # Only set file info if dialog was accepted + self.input_box.set_file_info(book_path) + return True + + dialog.finished.connect(on_dialog_finished) + return True + + def open_textbox_dialog(self, file_path=None): + """Shows dialog for direct text input or editing and processes the entered text""" + if self.is_converting: + return + + editing = False + is_cache_file = False + # If path is explicitly provided, use it + if file_path and os.path.exists(file_path): + editing = True + edit_file = file_path + # Check if this is a cache file + is_cache_file = get_user_cache_path() in file_path + # Otherwise use selected_file if it's a txt file + elif ( + self.selected_file_type == "txt" + and self.selected_file + and os.path.exists(self.selected_file) + ): + editing = True + edit_file = self.selected_file + # Check if this is a cache file + is_cache_file = get_user_cache_path() in self.selected_file + + dialog = TextboxDialog(self) + if editing: + try: + with open(edit_file, "r", encoding="utf-8", errors="ignore") as f: + dialog.text_edit.setText(f.read()) + dialog.update_char_count() + dialog.original_text = ( + dialog.text_edit.toPlainText() + ) # Store original text + + # If editing a non-cache file, alert the user + if not is_cache_file: + dialog.is_non_cache_file = True + dialog.non_cache_file_path = edit_file + except Exception: + pass + if dialog.exec() == QDialog.DialogCode.Accepted: + text = dialog.get_text() + if not text.strip(): + self._show_error_message_box("Textbox Error", "Text cannot be empty.") + return + try: + if editing: + with open(edit_file, "w", encoding="utf-8") as f: + f.write(text) + # Update the display path to the edited file + self.displayed_file_path = edit_file + self.input_box.set_file_info(edit_file) + # Hide chapters button since we're using custom text now + self.input_box.chapters_btn.hide() + else: + cache_dir = get_user_cache_path() + fd, tmp = tempfile.mkstemp( + prefix="abogen_", suffix=".txt", dir=cache_dir + ) + os.close(fd) + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + self.selected_file = tmp + self.selected_file_type = "txt" + self.displayed_file_path = None + self.input_box.set_file_info(tmp) + # Hide chapters button since we're using custom text now + self.input_box.chapters_btn.hide() + if hasattr(self, "conversion_thread"): + self.conversion_thread.is_direct_text = True + except Exception as e: + self._show_error_message_box( + "Textbox Error", f"Could not process text input:\n{e}" + ) + + def update_speed_label(self): + s = self.speed_slider.value() / 100.0 + self.speed_label.setText(f"{s}") + self.config["speed"] = s + save_config(self.config) + + def update_subtitle_options_availability(self): + """ + Update the enabled state of subtitle options based on the selected language. + For non-English languages, only sentence-based and line-based modes are supported. + """ + # Check if current file is a subtitle file + is_subtitle_input = False + if self.selected_file and self.selected_file.lower().endswith( + (".srt", ".ass", ".vtt") + ): + is_subtitle_input = True + + if self.selected_lang not in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: + self.subtitle_combo.setEnabled(False) + self.subtitle_format_combo.setEnabled(False) + return + + # Only enable subtitle_combo if it's NOT a subtitle input + self.subtitle_combo.setEnabled(not is_subtitle_input) + self.subtitle_format_combo.setEnabled(True) + + is_english = self.selected_lang in ["a", "b"] + + # Items to keep enabled for non-English + allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"] + + model = self.subtitle_combo.model() + for i in range(self.subtitle_combo.count()): + text = self.subtitle_combo.itemText(i) + item = model.item(i) + if not item: + continue + + if is_english: + item.setEnabled(True) + else: + if text in allowed_modes: + item.setEnabled(True) + else: + item.setEnabled(False) + + # If current selection is disabled, switch to a valid one + current_text = self.subtitle_combo.currentText() + current_idx = self.subtitle_combo.currentIndex() + current_item = model.item(current_idx) + + if current_item and not current_item.isEnabled(): + # Switch to "Sentence" if available, else "Disabled" + sentence_idx = self.subtitle_combo.findText("Sentence") + if sentence_idx >= 0: + self.subtitle_combo.setCurrentIndex(sentence_idx) + else: + self.subtitle_combo.setCurrentIndex(0) # Disabled + + self.subtitle_mode = self.subtitle_combo.currentText() + + def on_voice_changed(self, index): + voice = self.voice_combo.itemData(index) + self.selected_voice, self.selected_lang = voice, voice[0] + self.config["selected_voice"] = voice + save_config(self.config) + # Enable/disable subtitle options based on language + self.update_subtitle_options_availability() + + def on_voice_combo_changed(self, index): + data = self.voice_combo.itemData(index) + if isinstance(data, str) and data.startswith("profile:"): + pname = data.split(":", 1)[1] + self.selected_profile_name = pname + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(pname, {}) + # set mixed voices and language + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + self.selected_voice = None + self.config["selected_profile_name"] = pname + self.config.pop("selected_voice", None) + save_config(self.config) + # enable subtitles based on profile language + self.update_subtitle_options_availability() + else: + self.mixed_voice_state = None + self.selected_profile_name = None + self.selected_voice, self.selected_lang = data, data[0] + self.config["selected_voice"] = data + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + save_config(self.config) + self.update_subtitle_options_availability() + + def update_subtitle_combo_for_profile(self, profile_name): + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(profile_name, {}) + lang = entry.get("language") if isinstance(entry, dict) else None + enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) + self.subtitle_format_combo.setEnabled(enable) + + def populate_profiles_in_voice_combo(self): + # preserve current voice or profile + current = self.voice_combo.currentData() + self.voice_combo.blockSignals(True) + self.voice_combo.clear() + # re-add profiles + profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png")) + for pname in load_profiles().keys(): + self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}") + # re-add voices + for v in VOICES_INTERNAL: + icon = QIcon() + flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png") + if flag_path and os.path.exists(flag_path): + icon = QIcon(flag_path) + self.voice_combo.addItem(icon, f"{v}", v) + # restore selection + idx = -1 + if self.selected_profile_name: + idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") + elif current: + idx = self.voice_combo.findData(current) + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + # Also update subtitle combo for selected profile + data = self.voice_combo.itemData(idx) + if isinstance(data, str) and data.startswith("profile:"): + pname = data.split(":", 1)[1] + self.update_subtitle_combo_for_profile(pname) + self.voice_combo.blockSignals(False) + # If no profiles exist, clear selected_profile_name from config + if not load_profiles(): + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + save_config(self.config) + + def convert_input_box_to_log(self): + self.input_box.hide() + self.log_text.show() + self.log_text.clear() + QApplication.processEvents() + + def restore_input_box(self): + self.log_text.hide() + self.input_box.show() + + def update_log(self, message): + # Use signal-based approach for thread-safe logging + if QThread.currentThread() != QApplication.instance().thread(): + # We're in a background thread, emit signal for the main thread + self.log_signal.emit_log(message) + return + + # Direct update if already on main thread + self._update_log_main_thread(message) + + def _update_log_main_thread(self, message): + txt = self.log_text + sb = txt.verticalScrollBar() + at_bottom = sb.value() == sb.maximum() + + cursor = txt.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + + fmt = cursor.charFormat() + if isinstance(message, tuple): + text, spec = message + fmt.setForeground(QColor(LOG_COLOR_MAP.get(spec, COLORS["LIGHT_DISABLED"]))) + else: + text = str(message) + fmt.clearForeground() + cursor.setCharFormat(fmt) + cursor.insertText(text + "\n") + + doc = txt.document() + excess = doc.blockCount() - self.log_window_max_lines + if excess > 0: + start = doc.findBlockByNumber(0).position() + end = doc.findBlockByNumber(excess).position() + trim_cursor = QTextCursor(doc) + trim_cursor.setPosition(start) + trim_cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor) + trim_cursor.removeSelectedText() + + if at_bottom: + sb.setValue(sb.maximum()) + + def _get_queue_progress_format(self, value=None): + """Return the progress bar format string for queue mode.""" + if ( + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): + N = self.current_queue_index + 1 + M = len(self.queued_items) + percent = value if value is not None else self.progress_bar.value() + return f"{percent}% ({N}/{M})" + else: + percent = value if value is not None else self.progress_bar.value() + return f"{percent}%" + + def update_progress(self, value, etr_str): # Add etr_str parameter + # Ensure progress doesn't exceed 99% + if value >= 100: + value = 99 + self.progress_bar.setValue(value) + # Show queue progress if in queue mode + if ( + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): + N = self.current_queue_index + 1 + M = len(self.queued_items) + self.progress_bar.setFormat(f"{value}% ({N}/{M})") + else: + self.progress_bar.setFormat(f"{value}%") + self.etr_label.setText( + f"Estimated time remaining: {etr_str}" + ) # Update ETR label + self.etr_label.show() # Show only when estimate is ready + + # Disable cancel button if progress is >= 98% + if value >= 98: + self.btn_cancel.setEnabled(False) + + self.progress_bar.repaint() + QApplication.processEvents() + + def enable_disable_queue_buttons(self): + enabled = bool(self.queued_items) + self.btn_clear_queue.setEnabled(enabled) + # Update Manage Queue button text with count + if enabled: + self.btn_manage_queue.setText(f"Manage Queue ({len(self.queued_items)})") + self.btn_manage_queue.setStyleSheet( + f"QPushButton {{ color: {COLORS['GREEN']}; }}" + ) + else: + self.btn_manage_queue.setText("Manage Queue") + self.btn_manage_queue.setStyleSheet("") + # Change main Start button to 'Start queue' if queue has items + if enabled: + self.btn_start.setText(f"Start queue ({len(self.queued_items)})") + try: + self.btn_start.clicked.disconnect() + except Exception: + pass + self.btn_start.clicked.connect(self.start_queue) + else: + self.btn_start.setText("Start") + try: + self.btn_start.clicked.disconnect() + except Exception: + pass + self.btn_start.clicked.connect(self.start_conversion) + + def enqueue(self, item: QueuedItem): + self.queued_items.append(item) + # self.update_log((f"Enqueued: {item.file_name}", True)) + # enable start queue button, manage queue button + self.enable_disable_queue_buttons() + + def get_queue(self): + return self.queued_items + + def add_to_queue(self): + # For epub/pdf, always use the converted txt file (selected_file) + if self.selected_file_type in ["epub", "pdf", "md", "markdown"]: + file_to_queue = self.selected_file + # Use the original file path for save location + save_base_path = ( + self.displayed_file_path if self.displayed_file_path else file_to_queue + ) + else: + file_to_queue = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) + save_base_path = file_to_queue # For non-EPUB, it's the same + + if not file_to_queue: + self.input_box.set_error("Please add a file.") + return + actual_subtitle_mode = self.get_actual_subtitle_mode() + voice_formula = self.get_voice_formula() + selected_lang = self.get_selected_lang(voice_formula) + + item_queue = QueuedItem( + file_name=file_to_queue, + lang_code=selected_lang, + speed=self.speed_slider.value() / 100.0, + voice=voice_formula, + save_option=self.save_option, + output_folder=self.selected_output_folder, + subtitle_mode=actual_subtitle_mode, + output_format=self.selected_format, + total_char_count=self.char_count, + replace_single_newlines=self.replace_single_newlines, + use_silent_gaps=self.use_silent_gaps, + subtitle_speed_method=self.subtitle_speed_method, + save_base_path=save_base_path, + save_chapters_separately=getattr(self, "save_chapters_separately", None), + merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None), + ) + + # Prevent adding duplicate items to the queue + for queued_item in self.queued_items: + if ( + queued_item.file_name == item_queue.file_name + and queued_item.lang_code == item_queue.lang_code + and queued_item.speed == item_queue.speed + and queued_item.voice == item_queue.voice + and queued_item.save_option == item_queue.save_option + and queued_item.output_folder == item_queue.output_folder + and queued_item.subtitle_mode == item_queue.subtitle_mode + and queued_item.output_format == item_queue.output_format + and getattr(queued_item, "replace_single_newlines", True) + == item_queue.replace_single_newlines + and getattr(queued_item, "save_base_path", None) + == item_queue.save_base_path + and getattr(queued_item, "save_chapters_separately", None) + == item_queue.save_chapters_separately + and getattr(queued_item, "merge_chapters_at_end", None) + == item_queue.merge_chapters_at_end + ): + QMessageBox.warning( + self, "Duplicate Item", "This item is already in the queue." + ) + return + + self.enqueue(item_queue) + # Clear input after adding to queue + self.input_box.clear_input() + self.input_box_cleared_by_queue = True # Set flag + self.enable_disable_queue_buttons() + + def clear_queue(self): + # Warn user if more than 1 item in the queue before clearing + if len(self.queued_items) > 1: + reply = QMessageBox.question( + self, + "Confirm Clear Queue", + f"Are you sure you want to clear {len(self.queued_items)} items from the queue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.queued_items = [] + self.enable_disable_queue_buttons() + + def manage_queue(self): + # show a dialog to manage the queue + dialog = QueueManager(self, self.queued_items) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.queued_items = dialog.get_queue() + + # Reload config to capture the new "Override" setting + # The QueueManager writes to disk, so we must refresh our local copy + self.config = load_config() + + # re-enable/disable buttons based on queue state + self.enable_disable_queue_buttons() + + def start_queue(self): + self.current_queue_index = 0 # Start from the first item + # Set progress bar to 0% (1/M) immediately + if self.queued_items: + self.progress_bar.setValue(0) + self.progress_bar.setFormat(f"0% (1/{len(self.queued_items)})") + self.progress_bar.show() + self.start_next_queued_item() + + def start_next_queued_item(self): + if self.current_queue_index < len(self.queued_items): + queued_item = self.queued_items[self.current_queue_index] + + self.selected_file = queued_item.file_name + self.char_count = queued_item.total_char_count + + # Restore the original file path for save location (Important for EPUB/PDF) + self.displayed_file_path = ( + queued_item.save_base_path or queued_item.file_name + ) + + # Restore chapter options (Structure specific, must be preserved) + self.save_chapters_separately = getattr( + queued_item, "save_chapters_separately", None + ) + self.merge_chapters_at_end = getattr( + queued_item, "merge_chapters_at_end", None + ) + + # CHECK GLOBAL OVERRIDE SETTING + if not self.config.get("queue_override_settings", False): + self.selected_lang = queued_item.lang_code + self.speed_slider.setValue(int(queued_item.speed * 100)) + + # Load the specific voice string + self.selected_voice = queued_item.voice + # Clear complex GUI states so the specific voice string is used + self.mixed_voice_state = None + self.selected_profile_name = None + + self.save_option = queued_item.save_option + self.selected_output_folder = queued_item.output_folder + self.subtitle_mode = queued_item.subtitle_mode + self.selected_format = queued_item.output_format + self.replace_single_newlines = getattr( + queued_item, "replace_single_newlines", True + ) + self.use_silent_gaps = getattr(queued_item, "use_silent_gaps", False) + self.subtitle_speed_method = getattr( + queued_item, "subtitle_speed_method", "tts" + ) + + # This ensures that if conversion.py (or utils) reads from config/disk + # instead of using passed arguments, it sees the correct queue values. + self.config["replace_single_newlines"] = self.replace_single_newlines + self.config["subtitle_mode"] = self.subtitle_mode + self.config["selected_format"] = self.selected_format + self.config["use_silent_gaps"] = self.use_silent_gaps + self.config["subtitle_speed_method"] = self.subtitle_speed_method + + # Sync Voice/Profile in config + self.config["selected_voice"] = self.selected_voice + if "selected_profile_name" in self.config: + del self.config["selected_profile_name"] + + # Note: Speed is already synced via self.speed_slider.setValue() -> update_speed_label() + save_config(self.config) + + self.start_conversion(from_queue=True) + else: + # Queue finished, reset index + self.current_queue_index = 0 + + def queue_item_conversion_finished(self): + # Called after each conversion finishes + self.current_queue_index += 1 + if self.current_queue_index < len(self.queued_items): + self.start_next_queued_item() + else: + self.current_queue_index = 0 # Reset for next time + + def get_voice_formula(self) -> str: + if self.mixed_voice_state: + formula_components = [ + f"{name}*{weight}" for name, weight in self.mixed_voice_state + ] + return " + ".join(filter(None, formula_components)) + else: + return self.selected_voice + + def get_selected_lang(self, voice_formula) -> str: + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + selected_lang = entry.get("language") + else: + selected_lang = self.selected_voice[0] if self.selected_voice else None + # fallback: extract from formula if missing + if not selected_lang: + m = re.search(r"\b([a-z])", voice_formula) + selected_lang = m.group(1) if m else None + return selected_lang + + def get_actual_subtitle_mode(self) -> str: + return "Disabled" if not self.subtitle_combo.isEnabled() else self.subtitle_mode + + def start_conversion(self, from_queue=False): + if not self.selected_file: + self.input_box.set_error("Please add a file.") + return + + # Ensure we honor the currently selected save option when not running from queue + if not from_queue: + current_option = self.save_combo.currentText() + self.save_option = current_option + self.config["save_option"] = current_option + # If user is not choosing a specific folder, clear any residual folder + if current_option != "Choose output folder": + self.selected_output_folder = None + self.config["selected_output_folder"] = None + save_config(self.config) + + prevent_sleep_start() + self.is_converting = True + self.convert_input_box_to_log() + self.progress_bar.setValue(0) + # Show queue progress if in queue mode + if ( + from_queue + and hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): + N = self.current_queue_index + 1 + M = len(self.queued_items) + self.progress_bar.setFormat(f"0% ({N}/{M})") + else: + self.progress_bar.setFormat("%p%") # Reset format initially + self.etr_label.hide() # Hide ETR label initially + self.controls_widget.hide() + self.queue_row_widget.hide() # Hide queue row when process starts + self.progress_bar.show() + self.btn_cancel.show() + QApplication.processEvents() + self.btn_cancel.setEnabled(False) + self.start_time = time.time() + self.finish_widget.hide() + speed = self.speed_slider.value() / 100.0 + + # Get the display file path for logs + display_path = ( + self.displayed_file_path if self.displayed_file_path else self.selected_file + ) + + # Get file size string + try: + file_size_str = self.input_box._human_readable_size( + os.path.getsize(self.selected_file) + ) + except Exception: + file_size_str = "Unknown" + + # pipeline_loaded_callback remains unchanged + def pipeline_loaded_callback(np_module, kpipeline_class, error): + if error: + self.update_log((f"Error loading numpy or KPipeline: {error}", "red")) + prevent_sleep_end() + return + + self.btn_cancel.setEnabled(True) + + # Override subtitle_mode to "Disabled" if subtitle_combo is disabled + actual_subtitle_mode = self.get_actual_subtitle_mode() + + # if voice formula is not None, use the selected voice + voice_formula = self.get_voice_formula() + # determine selected language: use profile setting if profile selected, else voice code + selected_lang = self.get_selected_lang(voice_formula) + + self.conversion_thread = ConversionThread( + self.selected_file, + selected_lang, + speed, + voice_formula, + self.save_option, + self.selected_output_folder, + subtitle_mode=actual_subtitle_mode, + output_format=self.selected_format, + np_module=np_module, + kpipeline_class=kpipeline_class, + start_time=self.start_time, + total_char_count=self.char_count, + use_gpu=self.gpu_ok, + from_queue=from_queue, + save_base_path=self.displayed_file_path, # Pass the save base path (original file for EPUB) + ) # Use gpu_ok status + # Pass the displayed file path to the log_updated signal handler in ConversionThread + self.conversion_thread.display_path = display_path + # Pass the file size string + self.conversion_thread.file_size_str = file_size_str + # Pass max_subtitle_words from config + self.conversion_thread.max_subtitle_words = self.max_subtitle_words + # Pass silence_duration from config + self.conversion_thread.silence_duration = self.silence_duration + # Pass replace_single_newlines setting + self.conversion_thread.replace_single_newlines = ( + self.replace_single_newlines + ) + # Pass use_silent_gaps setting + self.conversion_thread.use_silent_gaps = self.use_silent_gaps + # Pass subtitle_speed_method setting + self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method + # Pass use_spacy_segmentation setting + self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation + # Pass separate_chapters_format setting + self.conversion_thread.separate_chapters_format = ( + self.separate_chapters_format + ) + # Pass subtitle format setting + self.conversion_thread.subtitle_format = self.config.get( + "subtitle_format", "ass_centered_narrow" + ) + # Pass chapter count for EPUB or PDF files + if self.selected_file_type in ["epub", "pdf", "md", "markdown"] and hasattr( + self, "selected_chapters" + ): + self.conversion_thread.chapter_count = len(self.selected_chapters) + # Pass save_chapters_separately flag if available + self.conversion_thread.save_chapters_separately = getattr( + self, "save_chapters_separately", False + ) + # Pass merge_chapters_at_end flag if available + self.conversion_thread.merge_chapters_at_end = getattr( + self, "merge_chapters_at_end", True + ) + self.conversion_thread.progress_updated.connect(self.update_progress) + self.conversion_thread.log_updated.connect(self.update_log) + self.conversion_thread.conversion_finished.connect( + self.on_conversion_finished + ) + + # Connect chapters_detected signal + self.conversion_thread.chapters_detected.connect( + self.show_chapter_options_dialog + ) + + self.conversion_thread.start() + QApplication.processEvents() + + # Run GPU acceleration and module loading in a background thread + def gpu_and_load(): + self.update_log("Checking GPU acceleration...") + # Pass the use_gpu setting from the checkbox + gpu_msg, gpu_ok = get_gpu_acceleration(self.gpu_checkbox.isChecked()) + # Store gpu_ok status to use when creating the conversion thread + self.gpu_ok = gpu_ok + self.update_log((gpu_msg, gpu_ok)) + self.update_log("Loading modules...") + load_thread = LoadPipelineThread(pipeline_loaded_callback) + load_thread.start() + + threading.Thread(target=gpu_and_load, daemon=True).start() + + def show_queue_summary(self): + """Show a summary dialog after queue finishes.""" + if not self.queued_items: + return + + # Check if override was active (this determines which settings were ACTUALLY used) + override_active = self.config.get("queue_override_settings", False) + + # If override is ON, capture the global settings that were used for processing + if override_active: + g_voice = self.get_voice_formula() + g_lang = self.get_selected_lang(g_voice) + g_speed = self.speed_slider.value() / 100.0 + g_sub_mode = self.get_actual_subtitle_mode() + g_format = self.selected_format + g_newlines = self.replace_single_newlines + g_silent_gaps = self.use_silent_gaps + g_speed_method = self.subtitle_speed_method + + # Build HTML summary (Default Styling) + summary_html = "" + + header_text = "Queue finished" + if override_active: + header_text += " (Global Settings Applied)" + + summary_html += ( + f"

    {header_text}

    " + f"Processed {len(self.queued_items)} items:

    " + ) + + for idx, item in enumerate(self.queued_items, 1): + # Resolve Effective Settings + if override_active: + eff_lang = g_lang + eff_voice = g_voice + eff_speed = g_speed + eff_sub_mode = g_sub_mode + eff_format = g_format + eff_newlines = g_newlines + eff_silent = g_silent_gaps + eff_method = g_speed_method + else: + eff_lang = item.lang_code + eff_voice = item.voice + eff_speed = item.speed + eff_sub_mode = item.subtitle_mode + eff_format = item.output_format + eff_newlines = getattr(item, "replace_single_newlines", True) + eff_silent = getattr(item, "use_silent_gaps", False) + eff_method = getattr(item, "subtitle_speed_method", "tts") + + # Retrieve File-Specific Data (Never Overridden) + eff_chars = item.total_char_count + eff_input = item.file_name + eff_output = getattr(item, "output_path", "Unknown") + eff_save_sep = getattr(item, "save_chapters_separately", None) + eff_merge = getattr(item, "merge_chapters_at_end", None) + + # --- Construct Display Block --- + summary_html += ( + f"{idx}) {os.path.basename(eff_input)}
    " + f"Language: {eff_lang}
    " + f"Voice: {eff_voice}
    " + f"Speed: {eff_speed}
    " + f"Characters: {eff_chars}
    " + f"Format: {eff_format}
    " + f"Subtitle Mode: {eff_sub_mode}
    " + f"Method: {eff_method}
    " + f"Silent Gaps: {eff_silent}
    " + f"Repl. Newlines: {eff_newlines}
    " + ) + + # Book/Chapter specific options + if eff_save_sep is not None: + summary_html += f"Split Chapters: {eff_save_sep}
    " + if eff_save_sep and eff_merge is not None: + summary_html += f"Merge End: {eff_merge}
    " + + summary_html += ( + f"Input: {eff_input}
    " + f"Output: {eff_output}

    " + ) + + summary_html += "" + + dialog = QDialog(self) + dialog.setWindowTitle("Queue Summary") + # Allow resizing + dialog.resize(550, 650) + + layout = QVBoxLayout(dialog) + text_edit = QTextEdit(dialog) + text_edit.setReadOnly(True) + text_edit.setHtml(summary_html) + layout.addWidget(text_edit) + + close_btn = QPushButton("Close", dialog) + close_btn.setFixedHeight(36) + close_btn.clicked.connect(dialog.accept) + layout.addWidget(close_btn) + + dialog.setLayout(layout) + dialog.setMinimumSize(400, 300) + dialog.setSizeGripEnabled(True) + dialog.exec() + + def on_conversion_finished(self, message, output_path): + prevent_sleep_end() + if message == "Cancelled": + self.etr_label.hide() # Hide ETR label + self.progress_bar.hide() + self.btn_cancel.hide() + self.is_converting = False + self.controls_widget.show() + self.finish_widget.hide() + self.restore_input_box() + display_path = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) + # Only repopulate if not cleared by queue + if not getattr(self, "input_box_cleared_by_queue", False): + if display_path and os.path.exists(display_path): + self.input_box.set_file_info(display_path) + else: + self.input_box.clear_input() + else: + self.input_box.clear_input() + return + + self.update_log(message) + if output_path: + self.last_output_path = output_path + # Store output_path in the current queued item if in queue mode + if self.queued_items and self.current_queue_index < len(self.queued_items): + self.queued_items[self.current_queue_index].output_path = output_path + + self.etr_label.hide() # Hide ETR label + self.progress_bar.setValue(100) + self.progress_bar.hide() + self.btn_cancel.hide() + self.is_converting = False + elapsed = int(time.time() - self.start_time) + h, m, s = elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60 + self.update_log((f"\nTime elapsed: {h:02d}:{m:02d}:{s:02d}", "grey")) + + # Default to showing the button + show_open_file_button = True + # Check conditions to hide the button (only if flags exist for the completed conversion) + save_sep = getattr(self, "save_chapters_separately", False) + merge_end = getattr( + self, "merge_chapters_at_end", True + ) # Default to True if flag doesn't exist + if save_sep and not merge_end: + show_open_file_button = False + + if self.open_file_btn: + self.open_file_btn.setVisible(show_open_file_button) + + # Only show finish_widget if queue is done + if ( + self.current_queue_index + 1 >= len(self.queued_items) + or not self.queued_items + ): + # Queue finished, show finish screen + self.controls_widget.hide() + self.finish_widget.show() + sb = self.log_text.verticalScrollBar() + sb.setValue(sb.maximum()) + save_config(self.config) + # Show queue summary if more than one item + if len(self.queued_items) > 1: + self.show_queue_summary() + else: + # More items in queue: clear log and reload for next item + self.log_text.clear() + QApplication.processEvents() + + # Start new queued item, if we're using a queued conversion + self.queue_item_conversion_finished() + + def reset_ui(self): + try: + self.etr_label.hide() # Hide ETR label + self.progress_bar.setValue(0) + self.progress_bar.hide() + self.selected_file = self.selected_file_type = self.selected_book_path = ( + None + ) + self.selected_chapters = set() # Reset selected chapters + + # Ensure open file button is visible when resetting + if self.open_file_btn: + self.open_file_btn.show() + self.controls_widget.show() + self.queue_row_widget.show() # Show queue row on reset + self.finish_widget.hide() + self.btn_start.setText("Start") + # Disconnect only if connected, then reconnect + try: + self.btn_start.clicked.disconnect() + except TypeError: + pass # Ignore error if not connected + self.btn_start.clicked.connect(self.start_conversion) + self.enable_disable_queue_buttons() + self.restore_input_box() + self.input_box.clear_input() # Reset text and style + # Trigger the "Clear Queue" button (simulate user click) + self.btn_clear_queue.click() + except Exception as e: + self._show_error_message_box("Reset Error", f"Could not reset UI:\n{e}") + + def go_back_ui(self): + self.finish_widget.hide() + self.controls_widget.show() + self.queue_row_widget.show() # Show queue row on go back + self.progress_bar.hide() + self.restore_input_box() + self.log_text.clear() + + # Use displayed_file_path instead of selected_file for EPUBs or PDFs + display_path = ( + self.displayed_file_path if self.displayed_file_path else self.selected_file + ) + + # Only repopulate if not cleared by queue + if not getattr(self, "input_box_cleared_by_queue", False): + if display_path and os.path.exists(display_path): + self.input_box.set_file_info(display_path) + else: + self.input_box.clear_input() + else: + self.input_box.clear_input() + + # Ensure open file button is visible when going back + if self.open_file_btn: + self.open_file_btn.show() + + def on_save_option_changed(self, option): + self.save_option = option + self.config["save_option"] = option + if option == "Choose output folder": + try: + folder = QFileDialog.getExistingDirectory( + self, "Select Output Folder", "" + ) + if folder: + self.selected_output_folder = folder + self.save_path_label.setText(folder) + self.save_path_row_widget.show() + self.config["selected_output_folder"] = folder + else: + self.save_option = "Save next to input file" + self.save_combo.setCurrentText(self.save_option) + self.config["save_option"] = self.save_option + except Exception as e: + self._show_error_message_box( + "Folder Dialog Error", f"Could not open folder dialog:\n{e}" + ) + self.save_option = "Save next to input file" + self.save_combo.setCurrentText(self.save_option) + self.config["save_option"] = self.save_option + else: + self.save_path_row_widget.hide() + self.selected_output_folder = None + self.config["selected_output_folder"] = None + save_config(self.config) + + def go_to_file(self): + path = self.last_output_path + if not path: + return + try: + # Check if path is a directory (for multiple chapter files) + if os.path.isdir(path): + folder = path + else: + folder = os.path.dirname(path) + QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) + except Exception as e: + self._show_error_message_box( + "Open Folder Error", f"Could not open folder:\n{e}" + ) + + def open_file(self): + path = self.last_output_path + if not path: + return + try: + # Check if path exists and is a file before opening + if os.path.exists(path): + if os.path.isdir(path): + self._show_error_message_box( + "Open File Error", + "Cannot open a directory as a file. Please use 'Go to folder' instead.", + ) + return + QDesktopServices.openUrl(QUrl.fromLocalFile(path)) + else: + self._show_error_message_box( + "Open File Error", f"File not found: {path}" + ) + except Exception as e: + self._show_error_message_box( + "Open File Error", f"Could not open file:\n{e}" + ) + + def _get_preview_cache_path(self): + """Generate the expected cache path for the current voice settings.""" + speed = self.speed_slider.value() / 100.0 + voice_to_cache = "" + lang_to_cache = "" + + if self.mixed_voice_state: + components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] + voice_formula = " + ".join(filter(None, components)) + voice_to_cache = voice_formula + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang_to_cache = entry.get("language") + else: + lang_to_cache = self.selected_lang + if not lang_to_cache and self.mixed_voice_state: + lang_to_cache = ( + self.mixed_voice_state[0][0][0] + if self.mixed_voice_state and self.mixed_voice_state[0][0] + else None + ) + elif self.selected_voice: + lang_to_cache = self.selected_voice[0] + voice_to_cache = self.selected_voice + else: # No voice or profile selected + return None + + if not lang_to_cache or not voice_to_cache: # Not enough info + return None + + cache_dir = get_user_cache_path("preview_cache") + + if "*" in voice_to_cache: # Voice formula + voice_id = ( + f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}" + ) + else: # Single voice + voice_id = voice_to_cache + + filename = f"{voice_id}_{lang_to_cache}_{speed:.2f}.wav" + return os.path.join(cache_dir, filename) + + def preview_voice(self): + if self.preview_playing: + try: + if self.play_audio_thread and self.play_audio_thread.isRunning(): + # Call the stop method on PlayAudioThread to safely handle stopping + self.play_audio_thread.stop() + self.play_audio_thread.wait(500) # Wait a bit + except Exception as e: + print(f"Error stopping preview audio: {e}") + self._preview_cleanup() + return + + if hasattr(self, "preview_thread") and self.preview_thread.isRunning(): + return + + # Check for cache first + cached_path = self._get_preview_cache_path() + if cached_path and os.path.exists(cached_path): + print(f"Cache hit for {cached_path}") + self.btn_preview.setEnabled(False) # Disable button briefly + self.voice_combo.setEnabled(False) + self.btn_voice_formula_mixer.setEnabled(False) + self.btn_start.setEnabled(False) + + # Directly play from cache + self.preview_playing = True + self.btn_preview.setIcon(self.stop_icon) + self.btn_preview.setToolTip("Stop preview") + self.btn_preview.setEnabled(True) + + def cleanup_cached_play(): + self._preview_cleanup() + + try: + # Ensure pygame mixer is initialized for the audio thread + import pygame + + if not pygame.mixer.get_init(): + pygame.mixer.init() + + self.play_audio_thread = PlayAudioThread(cached_path) + self.play_audio_thread.finished.connect(cleanup_cached_play) + self.play_audio_thread.error.connect( + lambda msg: ( + self._show_preview_error_box(msg), + cleanup_cached_play(), + ) + ) + self.play_audio_thread.start() + except Exception as e: + self._show_error_message_box( + "Preview Error", f"Could not play cached preview audio:\n{e}" + ) + cleanup_cached_play() + return + + # If no cache hit, proceed to load pipeline and generate + self.btn_preview.setEnabled(False) + self.btn_preview.setToolTip("Loading...") + self.voice_combo.setEnabled(False) + self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button + self.btn_start.setEnabled(False) # Disable start button during preview + + # Start loading animation - ensure signal connection is always active + if hasattr(self, "loading_movie"): + # Disconnect previous connections to avoid multiple connections + try: + self.loading_movie.frameChanged.disconnect() + except TypeError: + pass # Ignore error if not connected + + # Reconnect the signal + self.loading_movie.frameChanged.connect( + lambda: self.btn_preview.setIcon( + QIcon(self.loading_movie.currentPixmap()) + ) + ) + self.loading_movie.start() + + def pipeline_loaded_callback(np_module, kpipeline_class, error): + self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error) + + load_thread = LoadPipelineThread(pipeline_loaded_callback) + load_thread.start() + + def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error): + # stop loading animation and restore icon on error + if error: + self.loading_movie.stop() + self._show_error_message_box( + "Loading Error", f"Error loading numpy or KPipeline: {error}" + ) + self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setEnabled(True) + self.btn_preview.setToolTip("Preview selected voice") + self.voice_combo.setEnabled(True) + self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button + self.btn_start.setEnabled(True) # Re-enable start button on error + return + + # Support preview for voice profiles + speed = self.speed_slider.value() / 100.0 + if self.mixed_voice_state: + # Build voice formula string + components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] + voice = " + ".join(filter(None, components)) + # determine language: use profile setting, else explicit mixer selection, else fallback to first voice code + if self.selected_profile_name: + from abogen.voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang = entry.get("language") + else: + lang = self.selected_lang + if not lang and self.mixed_voice_state: + lang = ( + self.mixed_voice_state[0][0][0] + if self.mixed_voice_state and self.mixed_voice_state[0][0] + else None + ) + else: + lang = self.selected_voice[0] + voice = self.selected_voice + + # use same gpu/cpu logic as in conversion + gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu) + + self.preview_thread = VoicePreviewThread( + np_module, kpipeline_class, lang, voice, speed, gpu_ok + ) + self.preview_thread.finished.connect(self._play_preview_audio) + self.preview_thread.error.connect(self._preview_error) + self.preview_thread.start() + + def _play_preview_audio(self, from_cache=True): # from_cache default is now False + # If preview_thread is the source, get temp_wav from it + if hasattr(self, "preview_thread") and not from_cache: + temp_wav = self.preview_thread.temp_wav + elif from_cache: # This case is now handled before calling _play_preview_audio + cached_path = self._get_preview_cache_path() + if cached_path and os.path.exists(cached_path): + temp_wav = cached_path + else: # Should not happen if cache check was done + self._show_error_message_box( + "Preview Error", + "Cache file expected but not found, please try again.", + ) + self._preview_cleanup() + return + else: # Should have temp_wav from preview_thread or handled by cache check + self._show_error_message_box( + "Preview Error", "Preview audio path not found." + ) + self._preview_cleanup() + return + + if not temp_wav: + if hasattr(self, "loading_movie"): + self.loading_movie.stop() + self._show_error_message_box( + "Preview Error", "Preview error: No audio generated." + ) + self._preview_cleanup() + return + + # stop loading animation, switch to stop icon + if hasattr(self, "loading_movie"): + self.loading_movie.stop() + self.preview_playing = True + self.btn_preview.setIcon(self.stop_icon) + self.btn_preview.setToolTip("Stop preview") + self.btn_preview.setEnabled(True) + + def cleanup(): + # Only remove if not from cache AND it's a temp file from VoicePreviewThread + if ( + not from_cache + and hasattr(self, "preview_thread") + and hasattr(self.preview_thread, "temp_wav") + and self.preview_thread.temp_wav == temp_wav + ): + try: + if os.path.exists( + temp_wav + ): # Ensure it exists before trying to remove + os.remove(temp_wav) + except Exception: + pass + self._preview_cleanup() + + try: + # Ensure pygame mixer is initialized for the audio thread + import pygame + + if not pygame.mixer.get_init(): + pygame.mixer.init() + + self.play_audio_thread = PlayAudioThread(temp_wav) + self.play_audio_thread.finished.connect(cleanup) + self.play_audio_thread.error.connect( + lambda msg: (self._show_preview_error_box(msg), cleanup()) + ) + self.play_audio_thread.start() + except Exception as e: + self._show_error_message_box( + "Preview Error", f"Could not play preview audio:\n{e}" + ) + cleanup() + + def _show_error_message_box(self, title, message): + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Critical) + box.setWindowTitle(title) + box.setText(message) + copy_btn = QPushButton("Copy") + box.addButton(copy_btn, QMessageBox.ButtonRole.ActionRole) + box.addButton(QMessageBox.StandardButton.Ok) + copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(message)) + box.exec() + + def _show_preview_error_box(self, msg): + self._show_error_message_box("Preview Error", f"Preview error: {msg}") + + def _preview_cleanup(self): + self.preview_playing = False + if hasattr(self, "loading_movie"): + self.loading_movie.stop() + try: + if hasattr(self, "loading_movie"): + self.loading_movie.frameChanged.disconnect() + except Exception: + pass # Ignore error if not connected + self.btn_preview.setIcon(self.play_icon) + self.btn_preview.setToolTip("Preview selected voice") + self.btn_preview.setEnabled(True) + self.voice_combo.setEnabled(True) + self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button + self.btn_start.setEnabled(True) + + def _preview_error(self, msg): + self._show_error_message_box("Preview Error", f"Preview error: {msg}") + self._preview_cleanup() + + def cancel_conversion(self): + if self.is_converting: + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Cancel Conversion") + box.setText( + "A conversion is currently running. Are you sure you want to cancel?" + ) + box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + box.setDefaultButton(QMessageBox.StandardButton.No) + if box.exec() != QMessageBox.StandardButton.Yes: + return + try: + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() + ): + if not hasattr(self, "_conversion_lock"): + self._conversion_lock = threading.Lock() + + def _cancel(): + with self._conversion_lock: + self.conversion_thread.cancel() # <-- Use cancel() method + self.conversion_thread.wait() + + threading.Thread(target=_cancel, daemon=True).start() + + self.is_converting = False + self.etr_label.hide() # Hide ETR label + self.progress_bar.hide() + self.btn_cancel.hide() + self.controls_widget.show() + self.queue_row_widget.show() # Show queue row on cancel + self.finish_widget.hide() + self.restore_input_box() + self.log_text.clear() + display_path = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) + # Only repopulate if not cleared by queue + if not getattr(self, "input_box_cleared_by_queue", False): + if display_path and os.path.exists(display_path): + self.input_box.set_file_info(display_path) + else: + self.input_box.clear_input() + else: + self.input_box.clear_input() + prevent_sleep_end() + except Exception as e: + self._show_error_message_box( + "Cancel Error", f"Could not cancel conversion:\n{e}" + ) + + def on_subtitle_mode_changed(self, mode): + self.subtitle_mode = mode + self.config["subtitle_mode"] = mode + save_config(self.config) + # Disable subtitle format combo if subtitles are disabled + if mode == "Disabled": + self.subtitle_format_combo.setEnabled(False) + else: + self.subtitle_format_combo.setEnabled(True) + # If highlighting mode selected, SRT is not supported. Disable SRT option and + # switch away from it if currently selected. + try: + idx_srt = self.subtitle_format_combo.findData("srt") + if mode == "Sentence + Highlighting": + if idx_srt >= 0: + item = self.subtitle_format_combo.model().item(idx_srt) + if item is not None: + item.setEnabled(False) + # If current format is SRT, switch to a compatible ASS format + if self.subtitle_format_combo.currentData() == "srt": + new_idx = self.subtitle_format_combo.findData("ass_centered_narrow") + if new_idx >= 0: + self.subtitle_format_combo.setCurrentIndex(new_idx) + self.set_subtitle_format( + self.subtitle_format_combo.itemData(new_idx) + ) + else: + # Re-enable SRT option when not in highlighting mode + if idx_srt >= 0: + item = self.subtitle_format_combo.model().item(idx_srt) + if item is not None: + item.setEnabled(True) + except Exception: + # Ignore errors interacting with model (defensive) + pass + + def on_format_changed(self, fmt): + self.selected_format = fmt + self.config["selected_format"] = fmt + save_config(self.config) + + def on_gpu_setting_changed(self, state): + self.use_gpu = state == Qt.CheckState.Checked.value + self.config["use_gpu"] = self.use_gpu + save_config(self.config) + + def cleanup_conversion_thread(self): + # Stop conversion thread + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread is not None + and self.conversion_thread.isRunning() + ): + self.conversion_thread.cancel() + self.conversion_thread.wait() + + def cleanup_preview_threads(self): + # Stop preview generation thread + if ( + hasattr(self, "preview_thread") + and self.preview_thread is not None + and self.preview_thread.isRunning() + ): + self.preview_thread.terminate() + self.preview_thread.wait() + + # Stop audio playback thread + if ( + hasattr(self, "play_audio_thread") + and self.play_audio_thread is not None + and self.play_audio_thread.isRunning() + ): + self.play_audio_thread.stop() + self.play_audio_thread.wait() + + # Cleanup pygame mixer if initialized + try: + pygame = sys.modules.get("pygame") + if pygame and pygame.mixer.get_init(): + pygame.mixer.quit() + except Exception: + pass + + def closeEvent(self, event): + if self.is_converting: + box = QMessageBox(self) + box.setIcon(QMessageBox.Icon.Warning) + box.setWindowTitle("Conversion in Progress") + box.setText( + "A conversion is currently running. Are you sure you want to exit?" + ) + box.setStandardButtons( + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + box.setDefaultButton(QMessageBox.StandardButton.No) + if box.exec() == QMessageBox.StandardButton.Yes: + self.cleanup_conversion_thread() + self.cleanup_preview_threads() + event.accept() + else: + event.ignore() + else: + self.cleanup_conversion_thread() + self.cleanup_preview_threads() + event.accept() + + def show_chapter_options_dialog(self, chapter_count): + """Show dialog to ask user about chapter processing options when chapters are detected in a .txt file""" + # Check if this is a timestamp detection (-1) or chapter detection + if chapter_count == -1: + from abogen.conversion import TimestampDetectionDialog + + dialog = TimestampDetectionDialog(parent=self) + dialog.setWindowModality(Qt.WindowModality.ApplicationModal) + + # Dialog always accepts (Yes or No), never cancels the conversion + dialog.exec() + treat_as_subtitle = dialog.use_timestamps() + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() + ): + self.conversion_thread.set_timestamp_response(treat_as_subtitle) + return + + # Normal chapter detection + from abogen.conversion import ChapterOptionsDialog + + dialog = ChapterOptionsDialog(chapter_count, parent=self) + dialog.setWindowModality(Qt.WindowModality.ApplicationModal) + + if dialog.exec() == QDialog.DialogCode.Accepted: + options = dialog.get_options() + if ( + hasattr(self, "conversion_thread") + and self.conversion_thread.isRunning() + ): + self.conversion_thread.set_chapter_options(options) + else: + self.cancel_conversion() + + def apply_theme(self, theme): + + app = QApplication.instance() + is_windows = platform.system() == "Windows" + available_styles = [s.lower() for s in QStyleFactory.keys()] + + def is_windows_dark_mode(): + try: + import winreg + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + ) as key: + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + return value == 0 + except Exception: + return False + + # --- Theme selection logic --- + def set_dark_palette(): + palette = QPalette() + dark_bg = QColor(COLORS["DARK_BG"]) + base_bg = QColor(COLORS["DARK_BASE"]) + alt_bg = QColor(COLORS["DARK_ALT"]) + button_bg = QColor(COLORS["DARK_BUTTON"]) + disabled_fg = QColor(COLORS["DARK_DISABLED"]) + palette.setColor(QPalette.ColorRole.Window, dark_bg) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Base, base_bg) + palette.setColor(QPalette.ColorRole.AlternateBase, alt_bg) + palette.setColor(QPalette.ColorRole.ToolTipBase, dark_bg) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Button, button_bg) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + # Disabled roles + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg + ) + app.setPalette(palette) + + def set_light_palette(): + palette = QPalette() + disabled_fg = QColor(COLORS["LIGHT_DISABLED"]) + palette.setColor(QPalette.ColorRole.Window, QColor(COLORS["LIGHT_BG"])) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Base, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.AlternateBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.black) + palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black) + # Disabled roles + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Base, + Qt.GlobalColor.white, + ) + palette.setColor( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Button, + Qt.GlobalColor.white, + ) + app.setPalette(palette) + + # --- Dark title bar support for Windows --- + def set_title_bar_dark_mode(window, enable): + if is_windows: + try: + window.update() + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + set_window_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute + hwnd = int(window.winId()) + value = ctypes.c_int(2 if enable else 0) + set_window_attribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value), + ) + except Exception: + pass + + # Main logic + dark_mode = theme == "dark" or ( + theme == "system" and is_windows and is_windows_dark_mode() + ) + if dark_mode: + app.setStyle("Fusion") + set_dark_palette() + elif (theme == "light" or theme == "system") and is_windows: + if "windowsvista" in available_styles: + app.setStyle("windowsvista") + else: + app.setStyle("Fusion") + app.setPalette(QPalette()) + elif theme == "light": + app.setStyle("Fusion") + set_light_palette() + else: + app.setStyle("Fusion") + app.setPalette(QPalette()) + + # Always set the title bar mode according to the current theme for all top-level widgets + for widget in app.topLevelWidgets(): + set_title_bar_dark_mode(widget, dark_mode) + + # Refresh all top-level widgets + style_name = app.style().objectName() + app.setStyle(style_name) + for widget in app.topLevelWidgets(): + app.style().polish(widget) + widget.update() + + # Remove old event filter if present, then install a new one for dark title bar on new windows + if hasattr(app, "_dark_titlebar_event_filter"): + app.removeEventFilter(app._dark_titlebar_event_filter) + delattr(app, "_dark_titlebar_event_filter") + + def get_dark_mode(): + return theme == "dark" or ( + theme == "system" and is_windows and is_windows_dark_mode() + ) + + app._dark_titlebar_event_filter = DarkTitleBarEventFilter( + is_windows, get_dark_mode, set_title_bar_dark_mode + ) + app.installEventFilter(app._dark_titlebar_event_filter) + + # Save config if changed + if self.config.get("theme", "system") != theme: + self.config["theme"] = theme + save_config(self.config) + + def show_settings_menu(self): + """Show a dropdown menu for settings options.""" + menu = QMenu(self) + + theme_menu = QMenu("Theme", self) + theme_menu.setToolTip("Choose the application theme") + + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + + # Theme options: (internal_value, display_text) + theme_options = [ + ("system", "System"), + ("light", "Light"), + ("dark", "Dark"), + ] + + # Get current theme from config, default to "system" + current_theme = self.config.get("theme", "system") + for value, text in theme_options: + theme_action = QAction(text, self) + theme_action.setCheckable(True) + theme_action.setChecked(current_theme == value) + theme_action.triggered.connect(lambda checked, v=value: self.apply_theme(v)) + theme_group.addAction(theme_action) + theme_menu.addAction(theme_action) + + menu.addMenu(theme_menu) + + # Add separate chapters format option + separate_chapters_format_menu = QMenu("Separate chapters audio format", self) + separate_chapters_format_menu.setToolTip( + "Choose the format for individual chapter files" + ) + + format_group = QActionGroup(self) + format_group.setExclusive(True) + + for format_option in ["wav", "flac", "mp3", "opus"]: + format_action = QAction(format_option, self) + format_action.setCheckable(True) + format_action.setChecked(self.separate_chapters_format == format_option) + format_action.triggered.connect( + lambda checked, fmt=format_option: self.set_separate_chapters_format( + fmt + ) + ) + format_group.addAction(format_action) + separate_chapters_format_menu.addAction(format_action) + + menu.addMenu(separate_chapters_format_menu) + + # Add max words per subtitle option + max_words_action = QAction("Configure max words per subtitle", self) + max_words_action.triggered.connect(self.set_max_subtitle_words) + menu.addAction(max_words_action) + + # Add silence between chapters option + silence_action = QAction("Configure silence between chapters", self) + silence_action.triggered.connect(self.set_silence_between_chapters) + menu.addAction(silence_action) + + max_lines_action = QAction("Configure max lines in log window", self) + max_lines_action.triggered.connect(self.set_max_log_lines) + menu.addAction(max_lines_action) + + # Add separator + menu.addSeparator() + + # Add shortcut to desktop (Windows or Linux) + if platform.system() == "Windows" or platform.system() == "Linux": + # Use extended label on Linux + label = ( + "Create desktop shortcut and install" + if platform.system() == "Linux" + else "Create desktop shortcut" + ) + add_shortcut_action = QAction(label, self) + add_shortcut_action.triggered.connect(self.add_shortcut_to_desktop) + menu.addAction(add_shortcut_action) + + # Add reveal config option + reveal_config_action = QAction("Open configuration directory", self) + reveal_config_action.triggered.connect(self.reveal_config_in_explorer) + menu.addAction(reveal_config_action) + + # Add open cache directory option + open_cache_action = QAction("Open cache directory", self) + open_cache_action.triggered.connect(self.open_cache_directory) + menu.addAction(open_cache_action) + + # Add clear cache files option + clear_cache_action = QAction("Clear cache files", self) + clear_cache_action.triggered.connect(self.clear_cache_files) + menu.addAction(clear_cache_action) + + # Add separator + menu.addSeparator() + + # Add use silent gaps option (for subtitle files) + self.silent_gaps_action = QAction("Use silent gaps between subtitles", self) + self.silent_gaps_action.setCheckable(True) + self.silent_gaps_action.setChecked(self.use_silent_gaps) + self.silent_gaps_action.triggered.connect( + lambda checked: self.toggle_use_silent_gaps(checked) + ) + menu.addAction(self.silent_gaps_action) + + # Subtitle speed adjustment method + speed_method_menu = menu.addMenu("Subtitle speed adjustment method") + speed_method_menu.setToolTip( + "Choose speed adjustment method:\n" + "TTS Regeneration: Better quality\n" + "FFmpeg Time-stretch: Faster processing" + ) + + speed_method_group = QActionGroup(self) + speed_method_group.setExclusive(True) + + for method, label in [ + ("tts", "TTS Regeneration (better quality)"), + ("ffmpeg", "FFmpeg Time-stretch (better speed)"), + ]: + action = QAction(label, speed_method_menu) + action.setCheckable(True) + action.setChecked(self.subtitle_speed_method == method) + action.triggered.connect( + lambda checked, m=method: self.toggle_subtitle_speed_method(m) + ) + speed_method_group.addAction(action) + speed_method_menu.addAction(action) + + self.speed_method_group = speed_method_group + + # Add separator + menu.addSeparator() + + # Add spaCy sentence segmentation option + spacy_action = QAction("Use spaCy for sentence segmentation", self) + spacy_action.setCheckable(True) + spacy_action.setChecked(self.use_spacy_segmentation) + spacy_action.triggered.connect( + lambda checked: self.toggle_spacy_segmentation(checked) + ) + menu.addAction(spacy_action) + + # Add separator + menu.addSeparator() + + # Add "Pre-download models and voices for offline use" option + predownload_action = QAction( + "Pre-download models and voices for offline use", self + ) + predownload_action.triggered.connect(self.show_predownload_dialog) + menu.addAction(predownload_action) + + # Add "Disable Kokoro's internet access" option + disable_kokoro_action = QAction("Disable Kokoro's internet access", self) + disable_kokoro_action.setCheckable(True) + disable_kokoro_action.setChecked( + self.config.get("disable_kokoro_internet", False) + ) + disable_kokoro_action.triggered.connect( + lambda checked: self.toggle_kokoro_internet_access(checked) + ) + menu.addAction(disable_kokoro_action) + + # Add check for updates option + check_updates_action = QAction("Check for updates at startup", self) + check_updates_action.setCheckable(True) + check_updates_action.setChecked(self.config.get("check_updates", True)) + check_updates_action.triggered.connect(self.toggle_check_updates) + menu.addAction(check_updates_action) + + # Add "Reset to default settings" option + reset_defaults_action = QAction("Reset to default settings", self) + reset_defaults_action.triggered.connect(self.reset_to_default_settings) + menu.addAction(reset_defaults_action) + + # Add about action + about_action = QAction("About", self) + about_action.triggered.connect(self.show_about_dialog) + menu.addAction(about_action) + + menu.exec(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height()))) + + def toggle_replace_single_newlines(self, enabled): + self.replace_single_newlines = enabled + self.config["replace_single_newlines"] = enabled + save_config(self.config) + + def toggle_use_silent_gaps(self, enabled): + # Show confirmation dialog with explanation + action = "enable" if enabled else "disable" + message = ( + "When enabled, allows speech to continue naturally into the silent periods between subtitles, " + "preventing unnecessary audio speed-up based on subtitle end timestamps.\n\nWhen disabled, ensures strict subtitle timing where " + f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?" + ) + + reply = QMessageBox.question( + self, + "Use Silent Gaps Between Subtitles", + message, + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + ) + + if reply == QMessageBox.StandardButton.Ok: + self.use_silent_gaps = enabled + self.config["use_silent_gaps"] = enabled + save_config(self.config) + else: + # Revert the checkbox state if cancelled + self.silent_gaps_action.setChecked(not enabled) + + def toggle_subtitle_speed_method(self, method): + self.subtitle_speed_method = method + self.config["subtitle_speed_method"] = method + save_config(self.config) + + def toggle_spacy_segmentation(self, enabled): + self.use_spacy_segmentation = enabled + self.config["use_spacy_segmentation"] = enabled + save_config(self.config) + + def restart_app(self): + + import sys + + exe = sys.executable + args = sys.argv + + # On Windows, use .exe if available + if platform.system() == "Windows": + script_path = args[0] + if not script_path.lower().endswith(".exe"): + exe_path = os.path.splitext(script_path)[0] + ".exe" + if os.path.exists(exe_path): + args[0] = exe_path + + QProcess.startDetached(exe, args) + QApplication.quit() + + def toggle_kokoro_internet_access(self, disabled): + if disabled: + message = ( + "Disabling Kokoro's internet access will block downloads of models and voices from Hugging Face Hub. " + "This can make processing faster when there is no internet connection, since no requests will be made. " + "The app needs to restart to apply this change.\n\nDo you want to continue?" + ) + else: + message = ( + "Enabling Kokoro's internet access will allow it to download models and voices from Hugging Face Hub. " + "The app needs to restart to apply this change.\n\nDo you want to continue?" + ) + reply = QMessageBox.question( + self, + "Restart Required", + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.config["disable_kokoro_internet"] = disabled + save_config(self.config) + try: + self.restart_app() + except Exception as e: + QMessageBox.critical( + self, "Restart Failed", f"Failed to restart the application:\n{e}" + ) + + def reset_to_default_settings(self): + reply = QMessageBox.question( + self, + "Reset Settings", + "This will reset all settings to their default values and restart the application.\n\nDo you want to continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + from abogen.utils import get_user_config_path + + config_path = get_user_config_path() + try: + if os.path.exists(config_path): + os.remove(config_path) + self.restart_app() + except Exception as e: + QMessageBox.critical( + self, "Reset Error", f"Could not reset settings:\n{e}" + ) + + def reveal_config_in_explorer(self): + """Open the configuration file location in file explorer.""" + from abogen.utils import get_user_config_path + + try: + config_path = get_user_config_path() + # Open the directory containing the config file + QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(config_path))) + except Exception as e: + QMessageBox.critical( + self, "Config Error", f"Could not open config location:\n{e}" + ) + + def open_cache_directory(self): + """Open the cache directory used by the program.""" + try: + # Get the abogen cache directory + cache_dir = get_user_cache_path() + + # Create the directory if it doesn't exist + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + # Open the directory in file explorer + QDesktopServices.openUrl(QUrl.fromLocalFile(cache_dir)) + except Exception as e: + QMessageBox.critical( + self, "Cache Directory Error", f"Could not open cache directory:\n{e}" + ) + + def add_shortcut_to_desktop(self): + """Create a desktop shortcut to this program using PowerShell.""" + import sys + from platformdirs import user_desktop_dir + from abogen.utils import create_process + + try: + if platform.system() == "Windows": + # where to put the .lnk + desktop = user_desktop_dir() + shortcut_path = os.path.join(desktop, "abogen.lnk") + + # target exe + python_dir = os.path.dirname(sys.executable) + target = os.path.join(python_dir, "Scripts", "abogen.exe") + if not os.path.exists(target): + QMessageBox.critical( + self, + "Shortcut Error", + f"Could not find abogen.exe at:\n{target}", + ) + return + + # icon (fallback to exe if missing) + icon = get_resource_path("abogen.assets", "icon.ico") + if not icon or not os.path.exists(icon): + icon = target # Create a more direct PowerShell command + shortcut_ps = shortcut_path.replace("'", "''").replace("\\", "\\\\") + target_ps = target.replace("'", "''").replace("\\", "\\\\") + workdir_ps = ( + os.path.dirname(target).replace("'", "''").replace("\\", "\\\\") + ) + icon_ps = icon.replace("'", "''").replace("\\", "\\\\") + # Create PowerShell script as a single line with no line breaks (more reliable) + ps_cmd = f"$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('{shortcut_ps}'); $lnk.TargetPath='{target_ps}'; $lnk.WorkingDirectory='{workdir_ps}'; $lnk.IconLocation='{icon_ps}'; $lnk.Save()" + + # Run PowerShell with the command directly + proc = create_process( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "' + + ps_cmd + + '"' + ) + proc.wait() + + if proc.returncode == 0: + QMessageBox.information( + self, + "Shortcut Created", + f"Shortcut created on desktop:\n{shortcut_path}", + ) + else: + QMessageBox.critical( + self, + "Shortcut Error", + f"PowerShell failed with exit code: {proc.returncode}", + ) + elif platform.system() == "Linux": + desktop = user_desktop_dir() + if not desktop or not os.path.isdir(desktop): + QMessageBox.critical( + self, "Shortcut Error", "Could not determine desktop directory." + ) + return + + shortcut_path = os.path.join(desktop, "abogen.desktop") + + import shutil + + found = shutil.which("abogen") + if found: + target = found + else: + local_bin = os.path.expanduser("~/.local/bin/abogen") + if os.path.exists(local_bin): + target = local_bin + else: + python_dir = os.path.dirname(sys.executable) + target = os.path.join(python_dir, "bin", "abogen") + if not os.path.exists(target): + target_fallback = os.path.join(python_dir, "abogen") + if os.path.exists(target_fallback): + target = target_fallback + else: + QMessageBox.critical( + self, + "Shortcut Error", + "Could not find abogen executable in PATH or common installation directories.", + ) + return + + icon_path = get_resource_path("abogen.assets", "icon.png") + + desktop_entry_content = f"""[Desktop Entry] +Version={VERSION} +Name={PROGRAM_NAME} +Comment={PROGRAM_DESCRIPTION} +Exec={target} +Icon={icon_path} +Terminal=false +Type=Application +Categories=AudioVideo;Audio;Utility; +""" + with open(shortcut_path, "w", encoding="utf-8") as f: + f.write(desktop_entry_content) + + os.chmod(shortcut_path, 0o755) + + QMessageBox.information( + self, + "Shortcut Created", + f"Shortcut created on desktop:\n{shortcut_path}", + ) + + # Offer installation for current user under ~/.local/share/applications + reply = QMessageBox.question( + self, + "Install Application Entry", + "Install application entry for current user?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + import shutil + + user_app_dir = os.path.expanduser("~/.local/share/applications") + os.makedirs(user_app_dir, exist_ok=True) + user_entry = os.path.join(user_app_dir, "abogen.desktop") + try: + shutil.copyfile(shortcut_path, user_entry) + os.chmod(user_entry, 0o644) + QMessageBox.information( + self, + "Installation Complete", + f"Desktop entry installed to {user_entry}", + ) + except Exception as e: + QMessageBox.warning( + self, + "Install Error", + f"Could not install entry:\n{e}", + ) + else: + QMessageBox.information( + self, + "Unsupported OS", + "Desktop shortcut creation is not supported on this operating system.", + ) + + except Exception as e: + QMessageBox.critical( + self, "Shortcut Error", f"Could not create shortcut:\n{e}" + ) + + def toggle_check_updates(self, checked): + self.config["check_updates"] = checked + save_config(self.config) + + def show_voice_formula_dialog(self): + from abogen.voice_profiles import load_profiles + + profiles = load_profiles() + initial_state = None + selected_profile = self.selected_profile_name + if selected_profile: + entry = profiles.get(selected_profile, {}) + if isinstance(entry, dict): + initial_state = entry.get("voices", []) + else: + initial_state = entry + elif self.mixed_voice_state is not None: + initial_state = self.mixed_voice_state + elif self.selected_voice: + # If a single voice is selected, default to first profile if available + if profiles: + first_profile = next(iter(profiles)) + entry = profiles[first_profile] + selected_profile = first_profile + if isinstance(entry, dict): + initial_state = entry.get("voices", []) + else: + initial_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None + dialog = VoiceFormulaDialog( + self, initial_state=initial_state, selected_profile=selected_profile + ) + if dialog.exec() == QDialog.DialogCode.Accepted: + if dialog.current_profile: + self.selected_profile_name = dialog.current_profile + self.config["selected_profile_name"] = dialog.current_profile + if "selected_voice" in self.config: + del self.config["selected_voice"] + save_config(self.config) + self.populate_profiles_in_voice_combo() + idx = self.voice_combo.findData(f"profile:{dialog.current_profile}") + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) + self.mixed_voice_state = dialog.get_selected_voices() + + def show_predownload_dialog(self): + """Show the pre-download models and voices dialog.""" + from abogen.pyqt.predownload_gui import PreDownloadDialog + + dialog = PreDownloadDialog(self) + dialog.exec() + + def show_about_dialog(self): + """Show an About dialog with program information including GitHub link.""" + # Get application icon for dialog + icon = self.windowIcon() + + # Create custom dialog + dialog = QDialog(self) + dialog.setWindowTitle(f"About {PROGRAM_NAME}") + dialog.setWindowFlags( + dialog.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint + ) + dialog.setFixedSize(400, 320) # Increased height for new button + + layout = QVBoxLayout(dialog) + layout.setSpacing(10) + + # Header with icon and title + header_layout = QHBoxLayout() + icon_label = QLabel() + if not icon.isNull(): + icon_label.setPixmap(icon.pixmap(64, 64)) + else: + # Fallback text if icon not available + icon_label.setText("📚") + icon_label.setStyleSheet("font-size: 48px;") + + header_layout.addWidget(icon_label) + + # Fix: Added style to reduce space between h1 and h3 + title_label = QLabel( + f"

    {PROGRAM_NAME} v{VERSION}

    Audiobook Generator

    " + ) + title_label.setTextFormat(Qt.TextFormat.RichText) + header_layout.addWidget(title_label, 1) + layout.addLayout(header_layout) + + # Description + desc_label = QLabel( + 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/main.py b/abogen/pyqt/main.py new file mode 100644 index 0000000..f788e15 --- /dev/null +++ b/abogen/pyqt/main.py @@ -0,0 +1,187 @@ +import os +import sys +import platform +import atexit +import signal +from abogen.utils import get_resource_path, load_config, prevent_sleep_end + + +# Fix PyTorch DLL loading issue ([WinError 1114]) on Windows before importing PyQt6 +if platform.system() == "Windows": + import ctypes + from importlib.util import find_spec + + try: + if ( + (spec := find_spec("torch")) + and spec.origin + and os.path.exists( + dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") + ) + ): + ctypes.CDLL(os.path.normpath(dll_path)) + except Exception: + pass + + +# Qt platform plugin detection (fixes #59) +try: + from PyQt6.QtCore import QLibraryInfo + + # Get the path to the plugins directory + plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath) + + # Normalize path to use the OS-native separators and absolute path + platform_dir = os.path.normpath(os.path.join(plugins, "platforms")) + + # Ensure we work with an absolute path for clarity + platform_dir = os.path.abspath(platform_dir) + + if os.path.isdir(platform_dir): + os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir + print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir) + else: + print("PyQt6 platform plugins not found at", platform_dir) +except ImportError: + print("PyQt6 not installed.") + + +# Pre-load "libxcb-cursor" on Linux (fixes #101) +if platform.system() == "Linux": + arch = platform.machine().lower() + lib_filename = {"x86_64": "libxcb-cursor-amd64.so.0", "amd64": "libxcb-cursor-amd64.so.0", "aarch64": "libxcb-cursor-arm64.so.0", "arm64": "libxcb-cursor-arm64.so.0"}.get(arch) + if lib_filename: + import ctypes + try: + # Try to load the system libxcb-cursor.so.0 first + ctypes.CDLL('libxcb-cursor.so.0', mode=ctypes.RTLD_GLOBAL) + except OSError: + # System lib not available, load the bundled version + lib_path = get_resource_path('abogen.libs', lib_filename) + if lib_path: + try: + ctypes.CDLL(lib_path, mode=ctypes.RTLD_GLOBAL) + except OSError: + # If it fails (e.g. wrong glibc version on very old systems), + # we simply ignore it and hope the system has the library. + pass + + +# Set application ID for Windows taskbar icon +if platform.system() == "Windows": + try: + from abogen.constants import PROGRAM_NAME, VERSION + import ctypes + + app_id = f"{PROGRAM_NAME}.{VERSION}" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + except Exception as e: + print("Warning: failed to set AppUserModelID:", e) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import ( + QLibraryInfo, + qInstallMessageHandler, + QtMsgType, +) + +# Add the directory to Python path +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) + +# Set Hugging Face Hub environment variables +os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry +os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds) +os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds) +os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning +if load_config().get("disable_kokoro_internet", False): + print("INFO: Kokoro's internet access is disabled.") + os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access + +from abogen.pyqt.gui import abogen +from abogen.constants import PROGRAM_NAME, VERSION + +# Set environment variables for AMD ROCm +os.environ["MIOPEN_FIND_MODE"] = "FAST" +os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" + +# Reset sleep states +atexit.register(prevent_sleep_end) + + +# Also handle signals (Ctrl+C, kill, etc.) +def _cleanup_sleep(signum, frame): + prevent_sleep_end() + sys.exit(0) + + +signal.signal(signal.SIGINT, _cleanup_sleep) +signal.signal(signal.SIGTERM, _cleanup_sleep) + +# Ensure sys.stdout and sys.stderr are valid in GUI mode +if sys.stdout is None: + sys.stdout = open(os.devnull, "w") +if sys.stderr is None: + sys.stderr = open(os.devnull, "w") + +# Enable MPS GPU acceleration on Mac Apple Silicon +if platform.system() == "Darwin" and platform.processor() == "arm": + os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" + + +# Custom message handler to filter out specific Qt warnings +def qt_message_handler(mode, context, message): + # In PyQt6, the mode is an enum, so we compare with the enum members + if "Wayland does not support QWindow::requestActivate()" in message: + return # Suppress this specific message + if "setGrabPopup called with a parent, QtWaylandClient" in message: + return + + if mode == QtMsgType.QtWarningMsg: + print(f"Qt Warning: {message}") + elif mode == QtMsgType.QtCriticalMsg: + print(f"Qt Critical: {message}") + elif mode == QtMsgType.QtFatalMsg: + print(f"Qt Fatal: {message}") + elif mode == QtMsgType.QtInfoMsg: + print(f"Qt Info: {message}") + + +# Install the custom message handler +qInstallMessageHandler(qt_message_handler) + +# Handle Wayland on Linux GNOME +if platform.system() == "Linux": + xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower() + desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + if ( + "gnome" in desktop + and xdg_session == "wayland" + and "QT_QPA_PLATFORM" not in os.environ + ): + os.environ["QT_QPA_PLATFORM"] = "wayland" + + +def main(): + """Main entry point for console usage.""" + app = QApplication(sys.argv) + + # Set application icon using get_resource_path from utils + icon_path = get_resource_path("abogen.assets", "icon.ico") + if icon_path: + app.setWindowIcon(QIcon(icon_path)) + + # Set the .desktop name on Linux + if platform.system() == "Linux": + try: + app.setDesktopFileName("abogen") + except AttributeError: + pass + + ex = abogen() + ex.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/abogen/pyqt/predownload_gui.py b/abogen/pyqt/predownload_gui.py new file mode 100644 index 0000000..47138f1 --- /dev/null +++ b/abogen/pyqt/predownload_gui.py @@ -0,0 +1,590 @@ +""" +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, VOICES_INTERNAL +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 = VOICES_INTERNAL + 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 VOICES_INTERNAL: + 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(VOICES_INTERNAL) + 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/queue_manager_gui.py b/abogen/pyqt/queue_manager_gui.py new file mode 100644 index 0000000..384be79 --- /dev/null +++ b/abogen/pyqt/queue_manager_gui.py @@ -0,0 +1,860 @@ +# a simple window with a list of items in the queue, no checkboxes +# button to remove an item from the queue +# button to clear the queue + +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QDialogButtonBox, + QPushButton, + QListWidget, + QListWidgetItem, + QFileIconProvider, + QLabel, + QWidget, + QSizePolicy, + QAbstractItemView, + QCheckBox, +) +from PyQt6.QtCore import QFileInfo, Qt +from abogen.constants import COLORS +from copy import deepcopy +from PyQt6.QtGui import QFontMetrics +from abogen.utils import load_config, save_config + +# Define attributes that are safe to override with global settings +OVERRIDE_FIELDS = [ + "lang_code", + "speed", + "voice", + "save_option", + "output_folder", + "subtitle_mode", + "output_format", + "replace_single_newlines", + "use_silent_gaps", + "subtitle_speed_method", +] + + +class ElidedLabel(QLabel): + def __init__(self, text): + super().__init__(text) + self._full_text = text + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + self.setTextFormat(Qt.TextFormat.PlainText) + + def setText(self, text): + self._full_text = text + super().setText(text) + self.update() + + def resizeEvent(self, event): + metrics = QFontMetrics(self.font()) + elided = metrics.elidedText( + self._full_text, Qt.TextElideMode.ElideRight, self.width() + ) + super().setText(elided) + super().resizeEvent(event) + + def fullText(self): + return self._full_text + + +class QueueListItemWidget(QWidget): + def __init__(self, file_name, char_count): + super().__init__() + layout = QHBoxLayout() + layout.setContentsMargins(12, 0, 6, 0) + layout.setSpacing(0) + import os + + name_label = ElidedLabel(os.path.basename(file_name)) + char_label = QLabel(f"Chars: {char_count}") + char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") + char_label.setAlignment( + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter + ) + char_label.setSizePolicy( + QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred + ) + layout.addWidget(name_label, 1) + layout.addWidget(char_label, 0) + self.setLayout(layout) + + +class DroppableQueueListWidget(QListWidget): + def __init__(self, parent_dialog): + super().__init__() + self.parent_dialog = parent_dialog + self.setAcceptDrops(True) + # Overlay for drag hover + self.drag_overlay = QLabel("", self) + self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.drag_overlay.setStyleSheet( + f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" + ) + self.drag_overlay.setVisible(False) + self.drag_overlay.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, True + ) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + file_path = url.toLocalFile().lower() + if url.isLocalFile() and ( + file_path.endswith(".txt") + or file_path.endswith((".srt", ".ass", ".vtt")) + ): + self.drag_overlay.resize(self.size()) + self.drag_overlay.setVisible(True) + event.acceptProposedAction() + return + self.drag_overlay.setVisible(False) + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + file_path = url.toLocalFile().lower() + if url.isLocalFile() and ( + file_path.endswith(".txt") + or file_path.endswith((".srt", ".ass", ".vtt")) + ): + event.acceptProposedAction() + return + event.ignore() + + def dragLeaveEvent(self, event): + self.drag_overlay.setVisible(False) + event.accept() + + def dropEvent(self, event): + self.drag_overlay.setVisible(False) + if event.mimeData().hasUrls(): + file_paths = [ + url.toLocalFile() + for url in event.mimeData().urls() + if url.isLocalFile() + and ( + url.toLocalFile().lower().endswith(".txt") + or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt")) + ) + ] + if file_paths: + self.parent_dialog.add_files_from_paths(file_paths) + event.acceptProposedAction() + else: + event.ignore() + else: + event.ignore() + + def resizeEvent(self, event): + super().resizeEvent(event) + if hasattr(self, "drag_overlay"): + self.drag_overlay.resize(self.size()) + + +class QueueManager(QDialog): + def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)): + super().__init__() + self.queue = queue + self._original_queue = deepcopy( + queue + ) # Store a deep copy of the original queue + self.parent = parent + self.config = load_config() # Load config for persistence + + layout = QVBoxLayout() + layout.setContentsMargins(15, 15, 15, 15) # set main layout margins + layout.setSpacing(12) # set spacing between widgets in main layout + # list of queued items + self.listwidget = DroppableQueueListWidget(self) + self.listwidget.setSelectionMode( + QAbstractItemView.SelectionMode.ExtendedSelection + ) + self.listwidget.setAlternatingRowColors(True) + self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.listwidget.customContextMenuRequested.connect(self.show_context_menu) + # Add informative instructions at the top + instructions = QLabel( + "

    How Queue Works?

    " + "You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the 'Add files' button below. " + "To add PDF, EPUB or markdown files, use the input box in the main window and click the 'Add to Queue' button. " + "By default, each file in the queue keeps the configuration settings active when they were added. " + "Enabling the 'Override item settings with current selection' option below will force all items to use the configuration currently selected in the main window. " + "You can view each file's configuration by hovering over them." + ) + instructions.setAlignment(Qt.AlignmentFlag.AlignLeft) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Override Checkbox + self.override_chk = QCheckBox("Override item settings with current selection") + self.override_chk.setToolTip( + "If checked, all items in the queue will be processed using the \n" + "settings currently selected in the main window, ignoring their saved state." + ) + # Load saved state (default to False) + self.override_chk.setChecked(self.config.get("queue_override_settings", False)) + # Trigger process_queue to update tooltips immediately when toggled + self.override_chk.stateChanged.connect(self.process_queue) + self.override_chk.setStyleSheet("margin-bottom: 8px;") + layout.addWidget(self.override_chk) + + # Overlay label for empty queue + self.empty_overlay = QLabel( + "Drag and drop your text or subtitle files here or use the 'Add files' button.", + self.listwidget, + ) + self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_overlay.setStyleSheet( + f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" + ) + self.empty_overlay.setWordWrap(True) + self.empty_overlay.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents, True + ) + self.empty_overlay.hide() + # add queue items to the list + self.process_queue() + + button_row = QHBoxLayout() + button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row + button_row.setSpacing(7) # set spacing between buttons + # Add files button + add_files_button = QPushButton("Add files") + add_files_button.setFixedHeight(40) + add_files_button.clicked.connect(self.add_more_files) + button_row.addWidget(add_files_button) + + # Remove button + self.remove_button = QPushButton("Remove selected") + self.remove_button.setFixedHeight(40) + self.remove_button.clicked.connect(self.remove_item) + button_row.addWidget(self.remove_button) + + # Clear button + self.clear_button = QPushButton("Clear Queue") + self.clear_button.setFixedHeight(40) + self.clear_button.clicked.connect(self.clear_queue) + button_row.addWidget(self.clear_button) + + layout.addLayout(button_row) + layout.addWidget(self.listwidget) + + # Connect selection change to update button state + self.listwidget.currentItemChanged.connect(self.update_button_states) + self.listwidget.itemSelectionChanged.connect(self.update_button_states) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout.addWidget(buttons) + + self.setLayout(layout) + + self.setWindowTitle(title) + self.resize(*size) + + self.update_button_states() + + def process_queue(self): + """Process the queue items.""" + import os + + self.listwidget.clear() + if not self.queue: + self.empty_overlay.show() + self.update_button_states() + return + else: + self.empty_overlay.hide() + + # Get current global settings and checkbox state for overrides + current_global_settings = self.get_current_attributes() + is_override_active = self.override_chk.isChecked() + + icon_provider = QFileIconProvider() + for item in self.queue: + # Dynamic Attribute Retrieval Helper + def get_val(attr, default=""): + # If override is ON and attr is overrideable, use global setting + if is_override_active and attr in OVERRIDE_FIELDS: + return current_global_settings.get(attr, default) + # Otherwise return the item's saved attribute + return getattr(item, attr, default) + + # Determine display file path (prefer save_base_path for original file) + display_file_path = getattr(item, "save_base_path", None) or item.file_name + processing_file_path = item.file_name + + # Normalize paths for consistent display (fixes Windows path separator issues) + display_file_path = ( + os.path.normpath(display_file_path) + if display_file_path + else display_file_path + ) + processing_file_path = ( + os.path.normpath(processing_file_path) + if processing_file_path + else processing_file_path + ) + + # Only show the file name, not the full path + display_name = display_file_path + + if os.path.sep in display_file_path: + display_name = os.path.basename(display_file_path) + # Get icon for the display file + icon = icon_provider.icon(QFileInfo(display_file_path)) + list_item = QListWidgetItem() + + # Tooltip Generation + tooltip = "" + # If override is active, add the warning header on its own line + if is_override_active: + tooltip += "(Global Override Active)
    " + + output_folder = get_val("output_folder") + # For plain .txt inputs we don't need to show a separate processing file + show_processing = True + try: + if isinstance( + display_file_path, str + ) and display_file_path.lower().endswith(".txt"): + show_processing = False + except Exception: + show_processing = True + + tooltip += f"Input File: {display_file_path}
    " + if ( + show_processing + and processing_file_path + and processing_file_path != display_file_path + ): + tooltip += f"Processing File: {processing_file_path}
    " + + tooltip += ( + f"Language: {get_val('lang_code')}
    " + f"Speed: {get_val('speed')}
    " + f"Voice: {get_val('voice')}
    " + f"Save Option: {get_val('save_option')}
    " + ) + if output_folder not in (None, "", "None"): + tooltip += f"Output Folder: {output_folder}
    " + tooltip += ( + f"Subtitle Mode: {get_val('subtitle_mode')}
    " + f"Output Format: {get_val('output_format')}
    " + f"Characters: {getattr(item, 'total_char_count', '')}
    " + f"Replace Single Newlines: {get_val('replace_single_newlines', True)}
    " + f"Use Silent Gaps: {get_val('use_silent_gaps', False)}
    " + f"Speed Method: {get_val('subtitle_speed_method', 'tts')}" + ) + # Add book handler options if present (Preserve logic: specific to file structure) + save_chapters_separately = getattr(item, "save_chapters_separately", None) + merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None) + if save_chapters_separately is not None: + tooltip += f"
    Save chapters separately: {'Yes' if save_chapters_separately else 'No'}" + # Only show merge option if saving chapters separately + if save_chapters_separately and merge_chapters_at_end is not None: + tooltip += f"
    Merge chapters at the end: {'Yes' if merge_chapters_at_end else 'No'}" + list_item.setToolTip(tooltip) + list_item.setIcon(icon) + # Store both paths for context menu + list_item.setData( + Qt.ItemDataRole.UserRole, + { + "display_path": display_file_path, + "processing_path": processing_file_path, + }, + ) + # Use custom widget for display + char_count = getattr(item, "total_char_count", 0) + widget = QueueListItemWidget(display_file_path, char_count) + self.listwidget.addItem(list_item) + self.listwidget.setItemWidget(list_item, widget) + self.update_button_states() + + def remove_item(self): + items = self.listwidget.selectedItems() + if not items: + return + from PyQt6.QtWidgets import QMessageBox + + # Remove by index to ensure correct mapping + rows = sorted([self.listwidget.row(item) for item in items], reverse=True) + # Warn user if removing multiple files + if len(rows) > 1: + reply = QMessageBox.question( + self, + "Confirm Remove", + f"Are you sure you want to remove {len(rows)} selected items from the queue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + for row in rows: + if 0 <= row < len(self.queue): + del self.queue[row] + self.process_queue() + self.update_button_states() + + def clear_queue(self): + from PyQt6.QtWidgets import QMessageBox + + if len(self.queue) > 1: + reply = QMessageBox.question( + self, + "Confirm Clear Queue", + f"Are you sure you want to clear {len(self.queue)} items from the queue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.queue.clear() + self.listwidget.clear() + self.empty_overlay.resize( + self.listwidget.size() + ) # Ensure overlay is sized correctly + self.empty_overlay.show() # Show the overlay when queue is empty + self.update_button_states() + + def get_queue(self): + return self.queue + + def get_current_attributes(self): + # Fetch current attribute values from the parent abogen GUI + attrs = {} + parent = self.parent + if parent is not None: + # lang_code: use parent's get_voice_formula and get_selected_lang + if hasattr(parent, "get_voice_formula") and hasattr( + parent, "get_selected_lang" + ): + voice_formula = parent.get_voice_formula() + attrs["lang_code"] = parent.get_selected_lang(voice_formula) + attrs["voice"] = voice_formula + else: + attrs["lang_code"] = getattr(parent, "selected_lang", "") + attrs["voice"] = getattr(parent, "selected_voice", "") + # speed + if hasattr(parent, "speed_slider"): + attrs["speed"] = parent.speed_slider.value() / 100.0 + else: + attrs["speed"] = getattr(parent, "speed", 1.0) + # save_option + attrs["save_option"] = getattr(parent, "save_option", "") + # output_folder + attrs["output_folder"] = getattr(parent, "selected_output_folder", "") + # subtitle_mode + if hasattr(parent, "get_actual_subtitle_mode"): + attrs["subtitle_mode"] = parent.get_actual_subtitle_mode() + else: + attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "") + # output_format + attrs["output_format"] = getattr(parent, "selected_format", "") + # total_char_count + attrs["total_char_count"] = getattr(parent, "char_count", "") + # replace_single_newlines + attrs["replace_single_newlines"] = getattr( + parent, "replace_single_newlines", True + ) + # use_silent_gaps + attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False) + # subtitle_speed_method + attrs["subtitle_speed_method"] = getattr( + parent, "subtitle_speed_method", "tts" + ) + # book handler options + attrs["save_chapters_separately"] = getattr( + parent, "save_chapters_separately", None + ) + attrs["merge_chapters_at_end"] = getattr( + parent, "merge_chapters_at_end", None + ) + else: + # fallback: empty values + attrs = { + k: "" + for k in [ + "lang_code", + "speed", + "voice", + "save_option", + "output_folder", + "subtitle_mode", + "output_format", + "total_char_count", + "replace_single_newlines", + ] + } + attrs["save_chapters_separately"] = None + attrs["merge_chapters_at_end"] = None + return attrs + + def add_files_from_paths(self, file_paths): + from abogen.subtitle_utils import calculate_text_length + from PyQt6.QtWidgets import QMessageBox + import os + + current_attrs = self.get_current_attributes() + duplicates = [] + for file_path in file_paths: + + class QueueItem: + pass + + item = QueueItem() + item.file_name = file_path + item.save_base_path = ( + file_path # For .txt files, processing and save paths are the same + ) + for attr, value in current_attrs.items(): + setattr(item, attr, value) + # Override subtitle_mode to "Disabled" for subtitle files + if file_path.lower().endswith((".srt", ".ass", ".vtt")): + item.subtitle_mode = "Disabled" + # Read file content and calculate total_char_count using calculate_text_length + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + file_content = f.read() + item.total_char_count = calculate_text_length(file_content) + except Exception: + item.total_char_count = 0 + # Prevent adding duplicate items to the queue (check all attributes) + is_duplicate = False + for queued_item in self.queue: + if ( + getattr(queued_item, "file_name", None) + == getattr(item, "file_name", None) + and getattr(queued_item, "lang_code", None) + == getattr(item, "lang_code", None) + and getattr(queued_item, "speed", None) + == getattr(item, "speed", None) + and getattr(queued_item, "voice", None) + == getattr(item, "voice", None) + and getattr(queued_item, "save_option", None) + == getattr(item, "save_option", None) + and getattr(queued_item, "output_folder", None) + == getattr(item, "output_folder", None) + and getattr(queued_item, "subtitle_mode", None) + == getattr(item, "subtitle_mode", None) + and getattr(queued_item, "output_format", None) + == getattr(item, "output_format", None) + and getattr(queued_item, "total_char_count", None) + == getattr(item, "total_char_count", None) + and getattr(queued_item, "replace_single_newlines", True) + == getattr(item, "replace_single_newlines", True) + and getattr(queued_item, "use_silent_gaps", False) + == getattr(item, "use_silent_gaps", False) + and getattr(queued_item, "subtitle_speed_method", "tts") + == getattr(item, "subtitle_speed_method", "tts") + and getattr(queued_item, "save_base_path", None) + == getattr(item, "save_base_path", None) + and getattr(queued_item, "save_chapters_separately", None) + == getattr(item, "save_chapters_separately", None) + and getattr(queued_item, "merge_chapters_at_end", None) + == getattr(item, "merge_chapters_at_end", None) + ): + is_duplicate = True + break + if is_duplicate: + duplicates.append(os.path.basename(file_path)) + continue + self.queue.append(item) + if duplicates: + QMessageBox.warning( + self, + "Duplicate Item(s)", + f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.", + ) + self.process_queue() + self.update_button_states() + + def add_more_files(self): + from PyQt6.QtWidgets import QFileDialog + + # Allow .txt, .srt, .ass, and .vtt files + files, _ = QFileDialog.getOpenFileNames( + self, + "Select text or subtitle files", + "", + "Supported Files (*.txt *.srt *.ass *.vtt)", + ) + if not files: + return + self.add_files_from_paths(files) + + def resizeEvent(self, event): + super().resizeEvent(event) + if hasattr(self, "empty_overlay"): + self.empty_overlay.resize(self.listwidget.size()) + + def update_button_states(self): + # Enable Remove if at least one item is selected, else disable + if hasattr(self, "remove_button"): + selected_count = len(self.listwidget.selectedItems()) + self.remove_button.setEnabled(selected_count > 0) + if selected_count > 1: + self.remove_button.setText(f"Remove selected ({selected_count})") + else: + self.remove_button.setText("Remove selected") + # Disable Clear if queue is empty + if hasattr(self, "clear_button"): + self.clear_button.setEnabled(bool(self.queue)) + + def show_context_menu(self, pos): + from PyQt6.QtWidgets import QMenu + from PyQt6.QtGui import QAction, QDesktopServices + from PyQt6.QtCore import QUrl + import os + + global_pos = self.listwidget.viewport().mapToGlobal(pos) + selected_items = self.listwidget.selectedItems() + menu = QMenu(self) + if len(selected_items) == 1: + # Add Remove action + remove_action = QAction("Remove this item", self) + remove_action.triggered.connect(self.remove_item) + menu.addAction(remove_action) + + # Get paths for determining if it's a document input + item = selected_items[0] + paths = item.data(Qt.ItemDataRole.UserRole) + if isinstance(paths, dict): + display_path = paths.get("display_path", "") + processing_path = paths.get("processing_path", "") + else: + display_path = paths + processing_path = paths + + doc_exts = (".md", ".markdown", ".pdf", ".epub") + is_document_input = ( + isinstance(display_path, str) + and display_path.lower().endswith(doc_exts) + ) or ( + isinstance(processing_path, str) + and processing_path.lower().endswith(doc_exts) + ) + + # Add Open file action(s) + def open_file_by_path(path_label: str): + from PyQt6.QtWidgets import QMessageBox + + p = display_path if path_label == "display" else processing_path + if not p: + QMessageBox.warning( + self, "File Not Found", "Path is not available." + ) + return + + # Find the queue item and resolve the target path + target_path = None + for q in self.queue: + if ( + getattr(q, "save_base_path", None) == display_path + or q.file_name == display_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + if ( + getattr(q, "save_base_path", None) == processing_path + or q.file_name == processing_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + + # Fallback to the raw path if resolution failed + if not target_path: + target_path = p + + if not os.path.exists(target_path): + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) + return + QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) + + if is_document_input: + # For documents, show two open options + open_processed_action = QAction("Open processed file", self) + open_processed_action.triggered.connect( + lambda: open_file_by_path("processing") + ) + menu.addAction(open_processed_action) + + open_input_action = QAction("Open input file", self) + open_input_action.triggered.connect( + lambda: open_file_by_path("display") + ) + menu.addAction(open_input_action) + else: + # For plain text files, show single open option + open_file_action = QAction("Open file", self) + open_file_action.triggered.connect(lambda: open_file_by_path("display")) + menu.addAction(open_file_action) + + # Add Go to folder action + # If the queued item represents a converted document (markdown, pdf, epub) + # show two actions: Go to processed file (the cached .txt) and Go to input file (original source) + + from PyQt6.QtWidgets import QMessageBox + + def open_folder_for(path_label: str): + # path_label should be either 'display' or 'processing' + p = display_path if path_label == "display" else processing_path + if not p: + QMessageBox.warning( + self, "File Not Found", "Path is not available." + ) + return + # If the stored path is the display path (original) but the actual file may be + # stored on the queue object differently, try to resolve via the queue entry. + target_path = None + for q in self.queue: + if ( + getattr(q, "save_base_path", None) == display_path + or q.file_name == display_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + if ( + getattr(q, "save_base_path", None) == processing_path + or q.file_name == processing_path + ): + if path_label == "display": + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + else: + target_path = q.file_name + break + # Fallback to the raw path if resolution failed + if not target_path: + target_path = p + + if not os.path.exists(target_path): + QMessageBox.warning( + self, + "File Not Found", + f"The file does not exist: {target_path}", + ) + return + folder = os.path.dirname(target_path) + if os.path.exists(folder): + QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) + + if is_document_input: + processed_action = QAction("Go to processed file", self) + processed_action.triggered.connect( + lambda: open_folder_for("processing") + ) + menu.addAction(processed_action) + + input_action = QAction("Go to input file", self) + input_action.triggered.connect(lambda: open_folder_for("display")) + menu.addAction(input_action) + else: + # Default behavior for non-document inputs: single "Go to folder" action + go_to_folder_action = QAction("Go to folder", self) + + def go_to_folder(): + item = selected_items[0] + paths = item.data(Qt.ItemDataRole.UserRole) + if isinstance(paths, dict): + file_path = paths.get( + "display_path", paths.get("processing_path", "") + ) + else: + file_path = paths # Fallback for old format + # Find the queue item + for q in self.queue: + if ( + getattr(q, "save_base_path", None) == file_path + or q.file_name == file_path + ): + target_path = ( + getattr(q, "save_base_path", None) or q.file_name + ) + if not os.path.exists(target_path): + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) + return + folder = os.path.dirname(target_path) + if os.path.exists(folder): + QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) + break + + go_to_folder_action.triggered.connect(go_to_folder) + menu.addAction(go_to_folder_action) + + elif len(selected_items) > 1: + remove_action = QAction(f"Remove selected ({len(selected_items)})", self) + remove_action.triggered.connect(self.remove_item) + menu.addAction(remove_action) + # Always add Clear Queue + clear_action = QAction("Clear Queue", self) + clear_action.triggered.connect(self.clear_queue) + menu.addAction(clear_action) + menu.exec(global_pos) + + def accept(self): + # Save the override state to config so it persists globally + self.config["queue_override_settings"] = self.override_chk.isChecked() + save_config(self.config) + + super().accept() + + def reject(self): + # Cancel: restore original queue + from PyQt6.QtWidgets import QMessageBox + + # Warn if user changed a lot (e.g., more than 1 items difference) + original_count = len(self._original_queue) + current_count = len(self.queue) + if abs(original_count - current_count) > 1: + reply = QMessageBox.question( + self, + "Confirm Cancel", + f"Are you sure you want to cancel and discard all changes?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + self.queue.clear() + self.queue.extend(deepcopy(self._original_queue)) + super().reject() + + def keyPressEvent(self, event): + from PyQt6.QtCore import Qt + + if event.key() == Qt.Key.Key_Delete: + self.remove_item() + else: + super().keyPressEvent(event) diff --git a/abogen/pyqt/queued_item.py b/abogen/pyqt/queued_item.py new file mode 100644 index 0000000..38d4054 --- /dev/null +++ b/abogen/pyqt/queued_item.py @@ -0,0 +1,21 @@ +# represents a queued item - book, chapters, voice, etc. +from dataclasses import dataclass + + +@dataclass +class QueuedItem: + file_name: str + lang_code: str + speed: float + voice: str + save_option: str + output_folder: str + subtitle_mode: str + output_format: str + total_char_count: int + replace_single_newlines: bool = True + use_silent_gaps: bool = False + subtitle_speed_method: str = "tts" + save_base_path: str = None + save_chapters_separately: bool = None + merge_chapters_at_end: bool = None diff --git a/abogen/pyqt/voice_formula_gui.py b/abogen/pyqt/voice_formula_gui.py new file mode 100644 index 0000000..11f717d --- /dev/null +++ b/abogen/pyqt/voice_formula_gui.py @@ -0,0 +1,1521 @@ +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 ( + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + LANGUAGE_DESCRIPTIONS, + COLORS, +) +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 VOICES_INTERNAL 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 + self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100)) + self.spin_box.valueChanged.connect( + lambda val: self.slider.setValue(int(val * 100)) + ) + + # 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 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 = {} + # 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) + # 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) + # Detect modifications in voice mixers + for vm in self.voice_mixers: + vm.spin_box.valueChanged.connect(self.mark_profile_modified) + vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) + + 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 VOICES_INTERNAL: + 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) + ) + voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) + voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) + voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified) + 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 + 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 update_weighted_sums(self): + # Clear previous labels + while self.weighted_sums_layout.count(): + item = self.weighted_sums_layout.takeAt(0) + if item and item.widget(): + item.widget().deleteLater() + + # 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 + + # Add voice labels + for name, weight in selected: + percentage = weight / total * 100 + # Make the voice name bold and include percentage + voice_label = HoverLabel( + f'{name}: {percentage:.1f}%', + 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.weighted_sums_layout.addWidget(voice_label) + else: + 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() + # 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 + 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() + 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) + 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 + + profiles = load_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/queue_manager_gui.py b/abogen/queue_manager_gui.py index afdc89e..60023b1 100644 --- a/abogen/queue_manager_gui.py +++ b/abogen/queue_manager_gui.py @@ -1,9 +1,11 @@ -"""Legacy PyQt queue manager GUI removed.""" +"""Backwards-compatible re-export of the PyQt queue manager. + +The actual implementation lives in abogen.pyqt.queue_manager_gui. +""" from __future__ import annotations +from abogen.pyqt.queue_manager_gui import * # noqa: F401, F403 +from abogen.pyqt.queue_manager_gui import QueueManager -def __getattr__(name: str): # pragma: no cover - compatibility shim - raise AttributeError( - "The PyQt queue manager GUI has been removed. Use the web dashboard instead." - ) +__all__ = ["QueueManager"] diff --git a/abogen/spacy_utils.py b/abogen/spacy_utils.py new file mode 100644 index 0000000..54fbcf7 --- /dev/null +++ b/abogen/spacy_utils.py @@ -0,0 +1,161 @@ +""" +Lazy-loaded spaCy utilities for sentence segmentation. +""" + +# Cached spaCy module and models (lazy loaded) +_spacy = None +_nlp_cache = {} + +# Language code to spaCy model mapping +SPACY_MODELS = { + "a": "en_core_web_sm", # American English + "b": "en_core_web_sm", # British English + "e": "es_core_news_sm", # Spanish + "f": "fr_core_news_sm", # French + "i": "it_core_news_sm", # Italian + "p": "pt_core_news_sm", # Brazilian Portuguese + "z": "zh_core_web_sm", # Mandarin Chinese + "j": "ja_core_news_sm", # Japanese + "h": "xx_sent_ud_sm", # Hindi (multi-language model) +} + + +def _load_spacy(): + """Lazy load spaCy module.""" + global _spacy + if _spacy is None: + try: + import spacy + + _spacy = spacy + except ImportError: + return None + return _spacy + + +def get_spacy_model(lang_code, log_callback=None): + """ + Get or load a spaCy model for the given language code. + Downloads the model automatically if not available. + + Args: + lang_code: Language code (a, b, e, f, etc.) + log_callback: Optional function to log messages + + Returns: + Loaded spaCy model or None if unavailable + """ + + def log(msg, is_error=False): + # Prefer GUI log callback when provided to avoid spamming stdout. + if log_callback: + color = "red" if is_error else "grey" + try: + log_callback((msg, color)) + except Exception: + # Fallback to printing if callback misbehaves + print(msg) + else: + print(msg) + + # Check if model is cached + if lang_code in _nlp_cache: + return _nlp_cache[lang_code] + + # Check if language is supported + model_name = SPACY_MODELS.get(lang_code) + if not model_name: + log(f"\nspaCy: No model mapping for language '{lang_code}'...") + return None + + # Lazy load spaCy + spacy = _load_spacy() + if spacy is None: + log("\nspaCy: Module not installed, falling back to default segmentation...") + return None + + # Try to load the model + try: + log(f"\nLoading spaCy model '{model_name}'...") + # sentence segmentation involving parentheses, quotes, and complex structure. + # We only disable heavier components we don't need like NER. + nlp = spacy.load( + model_name, + disable=["ner", "tagger", "lemmatizer", "attribute_ruler"], + ) + + # Ensure a sentence segmentation strategy is in place + # The parser provides sents, but if it's missing (unlikely for core models), fallback to sentencizer + if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names: + nlp.add_pipe("sentencizer") + + _nlp_cache[lang_code] = nlp + return nlp + except OSError: + # Model not found, attempt download + log(f"\nspaCy: Downloading model '{model_name}'...") + try: + from spacy.cli import download + + download(model_name) + # Retry loading with the same fix + nlp = spacy.load( + model_name, + disable=["ner", "tagger", "lemmatizer", "attribute_ruler"], + ) + if "parser" not in nlp.pipe_names and "sentencizer" not in nlp.pipe_names: + nlp.add_pipe("sentencizer") + + _nlp_cache[lang_code] = nlp + log(f"spaCy model '{model_name}' downloaded and loaded") + return nlp + except Exception as e: + log( + f"\nspaCy: Failed to download model '{model_name}': {e}...", + is_error=True, + ) + return None + except Exception as e: + log(f"\nspaCy: Error loading model '{model_name}': {e}...", is_error=True) + return None + + +def segment_sentences(text, lang_code, log_callback=None): + """ + Segment text into sentences using spaCy. + + Args: + text: Text to segment + lang_code: Language code + log_callback: Optional function to log messages + + Returns: + List of sentence strings, or None if spaCy unavailable + """ + nlp = get_spacy_model(lang_code, log_callback) + if nlp is None: + return None + + # Ensure spaCy can handle large texts by adjusting max_length if necessary + try: + text_len = len(text or "") + if text_len and hasattr(nlp, "max_length") and text_len > nlp.max_length: + # increase a bit beyond the text length to be safe + nlp.max_length = text_len + 1000 + except Exception: + pass + + # Process text and extract sentences + doc = nlp(text) + return [sent.text.strip() for sent in doc.sents if sent.text.strip()] + + +def is_spacy_available(): + """Check if spaCy can be imported.""" + return _load_spacy() is not None + + +def clear_cache(): + """Clear the model cache to free memory.""" + global _nlp_cache + _nlp_cache.clear() diff --git a/abogen/subtitle_utils.py b/abogen/subtitle_utils.py new file mode 100644 index 0000000..73576c0 --- /dev/null +++ b/abogen/subtitle_utils.py @@ -0,0 +1,459 @@ +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"<]*>>") +_WHITESPACE_PATTERN = re.compile(r"[^\S\n]+") +_MULTIPLE_NEWLINES_PATTERN = re.compile(r"\n{3,}") +_SINGLE_NEWLINE_PATTERN = re.compile(r"(?]*>>") +_HTML_TAG_PATTERN = re.compile(r"<[^>]+>") +_VOICE_TAG_PATTERN = re.compile(r"{[^}]+}") +_ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}") +_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N") +_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n") +_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<>") +_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE) +_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL) +_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL) +_DOUBLE_NEWLINE_SPLIT_PATTERN = re.compile(r"\n\s*\n") +_VTT_TIMESTAMP_PATTERN = re.compile(r"([\d:.]+)\s*-->\s*([\d:.]+)") +_TIMESTAMP_ONLY_PATTERN = re.compile(r"^(\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)$") +_WINDOWS_ILLEGAL_CHARS_PATTERN = re.compile(r'[<>:"/\\|?*]') +_CONTROL_CHARS_PATTERN = re.compile(r"[\x00-\x1f]") +_LINUX_CONTROL_CHARS_PATTERN = re.compile( + r"[\x01-\x1f]" +) # Linux: exclude \x00 for separate handling +_MACOS_ILLEGAL_CHARS_PATTERN = re.compile(r"[:]") +_LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]") + + +def clean_subtitle_text(text): + """Remove chapter markers and metadata tags from subtitle text.""" + # Use pre-compiled patterns for better performance + text = _METADATA_TAG_PATTERN.sub("", text) + text = _CHAPTER_MARKER_PATTERN.sub("", text) + return text.strip() + +def calculate_text_length(text): + # Use pre-compiled patterns for better performance + # Ignore chapter markers and metadata patterns in a single pass + text = _CHAPTER_MARKER_PATTERN.sub("", text) + text = _METADATA_TAG_PATTERN.sub("", text) + # Ignore newlines and leading/trailing spaces + text = text.replace("\n", "").strip() + # Calculate character count + char_count = len(text) + return char_count + +def clean_text(text, *args, **kwargs): + # Remove metadata tags first + text = _METADATA_TAG_PATTERN.sub("", text) + # Load replace_single_newlines from config + cfg = load_config() + replace_single_newlines = cfg.get("replace_single_newlines", True) + # Collapse all whitespace (excluding newlines) into single spaces per line and trim edges + # Use pre-compiled pattern for better performance + lines = [_WHITESPACE_PATTERN.sub(" ", line).strip() for line in text.splitlines()] + text = "\n".join(lines) + # Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace + # Use pre-compiled pattern for better performance + text = _MULTIPLE_NEWLINES_PATTERN.sub("\n\n", text).strip() + # Optionally replace single newlines with spaces, but preserve double newlines + if replace_single_newlines: + # Use pre-compiled pattern for better performance + text = _SINGLE_NEWLINE_PATTERN.sub(" ", text) + return text + + +def parse_srt_file(file_path): + """ + Parse an SRT subtitle file and return a list of subtitle entries. + + Args: + file_path: Path to the SRT file + + Returns: + List of tuples: [(start_time_seconds, end_time_seconds, text), ...] + """ + encoding = detect_encoding(file_path) + with open(file_path, "r", encoding=encoding, errors="replace") as f: + content = f.read() + + # Split by double newlines to get individual subtitle blocks + blocks = re.split(r"\n\s*\n", content.strip()) + + subtitles = [] + for block in blocks: + if not block.strip(): + continue + + lines = block.strip().split("\n") + if len(lines) < 3: + continue + + # First line is index, second line is timestamp, rest is text + try: + timestamp_line = lines[1] + match = re.match( + r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})", + timestamp_line, + ) + if not match: + continue + + start_str = match.group(1) + end_str = match.group(2) + text = "\n".join(lines[2:]) + + # Convert timestamp to seconds + def time_to_seconds(t): + h, m, s_ms = t.split(":") + s, ms = s_ms.split(",") + return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0 + + start_sec = time_to_seconds(start_str) + end_sec = time_to_seconds(end_str) + + # Clean text of any styling tags using pre-compiled pattern + text = _HTML_TAG_PATTERN.sub("", text) + # Remove chapter markers and metadata tags + text = clean_subtitle_text(text) + + if text: # Only add non-empty subtitles + subtitles.append((start_sec, end_sec, text)) + except (ValueError, IndexError): + continue + + return subtitles + + +def parse_vtt_file(file_path): + """ + Parse a VTT (WebVTT) subtitle file and return a list of subtitle entries. + + Args: + file_path: Path to the VTT file + + Returns: + List of tuples: [(start_time_seconds, end_time_seconds, text), ...] + """ + encoding = detect_encoding(file_path) + with open(file_path, "r", encoding=encoding, errors="replace") as f: + content = f.read() + + # Remove WEBVTT header and any style/note blocks using pre-compiled patterns + content = _WEBVTT_HEADER_PATTERN.sub("", content) + content = _VTT_STYLE_PATTERN.sub("", content) + content = _VTT_NOTE_PATTERN.sub("", content) + + # Split by double newlines to get individual subtitle blocks using pre-compiled pattern + blocks = _DOUBLE_NEWLINE_SPLIT_PATTERN.split(content.strip()) + + subtitles = [] + for block in blocks: + if not block.strip(): + continue + + lines = block.strip().split("\n") + if len(lines) < 2: + continue + + # VTT can have optional identifier on first line, timestamp on second or first + timestamp_line = None + text_start_idx = 0 + + # Check if first line is timestamp + if "-->" in lines[0]: + timestamp_line = lines[0] + text_start_idx = 1 + elif len(lines) > 1 and "-->" in lines[1]: + timestamp_line = lines[1] + text_start_idx = 2 + else: + continue + + try: + # VTT format: 00:00:00.000 --> 00:00:05.000 or 00:00.000 --> 00:05.000 + # Use pre-compiled pattern + match = _VTT_TIMESTAMP_PATTERN.match(timestamp_line) + if not match: + continue + + start_str = match.group(1) + end_str = match.group(2) + text = "\n".join(lines[text_start_idx:]) + + # Convert timestamp to seconds + def time_to_seconds(t): + parts = t.split(":") + if len(parts) == 3: # HH:MM:SS.mmm + h, m, s = parts + s, ms = s.split(".") + return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0 + elif len(parts) == 2: # MM:SS.mmm + m, s = parts + s, ms = s.split(".") + return int(m) * 60 + int(s) + int(ms) / 1000.0 + return 0 + + start_sec = time_to_seconds(start_str) + end_sec = time_to_seconds(end_str) + + # Clean text of any styling tags and cue settings using pre-compiled patterns + text = _HTML_TAG_PATTERN.sub("", text) + text = _VOICE_TAG_PATTERN.sub("", text) # Remove voice tags + # Remove chapter markers and metadata tags + text = clean_subtitle_text(text) + + if text: # Only add non-empty subtitles + subtitles.append((start_sec, end_sec, text)) + except (ValueError, IndexError, AttributeError): + continue + + return subtitles + + +def detect_timestamps_in_text(file_path): + """Detect if text file contains timestamp markers (HH:MM:SS or HH:MM:SS,ms format) on separate lines.""" + try: + encoding = detect_encoding(file_path) + with open(file_path, "r", encoding=encoding, errors="replace") as f: + lines = [ + line.strip() for line in f.readlines()[:50] if line.strip() + ] # Check first 50 non-empty lines + + # Count lines that are ONLY timestamps (no other text) + # Supports HH:MM:SS or HH:MM:SS,ms format + # Use pre-compiled pattern for better performance + timestamp_lines = sum( + 1 for line in lines if _TIMESTAMP_ONLY_PATTERN.match(line) + ) + + # Must have at least 2 timestamp-only lines and they should be >5% of total lines + return timestamp_lines >= 2 and (timestamp_lines / max(len(lines), 1)) > 0.05 + except Exception: + return False + + +def parse_timestamp_text_file(file_path): + """Parse text file with timestamps. Returns list of (start_time, end_time, text) tuples. + Supports HH:MM:SS or HH:MM:SS,ms format. Returns time in seconds as float.""" + encoding = detect_encoding(file_path) + with open(file_path, "r", encoding=encoding, errors="replace") as f: + content = f.read() + + # Split by timestamp pattern (supports HH:MM:SS or HH:MM:SS,ms) + pattern = r"^(\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)$" + lines = content.split("\n") + + def parse_time(time_str): + """Convert HH:MM:SS or HH:MM:SS,ms to seconds as float.""" + time_str = time_str.replace(",", ".") + parts = time_str.split(":") + return float(int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])) + + entries = [] + current_time = None + current_text = [] + pre_timestamp_text = [] # Text before first timestamp + + for line in lines: + match = re.match(pattern, line.strip()) + if match: + # Save previous entry + if current_time is not None and current_text: + text = "\n".join(current_text).strip() + if text: + entries.append((current_time, text)) + elif current_time is None and pre_timestamp_text: + # First timestamp found, save pre-timestamp text with time 0 + text = "\n".join(pre_timestamp_text).strip() + if text: + entries.append((0.0, text)) + pre_timestamp_text = [] + + # Start new entry + time_str = match.group(1) + current_time = parse_time(time_str) + current_text = [] + elif current_time is not None: + current_text.append(line) + else: + # Text before first timestamp + pre_timestamp_text.append(line) + + # Save last entry + if current_time is not None and current_text: + text = "\n".join(current_text).strip() + if text: + entries.append((current_time, text)) + elif not entries and pre_timestamp_text: + # No timestamps found at all, treat entire file as starting at 0 + text = "\n".join(pre_timestamp_text).strip() + if text: + entries.append((0.0, text)) + + # Convert to subtitle format with end times + subtitles = [] + for i, (start_time, text) in enumerate(entries): + end_time = entries[i + 1][0] if i + 1 < len(entries) else None + # Remove chapter markers and metadata tags + text = clean_subtitle_text(text) + if text: # Only add non-empty entries + subtitles.append((start_time, end_time, text)) + + return subtitles + + +def parse_ass_file(file_path): + """ + Parse an ASS/SSA subtitle file and return a list of subtitle entries. + + Args: + file_path: Path to the ASS/SSA file + + Returns: + List of tuples: [(start_time_seconds, end_time_seconds, text), ...] + """ + encoding = detect_encoding(file_path) + with open(file_path, "r", encoding=encoding, errors="replace") as f: + lines = f.readlines() + + subtitles = [] + in_events = False + format_indices = {} + + for line in lines: + line = line.strip() + + if line.startswith("[Events]"): + in_events = True + continue + + if line.startswith("[") and in_events: + # New section, stop processing + break + + if in_events and line.startswith("Format:"): + # Parse format line to know column positions + parts = line.split(":", 1)[1].strip().split(",") + for i, part in enumerate(parts): + format_indices[part.strip().lower()] = i + continue + + if in_events and (line.startswith("Dialogue:") or line.startswith("Comment:")): + if line.startswith("Comment:"): + continue # Skip comments + + parts = line.split(":", 1)[1].strip().split(",", len(format_indices) - 1) + + if ( + "start" in format_indices + and "end" in format_indices + and "text" in format_indices + ): + start_str = parts[format_indices["start"]].strip() + end_str = parts[format_indices["end"]].strip() + text = parts[format_indices["text"]].strip() + + # Convert timestamp to seconds (ASS format: H:MM:SS.CS where CS is centiseconds) + def ass_time_to_seconds(t): + parts = t.split(":") + if len(parts) == 3: + h, m, s = parts + s_parts = s.split(".") + seconds = float(s_parts[0]) + centiseconds = float(s_parts[1]) if len(s_parts) > 1 else 0 + return ( + int(h) * 3600 + int(m) * 60 + seconds + centiseconds / 100.0 + ) + return 0 + + start_sec = ass_time_to_seconds(start_str) + end_sec = ass_time_to_seconds(end_str) + + # Clean text of ASS styling tags using pre-compiled patterns + text = _ASS_STYLING_PATTERN.sub("", text) # Remove {tags} + text = _ASS_NEWLINE_N_PATTERN.sub("\n", text) # Convert \N to newline + text = _ASS_NEWLINE_LOWER_N_PATTERN.sub( + "\n", text + ) # Convert \n to newline + # Remove chapter markers and metadata tags + text = clean_subtitle_text(text) + + if text: # Only add non-empty subtitles + subtitles.append((start_sec, end_sec, text)) + + return subtitles + + +def get_sample_voice_text(lang_code): + return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) + + +def sanitize_name_for_os(name, is_folder=True): + """ + Sanitize a filename or folder name based on the operating system. + + Args: + name: The name to sanitize + is_folder: Whether this is a folder name (default: True) + + Returns: + Sanitized name safe for the current OS + """ + if not name: + return "audiobook" + + system = platform.system() + + if system == "Windows": + # Windows illegal characters: < > : " / \ | ? * + # Also can't end with space or dot + # Use pre-compiled pattern for better performance + sanitized = _WINDOWS_ILLEGAL_CHARS_PATTERN.sub("_", name) + # Remove control characters (0-31) + sanitized = _CONTROL_CHARS_PATTERN.sub("_", sanitized) + # Remove trailing spaces and dots + sanitized = sanitized.rstrip(". ") + # Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) + reserved = ( + ["CON", "PRN", "AUX", "NUL"] + + [f"COM{i}" for i in range(1, 10)] + + [f"LPT{i}" for i in range(1, 10)] + ) + if sanitized.upper() in reserved or sanitized.upper().split(".")[0] in reserved: + sanitized = f"_{sanitized}" + elif system == "Darwin": # macOS + # macOS illegal characters: : (colon is converted to / by the system) + # Also can't start with dot (hidden file) for folders typically + # Use pre-compiled pattern for better performance + sanitized = _MACOS_ILLEGAL_CHARS_PATTERN.sub("_", name) + # Remove control characters + sanitized = _CONTROL_CHARS_PATTERN.sub("_", sanitized) + # Avoid leading dot for folders (creates hidden folders) + if is_folder and sanitized.startswith("."): + sanitized = "_" + sanitized[1:] + else: # Linux and others + # Linux illegal characters: / and null character + # Though / is illegal, most other chars are technically allowed + # Use pre-compiled pattern for better performance + sanitized = _LINUX_ILLEGAL_CHARS_PATTERN.sub("_", name) + # Remove other control characters for safety (excluding \x00 which is already handled) + sanitized = _LINUX_CONTROL_CHARS_PATTERN.sub("_", sanitized) + # Avoid leading dot for folders (creates hidden folders) + if is_folder and sanitized.startswith("."): + sanitized = "_" + sanitized[1:] + + # Ensure the name is not empty after sanitization + if not sanitized or sanitized.strip() == "": + sanitized = "audiobook" + + # Limit length to 255 characters (common limit across filesystems) + if len(sanitized) > 255: + sanitized = sanitized[:255].rstrip(". ") + + return sanitized diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 26315c9..3506848 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -1,15 +1,11 @@ -"""Legacy PyQt voice formula dialog removed.""" +"""Backwards-compatible re-export of the PyQt voice formula dialog. + +The actual implementation lives in abogen.pyqt.voice_formula_gui. +""" from __future__ import annotations - -class VoiceFormulaDialog: # pragma: no cover - legacy entry point - """Placeholder for removed PyQt dialog.""" - - def __init__(self, *_args, **_kwargs): - raise RuntimeError( - "The PyQt-based voice formula editor has been removed. Use the web tools instead." - ) - +from abogen.pyqt.voice_formula_gui import * # noqa: F401, F403 +from abogen.pyqt.voice_formula_gui import VoiceFormulaDialog __all__ = ["VoiceFormulaDialog"] diff --git a/build_pypi.py b/build_pypi.py new file mode 100644 index 0000000..076e538 --- /dev/null +++ b/build_pypi.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Build PyPI package (wheel and sdist) to `dist` folder for abogen.""" + +import subprocess +import os +import shutil +import tempfile + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + output_dir = os.path.join(script_dir, "dist") + + print("🔧 abogen PyPI Package Builder") + print("=" * 40) + print(f"📁 Script directory: {script_dir}") + print(f"📦 Output directory: {output_dir}") + + # Try to print package version if present + version = None + version_file = os.path.join(script_dir, "abogen", "VERSION") + if os.path.isfile(version_file): + try: + with open(version_file, "r", encoding="utf-8") as vf: + version = vf.read().strip() + except Exception: + version = None + if version: + print(f"🔖 Package version: {version}") + + # Check if build module is installed, install if not + # Temporarily remove script_dir from sys.path to avoid importing local build.py + import sys + original_path = sys.path[:] + try: + sys.path = [p for p in sys.path if os.path.abspath(p) != script_dir] + import build + except ImportError: + print("📦 Installing build module...") + subprocess.run([sys.executable, "-m", "pip", "install", "build"], check=True) + finally: + sys.path = original_path + + # Create output directory + print(f"📂 Preparing output directory: {output_dir}") + if os.path.exists(output_dir): + shutil.rmtree(output_dir) + os.makedirs(output_dir, exist_ok=True) + + print("🏗️ Building PyPI package...") + print(" Using temporary directory to avoid module conflicts...") + + # Run from temp directory to avoid local build.py shadowing the build module + with tempfile.TemporaryDirectory() as tmpdir: + print(f" Temp directory: {tmpdir}") + print(" Running: python -m build -o ") + + result = subprocess.run( + [sys.executable, "-m", "build", "-o", output_dir, script_dir], + check=False, + cwd=tmpdir, + ) + + print("\n" + "=" * 40) + if result.returncode == 0: + print("✅ Build successful!") + print(f"📦 Files created in {output_dir}:") + + files = os.listdir(output_dir) + if files: + for f in files: + file_path = os.path.join(output_dir, f) + size = os.path.getsize(file_path) + print(f" 📄 {f} ({size:,} bytes)") + else: + print(" (No files found)") + + print("\n🚀 Ready for upload with:\n") + print(" - To test on Test PyPI:") + print(f" python -m twine upload --repository testpypi {output_dir}/*") + print("\n - To upload to PyPI (when ready):") + print(f" python -m twine upload {output_dir}/*") + else: + print("❌ Build failed!") + print(f" Exit code: {result.returncode}") + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index e9dc4af..1acff9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,8 @@ dependencies = [ "numpy>=1.24.0", "gpustat>=1.1.1", "num2words>=0.5.13", - "httpx>=0.27.0" + "httpx>=0.27.0", + "PyQt6>=6.5.0" ] classifiers = [ @@ -62,11 +63,12 @@ allow-direct-references = true [project.gui-scripts] -abogen = "abogen.webui.app:main" +abogen = "abogen.pyqt.main:main" [project.scripts] abogen-cli = "abogen.webui.app:main" abogen-web = "abogen.webui.app:main" +abogen-pyqt = "abogen.pyqt.main:main" [tool.hatch.build.targets.sdist] exclude = [ diff --git a/tests/test_book_handler_regression.py b/tests/test_book_handler_regression.py new file mode 100644 index 0000000..e8420e5 --- /dev/null +++ b/tests/test_book_handler_regression.py @@ -0,0 +1,84 @@ +import unittest +import os +import sys +import shutil +import time +from PyQt6.QtWidgets import QApplication + +# Ensure we can import the module +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from abogen.book_handler import HandlerDialog +from ebooklib import epub + +# We need a QApplication instance for QWriter/QDialog +app = QApplication(sys.argv) + +class TestBookHandlerRegression(unittest.TestCase): + + def setUp(self): + self.test_dir = "tests/test_data_handler" + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + os.makedirs(self.test_dir) + self.sample_epub_path = os.path.join(self.test_dir, "test_book.epub") + self._create_sample_epub() + + def tearDown(self): + HandlerDialog.clear_content_cache() + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def _create_sample_epub(self): + book = epub.EpubBook() + book.set_identifier("id123456") + book.set_title("Sample Book") + book.set_language("en") + + c1 = epub.EpubHtml(title="Intro", file_name="intro.xhtml", lang="en") + c1.content = "

    Introduction

    Welcome to the book.

    " + book.add_item(c1) + book.spine = ["nav", c1] + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + epub.write_epub(self.sample_epub_path, book) + + def test_handler_initialization(self): + """Test that HandlerDialog processes the book correctly.""" + # HandlerDialog starts processing in a background thread in __init__ + # We assume headless environment, so we won't show it. + # But we need to wait for the thread to finish. + + dialog = HandlerDialog(self.sample_epub_path) + + # Wait for thread to finish + # The dialog emits no signal publicly, but we can check internal state or thread + + start_time = time.time() + while time.time() - start_time < 5: + # HandlerDialog logic: + # _loader_thread.finished connect to _on_load_finished + # _on_load_finished populates content_texts and content_lengths + + # We can check if content_texts is populated + if dialog.content_texts: + break + app.processEvents() # Process Qt events to let thread signals propagate + time.sleep(0.1) + + self.assertTrue(len(dialog.content_texts) > 0, "HandlerDialog failed to process content in time") + + # Validate content similar to what we expect + # intro.xhtml should be there + found_intro = False + for key, text in dialog.content_texts.items(): + if "Welcome to the book" in text: + found_intro = True + break + self.assertTrue(found_intro) + + # Cleanup + dialog.close() + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_book_parser.py b/tests/test_book_parser.py new file mode 100644 index 0000000..e8172ea --- /dev/null +++ b/tests/test_book_parser.py @@ -0,0 +1,210 @@ +import unittest +import os +import sys +import shutil +import fitz # PyMuPDF +from ebooklib import epub + +# Ensure we can import the module +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from abogen.book_parser import get_book_parser, PdfParser, EpubParser, MarkdownParser + +class TestBookParser(unittest.TestCase): + + def setUp(self): + self.test_dir = "tests/test_data" + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + os.makedirs(self.test_dir) + + self.sample_pdf_path = os.path.join(self.test_dir, "test_book.pdf") + self.sample_epub_path = os.path.join(self.test_dir, "test_book.epub") + self.sample_md_path = os.path.join(self.test_dir, "test_book.md") + + self._create_sample_pdf() + self._create_sample_epub() + self._create_sample_md() + + def tearDown(self): + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def _create_sample_pdf(self): + doc = fitz.open() + + # Page 1 + page1 = doc.new_page() + page1.insert_text((50, 50), "Page 1 content") + # Add pattern to be cleaned + page1.insert_text((50, 100), "[12]") + page1.insert_text((50, 200), "1") # Page number at bottom + + # Page 2 + page2 = doc.new_page() + page2.insert_text((50, 50), "Page 2 content") + + doc.save(self.sample_pdf_path) + doc.close() + + def _create_sample_epub(self): + book = epub.EpubBook() + book.set_identifier("id123456") + book.set_title("Sample Book") + book.set_language("en") + book.add_author("Test Author") + + c1 = epub.EpubHtml(title="Intro", file_name="intro.xhtml", lang="en") + c1.content = "

    Introduction

    Welcome to the book.

    " + + c2 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en") + c2.content = "

    Chapter 1

    1. Item One
    2. Item Two
    " + + book.add_item(c1) + book.add_item(c2) + + # Basic spine and nav + book.spine = ["nav", c1, c2] + + # Add NCX and NAV for compatibility + book.add_item(epub.EpubNcx()) + book.add_item(epub.EpubNav()) + + epub.write_epub(self.sample_epub_path, book) + + def _create_sample_md(self): + content = "# Chapter 1\nSome text.\n# Chapter 2\nMore text." + with open(self.sample_md_path, "w") as f: + f.write(content) + + def test_factory_returns_correct_class(self): + """Test that get_book_parser returns the correct subclass based on extension.""" + parser_pdf = get_book_parser(self.sample_pdf_path) + self.assertIsInstance(parser_pdf, PdfParser) + + parser_md = get_book_parser(self.sample_md_path) + self.assertIsInstance(parser_md, MarkdownParser) + + parser_epub = get_book_parser(self.sample_epub_path) + self.assertIsInstance(parser_epub, EpubParser) + + def test_factory_explicit_type(self): + """Test that explicit file type argument overrides extension.""" + # 1. Copy sample epub to something.pdf + wrong_ext_path = os.path.join(self.test_dir, "actually_epub.pdf") + shutil.copy(self.sample_epub_path, wrong_ext_path) + + # 2. Open it telling parser it IS epub + parser = get_book_parser(wrong_ext_path, file_type="epub") + self.assertIsInstance(parser, EpubParser) + + # Should load successfully + parser.load() + self.assertTrue(parser.book is not None) + + def test_pdf_parser_content(self): + """Test PdfParser content extraction.""" + parser = get_book_parser(self.sample_pdf_path) + parser.process_content() + + self.assertIn("page_1", parser.content_texts) + self.assertIn("page_2", parser.content_texts) + + text1 = parser.content_texts["page_1"] + self.assertIn("Page 1 content", text1) + self.assertNotIn("[12]", text1) + + def test_markdown_parser_content(self): + """Test MarkdownParser splitting logic.""" + parser = get_book_parser(self.sample_md_path) + parser.process_content() + + # Should have Chapter 1 and Chapter 2 keys (actual keys depend on ID generation) + # Markdown extensions might slugify IDs: "chapter-1" + self.assertIn("chapter-1", parser.content_texts) + self.assertIn("chapter-2", parser.content_texts) + + self.assertIn("Some text", parser.content_texts["chapter-1"]) + + def test_epub_parser_content(self): + """Test EpubParser processing.""" + parser = get_book_parser(self.sample_epub_path) + parser.process_content() + + self.assertIn("intro.xhtml", parser.content_texts) + self.assertIn("chap1.xhtml", parser.content_texts) + self.assertIn("Welcome to the book", parser.content_texts["intro.xhtml"]) + + def test_epub_metadata_extraction(self): + """Test metadata extraction in EpubParser.""" + parser = get_book_parser(self.sample_epub_path) + # Processing content triggers metadata extraction in current implementation + parser.process_content() + + metadata = parser.get_metadata() + self.assertEqual(metadata.get("title"), "Sample Book") + self.assertEqual(metadata.get("author"), "Test Author") + + def test_ordered_list_handling(self): + """Test
      handling in EpubParser.""" + parser = get_book_parser(self.sample_epub_path) + parser.process_content() + + text = parser.content_texts.get("chap1.xhtml", "") + self.assertIn("1) Item One", text) + self.assertIn("2) Item Two", text) + + def test_find_position_robust_logic(self): + """Unit test for _find_position_robust on EpubParser.""" + parser = EpubParser(self.sample_epub_path) # Instantiate directly + + html = '

      Start

      Heading

      End

      ' + parser.doc_content["dummy.html"] = html + + # Test finding ID + pos = parser._find_position_robust("dummy.html", "target") + self.assertGreater(pos, 0) + self.assertTrue(html[pos:].startswith('

      >", text) + self.assertIn("Some text", text) + + def test_file_type_property(self): + """Test that file_type property returns correct string for each parser.""" + pdf_parser = PdfParser(self.sample_pdf_path) + self.assertEqual(pdf_parser.file_type, "pdf") + + epub_parser = EpubParser(self.sample_epub_path) + self.assertEqual(epub_parser.file_type, "epub") + + md_parser = MarkdownParser(self.sample_md_path) + self.assertEqual(md_parser.file_type, "markdown") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_epub_content_slicing.py b/tests/test_epub_content_slicing.py new file mode 100644 index 0000000..16c616b --- /dev/null +++ b/tests/test_epub_content_slicing.py @@ -0,0 +1,199 @@ +import unittest +import os +import shutil +import sys +from ebooklib import epub + +# Ensure import path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from abogen.book_parser import get_book_parser + +class TestEpubContentSlicing(unittest.TestCase): + """ + Tests for the complex content slicing logic in _execute_nav_parsing_logic. + This covers scenarios where multiple chapters/sections are contained within + a single physical HTML file, separated by anchors (fragments). + """ + + def setUp(self): + self.test_dir = "tests/test_data_slicing" + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + os.makedirs(self.test_dir) + self.epub_path = os.path.join(self.test_dir, "slicing_test.epub") + + def tearDown(self): + if os.path.exists(self.test_dir): + shutil.rmtree(self.test_dir) + + def test_single_file_multiple_chapters(self): + """ + Test splitting one XHTML file into two chapters using an anchor. + """ + book = epub.EpubBook() + book.set_identifier("slice123") + book.set_title("Slicing Test Book") + + # Create a single content file with two sections + content_html = """ + + +

      Chapter 1

      +

      Text for chapter 1.

      +
      +

      Chapter 2

      +

      Text for chapter 2.

      + + + """ + c1 = epub.EpubHtml(title="Full Content", file_name="content.xhtml", lang="en") + c1.content = content_html + book.add_item(c1) + + # Create Nav that points to anchors in the SAME file + # We use EpubHtml for Nav to control content exactly without ebooklib interference + nav_html = """ + + """ + nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml") + nav.content = nav_html + book.add_item(nav) + + book.spine = [nav, c1] + + epub.write_epub(self.epub_path, book) + + # OPF Patching to valid crash + import zipfile + patched = False + with zipfile.ZipFile(self.epub_path, 'r') as zin: + opf_content = zin.read('EPUB/content.opf').decode('utf-8') + if 'toc="ncx"' in opf_content: + opf_content = opf_content.replace('toc="ncx"', '') + patched = True + + if patched: + TEMP_EPUB = self.epub_path + ".temp" + with zipfile.ZipFile(TEMP_EPUB, 'w') as zout: + for item in zin.infolist(): + if item.filename == 'EPUB/content.opf': + zout.writestr(item, opf_content) + else: + zout.writestr(item, zin.read(item.filename)) + + if patched: + shutil.move(TEMP_EPUB, self.epub_path) + + # Parse + parser = get_book_parser(self.epub_path) + parser.process_content() + chapters = parser.get_chapters() + + # Filter Nav/Intro + chapters = [c for c in chapters if "Chapter" in c[1]] + + self.assertEqual(len(chapters), 2) + self.assertEqual(chapters[0][1], "Chapter 1") + self.assertEqual(chapters[1][1], "Chapter 2") + + # Check content of Chapter 1 + # It should contain "Text for chapter 1" but NOT "Text for chapter 2" + # The parser logic slices from start_pos to next_pos + text1 = parser.content_texts[chapters[0][0]] + self.assertIn("Text for chapter 1", text1) + self.assertNotIn("Text for chapter 2", text1) + + # Check content of Chapter 2 + text2 = parser.content_texts[chapters[1][0]] + self.assertIn("Text for chapter 2", text2) + + def test_list_renumbering(self): + """ + Test that ordered lists are re-numbered when slicing. + The parser has logic to reset
        or insert numbers. + """ + book = epub.EpubBook() + book.set_identifier("list123") + book.set_title("List Test Book") + + content_html = """ + + +

        Part 1

        +
          +
        1. Item A
        2. +
        3. Item B
        4. +
        +

        Part 2

        +
          +
        1. Item C
        2. +
        3. Item D
        4. +
        + + + """ + c1 = epub.EpubHtml(title="Content", file_name="content.xhtml", lang="en") + c1.content = content_html + book.add_item(c1) + + nav_html = """ + + """ + nav = epub.EpubHtml(title="Nav", file_name="nav.xhtml") + nav.content = nav_html + book.add_item(nav) + book.spine = [nav, c1] + + epub.write_epub(self.epub_path, book) + + # Patch + import zipfile + patched = False + with zipfile.ZipFile(self.epub_path, 'r') as zin: + opf_content = zin.read('EPUB/content.opf').decode('utf-8') + if 'toc="ncx"' in opf_content: + opf_content = opf_content.replace('toc="ncx"', '') + patched = True + if patched: + TEMP_EPUB = self.epub_path + ".temp" + with zipfile.ZipFile(TEMP_EPUB, 'w') as zout: + for item in zin.infolist(): + if item.filename == 'EPUB/content.opf': + zout.writestr(item, opf_content) + else: + zout.writestr(item, zin.read(item.filename)) + if patched: + shutil.move(TEMP_EPUB, self.epub_path) + + parser = get_book_parser(self.epub_path) + parser.process_content() + chapters = parser.get_chapters() + chapters = [c for c in chapters if "Part" in c[1]] + + self.assertEqual(len(chapters), 2) + + # Check Part 1 text + text1 = parser.content_texts[chapters[0][0]] + # The parser explicitly replaces li with "1) Item A" style text + self.assertIn("1) Item A", text1) + self.assertIn("2) Item B", text1) + + # Check Part 2 text + text2 = parser.content_texts[chapters[1][0]] + # Should convert start="3" to "3) Item C" + self.assertIn("3) Item C", text2) + self.assertIn("4) Item D", text2) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_epub_heuristic_nav.py b/tests/test_epub_heuristic_nav.py new file mode 100644 index 0000000..a69a374 --- /dev/null +++ b/tests/test_epub_heuristic_nav.py @@ -0,0 +1,117 @@ +import unittest +import os +import shutil +import sys +from ebooklib import epub + +# Ensure import path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +from abogen.book_parser import get_book_parser + +class TestEpubHeuristicNav(unittest.TestCase): + """ + Tests for the heuristic fallback in _identify_nav_item (Step 4), + where the parser scans ITEM_DOCUMENTs for