diff --git a/CHANGELOG.md b/CHANGELOG.md index c328716..1a7ab61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ -- Enhanced EPUB handling by treating all items in chapter list (including anchors) as chapters, improving navigation and organization for poorly structured books, mentioned by @Darthagnon in #4 -- Fixed the issue with some chapters in EPUB files had missing content. -- Fixed the issue with some EPUB files only having one chapter caused the program to ignore the entire book. -- Fixed "utf-8' codec can't decode byte" error, mentioned by @nigelp in #3 -- Added "Replace single newlines with spaces" option in the menu. This can be useful for texts that have imaginary line breaks. +- Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5. +- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles. +- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience. +- Added icons for flags and genders in the GUI, making it easier to identify different options. +- Improved the content and chapter extraction process for EPUB files, ensuring better handling of various structures. +- Switched to platformdirs for determining the correct desktop path, instead of using old methods. +- Fixed preview voices was not using GPU acceleration, which was causing performance issues. - Improvements in code and documentation. \ No newline at end of file diff --git a/README.md b/README.md index 570aa96..4ee7998 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ abogen 1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor) 2) Configure the settings: - Set speech speed - - Select a voice + - Select a voice (or create a custom voice using voice mixer) - Select subtitle generation style (by sentence, word, etc.) - Select output format - Select where to save the output @@ -83,6 +83,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex - **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor) - **Speed**: Adjust speech rate from `0.1x` to `2.0x` - **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. +- **Voice mixer**: Create custom voices by mixing different voice models with a profile system. - **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) - **Output formats**: `.WAV`, `.FLAC`, or `.MP3` - **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder` @@ -93,10 +94,15 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex - **Create desktop shortcut**: Creates a shortcut on your desktop for easy access. - **Open config.json directory**: Opens the directory where the configuration file is stored. - **Open temp directory**: Opens the temporary directory where converted text files are stored. - - **Clear all teporary files**: Deletes all temporary files created during the conversion process. + - **Clear all temporary files**: Deletes all temporary files created during the conversion process. - **Check for updates at startup**: Automatically checks for updates when the program starts. - **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`. +## `Voice Mixer` + + +With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5)) + ## `Supported Languages` ``` # 🇺🇸 'a' => American English, 🇬🇧 'b' => British English @@ -136,7 +142,7 @@ Abogen is a standalone project, but it is inspired by and shares some similariti - [ ] Improve PDF support for better text extraction. - [ ] Add chapter metadata for .m4a files using ffmpeg-bin. - [ ] Add support for different languages in GUI. -- [ ] Add voice formula feature that enables mixing different voice models. https://github.com/denizsafak/abogen/issues/1 +- [x] Add voice formula feature that enables mixing different voice models. #1 - [ ] Add support for kokoro-onnx. - [ ] Add dark mode. @@ -154,7 +160,7 @@ If you'd like to modify the code and contribute to development, you can [downloa ```bash # Go to the directory where you extracted the repository and run: pip install -e . # Installs the package in editable mode -python -m build # Builds the package in dist folder +python -m build # Builds the package in dist folder (optional) abogen # Opens the GUI ``` Feel free to explore the code and make any changes you like. @@ -162,7 +168,9 @@ Feel free to explore the code and make any changes you like. ## `Credits` - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. +- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. +- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/). ## `License` This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. diff --git a/abogen/VERSION b/abogen/VERSION index e6d5cb8..e4c0d46 100644 --- a/abogen/VERSION +++ b/abogen/VERSION @@ -1 +1 @@ -1.0.2 \ No newline at end of file +1.0.3 \ No newline at end of file diff --git a/abogen/assets/female.png b/abogen/assets/female.png new file mode 100644 index 0000000..f252cce Binary files /dev/null and b/abogen/assets/female.png differ diff --git a/abogen/assets/flags/a.png b/abogen/assets/flags/a.png new file mode 100644 index 0000000..f770fcf Binary files /dev/null and b/abogen/assets/flags/a.png differ diff --git a/abogen/assets/flags/b.png b/abogen/assets/flags/b.png new file mode 100644 index 0000000..8e04f7a Binary files /dev/null and b/abogen/assets/flags/b.png differ diff --git a/abogen/assets/flags/e.png b/abogen/assets/flags/e.png new file mode 100644 index 0000000..693cf2a Binary files /dev/null and b/abogen/assets/flags/e.png differ diff --git a/abogen/assets/flags/f.png b/abogen/assets/flags/f.png new file mode 100644 index 0000000..d80e7a7 Binary files /dev/null and b/abogen/assets/flags/f.png differ diff --git a/abogen/assets/flags/h.png b/abogen/assets/flags/h.png new file mode 100644 index 0000000..015ec29 Binary files /dev/null and b/abogen/assets/flags/h.png differ diff --git a/abogen/assets/flags/i.png b/abogen/assets/flags/i.png new file mode 100644 index 0000000..6678202 Binary files /dev/null and b/abogen/assets/flags/i.png differ diff --git a/abogen/assets/flags/j.png b/abogen/assets/flags/j.png new file mode 100644 index 0000000..ad5569e Binary files /dev/null and b/abogen/assets/flags/j.png differ diff --git a/abogen/assets/flags/p.png b/abogen/assets/flags/p.png new file mode 100644 index 0000000..8abf47f Binary files /dev/null and b/abogen/assets/flags/p.png differ diff --git a/abogen/assets/flags/z.png b/abogen/assets/flags/z.png new file mode 100644 index 0000000..a0366c4 Binary files /dev/null and b/abogen/assets/flags/z.png differ diff --git a/abogen/assets/male.png b/abogen/assets/male.png new file mode 100644 index 0000000..65f2812 Binary files /dev/null and b/abogen/assets/male.png differ diff --git a/abogen/assets/profile.png b/abogen/assets/profile.png new file mode 100644 index 0000000..285ec1b Binary files /dev/null and b/abogen/assets/profile.png differ diff --git a/abogen/assets/voice_mixer.png b/abogen/assets/voice_mixer.png new file mode 100644 index 0000000..54393ff Binary files /dev/null and b/abogen/assets/voice_mixer.png differ diff --git a/abogen/book_handler.py b/abogen/book_handler.py index e0cbeb0..fb79b9d 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -4,7 +4,7 @@ import ebooklib import base64 import fitz # PyMuPDF for PDF support from ebooklib import epub -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, NavigableString from PyQt5.QtWidgets import ( QDialog, QTreeWidget, @@ -24,6 +24,13 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import Qt from utils import clean_text, calculate_text_length import os +import logging # Add logging +import urllib.parse + +# Setup logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) class HandlerDialog(QDialog): @@ -59,7 +66,37 @@ class HandlerDialog(QDialog): self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end # Load the book based on file type - self.book = epub.read_epub(book_path) if self.file_type == "epub" else None + try: + self.book = epub.read_epub(book_path) if self.file_type == "epub" else None + except KeyError as e: + logging.error( + f"EPUB file is missing a referenced file: {e}. Skipping missing file." + ) + # Try to patch ebooklib to skip missing files (monkey-patch read_file) + import types + + orig_read_file = None + try: + 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 + self.book = epub.read_epub(book_path) + reader_class.read_file = orig_read_file # Restore + except Exception as patch_e: + logging.error(f"Failed to patch ebooklib for missing files: {patch_e}") + raise e self.pdf_doc = fitz.open(book_path) if self.file_type == "pdf" else None # Extract book metadata @@ -129,235 +166,18 @@ class HandlerDialog(QDialog): def _preprocess_content(self): """Pre-process content from the document""" if self.file_type == "epub": - # Always process EPUB content using the anchor-based approach - self._process_epub_content() + try: + self._process_epub_content_nav() # Use the new navigation-based method + except Exception as e: + logging.error( + f"Error processing EPUB with navigation: {e}. Falling back to TOC/spine.", + exc_info=True, + ) + # Fallback to a simpler spine-based processing if nav fails + self._process_epub_content_spine_fallback() else: self._preprocess_pdf_content() - def _process_epub_content(self, split_anchors=True): - """ - Process EPUB content by globally ordering TOC entries and slicing content between them. - Ensures all content between defined TOC start points is captured. - """ - # split_anchors parameter kept for compatibility but always treated as True - book = self.book - - # 1. Cache all document HTML and determine spine order - self.doc_content = {} - # Correctly get hrefs from spine items - spine_docs = [] - for spine_item_tuple in book.spine: - item_id = spine_item_tuple[0] - item = book.get_item_with_id(item_id) - if item: - spine_docs.append(item.get_name()) # Use get_name() for href - else: - print(f"Warning: Spine item with id '{item_id}' not found in book items.") - - doc_order = {href: i for i, href in enumerate(spine_docs)} - - for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT): - href = item.get_name() - if href in doc_order: # Only process docs in spine - try: - html_content = item.get_content().decode('utf-8', errors='ignore') - self.doc_content[href] = html_content - except Exception: - self.doc_content[href] = "" # Handle decoding errors - - # 2. Get all TOC entries with hrefs and determine their positions - toc_entries_with_pos = [] - - def find_position(doc_href, fragment_id): - if doc_href not in self.doc_content: - return -1 - html_content = self.doc_content[doc_href] - if not fragment_id: # No fragment, position is 0 - return 0 - - # Find position of fragment identifier (id= or name=) - 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 - else: - return -1 # Anchor not found by simple string search - - # Backtrack to the start of the tag '<' - tag_start_pos = html_content.rfind('<', 0, pos) - return tag_start_pos if tag_start_pos != -1 else 0 # Default to 0 if '<' not found - - def collect_toc_entries(entries): - collected = [] - for entry in entries: - href, title = None, "Unknown" - children = [] - entry_obj = None # Store the original entry object - - if isinstance(entry, ebooklib.epub.Link): - href, title = entry.href, entry.title or entry.href - entry_obj = entry - elif isinstance(entry, tuple) and len(entry) >= 1: - section_or_link = entry[0] - entry_obj = section_or_link - if isinstance(section_or_link, ebooklib.epub.Section): - title = section_or_link.title - href = getattr(section_or_link, "href", None) - elif isinstance(section_or_link, ebooklib.epub.Link): - href, title = section_or_link.href, section_or_link.title or section_or_link.href - - if len(entry) > 1 and isinstance(entry[1], list): - children = entry[1] - - if href: - base_href, fragment = href.split('#', 1) if '#' in href else (href, None) - if base_href in doc_order: # Only consider entries pointing to spine documents - position = find_position(base_href, fragment) - if position != -1: # Only add if position is valid - collected.append({ - "href": href, # Use the original href from TOC as the key - "title": title, - "doc_href": base_href, - "position": position, - "doc_order": doc_order[base_href] - }) - - if children: - collected.extend(collect_toc_entries(children)) - return collected - - all_toc_entries = collect_toc_entries(self.book.toc) - - # Handle case where book has no TOC or empty TOC - if not all_toc_entries: - # Create a synthetic TOC entry for the first spine document - if spine_docs: - # Process all content as a single chapter - all_content_html = "" - for doc_href in spine_docs: - all_content_html += self.doc_content.get(doc_href, "") - - if all_content_html: - soup = BeautifulSoup(all_content_html, 'html.parser') - text = clean_text(soup.get_text()).strip() - - # Use the first spine document as the identifier - first_doc = spine_docs[0] - self.content_texts[first_doc] = text - self.content_lengths[first_doc] = len(text) - - # Create a synthetic TOC entry for tree building - self.book.toc = [(epub.Link(first_doc, "Main Content", first_doc),)] - return - - # 3. Sort TOC entries globally - all_toc_entries.sort(key=lambda x: (x["doc_order"], x["position"])) - - # 4. Slice content between sorted entries - self.content_texts = {} - self.content_lengths = {} - num_entries = len(all_toc_entries) - - for i in range(num_entries): - current_entry = all_toc_entries[i] - current_href = current_entry["href"] - current_doc = current_entry["doc_href"] - current_pos = current_entry["position"] - current_doc_html = self.doc_content.get(current_doc, "") - - start_slice_pos = current_pos - slice_html = "" - - # Find the start of the next TOC entry - next_entry = all_toc_entries[i + 1] if (i + 1) < num_entries else None - - if next_entry: - next_doc = next_entry["doc_href"] - next_pos = next_entry["position"] - - if current_doc == next_doc: - # Next entry is in the same document - slice_html = current_doc_html[start_slice_pos:next_pos] - else: - # Next entry is in a different document - # Take content from current position to end of current document - slice_html = current_doc_html[start_slice_pos:] - # Include content from intermediate documents in the spine - current_doc_index = current_entry["doc_order"] - next_doc_index = next_entry["doc_order"] - for doc_idx in range(current_doc_index + 1, next_doc_index): - intermediate_doc_href = spine_docs[doc_idx] - slice_html += self.doc_content.get(intermediate_doc_href, "") - # Add content from the beginning of the next document up to the next entry's position - next_doc_html = self.doc_content.get(next_doc, "") - slice_html += next_doc_html[:next_pos] - else: - # This is the last TOC entry - # Take content from current position to end of current document - slice_html = current_doc_html[start_slice_pos:] - # Include content from all remaining documents in the spine - current_doc_index = current_entry["doc_order"] - for doc_idx in range(current_doc_index + 1, len(spine_docs)): - intermediate_doc_href = spine_docs[doc_idx] - slice_html += self.doc_content.get(intermediate_doc_href, "") - - # 5. Extract text and store - slice_soup = BeautifulSoup(slice_html, 'html.parser') - - # Remove sup and sub tags from the HTML before extracting text - for tag in slice_soup.find_all(['sup', 'sub']): - tag.decompose() - - text = clean_text(slice_soup.get_text()).strip() - self.content_texts[current_href] = text # Store using the original TOC href - self.content_lengths[current_href] = len(text) - - # 6. Handle content BEFORE the first TOC entry - if all_toc_entries: - first_entry = all_toc_entries[0] - first_doc_href = first_entry["doc_href"] - first_pos = first_entry["position"] - first_doc_order = first_entry["doc_order"] - prefix_html = "" - # Include content from documents before the first entry's document - for doc_idx in range(first_doc_order): - intermediate_doc_href = spine_docs[doc_idx] - prefix_html += self.doc_content.get(intermediate_doc_href, "") - # Include content from the start of the first entry's document up to its position - 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') - # Remove sup and sub tags - for tag in prefix_soup.find_all(['sup', 'sub']): - tag.decompose() - prefix_text = clean_text(prefix_soup.get_text()).strip() - - if prefix_text: - # Create a new chapter for content before the first TOC entry - # Use a synthetic href to avoid collision with real TOC entries - prefix_chapter_href = "prefix_content_chapter" - self.content_texts[prefix_chapter_href] = prefix_text - self.content_lengths[prefix_chapter_href] = len(prefix_text) - - # Add a new entry to the TOC for the prefix content - prefix_link = epub.Link(prefix_chapter_href, "Introduction", prefix_chapter_href) - # Insert at beginning of TOC - if isinstance(self.book.toc, list): - self.book.toc.insert(0, (prefix_link,)) - else: - self.book.toc = [(prefix_link,)] + (self.book.toc or []) - def _preprocess_pdf_content(self): """Pre-process all page contents from PDF document""" for page_num in range(len(self.pdf_doc)): @@ -380,16 +200,624 @@ class HandlerDialog(QDialog): self.content_texts[page_id] = text self.content_lengths[page_id] = calculate_text_length(text) - def _build_tree(self): - """Build tree based on file type""" - if self.file_type == "epub": - self._build_epub_tree() - else: - self._build_pdf_tree() + def _process_epub_content_spine_fallback(self): + """Fallback EPUB processing based purely on spine order.""" + 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.") - def _build_epub_tree(self): - """Build the tree for EPUB files from TOC""" + # Cache content + 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 as e: + logging.error(f"Error decoding content for {href}: {e}") + self.doc_content[href] = "" + + # Create a simple TOC based on spine order + synthetic_toc = [] + 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") + # Remove sup and sub tags + for tag in soup.find_all(["sup", "sub"]): + tag.decompose() + text = clean_text(soup.get_text()).strip() + if text: + # Use doc_href as the identifier + self.content_texts[doc_href] = text + self.content_lengths[doc_href] = len(text) + # Create a synthetic TOC entry + title = f"Chapter {i+1}: {doc_href}" + # Try to get a better title from

