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"
{html_content}
", "html.parser")
+ target_element = temp_soup.find(id=fragment_id)
+ if target_element:
+ tag_str = str(target_element)
+ pos = html_content.find(tag_str[: min(len(tag_str), 200)])
+ if pos != -1:
+ 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 = ""
- # 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"
Error displaying cover image: {str(e)}
"
- # Add title, authors, publisher
if self.book_metadata["title"]:
html_content += (
f"
{self.book_metadata['title']}
"
@@ -1082,12 +1494,10 @@ class HandlerDialog(QDialog):
html_content += "
"
- # Add description
if self.book_metadata["description"]:
desc = re.sub(r"<[^>]+>", "", self.book_metadata["description"])
html_content += f"
Description:
{desc}
"
- # 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"
File type: PDF
Page count: {page_count}
"
@@ -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_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"<>"
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"<>"
@@ -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 1b601a1..404598f 100644
--- a/abogen/conversion.py
+++ b/abogen/conversion.py
@@ -4,6 +4,7 @@ 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
@@ -15,6 +16,7 @@ import static_ffmpeg
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()
@@ -150,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:")
@@ -172,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"):
@@ -206,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
@@ -279,7 +284,7 @@ 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:
@@ -361,9 +366,9 @@ 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:
+ if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
@@ -506,7 +511,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
):
@@ -557,7 +564,9 @@ class ConversionThread(QThread):
out_path = self._generate_m4b_with_chapters(out_path, chapters_time)
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"
@@ -740,7 +749,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
@@ -748,17 +764,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 72f9bcd..e8068b0 100644
--- a/abogen/gui.py
+++ b/abogen/gui.py
@@ -66,12 +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":
@@ -458,9 +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]
- self.mixed_voice_state = None # Store the mixed voice state
+ # 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(
@@ -468,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
@@ -489,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:
@@ -559,22 +583,20 @@ 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)
- self.btn_voice_formula_mixer.setText("🛠") # TODO add voice formula icon
+ 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; }")
@@ -639,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
@@ -764,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:
@@ -946,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()
@@ -1050,16 +1153,27 @@ class abogen(QWidget):
if not self.subtitle_combo.isEnabled()
else self.subtitle_mode
)
-
+
# if voice formula is not None, use the selected voice
if self.mixed_voice_state:
- formula_components = [f"{weight} * {name}" for name, weight in 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
- # selected language - use the first voice of the mix
- match = re.search(r'\b([a-z])', voice_formula)
- selected_lang = match.group(1)
+ # 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,
@@ -1083,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"
@@ -1314,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()
@@ -1328,23 +1445,46 @@ 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
+
+ # use same gpu/cpu logic as in conversion
+ gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
+
self.preview_thread = VoicePreviewThread(
- np_module, kpipeline_class, lang, voice, speed
+ np_module, kpipeline_class, lang, voice, speed, gpu_ok
)
self.preview_thread.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error)
@@ -1354,13 +1494,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
@@ -1406,10 +1548,16 @@ class abogen(QWidget):
def _preview_cleanup(self):
self.preview_playing = False
- self.btn_preview.setIcon(self.play_icon)
+ self.loading_movie.stop()
+ try:
+ self.loading_movie.frameChanged.disconnect()
+ except Exception:
+ pass # Ignore error if not connected
+ self.btn_preview.setIcon(self.play_icon)
self.btn_preview.setToolTip("Preview selected voice")
self.btn_preview.setEnabled(True)
self.voice_combo.setEnabled(True)
+ self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
self.btn_start.setEnabled(True)
def _preview_error(self, msg):
@@ -1546,7 +1694,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)
@@ -1619,7 +1767,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
@@ -1678,17 +1826,50 @@ class abogen(QWidget):
def toggle_check_updates(self, checked):
self.config["check_updates"] = checked
save_config(self.config)
-
+
def show_voice_formula_dialog(self):
- # get the current voice mix
- if self.mixed_voice_state is None:
- # if no voice mix is set, use the selected voice
- self.mixed_voice_state = [(self.selected_voice, 1.0)]
-
- dialog = VoiceFormulaDialog(self, initial_state=self.mixed_voice_state)
+ from voice_profiles import load_profiles
+ profiles = load_profiles()
+ initial_state = None
+ selected_profile = self.selected_profile_name
+ if selected_profile:
+ entry = profiles.get(selected_profile, {})
+ if isinstance(entry, dict):
+ initial_state = entry.get("voices", [])
+ else:
+ initial_state = entry
+ elif self.mixed_voice_state is not None:
+ initial_state = self.mixed_voice_state
+ elif self.selected_voice:
+ # If a single voice is selected, default to first profile if available
+ if profiles:
+ first_profile = next(iter(profiles))
+ entry = profiles[first_profile]
+ selected_profile = first_profile
+ if isinstance(entry, dict):
+ initial_state = entry.get("voices", [])
+ else:
+ initial_state = entry
+ else:
+ initial_state = []
+ else:
+ initial_state = []
+ dialog = VoiceFormulaDialog(
+ self, initial_state=initial_state, selected_profile=selected_profile
+ )
if dialog.exec_() == QDialog.Accepted:
+ if dialog.current_profile:
+ self.selected_profile_name = dialog.current_profile
+ self.config["selected_profile_name"] = dialog.current_profile
+ if "selected_voice" in self.config:
+ del self.config["selected_voice"]
+ save_config(self.config)
+ self.populate_profiles_in_voice_combo()
+ idx = self.voice_combo.findData(f"profile:{dialog.current_profile}")
+ if idx >= 0:
+ self.voice_combo.setCurrentIndex(idx)
self.mixed_voice_state = dialog.get_selected_voices()
-
+
def show_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
index 6ae2328..50da22b 100644
--- a/abogen/voice_formula_gui.py
+++ b/abogen/voice_formula_gui.py
@@ -1,3 +1,5 @@
+import json
+import os
from PyQt5.QtWidgets import (
QDialog,
QVBoxLayout,
@@ -9,181 +11,1318 @@ from PyQt5.QtWidgets import (
QScrollArea,
QWidget,
QPushButton,
- QSizePolicy
-)
-from PyQt5.QtCore import (
- Qt,
- QTimer
+ 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,
- FLAGS
+ 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 for voice names and flags
-VOICE_MIXER_WIDTH = 160
-FEMALE = "👩🦰"
-MALE = "👨"
+
+# 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_icon, initial_status=False, initial_weight=0.0):
+ def __init__(
+ self, voice_name, language_code, initial_status=False, initial_weight=0.0
+ ):
super().__init__()
-
self.voice_name = voice_name
-
- # Set fixed width for this widget
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)
- # Main Layout
layout = QVBoxLayout()
- # Checkbox at the top
+ # 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)
- voice_gender = self.get_voice_gender()
- name = voice_name[3:].capitalize()
- name_label = QLabel(f"{language_icon} {voice_gender} {name}")
- name_layout = QHBoxLayout()
- name_layout.addWidget(name_label)
- name_layout.setAlignment(name_label, Qt.AlignCenter)
- layout.addLayout(name_layout)
-
- # Input and Slider
+ # 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) # Set slider orientation to vertical
+ self.slider = QSlider(Qt.Vertical)
self.slider.setRange(0, 100)
self.slider.setValue(int(initial_weight * 100))
- self.slider.setFixedHeight(180)
- self.slider.valueChanged.connect(
- lambda val: self.spin_box.setValue(val / 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_layout.addWidget(self.slider, 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)
+ layout.addLayout(slider_layout, stretch=1)
self.setLayout(layout)
-
- # Disable inputs initially if the checkbox is unchecked
self.toggle_inputs()
-
+
def toggle_inputs(self):
- """Enable or disable inputs based on the checkbox state."""
is_enabled = self.checkbox.isChecked()
self.spin_box.setEnabled(is_enabled)
self.slider.setEnabled(is_enabled)
- def get_voice_gender(self):
- if self.voice_name in VOICES_INTERNAL:
- gender = self.voice_name[1]
- return FEMALE if gender == "f" else MALE
- return ""
-
- def get_formula_component(self):
- if self.checkbox.isChecked():
- weight = self.spin_box.value()
- return f"{weight:.3f} * {self.voice_name.lower().replace(' ', '_')}"
- return ""
-
-
def get_voice_weight(self):
- """Return the voice and its weight if selected."""
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):
+ 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.setFixedSize(1000, 500)
+ 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
- # Main Layout
- main_layout = QVBoxLayout()
+ # 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)
- # Header Label
- header_label = QLabel("Select Voices For the Mix and Adjust Weights")
- main_layout.addWidget(header_label)
+ # 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)
- # Scroll Area for Voice Panels
+ # 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.setFixedSize(1000, 400) # Keep scroll area height within 500
- 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.Fixed, QSizePolicy.Fixed)
- self.scroll_area.setWidget(self.voice_list_widget)
- main_layout.addWidget(self.scroll_area)
+ self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
+ self.scroll_area.viewport().installEventFilter(self)
- # Add buttons
+ 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")
- # Connect buttons to appropriate slots
- ok_button.clicked.connect(self.accept)
- cancel_button.clicked.connect(self.reject)
+ # Set OK button as default
+ ok_button.setDefault(True)
+ ok_button.setFocus()
- button_layout.addStretch() # Push buttons to the right
+ # 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)
- main_layout.addLayout(button_layout)
+ self.add_voices(initial_state or [])
+ self.update_weighted_sums()
- self.setLayout(main_layout)
-
- self.add_voices(initial_state)
+ # assemble splitter
+ splitter.addWidget(profile_widget)
+ splitter.addWidget(mixer_widget)
+ splitter.setStretchFactor(1, 1)
+ # set as main layout
+ self.setLayout(QHBoxLayout())
+ self.layout().addWidget(splitter)
+
+ # Connect profile actions
+ self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed)
+ # Track initial profile for proper dirty-state saving
+ self.last_profile_row = self.profile_list.currentRow()
+ self.btn_new_profile.clicked.connect(self.new_profile)
+ self.btn_export_profiles.clicked.connect(self.export_all_profiles)
+ self.btn_import_profiles.clicked.connect(self.import_profiles_dialog)
+ # Detect modifications in voice mixers
+ for vm in self.voice_mixers:
+ vm.spin_box.valueChanged.connect(self.mark_profile_modified)
+ vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified())
+
+ def keyPressEvent(self, event):
+ # Bind Delete key to delete_profile when a profile is selected
+ if event.key() == Qt.Key_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):
- """Add voice mixers to the dialog based on the initial state and scroll to first enabled one."""
- first_enabled_voice = None
-
+ first_enabled_voice = None
for voice in VOICES_INTERNAL:
- flag = FLAGS.get(voice[0], "")
- matching_voice = next((item for item in initial_state if item[0] == voice), None)
+ 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, flag, initial_status, initial_weight)
- # remember the first enabled voice
+ 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:
- self.scroll_to_voice(first_enabled_voice)
-
- def add_voice(self, voice_name, language_icon, initial_status=False, initial_weight=1.0):
- voice_mixer = VoiceMixer(voice_name, language_icon, initial_status, initial_weight)
+ QTimer.singleShot(
+ 0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice)
+ )
+
+ def add_voice(
+ self, voice_name, language_code, initial_status=False, initial_weight=1.0
+ ):
+ voice_mixer = VoiceMixer(
+ voice_name, language_code, initial_status, initial_weight
+ )
self.voice_mixers.append(voice_mixer)
self.voice_list_layout.addWidget(voice_mixer)
+ voice_mixer.checkbox.stateChanged.connect(
+ lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state)
+ )
+ voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums)
+ voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums)
+ voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified)
+ voice_mixer.checkbox.stateChanged.connect(
+ lambda *_: self.mark_profile_modified()
+ )
return voice_mixer
- def scroll_to_voice(self, voice_mixer):
- """Scroll the QScrollArea to ensure the given VoiceMixer is visible."""
- QTimer.singleShot(0, lambda: self.scroll_area.ensureWidgetVisible(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 the list of selected voices and their weights."""
- selected_voices = [
- mixer.get_voice_weight() for mixer in self.voice_mixers
+ return [
+ v
+ for v in (m.get_voice_weight() for m in self.voice_mixers)
+ if v and v[1] > 0
]
- return [voice for voice in selected_voices if voice] # Filter out None
+
+ 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'{name}: {percentage:.1f}%',
+ 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 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()
+
+ def save_profile_by_name(self, name):
+ profiles = load_profiles()
+ state = self._profile_states.get(name, None)
+ if state is not None:
+ # ensure dict format
+ if isinstance(state, dict):
+ entry = state
+ else:
+ entry = {"voices": state, "language": self.language_combo.currentData()}
+ profiles[name] = entry
+ save_profiles(profiles)
+ self._profile_dirty[name] = False
+ del self._profile_states[name]
+ self._virtual_new_profile = False
+ # Remove * marker
+ for i in range(self.profile_list.count()):
+ item = self.profile_list.item(i)
+ if item.text().lstrip("*") == name:
+ item.setText(name)
+ break
+ self.update_profile_list_colors()
+ self.update_profile_save_buttons()
+ self.update_weighted_sums()
+
+ def _handle_zero_weight_profiles(self):
+ profiles = load_profiles()
+ if len(profiles) < 1:
+ return False
+ zero = []
+ for i in range(self.profile_list.count()):
+ item = self.profile_list.item(i)
+ name = item.text().lstrip("*")
+ weights = profiles.get(name, {}).get("voices", [])
+ total = 0
+ if isinstance(weights, list):
+ for entry in weights:
+ if (
+ isinstance(entry, (list, tuple))
+ and len(entry) == 2
+ and isinstance(entry[1], (int, float))
+ ):
+ total += entry[1]
+ if total == 0:
+ zero.append((i, name))
+ if not zero:
+ return False
+ msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?"
+ reply = QMessageBox.question(
+ self,
+ "Invalid Profiles",
+ msg,
+ QMessageBox.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):
+ import re
+ while True:
+ name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:")
+ if not ok or not name:
+ break
+ name = name.strip() # Remove leading/trailing spaces
+ if not name:
+ continue
+ if not re.match(r'^[\w\- ]+$', name):
+ QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.")
+ continue
+ profiles = load_profiles()
+ # Remove 'New profile' placeholder if not persisted in JSON
+ if (
+ self.profile_list.count() == 1
+ and self.profile_list.item(0).text() == "New profile"
+ and "New profile" not in profiles
+ ):
+ self.profile_list.takeItem(0)
+ self._virtual_new_profile = False
+ self._profile_dirty.pop("New profile", None)
+ if name in profiles:
+ QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
+ continue
+ profiles[name] = {
+ "voices": [],
+ "language": self.language_combo.currentData(),
+ }
+ save_profiles(profiles)
+ self.profile_list.addItem(
+ QListWidgetItem(
+ QIcon(get_resource_path("abogen.assets", "profile.png")), name
+ )
+ )
+ self.profile_list.setCurrentRow(self.profile_list.count() - 1)
+ # reset UI mixers
+ for vm in self.voice_mixers:
+ vm.checkbox.setChecked(False)
+ vm.spin_box.setValue(1.0)
+ parent = self.parent()
+ if hasattr(parent, "populate_profiles_in_voice_combo"):
+ parent.populate_profiles_in_voice_combo()
+ break
+ self.update_profile_save_buttons()
+ self.update_profile_list_colors()
+ self.update_weighted_sums()
+
+ def export_all_profiles(self):
+ # Prevent export if any profile has total weight 0
+ profiles = load_profiles()
+ for name, weights in profiles.items():
+ total = 0
+ voices = weights.get("voices", [])
+ if isinstance(voices, list):
+ for entry in voices:
+ if (
+ isinstance(entry, (list, tuple))
+ and len(entry) == 2
+ and isinstance(entry[1], (int, float))
+ ):
+ total += entry[1]
+ if total == 0:
+ QMessageBox.warning(
+ self,
+ "Export Blocked",
+ f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.",
+ )
+ return
+ path, _ = QFileDialog.getSaveFileName(
+ self, "Export Profiles", "voice_profiles", "JSON Files (*.json)"
+ )
+ if path:
+ export_profiles(path)
+
+ def import_profiles_dialog(self):
+ path, _ = QFileDialog.getOpenFileName(
+ self, "Import Profiles", "", "JSON Files (*.json)"
+ )
+ if path:
+ from 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):
+ name = item.text().lstrip("*")
+ # block if profile has unsaved changes and it's not a virtual New profile
+ if self._profile_dirty.get(name, False) and not (self._virtual_new_profile and name == "New profile"):
+ QMessageBox.warning(
+ self, "Unsaved Changes", "Please save the profile before renaming."
+ )
+ return
+ old = item.text().lstrip("*")
+ import re
+ while True:
+ new, ok = QInputDialog.getText(
+ self, "Rename Profile", f"Profile name:", text=old
+ )
+ if not ok or not new or new == old:
+ break
+ new = new.strip() # Remove leading/trailing spaces
+ if not new:
+ continue
+ if not re.match(r'^[\w\- ]+$', new):
+ QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.")
+ continue
+
+ profiles = load_profiles()
+ if new in profiles:
+ QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
+ continue
+
+ # Special case for renaming the virtual "New profile"
+ if self._virtual_new_profile and name == "New profile":
+ # Create the profile with the new name
+ profiles[new] = {
+ "voices": self.get_selected_voices(),
+ "language": self.language_combo.currentData(),
+ }
+ save_profiles(profiles)
+
+ # Update tracking properties
+ self._virtual_new_profile = False
+ self._profile_dirty.pop("New profile", None)
+ self._profile_dirty[new] = False
+
+ # Update the current profile name
+ self.current_profile = new
+ item.setText(new)
+ else:
+ # Standard renaming for regular profiles
+ profiles[new] = profiles.pop(old)
+ save_profiles(profiles)
+ item.setText(new)
+
+ # Update the current profile name if it was renamed
+ if self.current_profile == old:
+ self.current_profile = new
+
+ parent = self.parent()
+ if hasattr(parent, "populate_profiles_in_voice_combo"):
+ parent.populate_profiles_in_voice_combo()
+ break
+ self.update_profile_save_buttons()
+ self.update_profile_list_colors()
+
+ def delete_profile(self, item):
+ name = item.text().lstrip("*")
+ if self._virtual_new_profile and name == "New profile":
+ row = self.profile_list.row(item)
+ self.profile_list.takeItem(row)
+ self._virtual_new_profile = False
+ self._profile_dirty.pop("New profile", None)
+ self.update_profile_save_buttons()
+ self.update_profile_list_colors()
+ return
+ reply = QMessageBox.question(
+ self,
+ "Delete Profile",
+ f"Delete profile '{name}'?",
+ QMessageBox.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):
+ name = item.text().lstrip("*")
+ # block duplicating if profile has unsaved changes
+ if self._profile_dirty.get(name, False):
+ QMessageBox.warning(
+ self, "Unsaved Changes", "Please save the profile before duplicating."
+ )
+ return
+ src = item.text().lstrip("*")
+ profiles = load_profiles()
+ base = f"{src}_duplicate"
+ new = base
+ i = 1
+ while new in profiles:
+ new = f"{base}{i}"
+ i += 1
+ duplicate_profile(src, new)
+ self.profile_list.addItem(
+ QListWidgetItem(
+ QIcon(get_resource_path("abogen.assets", "profile.png")), new
+ )
+ )
+ parent = self.parent()
+ if hasattr(parent, "populate_profiles_in_voice_combo"):
+ parent.populate_profiles_in_voice_combo()
+ self.update_profile_save_buttons()
+ self.update_profile_list_colors()
+
+ def update_profile_save_buttons(self):
+ # Remove all save buttons first
+ for i in range(self.profile_list.count()):
+ self.profile_list.setItemWidget(self.profile_list.item(i), None)
+ # Add save button to dirty profiles
+ for i in range(self.profile_list.count()):
+ item = self.profile_list.item(i)
+ name = item.text().lstrip("*")
+ if item.text().startswith("*"):
+ widget = SaveButtonWidget(
+ self.profile_list, name, self.save_profile_by_name
+ )
+ self.profile_list.setItemWidget(item, widget)
+
+ def update_profile_list_colors(self):
+ 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
index 8374395..c28dfbe 100644
--- a/abogen/voice_formulas.py
+++ b/abogen/voice_formulas.py
@@ -1,51 +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"
+ 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
+
+
+# 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('+')
-
+ voices = formula.split("+")
+
for term in voices:
- # Parse each term (format: "0.333 * voice_name")
- weight, voice_name = term.strip().split('*')
+ # 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)
+ weights = re.findall(r"\* *([\d.]+)", formula)
total_sum = sum(float(weight) for weight in weights)
- return total_sum
\ No newline at end of file
+ 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 ddf2099..83d973d 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",