diff --git a/CHANGELOG.md b/CHANGELOG.md index c328716..a55eed4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1 @@ -- Enhanced EPUB handling by treating all items in chapter list (including anchors) as chapters, improving navigation and organization for poorly structured books, mentioned by @Darthagnon in #4 -- Fixed the issue with some chapters in EPUB files had missing content. -- Fixed the issue with some EPUB files only having one chapter caused the program to ignore the entire book. -- Fixed "utf-8' codec can't decode byte" error, mentioned by @nigelp in #3 -- Added "Replace single newlines with spaces" option in the menu. This can be useful for texts that have imaginary line breaks. -- Improvements in code and documentation. \ No newline at end of file +- Added voice mixing functionality that enables combining multiple voices into a single "Mixed voice", as suggested by @PulsarFTW in #1. (Special thanks to @jborza for making this feature possible with his contributions in #5) \ No newline at end of file diff --git a/abogen/book_handler.py b/abogen/book_handler.py index e0cbeb0..3248f3d 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -152,7 +152,9 @@ class HandlerDialog(QDialog): if item: spine_docs.append(item.get_name()) # Use get_name() for href else: - print(f"Warning: Spine item with id '{item_id}' not found in book items.") + print( + f"Warning: Spine item with id '{item_id}' not found in book items." + ) doc_order = {href: i for i, href in enumerate(spine_docs)} @@ -160,7 +162,7 @@ class HandlerDialog(QDialog): href = item.get_name() if href in doc_order: # Only process docs in spine try: - html_content = item.get_content().decode('utf-8', errors='ignore') + html_content = item.get_content().decode("utf-8", errors="ignore") self.doc_content[href] = html_content except Exception: self.doc_content[href] = "" # Handle decoding errors @@ -193,8 +195,10 @@ class HandlerDialog(QDialog): return -1 # Anchor not found by simple string search # Backtrack to the start of the tag '<' - tag_start_pos = html_content.rfind('<', 0, pos) - return tag_start_pos if tag_start_pos != -1 else 0 # Default to 0 if '<' not found + tag_start_pos = html_content.rfind("<", 0, pos) + return ( + tag_start_pos if tag_start_pos != -1 else 0 + ) # Default to 0 if '<' not found def collect_toc_entries(entries): collected = [] @@ -213,23 +217,32 @@ class HandlerDialog(QDialog): 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 + href, title = ( + section_or_link.href, + section_or_link.title or section_or_link.href, + ) if len(entry) > 1 and isinstance(entry[1], list): children = entry[1] if href: - base_href, fragment = href.split('#', 1) if '#' in href else (href, None) - if base_href in doc_order: # Only consider entries pointing to spine documents + base_href, fragment = ( + href.split("#", 1) if "#" in href else (href, None) + ) + if ( + base_href in doc_order + ): # Only consider entries pointing to spine documents position = find_position(base_href, fragment) if position != -1: # Only add if position is valid - collected.append({ - "href": href, # Use the original href from TOC as the key - "title": title, - "doc_href": base_href, - "position": position, - "doc_order": doc_order[base_href] - }) + collected.append( + { + "href": href, # Use the original href from TOC as the key + "title": title, + "doc_href": base_href, + "position": position, + "doc_order": doc_order[base_href], + } + ) if children: collected.extend(collect_toc_entries(children)) @@ -247,14 +260,14 @@ class HandlerDialog(QDialog): all_content_html += self.doc_content.get(doc_href, "") if all_content_html: - soup = BeautifulSoup(all_content_html, 'html.parser') + soup = BeautifulSoup(all_content_html, "html.parser") text = clean_text(soup.get_text()).strip() - + # Use the first spine document as the identifier first_doc = spine_docs[0] self.content_texts[first_doc] = text self.content_lengths[first_doc] = len(text) - + # Create a synthetic TOC entry for tree building self.book.toc = [(epub.Link(first_doc, "Main Content", first_doc),)] return @@ -311,12 +324,12 @@ class HandlerDialog(QDialog): slice_html += self.doc_content.get(intermediate_doc_href, "") # 5. Extract text and store - slice_soup = BeautifulSoup(slice_html, 'html.parser') - + slice_soup = BeautifulSoup(slice_html, "html.parser") + # Remove sup and sub tags from the HTML before extracting text - for tag in slice_soup.find_all(['sup', 'sub']): + for tag in slice_soup.find_all(["sup", "sub"]): tag.decompose() - + text = clean_text(slice_soup.get_text()).strip() self.content_texts[current_href] = text # Store using the original TOC href self.content_lengths[current_href] = len(text) @@ -337,21 +350,23 @@ class HandlerDialog(QDialog): prefix_html += first_doc_html[:first_pos] if prefix_html.strip(): - prefix_soup = BeautifulSoup(prefix_html, 'html.parser') + prefix_soup = BeautifulSoup(prefix_html, "html.parser") # Remove sup and sub tags - for tag in prefix_soup.find_all(['sup', 'sub']): + for tag in prefix_soup.find_all(["sup", "sub"]): tag.decompose() prefix_text = clean_text(prefix_soup.get_text()).strip() - + if prefix_text: # Create a new chapter for content before the first TOC entry # Use a synthetic href to avoid collision with real TOC entries prefix_chapter_href = "prefix_content_chapter" self.content_texts[prefix_chapter_href] = prefix_text self.content_lengths[prefix_chapter_href] = len(prefix_text) - + # Add a new entry to the TOC for the prefix content - prefix_link = epub.Link(prefix_chapter_href, "Introduction", prefix_chapter_href) + prefix_link = epub.Link( + prefix_chapter_href, "Introduction", prefix_chapter_href + ) # Insert at beginning of TOC if isinstance(self.book.toc, list): self.book.toc.insert(0, (prefix_link,)) @@ -423,7 +438,11 @@ class HandlerDialog(QDialog): item.setData(0, Qt.UserRole, href) # Make item checkable if it has content - has_content = href and href in self.content_texts and self.content_texts[href].strip() + 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 @@ -652,16 +671,21 @@ class HandlerDialog(QDialog): 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) @@ -753,7 +777,9 @@ class HandlerDialog(QDialog): # 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 + ) # Now using rightWidget that includes preview and label self.splitter.setSizes([280, 420]) # Set main layout @@ -1031,7 +1057,9 @@ class HandlerDialog(QDialog): 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)") diff --git a/abogen/conversion.py b/abogen/conversion.py index e4d8ba4..1b2a4f5 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -15,6 +15,7 @@ from voice_formulas import get_new_voice def get_sample_voice_text(lang_code): return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) + def detect_encoding(file_path): with open(file_path, "rb") as f: raw_data = f.read() @@ -172,9 +173,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 +205,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 @@ -351,13 +352,13 @@ 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 - + for result in tts( chapter_text, voice=loaded_voice, @@ -493,7 +494,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 ): @@ -534,7 +537,9 @@ class ConversionThread(QThread): srt_path = os.path.splitext(out_path)[0] + ".srt" sf.write(out_path, audio, 24000, format=self.output_format) if self.subtitle_mode != "Disabled": - with open(srt_path, "w", encoding="utf-8", errors="replace") as srt_file: + with open( + srt_path, "w", encoding="utf-8", errors="replace" + ) as srt_file: for i, (start, end, text) in enumerate(subtitle_entries, 1): srt_file.write( f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" diff --git a/abogen/gui.py b/abogen/gui.py index 345b307..bf04f19 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -571,10 +571,10 @@ class abogen(QWidget): '"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 + self.btn_voice_formula_mixer.setText("🛠") # TODO add voice formula icon 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; }") @@ -1050,15 +1050,17 @@ 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"{weight} * {name}" 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) + match = re.search(r"\b([a-z])", voice_formula) selected_lang = match.group(1) self.conversion_thread = ConversionThread( @@ -1083,7 +1085,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" @@ -1678,17 +1682,17 @@ 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) if dialog.exec_() == QDialog.Accepted: 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/voice_formula_gui.py b/abogen/voice_formula_gui.py index b9fd006..acde5ac 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -10,251 +10,430 @@ from PyQt5.QtWidgets import ( QWidget, QPushButton, QSizePolicy, - QMessageBox -) -from PyQt5.QtCore import ( - Qt, - QTimer -) -from constants import ( - VOICES_INTERNAL, - FLAGS + QMessageBox, + QFrame, + QLayout, + QStyle, ) +from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize +from constants import VOICES_INTERNAL, FLAGS -# Constants for voice names and flags +# Constants VOICE_MIXER_WIDTH = 160 -SLIDER_WIDTH = 32 # Added slider width -MIN_WINDOW_WIDTH = 600 # Minimum window width -MIN_WINDOW_HEIGHT = 400 # Minimum window height -INITIAL_WINDOW_WIDTH = 1000 # Initial window width -INITIAL_WINDOW_HEIGHT = 500 # Initial window height -FEMALE = "👩‍🦰" -MALE = "👨" +SLIDER_WIDTH = 32 +MIN_WINDOW_WIDTH = 600 +MIN_WINDOW_HEIGHT = 400 +INITIAL_WINDOW_WIDTH = 1000 +INITIAL_WINDOW_HEIGHT = 500 +FEMALE, MALE = "👩‍🦰", "👨" + + +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_icon, 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 + # Checkbox 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() + # Voice name label + voice_gender = ( + FEMALE + if self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" + else MALE + ) 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) + name_layout.addWidget( + QLabel(f"{language_icon} {voice_gender} {name}"), alignment=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.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Fixed width, expanding height - self.slider.setFixedWidth(SLIDER_WIDTH) # Set fixed width for slider - 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, stretch=1) # Use stretch to expand slider + + 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, 2) # Make slider take all available vertical space + slider_layout.setStretch(2, 1) - layout.addLayout(slider_layout, stretch=1) # Make slider layout expand + 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.setStyleSheet( + """ + QPushButton { + background-color: red; + color: white; + border-radius: 8px; + font-weight: bold; + font-size: 12px; + border: none; + padding: 0px; + text-align: center; + } + QPushButton:hover { + background-color: #ff5555; + } + """ + ) + self.delete_button.setCursor(Qt.PointingHandCursor) + self.delete_button.hide() + + def resizeEvent(self, event): + super().resizeEvent(event) + 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): super().__init__(parent) - self.setWindowTitle("Voice Mixer") - self.setMinimumWidth(MIN_WINDOW_WIDTH) - self.setMinimumHeight(MIN_WINDOW_HEIGHT) + 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 - self.header_label = QLabel("Select Voices For the Mix and Adjust Weights") + # Header + 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) main_layout.addWidget(self.header_label) - # Weighted Sums Label - self.weighted_sums_label = QLabel() - self.weighted_sums_label.setAlignment(Qt.AlignCenter) # Center align the label - self.weighted_sums_label.setWordWrap(True) # Enable word wrap - main_layout.addWidget(self.weighted_sums_label) + # Error message + self.error_label = QLabel( + "No voices selected or all weights are 0. 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() + main_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) + main_layout.addWidget(self.weighted_sums_container) + + # Separator + separator = QFrame() + separator.setFrameShadow(QFrame.Sunken) + main_layout.addWidget(separator) + + # Voice list scroll area self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.scroll_area.viewport().installEventFilter(self) # Install event filter for wheel events + self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.scroll_area.viewport().installEventFilter(self) self.voice_list_widget = QWidget() - self.voice_list_layout = QHBoxLayout() + self.voice_list_layout = QHBoxLayout() self.voice_list_widget.setLayout(self.voice_list_layout) - self.voice_list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.voice_list_widget.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Expanding + ) self.scroll_area.setWidget(self.voice_list_widget) - main_layout.addWidget(self.scroll_area) + main_layout.addWidget(self.scroll_area, stretch=1) - # Add buttons + # 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) - main_layout.addLayout(button_layout) self.setLayout(main_layout) - - self.add_voices(initial_state) + + # Setup voices and display + self.add_voices(initial_state or []) self.update_weighted_sums() 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) + 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 0.0 + 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 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_icon, initial_status=False, initial_weight=1.0 + ): + voice_mixer = VoiceMixer( + voice_name, language_icon, initial_status, initial_weight + ) self.voice_mixers.append(voice_mixer) self.voice_list_layout.addWidget(voice_mixer) - # Connect signals to update weighted sums - voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) + 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) 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): - selected = [(m.voice_name, m.spin_box.value()) for m in self.voice_mixers if m.checkbox.isChecked()] + # 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: - lines = [f"{name}: {weight/total*100:.1f}%" for name, weight in selected] - joined = " | ".join(lines) + 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 + 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: - joined = "" - self.weighted_sums_label.setText(joined) # Remove the prefix + 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): - """Event filter to handle mouse wheel events for horizontal scrolling.""" - if (source is self.scroll_area.viewport() and event.type() == event.Wheel): - - # Check if the event is over a slider - # Check if mouse is over an enabled slider - if any(mixer.slider.underMouse() and mixer.slider.isEnabled() for mixer in self.voice_mixers): + 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 - # Convert vertical wheel movement to horizontal scrolling + + # Horizontal scrolling horiz_bar = self.scroll_area.horizontalScrollBar() - if event.angleDelta().y() > 0: - horiz_bar.setValue(horiz_bar.value() - 120) # Scroll left - else: - horiz_bar.setValue(horiz_bar.value() + 120) # Scroll right + delta = -120 if event.angleDelta().y() > 0 else 120 + horiz_bar.setValue(horiz_bar.value() + delta) return True return super().eventFilter(source, event) - def resizeEvent(self, event): - """Handle resize events to adjust slider heights""" - super().resizeEvent(event) - - # Calculate available height for sliders - header_height = self.header_label.height() + self.weighted_sums_label.height() - button_area_height = 50 # Approximate height for button area - available_height = self.height() - header_height - button_area_height - 220 # Add more margin for safety - - # Set slider height (don't make them too small) - slider_height = max(available_height, 100) - - # Update all sliders - for mixer in self.voice_mixers: - mixer.slider.setFixedHeight(slider_height) # Use fixed height instead of minimum height - def accept(self): selected_voices = self.get_selected_voices() total_weight = sum(weight for _, weight in selected_voices) @@ -262,7 +441,7 @@ class VoiceFormulaDialog(QDialog): QMessageBox.warning( self, "Invalid Weights", - "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights." + "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", ) return super().accept() diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index b6f7e81..fa867cd 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -1,6 +1,7 @@ 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: @@ -12,43 +13,45 @@ def get_new_voice(pipeline, formula, use_gpu): 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('*') + weight, voice_name = 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