or + h1 = soup.find("h1") + if h1 and h1.get_text(strip=True): + title = h1.get_text(strip=True) + else: + title_tag = soup.find("title") + if title_tag and title_tag.get_text(strip=True): + title = title_tag.get_text(strip=True) + + synthetic_toc.append( + (epub.Link(doc_href, title, doc_href), []) + ) # Wrap in tuple and empty list for compatibility + + # Replace book.toc with the synthetic one if it was empty or fallback was triggered + if not self.book.toc or not hasattr( + self, "processed_nav_structure" + ): # Check if nav processing failed + self.book.toc = synthetic_toc + logging.info(f"Generated synthetic TOC with {len(synthetic_toc)} entries.") + + def _process_epub_content_nav(self): + """ + Process EPUB content using ITEM_NAVIGATION (NAV HTML) or ITEM_NCX. + Globally orders navigation entries and slices content between them. + """ + logging.info( + "Attempting to process EPUB using navigation document (NAV/NCX)..." + ) + nav_item = None + nav_type = None + + # 1. Check ITEM_NAVIGATION for actual NAV HTML (.xhtml/.html) + nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) + if nav_items: + # Prefer files explicitly named 'nav.xhtml' or similar + preferred_nav = next( + ( + item + for item in nav_items + if "nav" in item.get_name().lower() + and item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if preferred_nav: + nav_item = preferred_nav + nav_type = "html" + logging.info(f"Found preferred NAV HTML item: {nav_item.get_name()}") + else: + # Check if any ITEM_NAVIGATION is actually HTML + html_nav = next( + ( + item + for item in nav_items + if item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if html_nav: + nav_item = html_nav + nav_type = "html" + logging.info( + f"Found NAV HTML item in ITEM_NAVIGATION: {html_nav.get_name()}" + ) + + # 2. If no NAV HTML found via ITEM_NAVIGATION, check if ITEM_NAVIGATION points to NCX + 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" + logging.info( + f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}" + ) + + # 3. If still no nav_item, check for ITEM_NCX directly + if not nav_item: + ncx_items = list(self.book.get_items_of_type(ebooklib.ITEM_NCX)) + if ncx_items: + nav_item = ncx_items[0] # Take the first one + nav_type = "ncx" + logging.info(f"Found NCX item via ITEM_NCX: {ncx_items[0].get_name()}") + + # 4. If no navigation item found by any method, trigger fallback + if not nav_item or not nav_type: + logging.warning( + "No suitable EPUB navigation document (NAV HTML or NCX) found. Falling back." + ) + raise ValueError("No navigation document found") # Trigger fallback + + # Determine parser based on the confirmed nav_type + parser_type = "html.parser" if nav_type == "html" else "xml" + logging.info(f"Using parser: '{parser_type}' for {nav_item.get_name()}") + try: + nav_content = nav_item.get_content().decode("utf-8", errors="ignore") + nav_soup = BeautifulSoup(nav_content, parser_type) + except Exception as e: + logging.error( + f"Failed to parse navigation content ({nav_item.get_name()}) using {parser_type}: {e}", + exc_info=True, + ) + raise ValueError( + f"Failed to parse navigation content: {e}" + ) # Trigger fallback + + # --- Rest of the processing logic --- + # 1. Cache all document HTML and determine spine order (no changes needed here) + 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.") + doc_order = {href: i for i, href in enumerate(spine_docs)} + # Add a mapping for unquoted (decoded) hrefs as well + doc_order_decoded = { + urllib.parse.unquote(href): i for href, i in doc_order.items() + } + + # Clear previous content/lengths before processing + self.content_texts = {} + self.content_lengths = {} + + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href in doc_order or any( + href in nav_point.get("src", "") + for nav_point in nav_soup.find_all(["content", "a"]) + ): + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + self.doc_content[href] = html_content + except Exception as e: + logging.error(f"Error decoding content for {href}: {e}") + self.doc_content[href] = "" + + # 2. Extract and order navigation entries globally + ordered_nav_entries = [] + + # Define find_position locally or ensure self._find_position_robust is used correctly + # Using self._find_position_robust is preferred as it's a method of the class + find_position_func = self._find_position_robust + + # Store the parsed structure for tree building later + self.processed_nav_structure = [] + + # Call the correct parsing function based on confirmed nav_type + parse_successful = False + if nav_type == "ncx": + nav_map = nav_soup.find("navMap") + if nav_map: + logging.info("Parsing NCX <navMap>...") + for nav_point in nav_map.find_all("navPoint", recursive=False): + self._parse_ncx_navpoint( + nav_point, + ordered_nav_entries, + doc_order, + doc_order_decoded, + self.processed_nav_structure, + find_position_func, + ) + parse_successful = bool( + ordered_nav_entries + ) # Success if entries were added + else: + logging.warning("Could not find <navMap> in NCX file.") + elif nav_type == "html": + logging.info("Parsing NAV HTML...") + toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"}) + if not toc_nav: + # Fallback: look for any <nav> element containing an <ol> + all_navs = nav_soup.find_all("nav") + for nav in all_navs: + if nav.find("ol"): + toc_nav = nav + logging.info("Found fallback TOC structure in <nav> with <ol>.") + break + if toc_nav: + top_ol = toc_nav.find("ol", recursive=False) + if top_ol: + for li in top_ol.find_all("li", recursive=False): + self._parse_html_nav_li( + li, + ordered_nav_entries, + doc_order, + doc_order_decoded, + self.processed_nav_structure, + find_position_func, + ) + parse_successful = bool( + ordered_nav_entries + ) # Success if entries were added + else: + logging.warning("Found <nav> for TOC but no top-level <ol> inside.") + else: + logging.warning( + "Could not find TOC structure (<nav epub:type='toc'> or <nav><ol>) in NAV HTML." + ) + + # Handle case where parsing ran but found no valid entries OR parsing failed + if not parse_successful: + logging.warning( + "Navigation parsing completed but found no valid entries, or parsing failed. Falling back." + ) + raise ValueError("No valid navigation entries found after parsing") + + # Sort entries globally by document order and position within the document + ordered_nav_entries.sort(key=lambda x: (x["doc_order"], x["position"])) + logging.info(f"Sorted {len(ordered_nav_entries)} navigation entries.") + + # 3. Slice content ONLY between sorted TOC entries + num_entries = len(ordered_nav_entries) + for i in range(num_entries): + current_entry = ordered_nav_entries[i] + current_src = current_entry["src"] + current_doc = current_entry["doc_href"] + current_pos = current_entry["position"] + current_doc_html = self.doc_content.get(current_doc, "") + + start_slice_pos = current_pos + slice_html = "" + + next_entry = ordered_nav_entries[i + 1] if (i + 1) < num_entries else None + + if next_entry: + next_doc = next_entry["doc_href"] + next_pos = next_entry["position"] + + # Always include all content from current position to next position, even if next_doc is before current_doc + if current_doc == next_doc: + slice_html = current_doc_html[start_slice_pos:next_pos] + else: + # Collect all content from current_doc (from start_slice_pos to end), + # then all intermediate docs (in spine order), + # then up to next_pos in next_doc (even if next_doc is before current_doc in spine) + slice_html = current_doc_html[start_slice_pos:] + docs_between = [] + try: + idx_current = spine_docs.index(current_doc) + idx_next = spine_docs.index(next_doc) + if idx_current < idx_next: + for doc_idx in range(idx_current + 1, idx_next): + docs_between.append(spine_docs[doc_idx]) + elif idx_current > idx_next: + for doc_idx in range(idx_current + 1, len(spine_docs)): + docs_between.append(spine_docs[doc_idx]) + for doc_idx in range(0, idx_next): + docs_between.append(spine_docs[doc_idx]) + except Exception: + 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: + # Last TOC entry: include all content from current position to end of book + 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)): + intermediate_doc_href = spine_docs[doc_idx] + slice_html += self.doc_content.get(intermediate_doc_href, "") + except Exception: + pass + + if slice_html.strip(): + slice_soup = BeautifulSoup(slice_html, "html.parser") + # Add double newlines after <p> and <div> tags + for tag in slice_soup.find_all(["p", "div"]): + tag.append("\n\n") + 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] = len(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 + + # 4. Extract text and store using the original TOC entry src as the key + 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, "") + else: + logging.warning( + f"Document index {doc_idx} out of bounds for spine (length {len(spine_docs)})." + ) + + 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": [], + }, + ) + logging.info( + f"Added prefix content chapter '{prefix_chapter_src}'." + ) + + logging.info( + f"Finished processing EPUB navigation. Found {len(self.content_texts)} content sections linked to TOC." + ) + + def _parse_ncx_navpoint( + self, + nav_point, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + 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) + # Try both original and decoded hrefs + doc_key = None + if base_href in doc_order: + doc_key = base_href + doc_idx = doc_order[base_href] + elif urllib.parse.unquote(base_href) in doc_order: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order[doc_key] + elif base_href in doc_order_decoded: + doc_key = base_href + doc_idx = doc_order_decoded[base_href] + elif urllib.parse.unquote(base_href) in doc_order_decoded: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order_decoded[doc_key] + else: + logging.warning( + f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list." + ) + current_entry_node["has_content"] = False + doc_key = None + 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: + logging.warning(f"Navigation entry '{title}' has no 'src' attribute.") + current_entry_node["has_content"] = False + + child_navpoints = nav_point.find_all("navPoint", recursive=False) + if child_navpoints: + for child_np in child_navpoints: + # Pass find_position_func down recursively + 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 _parse_html_nav_li( + self, + li_element, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + link = li_element.find("a", recursive=False) + span_text = li_element.find("span", recursive=False) + title = "Untitled Section" + src = None + current_entry_node = {"children": []} + + if link and "href" in link.attrs: + src = link["href"] + title = link.get_text(strip=True) or title + if not title.strip() and span_text: + title = span_text.get_text(strip=True) or title + if not title.strip(): + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + elif span_text: + title = span_text.get_text(strip=True) or title + if not title.strip(): + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + else: + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + + current_entry_node["title"] = title + current_entry_node["src"] = src + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + # Try both original and decoded hrefs + doc_key = None + if base_href in doc_order: + doc_key = base_href + doc_idx = doc_order[base_href] + elif urllib.parse.unquote(base_href) in doc_order: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order[doc_key] + elif base_href in doc_order_decoded: + doc_key = base_href + doc_idx = doc_order_decoded[base_href] + elif urllib.parse.unquote(base_href) in doc_order_decoded: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order_decoded[doc_key] + else: + logging.warning( + f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list." + ) + current_entry_node["has_content"] = False + doc_key = None + 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 + + child_ol = li_element.find("ol", recursive=False) + if child_ol: + for child_li in child_ol.find_all("li", recursive=False): + # Pass find_position_func down recursively + self._parse_html_nav_li( + child_li, + 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 _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"<div>{html_content}</div>", "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: + logging.debug( + f"Found position for id='{fragment_id}' in {doc_href} using BeautifulSoup: {pos}" + ) + return pos + except Exception as e: + logging.warning( + f"BeautifulSoup failed to find id='{fragment_id}' in {doc_href}: {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: + pos = match.start() + logging.debug( + f"Found position for id/name='{fragment_id}' in {doc_href} using regex: {pos}" + ) + return pos + + 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 + logging.debug( + f"Found position for id/name='{fragment_id}' in {doc_href} using string search: {final_pos}" + ) + return final_pos + + logging.warning( + f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0." + ) + return 0 + + def _build_tree(self): self.treeWidget.clear() + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) info_item.setData(0, Qt.UserRole, "info:bookinfo") info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable) @@ -397,72 +825,129 @@ class HandlerDialog(QDialog): font.setBold(True) info_item.setFont(0, font) - # Regular tree building - def build_tree(toc_entries, parent_item): - for entry in toc_entries: - href, title, children = None, "Unknown", [] - if isinstance(entry, ebooklib.epub.Link): - href, title = entry.href, entry.title or entry.href - elif isinstance(entry, tuple) and len(entry) >= 1: - section_or_link = entry[0] - if isinstance(section_or_link, ebooklib.epub.Section): - title = section_or_link.title - href = getattr(section_or_link, "href", None) - elif isinstance(section_or_link, ebooklib.epub.Link): - href, title = ( - section_or_link.href, - section_or_link.title or section_or_link.href, - ) - if len(entry) > 1 and isinstance(entry[1], list): - children = entry[1] + if self.file_type == "epub": + if ( + hasattr(self, "processed_nav_structure") + and self.processed_nav_structure + ): + self._build_epub_tree_from_nav( + self.processed_nav_structure, self.treeWidget + ) + else: + logging.warning("Building EPUB tree using fallback book.toc.") + self._build_epub_tree_fallback(self.book.toc, self.treeWidget) + else: + self._build_pdf_tree() + + has_parents = False + iterator = QTreeWidgetItemIterator( + self.treeWidget, QTreeWidgetItemIterator.HasChildren + ) + if iterator.value(): + has_parents = True + self.treeWidget.setRootIsDecorated(has_parents) + + def _build_epub_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.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: - continue + seen_content_hashes.add(content_hash) - # Create tree item - item = QTreeWidgetItem(parent_item, [title]) - item.setData(0, Qt.UserRole, href) + if src and not is_empty and not is_duplicate: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + is_checked = src in self.checked_chapters + item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked) + elif is_duplicate: + # Mark as duplicate and remove checkbox + item.setText(0, f"{title} (Duplicate)") + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + elif children: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Unchecked) + else: + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) - # Make item checkable if it has content - has_content = href and href in self.content_texts and self.content_texts[href].strip() - if has_content or children: - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - is_checked = href and href in self.checked_chapters - item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked) - else: - item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + if children: + self._build_epub_tree_from_nav(children, item, seen_content_hashes) - # Process children - if children: - build_tree(children, item) + def _build_epub_tree_fallback(self, toc_entries, parent_item): + for entry in toc_entries: + href, title, children = None, "Unknown", [] + entry_obj = None + if isinstance(entry, ebooklib.epub.Link): + href, title = entry.href, entry.title or entry.href + entry_obj = entry + elif isinstance(entry, tuple) and len(entry) >= 1: + section_or_link = entry[0] + entry_obj = section_or_link + if isinstance(section_or_link, ebooklib.epub.Section): + title = section_or_link.title + href = getattr(section_or_link, "href", None) + elif isinstance(section_or_link, ebooklib.epub.Link): + href, title = ( + section_or_link.href, + section_or_link.title or section_or_link.href, + ) - build_tree(self.book.toc, self.treeWidget) + if len(entry) > 1 and isinstance(entry[1], list): + children = entry[1] + else: + continue + + item = QTreeWidgetItem(parent_item, [title]) + item.setData(0, Qt.UserRole, href) + + has_content = ( + href and href in self.content_texts and self.content_texts[href].strip() + ) + + if has_content or children: + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + is_checked = href and href in self.checked_chapters + item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked) + else: + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + + if children: + self._build_epub_tree_fallback(children, item) def _build_pdf_tree(self): - """Build the tree for PDF files combining outline/bookmarks with pages""" - # Get outline and store if this PDF has bookmarks outline = self.pdf_doc.get_toc() self.has_pdf_bookmarks = bool(outline) if not outline: - # No bookmarks/outline available, create a simple page list self._build_pdf_pages_tree() return - # Process the outline to determine page ranges bookmark_pages = [] page_to_bookmark = {} next_page_boundaries = {} - # Track added pages to prevent duplicates added_pages = set() - # Extract page numbers from outline recursively def extract_page_numbers(entries): for entry in entries: - if ( - len(entry) >= 3 - ): # Valid outline entry has at least level, title, page + if len(entry) >= 3: _, title, page = entry[:3] - # Convert page reference to actual page number (0-based) page_num = ( page - 1 if isinstance(page, int) @@ -470,27 +955,21 @@ class HandlerDialog(QDialog): ) bookmark_pages.append((page_num, title)) - # Process children recursively if len(entry) > 3 and isinstance(entry[3], list): extract_page_numbers(entry[3]) extract_page_numbers(outline) bookmark_pages.sort() - # Determine page ranges for each bookmark 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 - # Helper function to build the tree structure recursively def build_outline_tree(entries, parent_item): for entry in entries: - if ( - len(entry) >= 3 - ): # Valid outline entry has at least level, title, page + if len(entry) >= 3: entry_level, title, page = entry[:3] - # Get actual page number (0-based) page_num = ( page - 1 if isinstance(page, int) @@ -498,7 +977,6 @@ class HandlerDialog(QDialog): ) page_id = f"page_{page_num+1}" - # Create bookmark item bookmark_item = QTreeWidgetItem(parent_item, [title]) bookmark_item.setData(0, Qt.UserRole, page_id) bookmark_item.setFlags( @@ -513,15 +991,10 @@ class HandlerDialog(QDialog): ), ) - # Mark this page as added added_pages.add(page_num) - # Add child pages that belong to this bookmark next_page = next_page_boundaries.get(page_num, len(self.pdf_doc)) - for sub_page_num in range( - page_num + 1, next_page - ): # Skip the bookmark page itself - # Skip if this page is a bookmark itself or already added as a child elsewhere + 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 @@ -531,7 +1004,6 @@ class HandlerDialog(QDialog): page_id = f"page_{sub_page_num+1}" page_title = f"Page {sub_page_num+1}" - # Try to get a better title from the first line of content page_text = self.content_texts.get(page_id, "").strip() if page_text: first_line = page_text.split("\n", 1)[0].strip() @@ -550,22 +1022,15 @@ class HandlerDialog(QDialog): ), ) - # Mark this page as added added_pages.add(sub_page_num) - # Process child bookmarks if any if len(entry) > 3 and isinstance(entry[3], list): build_outline_tree(entry[3], bookmark_item) - # Start building the tree from the outline build_outline_tree(outline, self.treeWidget) - # Add pages not covered by bookmarks - covered_pages = set( - added_pages - ) # Use our tracked pages to find uncategorized ones + covered_pages = set(added_pages) - # Add remaining pages as top-level items under "Other Pages" uncategorized_pages = [ i for i in range(len(self.pdf_doc)) if i not in covered_pages ] @@ -573,7 +1038,6 @@ class HandlerDialog(QDialog): self._add_other_pages(uncategorized_pages) def _build_pdf_pages_tree(self): - """Build a simple page list for PDFs without bookmarks""" pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"]) pages_item.setFlags(pages_item.flags() & ~Qt.ItemIsUserCheckable) font = pages_item.font(0) @@ -584,7 +1048,6 @@ class HandlerDialog(QDialog): page_id = f"page_{page_num+1}" page_title = f"Page {page_num+1}" - # Try to get a better title from the first line of content page_text = self.content_texts.get(page_id, "").strip() if page_text: first_line = page_text.split("\n", 1)[0].strip() @@ -599,7 +1062,6 @@ class HandlerDialog(QDialog): ) def _add_other_pages(self, uncategorized_pages): - """Add uncategorized pages to the tree""" other_pages = QTreeWidgetItem(self.treeWidget, ["Other Pages"]) other_pages.setFlags(other_pages.flags() & ~Qt.ItemIsUserCheckable) font = other_pages.font(0) @@ -610,7 +1072,6 @@ class HandlerDialog(QDialog): page_id = f"page_{page_num+1}" page_title = f"Page {page_num+1}" - # Try to get better title from first line page_text = self.content_texts.get(page_id, "").strip() if page_text: first_line = page_text.split("\n", 1)[0].strip() @@ -625,11 +1086,9 @@ class HandlerDialog(QDialog): ) def _are_provided_checks_relevant(self): - """Check if provided checks are relevant to this book""" if not self.checked_chapters: return False - # Collect all identifiers present in tree all_identifiers = set() iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -640,55 +1099,49 @@ class HandlerDialog(QDialog): all_identifiers.add(identifier) iterator += 1 - # Check for any intersection with provided chapters return bool(self.checked_chapters.intersection(all_identifiers)) def _setup_ui(self): - """Set up the user interface""" - # Add preview panel self.previewEdit = QTextEdit(self) self.previewEdit.setReadOnly(True) self.previewEdit.setMinimumWidth(300) self.previewEdit.setStyleSheet("QTextEdit { border: none; }") - # Create informative text label below preview - 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.", self) + 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.', + self, + ) self.previewInfoLabel.setWordWrap(True) - self.previewInfoLabel.setStyleSheet("QLabel { color: #666; font-style: italic; }") + self.previewInfoLabel.setStyleSheet( + "QLabel { color: #666; font-style: italic; }" + ) - # Right panel layout (preview and info label) previewLayout = QVBoxLayout() previewLayout.setContentsMargins(0, 0, 0, 0) previewLayout.addWidget(self.previewEdit, 1) previewLayout.addWidget(self.previewInfoLabel, 0) - + rightWidget = QWidget() rightWidget.setLayout(previewLayout) - # Dialog buttons buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) - # Selection buttons item_type = "chapters" if self.file_type == "epub" else "pages" - # Auto-select button 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}") - # Selection buttons layout buttons_layout = QVBoxLayout() buttons_layout.setContentsMargins(0, 0, 0, 0) buttons_layout.setSpacing(10) - # Row 1: Auto-select auto_select_layout = QHBoxLayout() auto_select_layout.addWidget(self.auto_select_btn) buttons_layout.addLayout(auto_select_layout) - # Row 2: Select/Deselect All select_layout = QHBoxLayout() self.select_all_btn = QPushButton("Select all", self) self.select_all_btn.clicked.connect(self.select_all_chapters) @@ -698,7 +1151,6 @@ class HandlerDialog(QDialog): select_layout.addWidget(self.deselect_all_btn) buttons_layout.addLayout(select_layout) - # Row 3: Parent selection parent_layout = QHBoxLayout() self.select_parents_btn = QPushButton("Select parents", self) self.select_parents_btn.clicked.connect(self.select_parent_chapters) @@ -708,7 +1160,6 @@ class HandlerDialog(QDialog): parent_layout.addWidget(self.deselect_parents_btn) buttons_layout.addLayout(parent_layout) - # Row 4: Expand/Collapse expand_layout = QHBoxLayout() self.expand_all_btn = QPushButton("Expand All", self) self.expand_all_btn.clicked.connect(self.treeWidget.expandAll) @@ -718,13 +1169,11 @@ class HandlerDialog(QDialog): expand_layout.addWidget(self.collapse_all_btn) buttons_layout.addLayout(expand_layout) - # Left panel layout leftLayout = QVBoxLayout() leftLayout.setContentsMargins(0, 0, 5, 0) leftLayout.addLayout(buttons_layout) leftLayout.addWidget(self.treeWidget) - # Save options checkboxes checkbox_text = ( "Save each chapter separately" if self.file_type == "epub" @@ -746,31 +1195,25 @@ class HandlerDialog(QDialog): leftLayout.addWidget(buttons) - # Create left panel widget leftWidget = QWidget() leftWidget.setLayout(leftLayout) - # Create splitter for left panel and preview self.splitter = QSplitter(Qt.Horizontal) self.splitter.addWidget(leftWidget) - self.splitter.addWidget(rightWidget) # Now using rightWidget that includes preview and label + self.splitter.addWidget(rightWidget) self.splitter.setSizes([280, 420]) - # Set main layout mainLayout = QVBoxLayout(self) mainLayout.addWidget(self.splitter) self.setLayout(mainLayout) def _update_checkbox_states(self): - """Update checkboxes enabled states based on document type and selection""" - # Make sure checkboxes exist before trying to modify them if ( not hasattr(self, "save_chapters_checkbox") or not self.save_chapters_checkbox ): return - # For PDFs without bookmarks, always disable separate chapters option if ( self.file_type == "pdf" and hasattr(self, "has_pdf_bookmarks") @@ -780,11 +1223,9 @@ class HandlerDialog(QDialog): self.merge_chapters_checkbox.setEnabled(False) return - # Count checked items differently based on file type checked_count = 0 if self.file_type == "epub": - # For EPUB: Count all checked items iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -797,9 +1238,7 @@ class HandlerDialog(QDialog): break iterator += 1 - else: # PDF - # For PDF: Count distinct parent groups - # We need content from at least 2 different parents to enable "save separately" + else: parent_groups = set() iterator = QTreeWidgetItemIterator(self.treeWidget) @@ -809,30 +1248,24 @@ class HandlerDialog(QDialog): item.flags() & Qt.ItemIsUserCheckable and item.checkState(0) == Qt.Checked ): - # Get the parent (or the item itself if it's a top-level item) parent = item.parent() if parent and parent != self.treeWidget.invisibleRootItem(): - # Use memory address as a unique identifier since QTreeWidgetItem is not hashable parent_groups.add(id(parent)) else: - # Top-level items count as their own parent group parent_groups.add(id(item)) iterator += 1 checked_count = len(parent_groups) - # Enable save separately only if enough distinct groups are checked min_groups_required = 2 self.save_chapters_checkbox.setEnabled(checked_count >= min_groups_required) - # Enable merge only if save separately is enabled and checked self.merge_chapters_checkbox.setEnabled( self.save_chapters_checkbox.isEnabled() and self.save_chapters_checkbox.isChecked() ) def select_all_chapters(self): - """Select all chapters/pages""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -844,7 +1277,6 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def deselect_all_chapters(self): - """Deselect all chapters/pages""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -856,7 +1288,6 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def select_parent_chapters(self): - """Select only parent chapters/sections""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -868,7 +1299,6 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def deselect_parent_chapters(self): - """Deselect only parent chapters/sections""" self._block_signals = True iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -880,23 +1310,20 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def auto_select_chapters(self): - """Auto-select chapters/pages""" self._run_auto_check() def _run_auto_check(self): - """Run automatic content selection based on file type""" self._block_signals = True if self.file_type == "epub": self._run_epub_auto_check() - else: # PDF + else: self._run_pdf_auto_check() self._block_signals = False self._update_checked_set_from_tree() def _run_epub_auto_check(self): - """Auto-check logic for EPUB files""" iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -904,25 +1331,30 @@ class HandlerDialog(QDialog): iterator += 1 continue - href = item.data(0, Qt.UserRole) - lookup_href = href.split("#")[0] if href else None + src = item.data(0, Qt.UserRole) - # Check based on length (> 1000 chars) and parent status - if ( - lookup_href and self.content_lengths.get(lookup_href, 0) > 1000 - ) or item.childCount() > 0: + 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.Checked) - # Check children of parents - if item.childCount() > 0: + if is_parent: for i in range(item.childCount()): child = item.child(i) if child.flags() & Qt.ItemIsUserCheckable: - child.setCheckState(0, Qt.Checked) + child_src = child.data(0, Qt.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.Checked) + else: + item.setCheckState(0, Qt.Unchecked) + iterator += 1 def _run_pdf_auto_check(self): - """Auto-check logic for PDF files""" - # If there are no bookmarks, just check all pages if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks: iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -932,7 +1364,6 @@ class HandlerDialog(QDialog): iterator += 1 return - # For PDFs with bookmarks, select all bookmark items and non-empty pages iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -942,7 +1373,6 @@ class HandlerDialog(QDialog): identifier = item.data(0, Qt.UserRole) - # Always select bookmark items or non-empty pages if not identifier: iterator += 1 continue @@ -956,7 +1386,6 @@ class HandlerDialog(QDialog): iterator += 1 def _update_checked_set_from_tree(self): - """Update the internal set of checked items""" self.checked_chapters.clear() iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -966,18 +1395,15 @@ class HandlerDialog(QDialog): if identifier: self.checked_chapters.add(identifier) iterator += 1 - # Only update checkbox states if they exist if hasattr(self, "save_chapters_checkbox") and self.save_chapters_checkbox: self._update_checkbox_states() def handle_item_check(self, item): - """Handle item check/uncheck by updating children""" if self._block_signals: return self._block_signals = True - # Update children recursively if item.flags() & Qt.ItemIsUserCheckable: for i in range(item.childCount()): child = item.child(i) @@ -988,50 +1414,40 @@ class HandlerDialog(QDialog): self._update_checked_set_from_tree() def handle_item_double_click(self, item, column=0): - """Toggle check state when a non-parent item is double-clicked on the text, not the checkbox""" - # Only toggle items that are checkable and don't have children if item.flags() & Qt.ItemIsUserCheckable and item.childCount() == 0: - # Get the rectangle of the checkbox rect = self.treeWidget.visualItemRect(item) - checkbox_width = 20 # Approximate width of the checkbox + checkbox_width = 20 - # Get current mouse position mouse_pos = self.treeWidget.mapFromGlobal(self.treeWidget.cursor().pos()) - # Only toggle if click position is not on the checkbox if mouse_pos.x() > rect.x() + checkbox_width: - # Toggle the check state new_state = ( Qt.Unchecked if item.checkState(0) == Qt.Checked else Qt.Checked ) item.setCheckState(0, new_state) def update_preview(self, current): - """Update the preview panel with selected item content""" if not current: self.previewEdit.clear() return identifier = current.data(0, Qt.UserRole) - # Special case for the Information item if identifier == "info:bookinfo": self._display_book_info() return - # Get content based on file type text = None if self.file_type == "epub": - # For EPUB, always use the exact href from the TOC text = self.content_texts.get(identifier) - else: # PDF + else: text = self.content_texts.get(identifier) - # Display content or placeholder text - never remove titles if text is None: title = current.text(0) - # Add title to preview even if no content - self.previewEdit.setPlainText(f"{title}\n\n(No content available for this item)") + 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)") @@ -1039,18 +1455,15 @@ class HandlerDialog(QDialog): self.previewEdit.setPlainText(text) def _display_book_info(self): - """Display book metadata and cover image in the preview panel""" self.previewEdit.clear() html_content = "<html><body style='font-family: Arial, sans-serif;'>" - # Add cover image if available if self.book_metadata["cover_image"]: try: image_data = base64.b64encode(self.book_metadata["cover_image"]).decode( "utf-8" ) - # Determine image type image_type = "jpeg" if self.book_metadata["cover_image"].startswith(b"\x89PNG"): image_type = "png" @@ -1067,7 +1480,6 @@ class HandlerDialog(QDialog): except Exception as e: html_content += f"<p>Error displaying cover image: {str(e)}</p>" - # Add title, authors, publisher if self.book_metadata["title"]: html_content += ( f"<h2 style='text-align: center;'>{self.book_metadata['title']}</h2>" @@ -1082,12 +1494,10 @@ class HandlerDialog(QDialog): html_content += "<hr/>" - # Add description if self.book_metadata["description"]: desc = re.sub(r"<[^>]+>", "", self.book_metadata["description"]) html_content += f"<h3>Description:</h3><p>{desc}</p>" - # Add file type and page count for PDFs if self.file_type == "pdf": page_count = len(self.pdf_doc) if self.pdf_doc else 0 html_content += f"<p>File type: PDF<br>Page count: {page_count}</p>" @@ -1096,7 +1506,6 @@ class HandlerDialog(QDialog): self.previewEdit.setHtml(html_content) def _extract_book_metadata(self): - """Extract book metadata""" metadata = { "title": None, "authors": [], @@ -1106,7 +1515,6 @@ class HandlerDialog(QDialog): } if self.file_type == "epub": - # Extract EPUB metadata title_items = self.book.get_metadata("DC", "title") if title_items: metadata["title"] = title_items[0][0] @@ -1123,7 +1531,6 @@ class HandlerDialog(QDialog): if publisher_items: metadata["publisher"] = publisher_items[0][0] - # Try to find cover image for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): metadata["cover_image"] = item.get_content() break @@ -1133,8 +1540,7 @@ class HandlerDialog(QDialog): if "cover" in item.get_name().lower(): metadata["cover_image"] = item.get_content() break - else: # PDF - # Extract PDF metadata + else: pdf_info = self.pdf_doc.metadata if pdf_info: metadata["title"] = pdf_info.get("title", None) @@ -1154,7 +1560,6 @@ class HandlerDialog(QDialog): metadata["publisher"] = pdf_info.get("creator", None) - # Try to get cover image from first page if len(self.pdf_doc) > 0: try: pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2)) @@ -1165,54 +1570,52 @@ class HandlerDialog(QDialog): return metadata def get_selected_text(self): - """Get selected text and checked identifiers based on file type""" if self.file_type == "epub": return self._get_epub_selected_text() - else: # PDF + else: return self._get_pdf_selected_text() def _get_epub_selected_text(self): - """Get selected text from EPUB content""" - all_checked_hrefs = set() - chapter_titles = [] + all_checked_identifiers = set() + chapter_texts = [] - # Collect all checked hrefs in tree order to preserve chapter sequence + 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.Checked: - href = item.data(0, Qt.UserRole) - if href and href != "info:bookinfo": - all_checked_hrefs.add(href) - ordered_checked_items.append((item, href)) + identifier = item.data(0, Qt.UserRole) + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) iterator += 1 - # Process checked items in order - for item, href in ordered_checked_items: - # Always use the exact href (including fragment) from the TOC - text = self.content_texts.get(href) + 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) - title = re.sub(r"^\s*-\s*", "", title).strip() + title = re.sub(r"^\s*[-–—]\s*", "", title).strip() marker = f"<<CHAPTER_MARKER:{title}>>" - chapter_titles.append((title, marker + "\n" + text)) + chapter_texts.append(marker + "\n" + text) - return "\n\n".join([t[1] for t in chapter_titles]), all_checked_hrefs + full_text = "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers def _get_pdf_selected_text(self): - """Get selected text from PDF content""" all_checked_identifiers = set() included_text_ids = set() section_titles = [] all_content = [] - # Check if PDF has no bookmarks pdf_has_no_bookmarks = ( hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks ) - # Collect all checked identifiers iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1222,7 +1625,6 @@ class HandlerDialog(QDialog): all_checked_identifiers.add(identifier) iterator += 1 - # For PDFs without bookmarks, collect all content without chapter markers if pdf_has_no_bookmarks: sorted_page_ids = sorted( [id for id in all_checked_identifiers if id.startswith("page_")], @@ -1236,8 +1638,6 @@ class HandlerDialog(QDialog): included_text_ids.add(page_id) return "\n\n".join(all_content), all_checked_identifiers - # For PDFs with bookmarks, process content with parent-child relationships - # If only child pages are selected (not parent), use parent's name as chapter marker at first selected child iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1245,7 +1645,6 @@ class HandlerDialog(QDialog): parent_checked = item.checkState(0) == Qt.Checked parent_id = item.data(0, Qt.UserRole) parent_title = item.text(0) - # Gather checked children checked_children = [] for i in range(item.childCount()): child = item.child(i) @@ -1256,7 +1655,6 @@ class HandlerDialog(QDialog): and child_id not in included_text_ids ): checked_children.append((child, child_id)) - # If parent is checked, use old logic (parent marker, all content) 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: @@ -1269,7 +1667,6 @@ class HandlerDialog(QDialog): marker = f"<<CHAPTER_MARKER:{title}>>" section_titles.append((title, marker + "\n" + combined_text)) included_text_ids.add(parent_id) - # If only children are checked, use parent's name as marker at first child elif not parent_checked and checked_children: title = re.sub(r"^\s*-\s*", "", parent_title).strip() marker = f"<<CHAPTER_MARKER:{title}>>" @@ -1300,18 +1697,15 @@ class HandlerDialog(QDialog): return "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers def on_save_chapters_changed(self, state): - """Update the save_chapters_separately flag""" 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): - """Update the merge_chapters_at_end flag""" self.merge_chapters_at_end = bool(state) HandlerDialog._merge_chapters_at_end = self.merge_chapters_at_end def get_save_chapters_separately(self): - """Return whether to save chapters separately""" return ( self.save_chapters_separately if self.save_chapters_checkbox.isEnabled() @@ -1319,11 +1713,9 @@ class HandlerDialog(QDialog): ) def get_merge_chapters_at_end(self): - """Return whether to merge chapters at the end""" return self.merge_chapters_at_end def on_tree_context_menu(self, pos): - """Handle context menu on tree items""" item = self.treeWidget.itemAt(pos) if ( not item @@ -1348,7 +1740,6 @@ class HandlerDialog(QDialog): menu.exec_(self.treeWidget.mapToGlobal(pos)) def closeEvent(self, event): - """Clean up resources when the dialog is closed""" if self.pdf_doc is not None: self.pdf_doc.close() event.accept() diff --git a/abogen/constants.py b/abogen/constants.py index e407ae6..a8fdfef 100644 --- a/abogen/constants.py +++ b/abogen/constants.py @@ -102,16 +102,3 @@ SAMPLE_VOICE_TEXTS = { "p": "Este é um exemplo da voz selecionada.", "z": "这是所选语音的示例。", } - -# flags mapping for voice display -FLAGS = { - "a": "🇺🇸", - "b": "🇬🇧", - "e": "🇪🇸", - "f": "🇫🇷", - "h": "🇮🇳", - "i": "🇮🇹", - "j": "🇯🇵", - "p": "🇧🇷", - "z": "🇨🇳", -} diff --git a/abogen/conversion.py b/abogen/conversion.py index de12f65..248306b 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -4,16 +4,19 @@ import tempfile import time import chardet import charset_normalizer +from platformdirs import user_desktop_dir from PyQt5.QtCore import QThread, pyqtSignal, Qt from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox import soundfile as sf from utils import clean_text from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS +from voice_formulas import get_new_voice def get_sample_voice_text(lang_code): return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) + def detect_encoding(file_path): with open(file_path, "rb") as f: raw_data = f.read() @@ -149,6 +152,9 @@ class ConversionThread(QThread): self.max_subtitle_words = 50 # Default value, will be overridden from GUI 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: # Show configuration self.log_updated.emit("Configuration:") @@ -171,9 +177,7 @@ class ConversionThread(QThread): self.log_updated.emit(f"- Output format: {self.output_format}") self.log_updated.emit(f"- Save option: {self.save_option}") if self.replace_single_newlines: - self.log_updated.emit( - f"- Replace single newlines: Yes" - ) + self.log_updated.emit(f"- Replace single newlines: Yes") # Display save_chapters_separately flag if it's set if hasattr(self, "save_chapters_separately"): @@ -205,7 +209,9 @@ class ConversionThread(QThread): 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: + with open( + self.file_name, "r", encoding=encoding, errors="replace" + ) as file: text = file.read() # Clean up text using utility function @@ -278,11 +284,19 @@ class ConversionThread(QThread): base_path = self.display_path if self.display_path else self.file_name base_name = os.path.splitext(os.path.basename(base_path))[0] if self.save_option == "Save to Desktop": - parent_dir = os.path.join(os.path.expanduser("~"), "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 while True: @@ -342,9 +356,16 @@ class ConversionThread(QThread): # Set split_pattern to \n+ which will split on one or more newlines split_pattern = r"\n+" + + # 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 + for result in tts( chapter_text, - voice=self.voice, + voice=loaded_voice, speed=self.speed, split_pattern=split_pattern, ): @@ -477,7 +498,9 @@ class ConversionThread(QThread): chapter_srt_path = os.path.join( chapters_out_dir, f"{chapter_filename}.srt" ) - with open(chapter_srt_path, "w", encoding="utf-8", errors="replace") as srt_file: + with open( + chapter_srt_path, "w", encoding="utf-8", errors="replace" + ) as srt_file: for i, (start, end, text) in enumerate( chapter_subtitle_entries, 1 ): @@ -518,7 +541,9 @@ class ConversionThread(QThread): srt_path = os.path.splitext(out_path)[0] + ".srt" sf.write(out_path, audio, 24000, format=self.output_format) if self.subtitle_mode != "Disabled": - with open(srt_path, "w", encoding="utf-8", errors="replace") as srt_file: + with open( + srt_path, "w", encoding="utf-8", errors="replace" + ) as srt_file: for i, (start, end, text) in enumerate(subtitle_entries, 1): srt_file.write( f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" @@ -661,7 +686,14 @@ class VoicePreviewThread(QThread): error = pyqtSignal(str) def __init__( - self, np_module, kpipeline_class, lang_code, voice, speed, parent=None + self, + np_module, + kpipeline_class, + lang_code, + voice, + speed, + use_gpu=False, + parent=None, ): super().__init__(parent) self.np_module = np_module @@ -669,17 +701,26 @@ class VoicePreviewThread(QThread): self.lang_code = lang_code self.voice = voice self.speed = speed - self.temp_wav = None + self.use_gpu = use_gpu def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" + ) try: + device = "cuda" if self.use_gpu else "cpu" tts = self.kpipeline_class( - lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M" + 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=self.voice, speed=self.speed, split_pattern=None + sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None ): audio_segments.append(result.audio) if audio_segments: diff --git a/abogen/gui.py b/abogen/gui.py index d6fe50f..3f7ceec 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -3,6 +3,7 @@ import time import tempfile import platform import base64 +import re from PyQt5.QtWidgets import ( QApplication, QWidget, @@ -65,11 +66,13 @@ from constants import ( GITHUB_URL, PROGRAM_DESCRIPTION, LANGUAGE_DESCRIPTIONS, - FLAGS, VOICES_INTERNAL, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, ) from threading import Thread +from voice_formula_gui import VoiceFormulaDialog +from platformdirs import user_desktop_dir +from voice_profiles import load_profiles # Import ctypes for Windows-specific taskbar icon if platform.system() == "Windows": @@ -456,8 +459,17 @@ class abogen(QWidget): self.selected_chapters = set() self.last_opened_book_path = None # Track the last opened book path self.last_output_path = None - self.selected_voice = self.config.get("selected_voice", "af_heart") - self.selected_lang = self.selected_voice[0] + # 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( @@ -465,8 +477,8 @@ class abogen(QWidget): ) # Default max words per subtitle self.selected_format = self.config.get("selected_format", "wav") self.use_gpu = self.config.get( - "use_gpu", True - ) # Load GPU setting with default True + "use_gpu", True # Load GPU setting with default True + ) self.replace_single_newlines = self.config.get("replace_single_newlines", False) self._pending_close_event = None self.gpu_ok = False # Initialize GPU availability status @@ -486,17 +498,32 @@ class abogen(QWidget): self.initUI() self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) self.update_speed_label() - idx = self.voice_combo.findData(self.selected_voice) + # 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 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_label.show() self.subtitle_combo.setCurrentText(self.subtitle_mode) - # Enable/disable subtitle options based on selected language - self.subtitle_combo.setEnabled( - self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) + # 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) # loading gif for preview button loading_gif_path = get_resource_path("abogen.assets", "loading.gif") if loading_gif_path: @@ -556,19 +583,26 @@ class abogen(QWidget): voice_layout.addWidget(QLabel("Select Voice:", self)) voice_row = QHBoxLayout() self.voice_combo = QComboBox(self) - for v in VOICES_INTERNAL: - flag = FLAGS.get(v[0], "") - self.voice_combo.addItem(f"{flag} {v}", v) + self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed) self.voice_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.voice_combo.currentIndexChanged.connect(self.on_voice_changed) 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' ) voice_row.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_row.addWidget(self.btn_voice_formula_mixer) + # Play/Stop icons def make_icon(color, shape): pix = QPixmap(20, 20) @@ -627,10 +661,9 @@ class abogen(QWidget): ) self.subtitle_combo.setCurrentText(self.subtitle_mode) self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) - # Enable/disable subtitle options based on selected language - self.subtitle_combo.setEnabled( - self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) + # 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) subtitle_layout.addWidget(self.subtitle_combo) controls_layout.addLayout(subtitle_layout) # Output format @@ -752,6 +785,7 @@ class abogen(QWidget): container_layout.addWidget(self.finish_widget) outer_layout.addWidget(container) self.setLayout(outer_layout) + self.populate_profiles_in_voice_combo() def open_file_dialog(self): if self.is_converting: @@ -934,6 +968,87 @@ class abogen(QWidget): else: self.subtitle_combo.setEnabled(False) + 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 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.subtitle_combo.setEnabled( + self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + ) + 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) + if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: + self.subtitle_combo.setEnabled(True) + self.subtitle_mode = self.subtitle_combo.currentText() + else: + self.subtitle_combo.setEnabled(False) + + def update_subtitle_combo_for_profile(self, profile_name): + from 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) + + 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() @@ -1039,11 +1154,32 @@ class abogen(QWidget): else self.subtitle_mode ) + # if voice formula is not None, use the selected voice + if self.mixed_voice_state: + formula_components = [ + f"{name}*{weight}" for name, weight in self.mixed_voice_state + ] + voice_formula = " + ".join(filter(None, formula_components)) + else: + voice_formula = self.selected_voice + # determine selected language: use profile setting if profile selected, else voice code + if self.selected_profile_name: + from 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 + self.conversion_thread = ConversionThread( self.selected_file, - self.selected_lang, + selected_lang, speed, - self.selected_voice, + voice_formula, self.save_option, self.selected_output_folder, subtitle_mode=actual_subtitle_mode, @@ -1061,7 +1197,9 @@ class abogen(QWidget): # Pass max_subtitle_words from config self.conversion_thread.max_subtitle_words = self.max_subtitle_words # Pass replace_single_newlines setting - self.conversion_thread.replace_single_newlines = self.replace_single_newlines + self.conversion_thread.replace_single_newlines = ( + self.replace_single_newlines + ) # Pass chapter count for EPUB or PDF files if self.selected_file_type in ["epub", "pdf"] and hasattr( self, "selected_chapters" @@ -1292,6 +1430,7 @@ class abogen(QWidget): 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 self.loading_movie.start() @@ -1306,23 +1445,43 @@ class abogen(QWidget): # stop loading animation and restore icon on error if error: self.loading_movie.stop() - self.btn_preview.setIcon(self.play_icon) 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 - lang, voice, speed = ( - self.selected_voice[0], - self.selected_voice, - self.speed_slider.value() / 100.0, - ) + # 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 if available, else first voice code + if self.selected_profile_name: + from voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang = entry.get("language") + else: + lang = None + 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 + self.preview_thread = VoicePreviewThread( - np_module, kpipeline_class, lang, voice, speed + np_module, kpipeline_class, lang, voice, speed, self.use_gpu ) self.preview_thread.finished.connect(self._play_preview_audio) self.preview_thread.error.connect(self._preview_error) @@ -1332,13 +1491,15 @@ class abogen(QWidget): temp_wav = self.preview_thread.temp_wav if not temp_wav: self.loading_movie.stop() - self.btn_preview.setIcon(self.play_icon) + self._show_error_message_box( "Preview Error", "Preview error: No audio generated." ) - self.btn_preview.setEnabled(True) + 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) return # stop loading animation, switch to stop icon @@ -1388,6 +1549,7 @@ class abogen(QWidget): 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): @@ -1524,7 +1686,7 @@ class abogen(QWidget): menu.addAction(add_shortcut_action) # Add reveal config option - reveal_config_action = QAction("Open config.json directory", self) + reveal_config_action = QAction("Open configuration directory", self) reveal_config_action.triggered.connect(self.reveal_config_in_explorer) menu.addAction(reveal_config_action) @@ -1597,7 +1759,7 @@ class abogen(QWidget): try: # where to put the .lnk - desktop = os.path.join(os.environ.get("USERPROFILE", ""), "Desktop") + desktop = user_desktop_dir() shortcut_path = os.path.join(desktop, "abogen.lnk") # target exe @@ -1657,6 +1819,38 @@ class abogen(QWidget): self.config["check_updates"] = checked save_config(self.config) + def show_voice_formula_dialog(self): + # If a profile is selected in combo, open mixer with that profile + initial_state = None + selected_profile = self.selected_profile_name + if selected_profile: + entry = load_profiles().get(selected_profile, {}) + # support new dict format + 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 + else: + initial_state = [(self.selected_voice, 1.0)] if self.selected_voice else [] + dialog = VoiceFormulaDialog( + self, initial_state=initial_state, selected_profile=selected_profile + ) + if dialog.exec_() == QDialog.Accepted: + # After OK, select the profile in combo and update config + 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_about_dialog(self): """Show an About dialog with program information including GitHub link.""" # Get application icon for dialog diff --git a/abogen/utils.py b/abogen/utils.py index 56d90dc..f65bc8e 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -32,6 +32,14 @@ def get_resource_path(package, resource): except (ImportError, FileNotFoundError): pass + # Always try to resolve as a relative path from this file + parts = package.split(".") + rel_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource + ) + if os.path.exists(rel_path): + return rel_path + # Fallback to local file system try: # Extract the subdirectory from package name (e.g., 'assets' from 'abogen.assets') @@ -50,7 +58,7 @@ def get_resource_path(package, resource): def get_version(): """Return the current version of the application.""" try: - with open(get_resource_path("abogen", "VERSION"), "r") as f: + with open(get_resource_path("/", "VERSION"), "r") as f: return f.read().strip() except Exception: return "Unknown" diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py new file mode 100644 index 0000000..dc92b5d --- /dev/null +++ b/abogen/voice_formula_gui.py @@ -0,0 +1,1289 @@ +import json +import os +from PyQt5.QtWidgets import ( + QDialog, + QVBoxLayout, + QCheckBox, + QLabel, + QHBoxLayout, + QDoubleSpinBox, + QSlider, + QScrollArea, + QWidget, + QPushButton, + QSizePolicy, + QMessageBox, + QFrame, + QLayout, + QStyle, + QListWidget, + QListWidgetItem, + QInputDialog, + QFileDialog, + QSplitter, + QMenu, + QAction, + QComboBox, +) +from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize +from PyQt5.QtGui import QPixmap, QIcon, QColor +from constants import ( + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + LANGUAGE_DESCRIPTIONS, +) +from utils import get_resource_path +from 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 = 1000 +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.Orientations(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.PushButton, QSizePolicy.PushButton, Qt.Horizontal + ) + layout_spacing_y = style.layoutSpacing( + QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.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.Expanding, QSizePolicy.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.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.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.KeepAspectRatio, Qt.SmoothTransformation) + ) + gender_pixmap = QPixmap(gender_icon_path) + gender_label.setPixmap( + gender_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.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.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.Vertical) + self.slider.setRange(0, 100) + self.slider.setValue(int(initial_weight * 100)) + self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + self.slider.setFixedWidth(SLIDER_WIDTH) + + # 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.AlignCenter)) + + slider_center_layout = QHBoxLayout() + slider_center_layout.addWidget(self.slider, alignment=Qt.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.AlignCenter)) + slider_layout.setStretch(2, 1) + + layout.addLayout(slider_layout, stretch=1) + self.setLayout(layout) + self.toggle_inputs() + + 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: #e0e0e0; 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 = QPushButton("×", self) + self.delete_button.setFixedSize(16, 16) + self.delete_button.setStyleSheet( + """ + QPushButton { + background-color: #ff5555; + 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.NoFocus) + self.delete_button.setAttribute(Qt.WA_TransparentForMouseEvents, False) + self.delete_button.setCursor(Qt.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) + 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.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() + 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.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.Window | Qt.WindowCloseButtonHint | Qt.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) + 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.Sunken) + mixer_layout.addWidget(separator) + + # Voice list scroll area + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scroll_area.setVerticalScrollBarPolicy(Qt.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.Expanding, QSizePolicy.Expanding + ) + self.scroll_area.setWidget(self.voice_list_widget) + mixer_layout.addWidget(self.scroll_area, stretch=1) + + # Buttons + button_layout = QHBoxLayout() + clear_all_button = QPushButton("Clear all") + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + + # Set OK button as default + ok_button.setDefault(True) + ok_button.setFocus() + + # Connect buttons + clear_all_button.clicked.connect(self.clear_all_voices) + ok_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) + + button_layout.addStretch() + button_layout.addWidget(clear_all_button) + button_layout.addWidget(ok_button) + button_layout.addWidget(cancel_button) + mixer_layout.addLayout(button_layout) + + self.add_voices(initial_state or []) + self.update_weighted_sums() + self.update_subtitle_combo_enabled() + + # 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()) + vm.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled) + vm.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled) + + def keyPressEvent(self, event): + # Bind Delete key to delete_profile when a profile is selected + if event.key() == Qt.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.Save | QMessageBox.Discard | QMessageBox.Cancel, + QMessageBox.Save, + ) + if ret == QMessageBox.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.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.Warning) + box.setWindowTitle("Unsaved Changes") + box.setText( + "You have unsaved changes in your profile. Do you want to save the changes?" + ) + box.setStandardButtons( + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel + ) + box.setDefaultButton(QMessageBox.Save) + ret = box.exec_() + if ret == QMessageBox.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.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() + ) + voice_mixer.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled) + voice_mixer.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled) + return voice_mixer + + def handle_voice_checkbox(self, voice_mixer, state): + if state == Qt.Checked: + 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) + + 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'<b><span style="color:#1976d2">{name}: {percentage:.1f}%</span></b>', + name, + ) + voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.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 update_subtitle_combo_enabled(self): + # Only enable subtitle_combo if at least one selected voice is from supported languages + selected_langs = set() + for vm in self.voice_mixers: + if vm.checkbox.isChecked() and vm.spin_box.value() > 0: + lang_code = vm.voice_name[0] + selected_langs.add(lang_code) + enable = any( + lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + for lang in selected_langs + ) + if self.subtitle_combo: + self.subtitle_combo.setEnabled(enable) + + 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.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() + self.update_subtitle_combo_enabled() + + 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): + if self.profile_list.count() <= 1: + return False + zero = [] + profiles = load_profiles() + 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?" + # Use Delete instead of Ignore + reply = QMessageBox.question( + self, + "Invalid Profiles", + msg, + QMessageBox.Yes | QMessageBox.Cancel, + QMessageBox.Yes, + ) + if reply == QMessageBox.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): + # 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): + # 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 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): + name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") + if ok and name: + 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.") + return + 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() + 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 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.Yes | QMessageBox.No + ) + if reply != QMessageBox.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.Yes | QMessageBox.No + ) + if reply != QMessageBox.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): + old = item.text().lstrip("*") + new, ok = QInputDialog.getText( + self, "Rename Profile", f"Profile name:", text=old + ) + if ok and new and new != old: + profiles = load_profiles() + if new in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + return + profiles[new] = profiles.pop(old) + save_profiles(profiles) + item.setText(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 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.Yes | QMessageBox.No, + ) + if reply == QMessageBox.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): + 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): + 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": + item.setBackground(QColor("#fff59d")) # yellow + elif item.text().startswith("*"): + item.setBackground(QColor("#fff59d")) # yellow + else: + weights = profiles.get(name, {}).get("voices", []) + # Defensive: only sum if weights is a list of (voice, weight) pairs + 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: + item.setBackground(QColor("#ffcdd2")) # light red + else: + item.setBackground(QColor("white")) + self.update_profile_save_buttons() diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py new file mode 100644 index 0000000..c28dfbe --- /dev/null +++ b/abogen/voice_formulas.py @@ -0,0 +1,57 @@ +import re +from constants import VOICES_INTERNAL + + +# Calls parsing and loads the voice to gpu or cpu +def get_new_voice(pipeline, formula, use_gpu): + try: + weighted_voice = parse_voice_formula(pipeline, formula) + # device = "cuda" if use_gpu else "cpu" + # Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)" + # error when the device is gpu. So disabling this for now. + device = "cpu" + return weighted_voice.to(device) + except Exception as e: + raise ValueError(f"Failed to create voice: {str(e)}") + + +# Parse the formula and get the combined voice tensor +def parse_voice_formula(pipeline, formula): + if not formula.strip(): + raise ValueError("Empty voice formula") + + # Initialize the weighted sum + weighted_sum = None + + total_weight = calculate_sum_from_formula(formula) + + # Split the formula into terms + voices = formula.split("+") + + for term in voices: + # Parse each term (format: "voice_name*0.333") + voice_name, weight = term.strip().split("*") + weight = float(weight.strip()) + # normalize the weight + weight /= total_weight if total_weight > 0 else 1.0 + voice_name = voice_name.strip() + + # Get the voice tensor + if voice_name not in VOICES_INTERNAL: + raise ValueError(f"Unknown voice: {voice_name}") + + voice_tensor = pipeline.load_single_voice(voice_name) + + # Add to weighted sum + if weighted_sum is None: + weighted_sum = weight * voice_tensor + else: + weighted_sum += weight * voice_tensor + + return weighted_sum + + +def calculate_sum_from_formula(formula): + weights = re.findall(r"\* *([\d.]+)", formula) + total_sum = sum(float(weight) for weight in weights) + return total_sum diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py new file mode 100644 index 0000000..a77a98a --- /dev/null +++ b/abogen/voice_profiles.py @@ -0,0 +1,59 @@ +import os +import json +from utils import get_user_config_path, get_resource_path + + +def _get_profiles_path(): + config_path = get_user_config_path() + config_dir = os.path.dirname(config_path) + return os.path.join(config_dir, "voice_profiles.json") + + +def load_profiles(): + """Load all voice profiles from JSON file.""" + path = _get_profiles_path() + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if isinstance(data, dict) and "abogen_voice_profiles" in data: + return data["abogen_voice_profiles"] + # fallback: treat as profiles dict + if isinstance(data, dict): + return data + except Exception: + return {} + return {} + + +def save_profiles(profiles): + """Save all voice profiles to JSON file.""" + path = _get_profiles_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + # always save with abogen_voice_profiles wrapper + json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def delete_profile(name): + """Remove a profile by name.""" + profiles = load_profiles() + if name in profiles: + del profiles[name] + save_profiles(profiles) + + +def duplicate_profile(src, dest): + """Duplicate an existing profile.""" + profiles = load_profiles() + if src in profiles and dest: + profiles[dest] = profiles[src] + save_profiles(profiles) + + +def export_profiles(export_path): + """Export all profiles to specified JSON file.""" + profiles = load_profiles() + with open(export_path, "w", encoding="utf-8") as f: + json.dump({"abogen_voice_profiles": profiles}, f, indent=2) diff --git a/demo/abogen.png b/demo/abogen.png index 16adbaf..b11c5dd 100644 Binary files a/demo/abogen.png and b/demo/abogen.png differ diff --git a/demo/voice_mixer.png b/demo/voice_mixer.png new file mode 100644 index 0000000..6b40823 Binary files /dev/null and b/demo/voice_mixer.png differ diff --git a/pyproject.toml b/pyproject.toml index 03491fe..3f5ff16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,10 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko dependencies = [ "PyQt5>=5.15.11", "kokoro>=0.9.4", - "ebooklib>=0.18", + "ebooklib>=0.19", "beautifulsoup4>=4.13.4", "PyMuPDF>=1.25.5", + "platformdirs>=4.3.7", "soundfile>=0.13.1", "pygame>=2.6.1", "charset_normalizer>=3.4.1",