From 572a3c728510f58f510a91b8dba084c2e526b6b0 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Sun, 27 Apr 2025 16:24:46 +0200 Subject: [PATCH 01/26] Checking if the output folder exists - failing if it doesn't --- abogen/conversion.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/abogen/conversion.py b/abogen/conversion.py index f14c1e3..c2eadb3 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -279,6 +279,14 @@ class ConversionThread(QThread): parent_dir = os.path.dirname(base_path) else: parent_dir = self.output_folder or os.getcwd() + # Ensure the output folder exists, error if it doesn't + if not os.path.exists(parent_dir): + self.log_updated.emit( + ( + f"Output folder does not exist: {parent_dir}", + "red", + ) + ) # Find a unique suffix for both folder and merged file, always counter = 1 while True: From 44e555c83e73c25446e320a73388106ff10e9b14 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 09:19:48 +0200 Subject: [PATCH 02/26] added voice formulas for mixed text --- abogen/conversion.py | 6 +++++- abogen/gui.py | 14 +++++++++++-- abogen/voice_formulas.py | 43 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 abogen/voice_formulas.py diff --git a/abogen/conversion.py b/abogen/conversion.py index c2eadb3..66386dd 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -9,6 +9,7 @@ from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButt import soundfile as sf from utils import clean_text from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS +from voice_formulas import get_new_voice def get_sample_voice_text(lang_code): @@ -346,9 +347,12 @@ class ConversionThread(QThread): # Set split_pattern to \n+ which will split on one or more newlines split_pattern = r"\n+" + + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + for result in tts( chapter_text, - voice=self.voice, + voice=loaded_voice, speed=self.speed, split_pattern=split_pattern, ): diff --git a/abogen/gui.py b/abogen/gui.py index c8e30ed..269e12e 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -567,6 +567,13 @@ 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 - text box for now, add a dialog later + self.voice_formula = QTextEdit(self) + self.voice_formula.setAcceptRichText(False) + self.voice_formula.setPlaceholderText("Enter voice formula here...") + self.voice_formula.setText('0.242 * am_michael + 0.758 * bf_isabella') + voice_row.addWidget(self.voice_formula) # Play/Stop icons def make_icon(color, shape): @@ -1037,12 +1044,15 @@ class abogen(QWidget): if not self.subtitle_combo.isEnabled() else self.subtitle_mode ) - + + voice_formula = self.voice_formula.toPlainText() + self.conversion_thread = ConversionThread( self.selected_file, self.selected_lang, speed, - self.selected_voice, + #self.selected_voice, + voice_formula, self.save_option, self.selected_output_folder, subtitle_mode=actual_subtitle_mode, diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py new file mode 100644 index 0000000..29272e5 --- /dev/null +++ b/abogen/voice_formulas.py @@ -0,0 +1,43 @@ +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" + return weighted_voice.to(device) + except Exception as e: + raise ValueError(f"Failed to create voice: {str(e)}") + +# Parse the formula and get the combined voice tensor +def parse_voice_formula(pipeline, formula): + """Parse the voice formula string and return the combined voice tensor.""" + if not formula.strip(): + raise ValueError("Empty voice formula") + + # Initialize the weighted sum + weighted_sum = None + + # Split the formula into terms + terms = formula.split('+') + + for term in terms: + # Parse each term (format: "0.333 * voice_name") + weight, voice_name = term.strip().split('*') + weight = float(weight.strip()) + voice_name = voice_name.strip() + + # Get the voice tensor + # use VOICES_INTERNAL + 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 \ No newline at end of file From 8583f229f1ece1888e8d6e00013212cf60bf2b22 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 10:11:35 +0200 Subject: [PATCH 03/26] simple mixer dialog, showing a couple of voices and the slider for each --- abogen/gui.py | 14 ++- abogen/voice_formula_gui.py | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 abogen/voice_formula_gui.py diff --git a/abogen/gui.py b/abogen/gui.py index 269e12e..ae16244 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -70,6 +70,7 @@ from constants import ( SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, ) from threading import Thread +from voice_formula_gui import VoiceFormulaDialog # Import ctypes for Windows-specific taskbar icon if platform.system() == "Windows": @@ -575,6 +576,14 @@ class abogen(QWidget): self.voice_formula.setText('0.242 * am_michael + 0.758 * bf_isabella') voice_row.addWidget(self.voice_formula) + self.btn_voice_formula_mixer = QPushButton(self) + 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; }") + self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog) + voice_row.addWidget(self.btn_voice_formula_mixer) + # Play/Stop icons def make_icon(color, shape): pix = QPixmap(20, 20) @@ -1651,7 +1660,10 @@ class abogen(QWidget): def toggle_check_updates(self, checked): self.config["check_updates"] = checked save_config(self.config) - + + def show_voice_formula_dialog(self): + VoiceFormulaDialog(self).exec_() + 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 new file mode 100644 index 0000000..2f65a2b --- /dev/null +++ b/abogen/voice_formula_gui.py @@ -0,0 +1,181 @@ +from PyQt5.QtWidgets import ( + QDialog, + QVBoxLayout, + QCheckBox, + QLabel, + QHBoxLayout, + QDoubleSpinBox, + QSlider, + QScrollArea, + QWidget, + QPushButton +) +from PyQt5.QtCore import Qt + +class VoiceMixer(QWidget): + def __init__(self, voice_name, language_icon, update_formula_callback): + super().__init__() + + self.voice_name = voice_name + self.update_formula_callback = update_formula_callback + + # Main Layout + layout = QVBoxLayout() + + # Checkbox and Voice Name + self.checkbox = QCheckBox() + self.checkbox.setChecked(True) + self.checkbox.stateChanged.connect(self.update_formula_callback) + + name_label = QLabel(f"{voice_name} {language_icon}") + name_label.setStyleSheet("font-size: 16px;") + name_layout = QHBoxLayout() + name_layout.addWidget(self.checkbox) + name_layout.addWidget(name_label) + name_layout.addStretch() + layout.addLayout(name_layout) + + # Input 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.valueChanged.connect(self.update_formula_callback) + + self.slider = QSlider(Qt.Horizontal) + self.slider.setRange(0, 100) + self.slider.setValue(50) # Default to 0.5 + 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)) + ) + + slider_layout = QHBoxLayout() + slider_layout.addWidget(QLabel("0")) + slider_layout.addWidget(self.slider) + slider_layout.addWidget(QLabel("1")) + + input_layout = QVBoxLayout() + input_layout.addWidget(self.spin_box) + input_layout.addLayout(slider_layout) + + layout.addLayout(input_layout) + self.setLayout(layout) + + def get_formula_component(self): + if self.checkbox.isChecked(): + weight = self.spin_box.value() + return f"{weight:.3f} * {self.voice_name.lower().replace(' ', '_')}" + return "" + +class VoiceFormulaDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + + self.setWindowTitle("Voice Mixer") + self.voice_mixers = [] + + # Main Layout + main_layout = QVBoxLayout() + + # Scroll Area for Voice Panels + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.voice_list_widget = QWidget() + self.voice_list_layout = QVBoxLayout() + self.voice_list_widget.setLayout(self.voice_list_layout) + self.scroll_area.setWidget(self.voice_list_widget) + main_layout.addWidget(self.scroll_area) + + # Formula Label + self.formula_label = QLabel("Voice Formula: ") + self.formula_label.setStyleSheet("font-size: 14px; font-weight: bold;") + main_layout.addWidget(self.formula_label) + + + # Add buttons + button_layout = QHBoxLayout() + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + + # Connect buttons to appropriate slots + ok_button.clicked.connect(self.accept) # Close dialog and return QDialog.Accepted + cancel_button.clicked.connect(self.reject) # Close dialog and return QDialog.Rejected + + button_layout.addStretch() # Push buttons to the right + button_layout.addWidget(ok_button) + button_layout.addWidget(cancel_button) + + main_layout.addLayout(button_layout) + + self.setLayout(main_layout) + + # Initialize with fixed voices + self.add_fixed_voices() + + + def add_fixed_voices(self): + voices = ['af_bella', 'af_sarah', 'am_eric'] + language_icon = '🇺🇸' + for voice in voices: + self.add_voice(voice, language_icon) + + def add_voice(self, voice_name, language_icon): + voice_mixer = VoiceMixer(voice_name, language_icon, self.update_formula) + self.voice_mixers.append(voice_mixer) + self.voice_list_layout.addWidget(voice_mixer) + self.update_formula() + + def update_formula(self): + formula_components = [ + mixer.get_formula_component() for mixer in self.voice_mixers + ] + formula = " + ".join(filter(None, formula_components)) + self.formula_label.setText(f"Voice Formula: {formula}") + + # """ Show a dialog to mix the voice formula """ + # ### todo + # # Get application icon for dialog + # icon = self.windowIcon() + + # # Create custom dialog + # dialog = QDialog(self) + # dialog.setWindowTitle(f"Voice Formula Mixer") + # dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint) + # dialog.setFixedSize(1200, 400) # Increased height for new button + + # layout = QVBoxLayout(dialog) + # layout.setSpacing(10) + + # # add a picker for each voice + # # TODO add all voices + # voices = ['af_bella','af_sarah','am_eric'] + # language_icon = '🇺🇸' + # for voice in voices: + # self.add_voice(voice, language_icon) + + # # Scroll Area for Voice Panels + # self.scroll_area = QScrollArea() + # self.scroll_area.setWidgetResizable(True) + # self.voice_list_widget = QWidget() + # self.voice_list_layout = QVBoxLayout() + # self.voice_list_widget.setLayout(self.voice_list_layout) + # self.scroll_area.setWidget(self.voice_list_widget) + # layout.addWidget(self.scroll_area) + + # # TODO add all voices + # self.add_fixed_voices() + + + + # # Close button + # close_btn = QPushButton("Close") + # close_btn.clicked.connect(dialog.accept) + # close_btn.setFixedHeight(32) + # layout.addWidget(close_btn) + + # dialog.exec_() + + \ No newline at end of file From 31c8d00ae75b04792599a0880ffe0f8eae68c56f Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 10:25:14 +0200 Subject: [PATCH 04/26] moved to a vertical mixer component --- abogen/voice_formula_gui.py | 63 +++++-------------------------------- 1 file changed, 8 insertions(+), 55 deletions(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 2f65a2b..237b307 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -42,7 +42,7 @@ class VoiceMixer(QWidget): self.spin_box.setDecimals(2) self.spin_box.valueChanged.connect(self.update_formula_callback) - self.slider = QSlider(Qt.Horizontal) + self.slider = QSlider(Qt.Vertical) # Set slider orientation to vertical self.slider.setRange(0, 100) self.slider.setValue(50) # Default to 0.5 self.slider.valueChanged.connect( @@ -52,16 +52,13 @@ class VoiceMixer(QWidget): lambda val: self.slider.setValue(int(val * 100)) ) - slider_layout = QHBoxLayout() - slider_layout.addWidget(QLabel("0")) + slider_layout = QVBoxLayout() + slider_layout.addWidget(self.spin_box) + slider_layout.addWidget(QLabel("1", alignment=Qt.AlignCenter)) slider_layout.addWidget(self.slider) - slider_layout.addWidget(QLabel("1")) + slider_layout.addWidget(QLabel("0", alignment=Qt.AlignCenter)) - input_layout = QVBoxLayout() - input_layout.addWidget(self.spin_box) - input_layout.addLayout(slider_layout) - - layout.addLayout(input_layout) + layout.addLayout(slider_layout) self.setLayout(layout) def get_formula_component(self): @@ -70,6 +67,7 @@ class VoiceMixer(QWidget): return f"{weight:.3f} * {self.voice_name.lower().replace(' ', '_')}" return "" + class VoiceFormulaDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) @@ -84,7 +82,7 @@ class VoiceFormulaDialog(QDialog): self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.voice_list_widget = QWidget() - self.voice_list_layout = QVBoxLayout() + self.voice_list_layout = QHBoxLayout() self.voice_list_widget.setLayout(self.voice_list_layout) self.scroll_area.setWidget(self.voice_list_widget) main_layout.addWidget(self.scroll_area) @@ -134,48 +132,3 @@ class VoiceFormulaDialog(QDialog): ] formula = " + ".join(filter(None, formula_components)) self.formula_label.setText(f"Voice Formula: {formula}") - - # """ Show a dialog to mix the voice formula """ - # ### todo - # # Get application icon for dialog - # icon = self.windowIcon() - - # # Create custom dialog - # dialog = QDialog(self) - # dialog.setWindowTitle(f"Voice Formula Mixer") - # dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint) - # dialog.setFixedSize(1200, 400) # Increased height for new button - - # layout = QVBoxLayout(dialog) - # layout.setSpacing(10) - - # # add a picker for each voice - # # TODO add all voices - # voices = ['af_bella','af_sarah','am_eric'] - # language_icon = '🇺🇸' - # for voice in voices: - # self.add_voice(voice, language_icon) - - # # Scroll Area for Voice Panels - # self.scroll_area = QScrollArea() - # self.scroll_area.setWidgetResizable(True) - # self.voice_list_widget = QWidget() - # self.voice_list_layout = QVBoxLayout() - # self.voice_list_widget.setLayout(self.voice_list_layout) - # self.scroll_area.setWidget(self.voice_list_widget) - # layout.addWidget(self.scroll_area) - - # # TODO add all voices - # self.add_fixed_voices() - - - - # # Close button - # close_btn = QPushButton("Close") - # close_btn.clicked.connect(dialog.accept) - # close_btn.setFixedHeight(32) - # layout.addWidget(close_btn) - - # dialog.exec_() - - \ No newline at end of file From 4462ee7ed47f568f724f820778fff76b56200a92 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 13:52:25 +0200 Subject: [PATCH 05/26] vertical layout --- abogen/voice_formula_gui.py | 61 ++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 237b307..76ae39d 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -8,9 +8,17 @@ from PyQt5.QtWidgets import ( QSlider, QScrollArea, QWidget, - QPushButton + QPushButton, + QSizePolicy ) from PyQt5.QtCore import Qt +from constants import ( + VOICES_INTERNAL, + FLAGS +) + +# Constants for voice names and flags +voice_mixer_width = 160 class VoiceMixer(QWidget): def __init__(self, voice_name, language_icon, update_formula_callback): @@ -19,18 +27,26 @@ class VoiceMixer(QWidget): self.voice_name = voice_name self.update_formula_callback = update_formula_callback + # Set fixed width for this widget + self.setFixedWidth(voice_mixer_width) + # TODO Set CSS for rounded corners + # self.setObjectName("VoiceMixer") + # self.setStyleSheet(self.ROUNDED_CSS) + # Main Layout layout = QVBoxLayout() - # Checkbox and Voice Name + # Checkbox at the top self.checkbox = QCheckBox() self.checkbox.setChecked(True) self.checkbox.stateChanged.connect(self.update_formula_callback) + layout.addWidget(self.checkbox, alignment=Qt.AlignCenter) - name_label = QLabel(f"{voice_name} {language_icon}") + voice_gender = self.get_voice_gender() + name = voice_name[3:].capitalize() + name_label = QLabel(f"{language_icon} {voice_gender} {name}") name_label.setStyleSheet("font-size: 16px;") name_layout = QHBoxLayout() - name_layout.addWidget(self.checkbox) name_layout.addWidget(name_label) name_layout.addStretch() layout.addLayout(name_layout) @@ -44,7 +60,8 @@ class VoiceMixer(QWidget): self.slider = QSlider(Qt.Vertical) # Set slider orientation to vertical self.slider.setRange(0, 100) - self.slider.setValue(50) # Default to 0.5 + self.slider.setValue(100) # Default to 1.0 + self.slider.setFixedHeight(180) self.slider.valueChanged.connect( lambda val: self.spin_box.setValue(val / 100) ) @@ -55,11 +72,18 @@ class VoiceMixer(QWidget): slider_layout = QVBoxLayout() slider_layout.addWidget(self.spin_box) slider_layout.addWidget(QLabel("1", alignment=Qt.AlignCenter)) - slider_layout.addWidget(self.slider) + slider_layout.addWidget(self.slider, alignment=Qt.AlignCenter) slider_layout.addWidget(QLabel("0", alignment=Qt.AlignCenter)) layout.addLayout(slider_layout) self.setLayout(layout) + + + def get_voice_gender(self): + if self.voice_name in VOICES_INTERNAL: + gender = self.voice_name[1] + return "👩‍🦰" if gender == "f" else "👨" + return "" def get_formula_component(self): if self.checkbox.isChecked(): @@ -73,6 +97,7 @@ class VoiceFormulaDialog(QDialog): super().__init__(parent) self.setWindowTitle("Voice Mixer") + self.setFixedSize(1000, 500) self.voice_mixers = [] # Main Layout @@ -81,15 +106,17 @@ class VoiceFormulaDialog(QDialog): # Scroll Area for Voice Panels self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) + self.scroll_area.setFixedSize(1000, 500) # Keep scroll area height within 500 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.Fixed, QSizePolicy.Fixed) self.scroll_area.setWidget(self.voice_list_widget) main_layout.addWidget(self.scroll_area) # Formula Label - self.formula_label = QLabel("Voice Formula: ") - self.formula_label.setStyleSheet("font-size: 14px; font-weight: bold;") + self.formula_label = QLabel("Voice Combination Formula: ") + self.formula_label.setStyleSheet("font-size: 14px;") main_layout.addWidget(self.formula_label) @@ -111,14 +138,13 @@ class VoiceFormulaDialog(QDialog): self.setLayout(main_layout) # Initialize with fixed voices - self.add_fixed_voices() + self.add_voices() - def add_fixed_voices(self): - voices = ['af_bella', 'af_sarah', 'am_eric'] - language_icon = '🇺🇸' - for voice in voices: - self.add_voice(voice, language_icon) + def add_voices(self): + for voice in VOICES_INTERNAL: + flag = FLAGS.get(voice[0], "") + self.add_voice(voice, flag) def add_voice(self, voice_name, language_icon): voice_mixer = VoiceMixer(voice_name, language_icon, self.update_formula) @@ -126,6 +152,11 @@ class VoiceFormulaDialog(QDialog): self.voice_list_layout.addWidget(voice_mixer) self.update_formula() + def update_scroll_area_width(self): + # Calculate the width of the scrollable area based on the number of VoiceMixers + width = len(self.voice_mixers) * voice_mixer_width + self.voice_list_widget.setFixedWidth(width) + def update_formula(self): formula_components = [ mixer.get_formula_component() for mixer in self.voice_mixers From fe09b2140b6f3171ae41b434b1261d19b1d82cae Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 14:15:59 +0200 Subject: [PATCH 06/26] saving the state selected by the voice formula dialog --- abogen/gui.py | 13 ++++++++++++- abogen/voice_formula_gui.py | 38 +++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/abogen/gui.py b/abogen/gui.py index ae16244..2131622 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -459,6 +459,7 @@ class abogen(QWidget): 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 self.is_converting = False self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") self.max_subtitle_words = self.config.get( @@ -1662,7 +1663,17 @@ class abogen(QWidget): save_config(self.config) def show_voice_formula_dialog(self): - VoiceFormulaDialog(self).exec_() + # 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() + print("Selected voices and weights:", self.mixed_voice_state) + else: + print("Dialog canceled") def show_about_dialog(self): """Show an About dialog with program information including GitHub link.""" diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 76ae39d..5fc166b 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -21,7 +21,7 @@ from constants import ( voice_mixer_width = 160 class VoiceMixer(QWidget): - def __init__(self, voice_name, language_icon, update_formula_callback): + def __init__(self, voice_name, language_icon, update_formula_callback, initial_status=False, initial_weight=0.0): super().__init__() self.voice_name = voice_name @@ -38,7 +38,7 @@ class VoiceMixer(QWidget): # Checkbox at the top self.checkbox = QCheckBox() - self.checkbox.setChecked(True) + self.checkbox.setChecked(initial_status) self.checkbox.stateChanged.connect(self.update_formula_callback) layout.addWidget(self.checkbox, alignment=Qt.AlignCenter) @@ -56,11 +56,12 @@ class VoiceMixer(QWidget): self.spin_box.setRange(0, 1) self.spin_box.setSingleStep(0.01) self.spin_box.setDecimals(2) + self.spin_box.setValue(initial_weight) self.spin_box.valueChanged.connect(self.update_formula_callback) self.slider = QSlider(Qt.Vertical) # Set slider orientation to vertical self.slider.setRange(0, 100) - self.slider.setValue(100) # Default to 1.0 + self.slider.setValue(int(initial_weight * 100)) self.slider.setFixedHeight(180) self.slider.valueChanged.connect( lambda val: self.spin_box.setValue(val / 100) @@ -92,8 +93,14 @@ class VoiceMixer(QWidget): 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 VoiceFormulaDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None, initial_state=None): super().__init__(parent) self.setWindowTitle("Voice Mixer") @@ -106,7 +113,7 @@ class VoiceFormulaDialog(QDialog): # Scroll Area for Voice Panels self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFixedSize(1000, 500) # Keep scroll area height within 500 + 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) @@ -138,16 +145,16 @@ class VoiceFormulaDialog(QDialog): self.setLayout(main_layout) # Initialize with fixed voices - self.add_voices() - - - def add_voices(self): for voice in VOICES_INTERNAL: flag = FLAGS.get(voice[0], "") - self.add_voice(voice, flag) + 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 + self.add_voice(voice, flag, initial_status, initial_weight) - def add_voice(self, voice_name, language_icon): - voice_mixer = VoiceMixer(voice_name, language_icon, self.update_formula) + + def add_voice(self, voice_name, language_icon, initial_status=False, initial_weight=1.0): + voice_mixer = VoiceMixer(voice_name, language_icon, self.update_formula, initial_status, initial_weight) self.voice_mixers.append(voice_mixer) self.voice_list_layout.addWidget(voice_mixer) self.update_formula() @@ -163,3 +170,10 @@ class VoiceFormulaDialog(QDialog): ] formula = " + ".join(filter(None, formula_components)) self.formula_label.setText(f"Voice Formula: {formula}") + + 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 [voice for voice in selected_voices if voice] # Filter out None From 27cdd264b12b5d2c83bfae73093608f475fb39a6 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 14:25:48 +0200 Subject: [PATCH 07/26] voice mixer enabled based on its checkpoint status --- abogen/gui.py | 10 ++-------- abogen/voice_formula_gui.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/abogen/gui.py b/abogen/gui.py index 2131622..23b44ed 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -570,15 +570,9 @@ class abogen(QWidget): ) voice_row.addWidget(self.voice_combo) - # voice formula - text box for now, add a dialog later - self.voice_formula = QTextEdit(self) - self.voice_formula.setAcceptRichText(False) - self.voice_formula.setPlaceholderText("Enter voice formula here...") - self.voice_formula.setText('0.242 * am_michael + 0.758 * bf_isabella') - voice_row.addWidget(self.voice_formula) - + # 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; }") diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 5fc166b..52a468f 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -39,6 +39,7 @@ class VoiceMixer(QWidget): # Checkbox at the top self.checkbox = QCheckBox() self.checkbox.setChecked(initial_status) + self.checkbox.stateChanged.connect(self.toggle_inputs) self.checkbox.stateChanged.connect(self.update_formula_callback) layout.addWidget(self.checkbox, alignment=Qt.AlignCenter) @@ -78,7 +79,15 @@ class VoiceMixer(QWidget): layout.addLayout(slider_layout) 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: @@ -133,8 +142,8 @@ class VoiceFormulaDialog(QDialog): cancel_button = QPushButton("Cancel") # Connect buttons to appropriate slots - ok_button.clicked.connect(self.accept) # Close dialog and return QDialog.Accepted - cancel_button.clicked.connect(self.reject) # Close dialog and return QDialog.Rejected + ok_button.clicked.connect(self.accept) + cancel_button.clicked.connect(self.reject) button_layout.addStretch() # Push buttons to the right button_layout.addWidget(ok_button) From d18933d36206649f254e97ea5b8cd940849b46e1 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Mon, 28 Apr 2025 16:21:41 +0200 Subject: [PATCH 08/26] voice can either be a formula or just the old approach --- abogen/conversion.py | 6 +++++- abogen/gui.py | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/abogen/conversion.py b/abogen/conversion.py index 66386dd..ae26807 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -348,7 +348,11 @@ class ConversionThread(QThread): # Set split_pattern to \n+ which will split on one or more newlines split_pattern = r"\n+" - loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + # Check if the voice is a formula and load it if necessary + if '*' in self.voice: + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + else: + loaded_voice = self.voice for result in tts( chapter_text, diff --git a/abogen/gui.py b/abogen/gui.py index 23b44ed..f5bfe0b 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1049,13 +1049,17 @@ class abogen(QWidget): else self.subtitle_mode ) - voice_formula = self.voice_formula.toPlainText() - + # 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] + voice_formula = " + ".join(filter(None, formula_components)) + else: + voice_formula = self.selected_voice + self.conversion_thread = ConversionThread( self.selected_file, self.selected_lang, speed, - #self.selected_voice, voice_formula, self.save_option, self.selected_output_folder, From 8cff2ad9801de106f35df9ff83c0e754d3112834 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 08:06:36 +0200 Subject: [PATCH 09/26] male and female emojis stored in a constant --- abogen/voice_formula_gui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 52a468f..a490ff9 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -19,6 +19,8 @@ from constants import ( # Constants for voice names and flags voice_mixer_width = 160 +FEMALE = "👩‍🦰" +MALE = "👨" class VoiceMixer(QWidget): def __init__(self, voice_name, language_icon, update_formula_callback, initial_status=False, initial_weight=0.0): @@ -92,7 +94,7 @@ class VoiceMixer(QWidget): def get_voice_gender(self): if self.voice_name in VOICES_INTERNAL: gender = self.voice_name[1] - return "👩‍🦰" if gender == "f" else "👨" + return FEMALE if gender == "f" else MALE return "" def get_formula_component(self): @@ -160,7 +162,6 @@ class VoiceFormulaDialog(QDialog): initial_status = matching_voice is not None initial_weight = matching_voice[1] if matching_voice else 1.0 self.add_voice(voice, flag, initial_status, initial_weight) - def add_voice(self, voice_name, language_icon, initial_status=False, initial_weight=1.0): voice_mixer = VoiceMixer(voice_name, language_icon, self.update_formula, initial_status, initial_weight) From b0f35896186346db01f9bf2d7aa7a4bc88a876b2 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 08:07:24 +0200 Subject: [PATCH 10/26] setting selected_lang to the language of the (first) voice in the mix --- abogen/gui.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/abogen/gui.py b/abogen/gui.py index f5bfe0b..c0ef120 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1055,10 +1055,12 @@ class abogen(QWidget): voice_formula = " + ".join(filter(None, formula_components)) else: voice_formula = self.selected_voice + # selected language - use the first voice of the mix + selected_lang = self.selected_voice[0] self.conversion_thread = ConversionThread( self.selected_file, - self.selected_lang, + selected_lang, speed, voice_formula, self.save_option, From 06ad35fd74e3160e22e8a14a27ecc4c17e51e6c5 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 08:17:45 +0200 Subject: [PATCH 11/26] added a title label to the voice formula guii --- abogen/voice_formula_gui.py | 38 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index a490ff9..4ebcb7b 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -18,19 +18,18 @@ from constants import ( ) # Constants for voice names and flags -voice_mixer_width = 160 +VOICE_MIXER_WIDTH = 160 FEMALE = "👩‍🦰" MALE = "👨" class VoiceMixer(QWidget): - def __init__(self, voice_name, language_icon, update_formula_callback, 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 - self.update_formula_callback = update_formula_callback # Set fixed width for this widget - self.setFixedWidth(voice_mixer_width) + self.setFixedWidth(VOICE_MIXER_WIDTH) # TODO Set CSS for rounded corners # self.setObjectName("VoiceMixer") # self.setStyleSheet(self.ROUNDED_CSS) @@ -42,16 +41,14 @@ class VoiceMixer(QWidget): self.checkbox = QCheckBox() self.checkbox.setChecked(initial_status) self.checkbox.stateChanged.connect(self.toggle_inputs) - self.checkbox.stateChanged.connect(self.update_formula_callback) 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_label.setStyleSheet("font-size: 16px;") name_layout = QHBoxLayout() name_layout.addWidget(name_label) - name_layout.addStretch() + name_layout.setAlignment(name_label, Qt.AlignCenter) layout.addLayout(name_layout) # Input and Slider @@ -60,7 +57,6 @@ class VoiceMixer(QWidget): self.spin_box.setSingleStep(0.01) self.spin_box.setDecimals(2) self.spin_box.setValue(initial_weight) - self.spin_box.valueChanged.connect(self.update_formula_callback) self.slider = QSlider(Qt.Vertical) # Set slider orientation to vertical self.slider.setRange(0, 100) @@ -121,6 +117,10 @@ class VoiceFormulaDialog(QDialog): # Main Layout main_layout = QVBoxLayout() + # Header Label + header_label = QLabel("Select Voices For the Mix and Adjust Weights") + main_layout.addWidget(header_label) + # Scroll Area for Voice Panels self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) @@ -132,12 +132,6 @@ class VoiceFormulaDialog(QDialog): self.scroll_area.setWidget(self.voice_list_widget) main_layout.addWidget(self.scroll_area) - # Formula Label - self.formula_label = QLabel("Voice Combination Formula: ") - self.formula_label.setStyleSheet("font-size: 14px;") - main_layout.addWidget(self.formula_label) - - # Add buttons button_layout = QHBoxLayout() ok_button = QPushButton("OK") @@ -155,7 +149,9 @@ class VoiceFormulaDialog(QDialog): self.setLayout(main_layout) - # Initialize with fixed voices + self.add_voices(initial_state) + + def add_voices(self, initial_state): for voice in VOICES_INTERNAL: flag = FLAGS.get(voice[0], "") matching_voice = next((item for item in initial_state if item[0] == voice), None) @@ -164,23 +160,15 @@ class VoiceFormulaDialog(QDialog): self.add_voice(voice, flag, initial_status, initial_weight) def add_voice(self, voice_name, language_icon, initial_status=False, initial_weight=1.0): - voice_mixer = VoiceMixer(voice_name, language_icon, self.update_formula, initial_status, initial_weight) + voice_mixer = VoiceMixer(voice_name, language_icon, initial_status, initial_weight) self.voice_mixers.append(voice_mixer) self.voice_list_layout.addWidget(voice_mixer) - self.update_formula() def update_scroll_area_width(self): # Calculate the width of the scrollable area based on the number of VoiceMixers - width = len(self.voice_mixers) * voice_mixer_width + width = len(self.voice_mixers) * VOICE_MIXER_WIDTH self.voice_list_widget.setFixedWidth(width) - def update_formula(self): - formula_components = [ - mixer.get_formula_component() for mixer in self.voice_mixers - ] - formula = " + ".join(filter(None, formula_components)) - self.formula_label.setText(f"Voice Formula: {formula}") - def get_selected_voices(self): """Return the list of selected voices and their weights.""" selected_voices = [ From 3497c0105325a7973451d046b9101b8a915aca6c Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 08:27:48 +0200 Subject: [PATCH 12/26] scrolling to first enabled voice in the formula gui --- abogen/voice_formula_gui.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 4ebcb7b..6ae2328 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -11,7 +11,10 @@ from PyQt5.QtWidgets import ( QPushButton, QSizePolicy ) -from PyQt5.QtCore import Qt +from PyQt5.QtCore import ( + Qt, + QTimer +) from constants import ( VOICES_INTERNAL, FLAGS @@ -152,22 +155,31 @@ class VoiceFormulaDialog(QDialog): self.add_voices(initial_state) 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 + for voice in VOICES_INTERNAL: flag = FLAGS.get(voice[0], "") 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 - self.add_voice(voice, flag, initial_status, initial_weight) + 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) self.voice_mixers.append(voice_mixer) self.voice_list_layout.addWidget(voice_mixer) + return voice_mixer - def update_scroll_area_width(self): - # Calculate the width of the scrollable area based on the number of VoiceMixers - width = len(self.voice_mixers) * VOICE_MIXER_WIDTH - self.voice_list_widget.setFixedWidth(width) + 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 get_selected_voices(self): """Return the list of selected voices and their weights.""" From 82fe975d3cceb47c8c275f2e44bd622bf9f362ed Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 08:37:54 +0200 Subject: [PATCH 13/26] fixed the choice of selected language to include voice formula --- abogen/gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/abogen/gui.py b/abogen/gui.py index c0ef120..919ad72 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1056,7 +1056,7 @@ class abogen(QWidget): else: voice_formula = self.selected_voice # selected language - use the first voice of the mix - selected_lang = self.selected_voice[0] + selected_lang = voice_formula[0] self.conversion_thread = ConversionThread( self.selected_file, From f1677dcaf4049ada0a7d9bf7e3aaad3cd6af7ee0 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 09:00:53 +0200 Subject: [PATCH 14/26] fixed the choice of selected language to include voice formula (really) --- abogen/gui.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/abogen/gui.py b/abogen/gui.py index 919ad72..5b8f9b8 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -3,6 +3,7 @@ import time import tempfile import platform import base64 +import re from PyQt5.QtWidgets import ( QApplication, QWidget, @@ -1056,7 +1057,8 @@ class abogen(QWidget): else: voice_formula = self.selected_voice # selected language - use the first voice of the mix - selected_lang = voice_formula[0] + match = re.search(r'\b([a-z])', voice_formula) + selected_lang = match.group(1) self.conversion_thread = ConversionThread( self.selected_file, @@ -1671,9 +1673,6 @@ class abogen(QWidget): dialog = VoiceFormulaDialog(self, initial_state=self.mixed_voice_state) if dialog.exec_() == QDialog.Accepted: self.mixed_voice_state = dialog.get_selected_voices() - print("Selected voices and weights:", self.mixed_voice_state) - else: - print("Dialog canceled") def show_about_dialog(self): """Show an About dialog with program information including GitHub link.""" From be4a258589471bcf4f77cf68d1bf8b95d3ca076d Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Tue, 29 Apr 2025 09:01:27 +0200 Subject: [PATCH 15/26] normalizing the weight to 1.0 of the total weight of mixed voices --- abogen/voice_formulas.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index 29272e5..8374395 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -1,3 +1,4 @@ +import re from constants import VOICES_INTERNAL # Calls parsing and loads the voice to gpu or cpu @@ -11,24 +12,26 @@ def get_new_voice(pipeline, formula, use_gpu): # Parse the formula and get the combined voice tensor def parse_voice_formula(pipeline, formula): - """Parse the voice formula string and return the combined voice tensor.""" 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 - terms = formula.split('+') + voices = formula.split('+') - for term in terms: + for term in voices: # Parse each term (format: "0.333 * voice_name") 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 - # use VOICES_INTERNAL if voice_name not in VOICES_INTERNAL: raise ValueError(f"Unknown voice: {voice_name}") @@ -40,4 +43,9 @@ def parse_voice_formula(pipeline, formula): else: weighted_sum += weight * voice_tensor - return weighted_sum \ No newline at end of file + return weighted_sum + +def calculate_sum_from_formula(formula): + weights = re.findall(r'([\d.]+) \*', formula) + total_sum = sum(float(weight) for weight in weights) + return total_sum \ No newline at end of file From 7c55a6f6fac5c55bd84b98f7a1ebcd42513b39f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Tue, 29 Apr 2025 15:00:20 +0300 Subject: [PATCH 16/26] Fix "split_with_sizes(): argument 'split_sizes' (position 2)" error when using cuda --- abogen/voice_formulas.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index 8374395..b6f7e81 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -4,8 +4,11 @@ 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)}") From 28813b8975e35d7faac30110216b31e50207ff02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Tue, 29 Apr 2025 15:36:57 +0300 Subject: [PATCH 17/26] Enabled mousewheel --- abogen/voice_formula_gui.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 6ae2328..e096db5 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -71,7 +71,7 @@ class VoiceMixer(QWidget): self.spin_box.valueChanged.connect( lambda val: self.slider.setValue(int(val * 100)) ) - + slider_layout = QVBoxLayout() slider_layout.addWidget(self.spin_box) slider_layout.addWidget(QLabel("1", alignment=Qt.AlignCenter)) @@ -128,6 +128,10 @@ class VoiceFormulaDialog(QDialog): self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setFixedSize(1000, 400) # Keep scroll area height within 500 + 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.voice_list_widget = QWidget() self.voice_list_layout = QHBoxLayout() self.voice_list_widget.setLayout(self.voice_list_layout) @@ -187,3 +191,20 @@ class VoiceFormulaDialog(QDialog): mixer.get_voice_weight() for mixer in self.voice_mixers ] return [voice for voice in selected_voices if voice] # Filter out None + + 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): + return False + # Convert vertical wheel movement to 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 + return True + return super().eventFilter(source, event) From 9e75870c6dd5b13905d672d8706dd0039d8a5e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Tue, 29 Apr 2025 16:46:39 +0300 Subject: [PATCH 18/26] display weights as label --- abogen/voice_formula_gui.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index e096db5..fab52d6 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -124,6 +124,11 @@ class VoiceFormulaDialog(QDialog): header_label = QLabel("Select Voices For the Mix and Adjust Weights") main_layout.addWidget(header_label) + # Weighted Sums Label + self.weighted_sums_label = QLabel() + self.weighted_sums_label.setAlignment(Qt.AlignCenter) # Center align the label + main_layout.addWidget(self.weighted_sums_label) + # Scroll Area for Voice Panels self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) @@ -157,6 +162,7 @@ class VoiceFormulaDialog(QDialog): self.setLayout(main_layout) self.add_voices(initial_state) + 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.""" @@ -179,6 +185,9 @@ class VoiceFormulaDialog(QDialog): 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.spin_box.valueChanged.connect(self.update_weighted_sums) return voice_mixer def scroll_to_voice(self, voice_mixer): @@ -192,6 +201,16 @@ class VoiceFormulaDialog(QDialog): ] 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()] + 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) + else: + joined = "" + self.weighted_sums_label.setText(joined) # Remove the prefix + 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): From 7cf3884a4bd38b48ea356ed98aa87d54f2cf481f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Tue, 29 Apr 2025 18:00:29 +0300 Subject: [PATCH 19/26] Make window expandable and improvements --- abogen/voice_formula_gui.py | 59 ++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index fab52d6..b9fd006 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -9,7 +9,8 @@ from PyQt5.QtWidgets import ( QScrollArea, QWidget, QPushButton, - QSizePolicy + QSizePolicy, + QMessageBox ) from PyQt5.QtCore import ( Qt, @@ -22,6 +23,11 @@ from constants import ( # Constants for voice names and flags 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 = "👨" @@ -33,6 +39,7 @@ class VoiceMixer(QWidget): # 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) @@ -64,7 +71,8 @@ class VoiceMixer(QWidget): self.slider = QSlider(Qt.Vertical) # Set slider orientation to vertical self.slider.setRange(0, 100) self.slider.setValue(int(initial_weight * 100)) - self.slider.setFixedHeight(180) + 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) ) @@ -75,10 +83,11 @@ class VoiceMixer(QWidget): 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_layout.addWidget(self.slider, alignment=Qt.AlignCenter, stretch=1) # Use stretch to expand slider slider_layout.addWidget(QLabel("0", alignment=Qt.AlignCenter)) + slider_layout.setStretch(2, 2) # Make slider take all available vertical space - layout.addLayout(slider_layout) + layout.addLayout(slider_layout, stretch=1) # Make slider layout expand self.setLayout(layout) # Disable inputs initially if the checkbox is unchecked @@ -114,25 +123,27 @@ class VoiceFormulaDialog(QDialog): super().__init__(parent) self.setWindowTitle("Voice Mixer") - self.setFixedSize(1000, 500) + self.setMinimumWidth(MIN_WINDOW_WIDTH) + self.setMinimumHeight(MIN_WINDOW_HEIGHT) + self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) self.voice_mixers = [] # Main Layout main_layout = QVBoxLayout() # Header Label - header_label = QLabel("Select Voices For the Mix and Adjust Weights") - main_layout.addWidget(header_label) + self.header_label = QLabel("Select Voices For the Mix and Adjust Weights") + 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) # Scroll Area for Voice Panels self.scroll_area = QScrollArea() self.scroll_area.setWidgetResizable(True) - self.scroll_area.setFixedSize(1000, 400) # Keep scroll area height within 500 self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scroll_area.viewport().installEventFilter(self) # Install event filter for wheel events @@ -140,7 +151,7 @@ class VoiceFormulaDialog(QDialog): 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.voice_list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.scroll_area.setWidget(self.voice_list_widget) main_layout.addWidget(self.scroll_area) @@ -172,7 +183,7 @@ class VoiceFormulaDialog(QDialog): flag = FLAGS.get(voice[0], "") 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 + initial_weight = matching_voice[1] if matching_voice else 0.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: @@ -227,3 +238,31 @@ class VoiceFormulaDialog(QDialog): horiz_bar.setValue(horiz_bar.value() + 120) # Scroll right 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) + 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." + ) + return + super().accept() From 854ad1adea0781aa3059a1ba19ede875f66a8155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Wed, 30 Apr 2025 06:57:34 +0300 Subject: [PATCH 20/26] =?UTF-8?q?Improvements=20for=20v=C4=B1ice=5Fformula?= =?UTF-8?q?=20gui,=20reformat=20with=20black?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +- abogen/book_handler.py | 92 +++++--- abogen/conversion.py | 23 +- abogen/gui.py | 22 +- abogen/voice_formula_gui.py | 447 +++++++++++++++++++++++++----------- abogen/voice_formulas.py | 29 +-- 6 files changed, 417 insertions(+), 203 deletions(-) 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 From 68dbdf123dab4467b67c699f8d7b2b20a8973944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Wed, 30 Apr 2025 16:13:34 +0300 Subject: [PATCH 21/26] Added icons, imrpoved voice mixer gui and documentation --- README.md | 1 + abogen/assets/female.png | Bin 0 -> 340 bytes abogen/assets/male.png | Bin 0 -> 305 bytes abogen/assets/voice_mixer.png | Bin 0 -> 252 bytes abogen/gui.py | 7 +++++- abogen/voice_formula_gui.py | 41 +++++++++++++++++++++------------- 6 files changed, 32 insertions(+), 17 deletions(-) create mode 100644 abogen/assets/female.png create mode 100644 abogen/assets/male.png create mode 100644 abogen/assets/voice_mixer.png diff --git a/README.md b/README.md index 570aa96..218fd0c 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ Feel free to explore the code and make any changes you like. - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. +- [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Mix](https://icons8.com/icon/21700/adjust) icons by [Icons8](https://icons8.com/). ## `License` This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. diff --git a/abogen/assets/female.png b/abogen/assets/female.png new file mode 100644 index 0000000000000000000000000000000000000000..250dca3d35b45f1b8764506b8df5feb82163d8fa GIT binary patch literal 340 zcmV-a0jvIrP)!vu)O7cyoF7Y|580Xn75FpM!NZ$6?47H zwxv5DeFJObHc~^7{ch!ur^fmP(hCbm3mC{ESS~&DHBkUa9*p&>1tz%$$$fkQ$-A-a zwsYMx(s3NX@&uBz)-slJusn7E=m}VBWQQw%%Bvt58R>Bp&}^5pAiXovkI~ZFv)f2M zjrG>=q5xM@mNVdDSTAq9xDjOFS<+WVdgJb240#HcQzNO|lXJ+^?f{koq}Aj%{!2>%=Gv*?5SS>_$ne&&7U)?!_=O11UCexP?5JYD@<);T3K0RR+% Bc*p<% literal 0 HcmV?d00001 diff --git a/abogen/assets/voice_mixer.png b/abogen/assets/voice_mixer.png new file mode 100644 index 0000000000000000000000000000000000000000..31a71d749a480bf03435d8613bb3d2395bb65490 GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP;i~6 zi(^Q|oV}NIxtttDj((Ki!4ha7ab!t?(ajR>W9)iQ)*jHkaaOJQpi0NtXF?{kVj4a@ z=sf)Sr|CiFKI>z9~ y_&|bLB5_u=1oMe6^Y<5ofAi>C^|Ws0UdH1+hJr0+=k$RtXYh3Ob6Mw<&;$U;I%5a` literal 0 HcmV?d00001 diff --git a/abogen/gui.py b/abogen/gui.py index bf04f19..1f52886 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -574,7 +574,8 @@ class abogen(QWidget): # 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; }") @@ -1318,6 +1319,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() @@ -1339,6 +1341,7 @@ class abogen(QWidget): 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 @@ -1365,6 +1368,7 @@ class abogen(QWidget): 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) return # stop loading animation, switch to stop icon @@ -1414,6 +1418,7 @@ class abogen(QWidget): self.btn_preview.setToolTip("Preview selected voice") self.btn_preview.setEnabled(True) self.voice_combo.setEnabled(True) + self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button self.btn_start.setEnabled(True) def _preview_error(self, msg): diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index acde5ac..395dcac 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -16,10 +16,12 @@ from PyQt5.QtWidgets import ( QStyle, ) from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize +from PyQt5.QtGui import QPixmap from constants import VOICES_INTERNAL, FLAGS +from utils import get_resource_path # Constants -VOICE_MIXER_WIDTH = 160 +VOICE_MIXER_WIDTH = 120 SLIDER_WIDTH = 32 MIN_WINDOW_WIDTH = 600 MIN_WINDOW_HEIGHT = 400 @@ -119,6 +121,7 @@ class VoiceMixer(QWidget): ): super().__init__() self.voice_name = voice_name + #self.setFixedWidth(VOICE_MIXER_WIDTH) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # TODO Set CSS for rounded corners @@ -133,17 +136,18 @@ class VoiceMixer(QWidget): self.checkbox.stateChanged.connect(self.toggle_inputs) layout.addWidget(self.checkbox, alignment=Qt.AlignCenter) - # Voice name label - voice_gender = ( - FEMALE - if self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" - else MALE - ) + # Voice name label with gender icon + is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" + gender_icon_path = get_resource_path("abogen.assets", "female.png" if is_female else "male.png") name = voice_name[3:].capitalize() name_layout = QHBoxLayout() - name_layout.addWidget( - QLabel(f"{language_icon} {voice_gender} {name}"), alignment=Qt.AlignCenter - ) + # Gender icon + pixmap = QPixmap(gender_icon_path) + if not pixmap.isNull(): + gender_label = QLabel() + gender_label.setPixmap(pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + name_layout.addWidget(gender_label) + name_layout.addWidget(QLabel(f"{language_icon} {name}"), alignment=Qt.AlignCenter) layout.addLayout(name_layout) # Spinbox and slider @@ -211,26 +215,30 @@ class HoverLabel(QLabel): self.delete_button.setStyleSheet( """ QPushButton { - background-color: red; + background-color: #ff5555; color: white; - border-radius: 8px; + border-radius: 7px; font-weight: bold; font-size: 12px; border: none; padding: 0px; - text-align: center; + margin: 0px; } QPushButton:hover { - background-color: #ff5555; + 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) - self.delete_button.move(self.width() - 16, 0) + # 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() @@ -398,7 +406,8 @@ class VoiceFormulaDialog(QDialog): # Add voice labels for name, weight in selected: percentage = weight / total * 100 - voice_label = HoverLabel(f"{name}: {percentage:.1f}%", name) + # 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) From 04e16722e2703ff9a12fae0397f78a24c45bf39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Wed, 30 Apr 2025 16:29:47 +0300 Subject: [PATCH 22/26] Use platformdirs for finding the desktop path --- CHANGELOG.md | 3 ++- abogen/conversion.py | 3 ++- abogen/gui.py | 3 ++- pyproject.toml | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a55eed4..e5c2fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ -- 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 +- 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) +- Switched to platformdirs for determining the correct desktop path, instead of using old methods. \ No newline at end of file diff --git a/abogen/conversion.py b/abogen/conversion.py index 1b2a4f5..7fa8c89 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 @@ -280,7 +281,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: diff --git a/abogen/gui.py b/abogen/gui.py index 1f52886..2cc5f4d 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -72,6 +72,7 @@ from constants import ( ) from threading import Thread from voice_formula_gui import VoiceFormulaDialog +from platformdirs import user_desktop_dir # Import ctypes for Windows-specific taskbar icon if platform.system() == "Windows": @@ -1628,7 +1629,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 diff --git a/pyproject.toml b/pyproject.toml index 03491fe..7f626a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "ebooklib>=0.18", "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", From 4acf70954e4524df02451155866596b53571cd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Wed, 30 Apr 2025 18:45:02 +0300 Subject: [PATCH 23/26] Added icons for flags, improvements in voice mixer --- CHANGELOG.md | 1 + README.md | 2 +- abogen/assets/flags/a.png | Bin 0 -> 484 bytes abogen/assets/flags/b.png | Bin 0 -> 531 bytes abogen/assets/flags/e.png | Bin 0 -> 331 bytes abogen/assets/flags/f.png | Bin 0 -> 306 bytes abogen/assets/flags/h.png | Bin 0 -> 373 bytes abogen/assets/flags/i.png | Bin 0 -> 306 bytes abogen/assets/flags/j.png | Bin 0 -> 314 bytes abogen/assets/flags/p.png | Bin 0 -> 464 bytes abogen/assets/flags/z.png | Bin 0 -> 317 bytes abogen/constants.py | 13 -------- abogen/gui.py | 15 +++++++-- abogen/utils.py | 6 ++++ abogen/voice_formula_gui.py | 64 +++++++++++++++++++++++------------- 15 files changed, 61 insertions(+), 40 deletions(-) create mode 100644 abogen/assets/flags/a.png create mode 100644 abogen/assets/flags/b.png create mode 100644 abogen/assets/flags/e.png create mode 100644 abogen/assets/flags/f.png create mode 100644 abogen/assets/flags/h.png create mode 100644 abogen/assets/flags/i.png create mode 100644 abogen/assets/flags/j.png create mode 100644 abogen/assets/flags/p.png create mode 100644 abogen/assets/flags/z.png diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c2fa7..84cc88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,3 @@ - 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) +- Added icons for flags and genders in the GUI, making it easier to identify different options. - Switched to platformdirs for determining the correct desktop path, instead of using old methods. \ No newline at end of file diff --git a/README.md b/README.md index 218fd0c..ab5d746 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ Feel free to explore the code and make any changes you like. - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. -- [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Mix](https://icons8.com/icon/21700/adjust) icons by [Icons8](https://icons8.com/). +- [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Mix](https://icons8.com/icon/21700/adjust) icons by [Icons8](https://icons8.com/). ## `License` This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. diff --git a/abogen/assets/flags/a.png b/abogen/assets/flags/a.png new file mode 100644 index 0000000000000000000000000000000000000000..a110f86bb8e2e112796442afbc7f6a7a5d7d7a1f GIT binary patch literal 484 zcmV;kt|eR4C_AgIwL zbT@x-JB4if|2aDF*vH=X&Npw~@Zr=vVfK|LDMg3> zmo}dIKXLZe{~ePr{?D&H{y(Gq=>PXi790cPX%m*bb1~+}Fd#bn!20s$Q~wKUPyCNa z-~T_Yd$6)wFO=%~FpCi)u{Yxx7^gkkF-~Zs0z5gRK_WcL(<8lxF zcM02$aKVxV$KEYoaO`7!g&N!dhoEiMAph2No&BF&c<4XO{~&%=#nJ!CMTb#b0Cq)r zZZ_P2%!;GyN}CY=Mh=JmnODGGKz0ELe^|K~;f>m^Q$3wi&mY5w(OvKXP))yy_@yt&)*kQ=3hI6%Yfvf!}p(EymjPtdU7v{=8w~+sXw(gfBtd$ltcgj z{Q3L#-S@L^-v2lNGvL|Fug8v_c((stPxrp3R%S2Wb+qpP|Np-#+<^c8|L6X`bNk@u zRV$ADdT{^Ye+YoP;Q#;syMEodeFG%_`^n=c|NsAg`Tzg_Y`6hW!J!RNOguK3k8Hqv zk`1shormm&od5s--~Rjg^QX_NS04X<^2Fu;|Nno4DgOKS|4ooT{(k@d?aP|g$G;!g ze-fkz*#MOQVQYGe82RZ7y)n-?A*c=PthA-HYNzZ_e)?f#*^fB%2}dH(#hr;c`4 zUq?o2pv1(BK)=ba_Z&P>+jZtNu4oKR+57V0lg}sr|NXlVBOP41@-qfp^8#2?qrLxn0Z?+qnv?^!{(m>BlHUV4J2x=q;006py VwROOx%%cDR002ovPDHLkV1nOE8|VN4 literal 0 HcmV?d00001 diff --git a/abogen/assets/flags/e.png b/abogen/assets/flags/e.png new file mode 100644 index 0000000000000000000000000000000000000000..f6474407b271343c652508af1fa784d7dab3f657 GIT binary patch literal 331 zcmV-R0kr;!P)im`5-koHQ&);IFGIo18Xy!#cIggYUZ}rSxm=>!63CLn*Z{m=h{?Ei_v2iX7qKgf;n&O zZw}@Ae|$mS{}-EN2)Kf628IF0YApWudT9M$mKp#5Sd%?T29$yX_sp~WzbaYd|K>#f z|9j?I6EdKfvOwI=u0XwL1OaF&JV$myLNa6l)}*Y>@aL{J!#tdtkpU><+|gzzMaoR2 dL}ey&00463Z^aQxU_1Z-002ovPDHLkV1j-wlTH8t literal 0 HcmV?d00001 diff --git a/abogen/assets/flags/f.png b/abogen/assets/flags/f.png new file mode 100644 index 0000000000000000000000000000000000000000..f66e9cc92734c6670c4ff7b00360575c9e18d3a9 GIT binary patch literal 306 zcmV-20nPr2P)kl8wU%&e>4~#+VCr{J~YP9!XFJKqA zW%}aPH;%n||M5RE{=B&7*nb;_>Hl3A__1m>_SwW`AGr0Hec;yri&kI9FyPCg&SU>= z8UFvbVL0{Qo`DC$0K34g^Ff*k8UVrbP&C^FZBeod-13`f18f-n{I_RNLD(L!wFst} zNEd(%sX_!^z}6XL7_fj+1Ljla1zUzn>IEXj(a6BSz+e%&iqk%D>ro;S(#ij>4BQw7 zfPhQrZhpK;*_L4%&ZLYCz?td&r!r_}nhRzVlbOf?0Mx_6KP)RCt_YV4wx~KZ`;8{|ttj|1%gCg7I{Q>i^Rjvxp zf0wBo`@2MmIJ`{d*pCa7)!+tv`SPXW>({UUk@1Tc@BeSvas!NCzWjh92f|;zd?|+; z@b&B01?UR4ZM*S*!GiPu7cM*xW~0l0{rYttvH|n48359Z$AGzbz3}qo2jac3?!r2? z;JM+)g64)1g+t~>99@2L9U>4xz`WOHiAA5yf1+@&^&$pzU^dZ=&%D?A2oVMx2C2m` z00i{9UAe(7>9zTXX)8#~dND{1R?W!3yx&gKqSw08yw_#{2!q&`J(d~-HIf4Wfkl8wU%&e>4~#+VCr{J~YRsQ+FOV_M zeEOC%i;ul||M5RE{=B&7*nb;_>Hl3A__1nEn`z9IG0*&1#(azaTh1)PFyPCg&SU>= z8UFvbVL0{Qo`DC$fQ)$-^Ff*k8UVrbP&8-GHBm~RZ~mKT18f-n{I_RNK{$V&c@a!A zkuCrkQiX8-eDfJ(7_fj+1Ljla1zUzn>IEXj(a6BSz)-l#f-`--`B5Sg(#ij>4BQw7 zfI!+zV}86z*_L4%&ZLYCz?td&r!r_}nhRzVlbOf?02U>|?~c{G;{X5v07*qoM6N<$ Eg6r&sPyhe` literal 0 HcmV?d00001 diff --git a/abogen/assets/flags/j.png b/abogen/assets/flags/j.png new file mode 100644 index 0000000000000000000000000000000000000000..4bcf2f98f7134250fa09b717bf476bf61d233fc2 GIT binary patch literal 314 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkQ1FeX zi(^Q|oUOs$T!#z<-g1_tNBbx2om2Ywy-uA&?)$|`v9H3?Jk)MpKhZJY#8kwQnK|)L z_2loLgjoJZbnNPHTBeY3^k}uqToa~Nf!rxt?2h46PTlzWBDLejrzXcs2U)LmsJykG zv>>EX_($xPm6>d>Zxt{WOD$Q(p&ab8(Q^6Az2Os+tDBnAzOk^$G%pCcQ*1MH4@bk? zZG4rRyaP&{rY9r{>u9qC#;AAf);sveo6~tei_6>{YDS#*k0d8?E~tKIoEC5M_?L6d z#!`OI0WbcEP)@~7iNbMhP&gIS|R6@}Uw9j;9K@b!a z)UHgZY!NY2l-iZtB3<%$f83oH^6f^dDjD7_Ee} zAmJ=p$)J}rXT!#xt$}7D=%&tDSr-g!$yu3j7Sj@%qi(Y$q>93+_03^Y<6rjA%cditfitGre~L- zcQ6U<{c#9|7oc2p+0}41@dLnF6j|R2Zh*%&xXO;f?!he_9-ELpeSpQt1)OI_WbNbL zRZ&ADR)E#1BQHFgUjQf@nK3(jaR|?4;U*W5H6%eY!r6*q!M{f#neUczQFaw1G&wKN z&>q@UEOC$6F5c8sh=!$c;}0xNbCrbT=b(&?J0$PPC-XFFk*#5rud1?<>=9Bu5sV+7Nigzx{?)nxAN^R&@j=m9 P00000NkvXXu0mjfze<4p literal 0 HcmV?d00001 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/gui.py b/abogen/gui.py index 2cc5f4d..0dc7f2a 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -66,7 +66,6 @@ from constants import ( GITHUB_URL, PROGRAM_DESCRIPTION, LANGUAGE_DESCRIPTIONS, - FLAGS, VOICES_INTERNAL, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, ) @@ -560,9 +559,19 @@ 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) + # Get flag icon for this voice + lang_code = v[0] + flag_path = get_resource_path("abogen.assets.flags", f"{lang_code}.png") + + icon = QIcon() + if flag_path and os.path.exists(flag_path): + icon = QIcon(flag_path) + + # Add item with flag icon and voice name + self.voice_combo.addItem(icon, f"{v}", v) + self.voice_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) diff --git a/abogen/utils.py b/abogen/utils.py index 56d90dc..300faa8 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -32,6 +32,12 @@ 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') diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index 395dcac..4329d7b 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -17,11 +17,11 @@ from PyQt5.QtWidgets import ( ) from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize from PyQt5.QtGui import QPixmap -from constants import VOICES_INTERNAL, FLAGS +from constants import VOICES_INTERNAL from utils import get_resource_path # Constants -VOICE_MIXER_WIDTH = 120 +VOICE_MIXER_WIDTH = 100 SLIDER_WIDTH = 32 MIN_WINDOW_WIDTH = 600 MIN_WINDOW_HEIGHT = 400 @@ -117,11 +117,11 @@ class FlowLayout(QLayout): class VoiceMixer(QWidget): def __init__( - self, voice_name, language_icon, initial_status=False, initial_weight=0.0 + self, voice_name, language_code, initial_status=False, initial_weight=0.0 ): super().__init__() self.voice_name = voice_name - #self.setFixedWidth(VOICE_MIXER_WIDTH) + self.setFixedWidth(VOICE_MIXER_WIDTH) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # TODO Set CSS for rounded corners @@ -130,26 +130,39 @@ class VoiceMixer(QWidget): layout = QVBoxLayout() - # Checkbox + # 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 name label with gender icon - is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f" - gender_icon_path = get_resource_path("abogen.assets", "female.png" if is_female else "male.png") - name = voice_name[3:].capitalize() - name_layout = QHBoxLayout() - # Gender icon - pixmap = QPixmap(gender_icon_path) - if not pixmap.isNull(): - gender_label = QLabel() - gender_label.setPixmap(pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)) - name_layout.addWidget(gender_label) - name_layout.addWidget(QLabel(f"{language_icon} {name}"), alignment=Qt.AlignCenter) - layout.addLayout(name_layout) - # Spinbox and slider self.spin_box = QDoubleSpinBox() self.spin_box.setRange(0, 1) @@ -212,6 +225,8 @@ class HoverLabel(QLabel): # 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 { @@ -251,6 +266,9 @@ class VoiceFormulaDialog(QDialog): def __init__(self, parent=None, initial_state=None): super().__init__(parent) self.setWindowTitle("Voice Mixer") + self.setWindowFlags( + Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint + ) self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT) self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT) self.voice_mixers = [] @@ -334,13 +352,13 @@ class VoiceFormulaDialog(QDialog): def add_voices(self, initial_state): first_enabled_voice = None for voice in VOICES_INTERNAL: - flag = FLAGS.get(voice[0], "") + 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) + 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 @@ -350,10 +368,10 @@ class VoiceFormulaDialog(QDialog): ) def add_voice( - self, voice_name, language_icon, initial_status=False, initial_weight=1.0 + self, voice_name, language_code, initial_status=False, initial_weight=1.0 ): voice_mixer = VoiceMixer( - voice_name, language_icon, initial_status, initial_weight + voice_name, language_code, initial_status, initial_weight ) self.voice_mixers.append(voice_mixer) self.voice_list_layout.addWidget(voice_mixer) From 02b9990004606c4cf91210debab4e77813caad35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Fri, 2 May 2025 12:10:08 +0300 Subject: [PATCH 24/26] Added profile system to voice mixer, improvements in code --- CHANGELOG.md | 8 +- README.md | 16 +- abogen/assets/female.png | Bin 340 -> 442 bytes abogen/assets/flags/a.png | Bin 484 -> 571 bytes abogen/assets/flags/b.png | Bin 531 -> 786 bytes abogen/assets/flags/e.png | Bin 331 -> 521 bytes abogen/assets/flags/f.png | Bin 306 -> 372 bytes abogen/assets/flags/h.png | Bin 373 -> 465 bytes abogen/assets/flags/i.png | Bin 306 -> 381 bytes abogen/assets/flags/j.png | Bin 314 -> 441 bytes abogen/assets/flags/p.png | Bin 464 -> 617 bytes abogen/assets/flags/z.png | Bin 317 -> 431 bytes abogen/assets/male.png | Bin 305 -> 426 bytes abogen/assets/profile.png | Bin 0 -> 241 bytes abogen/assets/voice_mixer.png | Bin 252 -> 331 bytes abogen/conversion.py | 27 +- abogen/gui.py | 232 +++++++-- abogen/utils.py | 8 +- abogen/voice_formula_gui.py | 871 ++++++++++++++++++++++++++++++++-- abogen/voice_formulas.py | 6 +- abogen/voice_profiles.py | 59 +++ demo/voice_mixer.png | Bin 0 -> 28897 bytes 22 files changed, 1137 insertions(+), 90 deletions(-) create mode 100644 abogen/assets/profile.png create mode 100644 abogen/voice_profiles.py create mode 100644 demo/voice_mixer.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cc88f..a987142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ -- 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) +– Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5. +- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles. +- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience. - Added icons for flags and genders in the GUI, making it easier to identify different options. -- Switched to platformdirs for determining the correct desktop path, instead of using old methods. \ No newline at end of file +- Switched to platformdirs for determining the correct desktop path, instead of using old methods. +- Fixed preview voices was not using GPU acceleration, which was causing performance issues. +- Improvements in code and documentation. \ No newline at end of file diff --git a/README.md b/README.md index ab5d746..55abb0c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ abogen 1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor) 2) Configure the settings: - Set speech speed - - Select a voice + - Select a voice (or create a custom voice using voice mixer) - Select subtitle generation style (by sentence, word, etc.) - Select output format - Select where to save the output @@ -83,6 +83,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex - **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor) - **Speed**: Adjust speech rate from `0.1x` to `2.0x` - **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. +- **Voice mixer**: Create custom voices by mixing different voice models with a profile system. - **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) - **Output formats**: `.WAV`, `.FLAC`, or `.MP3` - **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder` @@ -93,10 +94,15 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex - **Create desktop shortcut**: Creates a shortcut on your desktop for easy access. - **Open config.json directory**: Opens the directory where the configuration file is stored. - **Open temp directory**: Opens the temporary directory where converted text files are stored. - - **Clear all teporary files**: Deletes all temporary files created during the conversion process. + - **Clear all temporary files**: Deletes all temporary files created during the conversion process. - **Check for updates at startup**: Automatically checks for updates when the program starts. - **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`. +## `Voice Mixer` + + +With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to @jborza for making this possible through his contributions in #5) + ## `Supported Languages` ``` # 🇺🇸 'a' => American English, 🇬🇧 'b' => British English @@ -136,7 +142,7 @@ Abogen is a standalone project, but it is inspired by and shares some similariti - [ ] Improve PDF support for better text extraction. - [ ] Add chapter metadata for .m4a files using ffmpeg-bin. - [ ] Add support for different languages in GUI. -- [ ] Add voice formula feature that enables mixing different voice models. https://github.com/denizsafak/abogen/issues/1 +- [x] Add voice formula feature that enables mixing different voice models. #1 - [ ] Add support for kokoro-onnx. - [ ] Add dark mode. @@ -154,7 +160,7 @@ If you'd like to modify the code and contribute to development, you can [downloa ```bash # Go to the directory where you extracted the repository and run: pip install -e . # Installs the package in editable mode -python -m build # Builds the package in dist folder +python -m build # Builds the package in dist folder (optional) abogen # Opens the GUI ``` Feel free to explore the code and make any changes you like. @@ -163,7 +169,7 @@ Feel free to explore the code and make any changes you like. - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. -- [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Mix](https://icons8.com/icon/21700/adjust) icons by [Icons8](https://icons8.com/). +- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id), [Person](https://icons8.com/icon/34105/person) icons by [Icons8](https://icons8.com/). ## `License` This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. diff --git a/abogen/assets/female.png b/abogen/assets/female.png index 250dca3d35b45f1b8764506b8df5feb82163d8fa..f252ccebd81cbf2ce1b17de2e5e1458d4a5a7ceb 100644 GIT binary patch delta 430 zcmV;f0a5mzPe{4xa zK~#90cJ07t>OYU)qLqJF=FCmZd0wW!0#3r=$J7wfj?;< Y6wEiDGy=l<00000NkvXX1g=70f-fGue*gdg delta 327 zcmV-N0l5CU1JnW`iBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyie+EfJ zK~#90wURMQ#6S>*gO#1O`U9-&6v4*URCnx*sYUHQ9JQ(h+Pm1e}Q05 zn0Gd6WKIMj#U=+nc(A6yaTL&Om$M+fGt!UI(%Q4zNIs4A*6*SKS5uZV;9^)W zZ@jn>WZ+rSS4Mi{?q3Xf3YJqNsoaxu$kXlsmI9>J<#7N2002ovPDHLkV1niEkN^Mx diff --git a/abogen/assets/flags/a.png b/abogen/assets/flags/a.png index a110f86bb8e2e112796442afbc7f6a7a5d7d7a1f..f770fcfff221beaa6793b6b32caadebf7a96ceb2 100644 GIT binary patch delta 560 zcmV-00?+;A1G@wviBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzPf9**` zK~#90V_+E8Kr3_=n@hy@$k^m7o(-UvzA`_(II=XdXzoe%8-wl4qVWES$@*qD_p z!)rUwWKEiL<#lb>+5d}H-~JDyC(OPA#`b|*|9^2d`~TU=?BQ2Ob3dXD4h>z!oLY2n z7RZ3G)P4W)(Qn>=MBsNHJBYHte<^rN@97J#|A(aP`;U!|%-sL~%8hsb-NJYL|Il1> z02zPEN~*$ZxP$-JV8`HX|AUkF{>MdoMehV-SP-FOoWT`ezn^2$oU5puG(U)a}Ti340g^s%XCH--iF0b759G?9Y8;0U76 zPG&zbEC6{6rV1V!^9due{&SnzE5#u*{yi0i>2_gHe1Fs>Sx%#WW05 ywtRLnz4zJK^cxsEncjnlnT3MXkY)Ig0RSvjwUx;I93}t&002ov22Mn-LSTZnO&E^= delta 472 zcmV;}0Vn>u1mpuDiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyif0ju^ zK~#90V_={Ka17qA9h9)A-ZyU762I7;%j^QTR(*0a)*z_SC3H7`apS4&$%Tjh2l3-_5B_%v+m3L-k_E@!EnaZ! zV||4h+yIB5ZPg(E)^(lzpImt8Kg|CiepbcN|H(y%QCt9aMR{&E+yUGOO_rU!1ohuzz9(QP1vL3-c@ds4>P{c!S0&~+8*q_38WTlYJBmzOe*(lw zL_t(|oZVILOA~PzH^}~&-pUa(xD7JQ!nW^nM82>WfzVH5{dOX114S(iA)*k1q8HU( z#O{`C+HRsoy0ly=Xr|7)+ibhdO*eCQw+ShW} z^!kt%QKtR@u2Zno+gJ^ez=~~rf2PyZXE0_@&PfAhBoQ^IMKrhvIYzMDWjGB1Sh0=I z@Hef2p4IAU!!BII0WG4sl*=fi1qn8kG|&(6-EG~!cr#P4Gg9f@5@-<(HCH^=!fyvC z2Y_@kDNfck41eD#L0Od}MfdrKQvrS?M2kGnLq^2Ue-mMMMRjky z5P_x5`(-_u`mx;gz{8aiG4AM*2>>Y0A&5WkL0l;zuo_||+TO~5D2kXxlH>r(C#bCi z<37PT=U*9G|K^+GkLi5)JZRL9iNWA60647{o^~l5o)sGIxx?l&_R9)!fcQcFHU%J1JNRrH{HrZxr?ig>{I>g*N9CoK_T&)G2?|ocTY0^M<>}XtP zg+z9wER$^rNJPz$$X+-s`2?>+ycYAVO#FzFTCaLS_l^7kd#ZtomSa9{00000NkvXX Hu0mjfmK1Y+ delta 520 zcmV+j0{8ur29pFKiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*wiw zL_t(|oMT|11+WX)qIvz!yS%5CCJR6q#0H5G)M(?qMbIvA>oWVmE&m=r`*Q55rRjeV z2C+e6_5oX#xPS&g|Nnpa|NsANxB*YWp$$QVQ zYGe82RZ7y)n-?A*c=PthA-HYNzZ_e)?f#*^fB%2}dH(#hr;c`4Uq?o2pv1(BK)=ba z_Z&P>+jZtNu4oKR+57V0UX#x!|Ns5F5F;I2x$;5CC3O24LJ7$(aO?T}`tt%fV;dh<%_s&1ZPGx`Of~^q)d*@N2LJ%NfwgtOq|BoL0000< KMNUMnLSTZtXB@-; diff --git a/abogen/assets/flags/e.png b/abogen/assets/flags/e.png index f6474407b271343c652508af1fa784d7dab3f657..693cf2a8f10cdbd63aac2bc64862a532c99e8c19 100644 GIT binary patch delta 510 zcmVmzOe*wEm zL_t(|oZVGTC`3^dKB+fDit)2GMHZU-QlhM6p;(cBBNjGFHkMX)vh!OaiR33Uh@otR z89xic$_DoiqZCnAj0JtVcpDxw^B#&*-{zfnzw^#H_kADHzmDT{vI>>zxJolzr8BNl z@CA%cIU_fBQ0<|EEQZL1gDQnee*vKqLLk5ym4{6XqA0`%i~SsYed@~}11iJgf|TzYJgbP&`l5R}QYVLLWjbJ1O&g7)G9Y>&-jyQ2_d z$R0rl*1JzfbTMwn?mz)trEzF4%Exk*4F`k8I3Kf{9?{E^k3FgIPGn=DF$1nPCsvxW z;G4|BktfyUk~b9Rw_Q`OR8sx>KiZE=g%(m3O^975g(=s)MwArc$h8*IM5K#_Bz|?e zGBOg<4ml&IP-&Vo@^eP_Z}7`!CyZ(gTjrmMXGl>c9W%V)2LJ#707*qoM6N<$g0OGh Ai2wiq delta 319 zcmV-F0l@x=1j_;;iBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*pbS zL_t(|oMT|11-Plppmax@q3o_U!%Pqcu^;F$C=%58T#teKt`5V(J30)1@9Hr8N5)_= zZHDw%1uq$B4lowJ4hZ@8yvHe~|sp|AXxRiNXii z|NcM7jtGmzOe*q>* zL_t(|oZXd8PQx$|gtb<^Ij~} za{xe233}V77uo4o^F0AX9LJ7$)HE1?;dc9SWws0ki16fyNUZ|`*1rp2cyrysB47uP zF2G$tz>NbiI|&%k(D~5paPtN*EWe$wTx6*?fFpI9*C1yefH9hjI(3L{J`WhT8rPql z$eFaSyAi;!pa_TX_%c&%%IlRW?zJgjtg3Z!<@II$nR*AqbKBtpdak1Y0000|_g^ny7r15m;?*~fy?OufKQjKj zxaZh^8;0rsT^RVWYBu)S#AP42^_YF&*8huEU&k=u%c9O>|7{um|F>Z{_1~U>2g3ln zz^(H^nh6>J!Shfw+XQV`$PQ6du3$^Whl+!zLcfJ^9Ze!NN9mSGys rq>K!}nd$wfGH7O+3uY6OnaBYE)WgE$C&8^z00000NkvXXu0mjf0ttwS diff --git a/abogen/assets/flags/h.png b/abogen/assets/flags/h.png index a3a420912fe13ddadef03daad727c5bf2ce8b800..015ec29ca282a4038f85e95d4b171fc7158bc488 100644 GIT binary patch delta 453 zcmV;$0XqKm0?`8@iBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzPe}hRx zK~#90-IKvf!eAK2-z5Eqgf`h1-Z~XpQS{$TJDU4!9^5=?MBpvSyI!}8c(l-*;EqD= z1EIqk-%yY?x3zhx2Ob#j-tYNs@5=}9uZ11RU;}PYhbPovf*P>3CvJ_u>p&)6cmpm` zgE{JeA+}J5i|D~Jq)>5sn*3aJUX<3Bu8}2Iv0aO-Q2#v%~-$ zrYOUq2e2|Lg|Fw6{yw}iD+LdLG4{Y1!(Co1me_6&UEB6@XW!aw55RGp*P!rv-9Xc{ z&@e1iRU;6>v-|+gt^=*s5DjC7rP2V)<$(+0wE%$Q2lxyEn5MM_m<0kDe`6nhfN{`> zRMl|*!ZhbmMl}BQl5KnGes%06cSpUV{Bqmhhr6R*(F4dfvuS~ncaaiIW@(a_^WKZb zm8+7|q<|xO?7d{$5~-xb1}NkXLM1~=P$U`yPz8j8aKL6Qt=Oa}h{(;1H9)QrGK{!e7!!!%$z!%`wN!(6f$Rr9sUYJZoh9Q(UOi8#DW<=Br4 zlhxn`eEIUF;_KJ1|B>;F7w`XXf7x;aj9(>S73bt*#@qfXB z^ZyqvJP&50%YXg)bsn+-^RXEK(u~J|xp=+s^5qBOy|C`WIOwfTo> zD@e?GF-Q$o&B(yK-%it_*SgZY*Jc3-gV>fmmKp>#k^=yOKe8{iVsg^}0000X diff --git a/abogen/assets/flags/i.png b/abogen/assets/flags/i.png index d602e54368fcccc479f1fe1567f0c5dc5b0e3d87..667820228014d56091e392e85dd0b0246a6facd9 100644 GIT binary patch delta 369 zcmV-%0gnE%0{sFZiBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzOe*rH^ zL_t(|oZXc{PQx$|MSoeecj*Cekesf&L!=bJ1xPlWp(m)I>~WBw0(u`ByxKhs zW0pvzX`RSNLOyzxjRynlgu)v|f3ZhK6#$tyE(mCp0wTdKzsxrREFq<3$(wHs$Yd|q z*{Iu!1bcM=NT`dJ<@9?eKh3uU5Or`=1jKCO48U=3xm<7N4B+FXn1Nsmhk(WJ0ysWw zC7L?S;MoFT4-Nr$ML^7s9hRtXZ8S67Y6CcCD<|x!U_os_2Z7Z*_nOXYM*xN>4MHOi zk<|giO5^%FlWXt5bayF$V@f{uQRB-Dq|&!@l|D+P-;A<#dQ|7{um|F>Z{_1~U>2g87j zc^30Qnh6>J!ShfwXU;WIN}q53dz)wjY#9Fhw`Wj6IDejb5ll0YE&v%)g>e3S^BH6q zuz*qn=2PYcTZT&N1tP`K$iTqBP`Jv1Gkw1KQ6du3$^Whl+!zLcK-x@We!NN9mSGys rq>K!}nd$wfGH7O+3uY6OnaBYE7A3*&j@7&400000NkvXXu0mjfI_!nd diff --git a/abogen/assets/flags/j.png b/abogen/assets/flags/j.png index 4bcf2f98f7134250fa09b717bf476bf61d233fc2..ad5569e2dda699487c537899dcd2e8a795635767 100644 GIT binary patch delta 430 zcmV;f0a5mzOe*tSr zL_t(|oZVF0PQpMCZ7}dLaU?)}WVP6P31VBHsRS+nPJ~or0g4Jss!#wN zZ}aLsA@QaZ7&eKpM?|&qmsbIYe-%l$T$ZZfu&5c#k4(}u7JBj*r}sWZj+>R<8wsR( zWe1NtNf6H%dDIY!AXf^O3h7lmGw# delta 302 zcmV+}0nz@s1G)ksiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*o-B zL_t(|oV8O+3c@fD?Fn>eTTfuUoOR>xEPg<3@1r7J>Q-h*CfV1>h@Uhy6hQ+6VZwWp z_wo`1|5%8+kAkKkXvM{QM4dDOg%E9&DhEMVl*-uk=w*o5@`6Fq!3EZcB<(+vpj3Gi z_+7NAX$I@sZ~=1~q^1ZXQ$(>jf2ZlaSCM0Tf`VrD1O^;~pi$vs^t4p00000NkvXX1g=70f&}1w A>i_@% diff --git a/abogen/assets/flags/p.png b/abogen/assets/flags/p.png index 9913de8531f27dcf9925a42cf524d821ef7a10d1..8abf47f0730402c3148037d9384b9c1bd8d423db 100644 GIT binary patch delta 607 zcmV-l0-*iS1L*`IiBL{Q4GJ0x0000DNk~Le0000O0000O2nGNE0N{5$_>mzOe*zmx zL_t(|oZVEc%tX6tDF}Kfw&~d(1EGi5C|(5d8bg$7X7j@| z5n9?3Xu%#@47h2NNDtja2#S(;>fw`bb(idBHXGx~4m?DZmQEy^QVxI>8lb!}dp>4a?h^_Y#Sbcg3 zPo9?{zFviopT0ro=Q$+a)}WYsXgO<4gSCVWBD+5Ls#zOkQz26t1HpTn!1Je2K6-6A zi{y}`#Muv#M8`PZog9TifBBkuNmGeM|85@|DlyNYtSGN8kwR6`1j%lrx~C~*76m3h zu(GzA{IaJ32ABQ0bx(ta=J>LXW`*>ywmj2xV78~huJ0M)p4+bC}8z}d{1^ZlGT)711IVeA;KgtH*wELzE+mosO>#-6Q# zW+LdO&RJO(3~b3+nQ#`<5}KoKf3q%VUTIW;ay3B+GFvVV?;DW1?1Id7Cyd68UqEzS z@vK{9&!qa`#q>Zf?T7ry5WE-Mu$0zpc5E{tf`+WDrJ@_AXP2ROFbVDbaR`MMpj>p> z)o?cP1Hf4nS>Fn7fX6nt%8tSA!7Usfn~*+zfW^oKoM%R4?c?56Q9~nEe}L7gBQHFg zUjQf@nK3(jaR|?4;U*W5H6%eY!r6*q!M{f#neUczQFaw1G&wKN&>q@UEOmzOe*s}h zL_t(|oZVErPC`KtJpsYcNk!ooU`u1-E|nHUqC~hp{=q~)!B~kHenNv^z_*ABCYF{4 z`(tJou2r=A4_VwIn6ysFK>OhW1w=@aAm%=(V_mt_$RT zW)YFVCGZ$J2w9ZyG)L~H7ZG@HHTNZSxGGrai9oScy(;Os1cp4wlHDTtzHQMh(%8m< zXE}1uqY{OoTR;va!;c+{>O{7cHQ#IcboJob9A4zf%RG6$VWA^|?M<@2dG1relS`lz z8&L7ie2n{P0U2Cz0d3FBe=2B93zU$d!t2r>O=A!{VPz_=K;Y0x9(hfzCCG1Pk{baa zMTOf0p5IRxVUrLGeAYjx25LX~UrnzQCbi$1*ZzvfvUx^g?hB6rrxn z53vdI?0Ay;sW1V@(-R72?m;K6f1%=bjI2e&L?+JyD|Qp)v7n)1l{83`4a+33c=o?^ zHxGG@dr~h+)(y81%>5RcmWGPe^;;HMi+o_Af}*d9!(5pEE2}8HzQpc}XI%T|pp1+= zB=5;5^E7Iatznd}smzOe*s)c zL_t(|ob8lLO2j}AhO^R@q6IKG21ZS5Q!^ z5?w|r(}oz5I0+-Tn1UjqAOBbB`a702jY)@8)RjPSRsL^4KD+>z1mNAD0KBZYqKAnB zyqvhx3s)uv@Cw9vd~ARh#Ir)tf1v~#NTATJo*Y{1giB zU7+Rc-d_P+c`DA;DcspQeV*fVG`0oM@EhXxKkPyaE9#a41kjNYe{_yt7xMEqye*~> zO@8H36ao}oCYyV>SGGn1bxaW#^#MS{o3o9}$HqytHtGSP)f8a&!_`U?bO6Ny=)E?; z*pi*lDJ??NzB!`v+5qEyu5QQ;&Ce%IR`g_wIH(OsYzlajI!R;O3tr|qU4^@;g8;9# z&*B-YNsP|eK%nrVUGtho;y=Dblh0O6e--?$$!}cwG^~>N0yr6MpJ;SP)c^nh07*qo IM6N<$f|BdDYXATM delta 292 zcmV+<0o(qn1F-@jiBL{Q4GJ0x0000DNk~Le0000G0000G2nGNE03Y-JVUZyhe*oi2 zL_t(|oXwFjZUR9JMJc((*egMDgybL`V;4#;5H66|fr>OK00D;qjz9|<|1!l!Gq51R zM!Tc|OJ5pU^WS`X8jXi9R!muuU#d314gD)CIzdkC>t;YvPSFd-MC|)!fGf;u{bQPP zfE(E}E0!d%AQmi0@K%_^oj(EYdSKp@*op)xDKb)9v*IWDz~G$5zmQ^f1>pFyt=u?o zo-U8`CMh=ICo1QpL+tdbugF+!`+tC-TMNK(VPzxOn#Cs7fOp?!#V#%}E)ZEbhhFmO qTa|zqSN&5=qVPw}1Gtv?ZuJW)1+1zig}}c60000FSZ>RclH7JO*v|qcobX|h_hNbHI{>dEqhAsP3?Yr674n5oJAD3}py&Ers8UtH{ zZ3J_`Bg;>AS9&ffuQUwGke01d5xu=L_sb;aDP41SOtI$u@Ms-_`|f?L4_3ZRZFt9U z!Lnh|#vE3L{=Uzv*aQw`GafiU=R8xA`jv2o8^y2P%Q_1`{@Q-Nu~z7TQSwTrFFW3x lS$+MhLgVc^i~R*x_{wymzPfBi{B zK~#90-P5s3LqQOQ;g2Xv8v7KMmSSmVCEBM~&|VB;msc?9RIIg9n}}Fw?E{zxNb3Vw z1;NOL4d`X>MsW%1fz#}nnV*4KKC33J+Jt;!%^5Zlc?@Ju-Y0f42E5U>P@f!ZYr%GY)6)5&>L922}yBBKgv37(1=9w1yG2j^MiF_}(N=0umV|scA6m<)3x&2G> QEdT%j07*qoM6N<$g23>C>i_@% delta 238 zcmV= 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,23 +583,10 @@ class abogen(QWidget): voice_layout.addWidget(QLabel("Select Voice:", self)) voice_row = QHBoxLayout() self.voice_combo = QComboBox(self) - - for v in VOICES_INTERNAL: - # Get flag icon for this voice - lang_code = v[0] - flag_path = get_resource_path("abogen.assets.flags", f"{lang_code}.png") - - icon = QIcon() - if flag_path and os.path.exists(flag_path): - icon = QIcon(flag_path) - - # Add item with flag icon and voice name - self.voice_combo.addItem(icon, f"{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' @@ -650,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 @@ -775,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: @@ -957,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() @@ -1065,14 +1157,23 @@ class abogen(QWidget): # 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 + 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, @@ -1355,13 +1456,32 @@ class abogen(QWidget): self.btn_start.setEnabled(True) # Re-enable start button on error return - lang, voice, speed = ( - self.selected_voice[0], - self.selected_voice, - self.speed_slider.value() / 100.0, - ) + # Support preview for voice profiles + speed = self.speed_slider.value() / 100.0 + if self.mixed_voice_state: + # Build voice formula string + components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state] + voice = " + ".join(filter(None, components)) + # determine language: use profile setting if available, else first voice code + if self.selected_profile_name: + from voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + lang = entry.get("language") + else: + lang = None + if not lang and self.mixed_voice_state: + lang = ( + self.mixed_voice_state[0][0][0] + if self.mixed_voice_state and self.mixed_voice_state[0][0] + else None + ) + else: + lang = self.selected_voice[0] + voice = self.selected_voice + self.preview_thread = VoicePreviewThread( - np_module, kpipeline_class, lang, voice, speed + np_module, kpipeline_class, lang, voice, speed, self.use_gpu ) self.preview_thread.finished.connect(self._play_preview_audio) self.preview_thread.error.connect(self._preview_error) @@ -1565,7 +1685,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) @@ -1699,13 +1819,35 @@ class abogen(QWidget): 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 a profile is selected in combo, open mixer with that profile + initial_state = None + selected_profile = self.selected_profile_name + if selected_profile: + entry = load_profiles().get(selected_profile, {}) + # support new dict format + if isinstance(entry, dict): + initial_state = entry.get("voices", []) + else: + initial_state = entry + elif self.mixed_voice_state is not None: + initial_state = self.mixed_voice_state + else: + initial_state = [(self.selected_voice, 1.0)] if self.selected_voice else [] + dialog = VoiceFormulaDialog( + self, initial_state=initial_state, selected_profile=selected_profile + ) if dialog.exec_() == QDialog.Accepted: + # After OK, select the profile in combo and update config + if dialog.current_profile: + self.selected_profile_name = dialog.current_profile + self.config["selected_profile_name"] = dialog.current_profile + if "selected_voice" in self.config: + del self.config["selected_voice"] + save_config(self.config) + self.populate_profiles_in_voice_combo() + idx = self.voice_combo.findData(f"profile:{dialog.current_profile}") + if idx >= 0: + self.voice_combo.setCurrentIndex(idx) self.mixed_voice_state = dialog.get_selected_voices() def show_about_dialog(self): diff --git a/abogen/utils.py b/abogen/utils.py index 300faa8..f65bc8e 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -33,8 +33,10 @@ def get_resource_path(package, resource): 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) + 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 @@ -56,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 4329d7b..dc92b5d 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, @@ -14,11 +16,31 @@ from PyQt5.QtWidgets import ( 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 -from constants import VOICES_INTERNAL +from PyQt5.QtGui import QPixmap, QIcon, QColor +from constants import ( + VOICES_INTERNAL, + SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, + LANGUAGE_DESCRIPTIONS, +) from utils import get_resource_path +from voice_profiles import ( + load_profiles, + save_profiles, + delete_profile, + duplicate_profile, + export_profiles, +) + # Constants VOICE_MIXER_WIDTH = 100 @@ -27,7 +49,22 @@ MIN_WINDOW_WIDTH = 600 MIN_WINDOW_HEIGHT = 400 INITIAL_WINDOW_WIDTH = 1000 INITIAL_WINDOW_HEIGHT = 500 -FEMALE, MALE = "👩‍🦰", "👨" + +# 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): @@ -141,22 +178,30 @@ class VoiceMixer(QWidget): 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_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)) + 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)) + 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) @@ -253,7 +298,7 @@ class HoverLabel(QLabel): 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) + self.delete_button.move(self.width() - 16, +0) def enterEvent(self, event): self.delete_button.show() @@ -263,8 +308,75 @@ class HoverLabel(QLabel): 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.setWindowFlags( Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint @@ -274,37 +386,55 @@ class VoiceFormulaDialog(QDialog): self.voice_mixers = [] self.last_enabled_voice = None - # Main Layout - main_layout = QVBoxLayout() - - # Header + # 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) - main_layout.addWidget(self.header_label) + header_row = QHBoxLayout() + header_row.addWidget(self.header_label, 1) + header_row.addStretch() + header_row.addWidget(QLabel("Language:")) + self.language_combo = QComboBox() + for code, desc in LANGUAGE_OPTIONS: + flag = get_resource_path("abogen.assets.flags", f"{code}.png") + if flag and os.path.exists(flag): + self.language_combo.addItem(QIcon(flag), desc, code) + else: + self.language_combo.addItem(desc, code) + # set current language for profile + prof = profiles.get(self.current_profile, {}) + lang = prof.get("language") if isinstance(prof, dict) else None + if not lang: + lang = list(LANGUAGE_DESCRIPTIONS.keys())[0] + idx = self.language_combo.findData(lang) + if idx >= 0: + self.language_combo.setCurrentIndex(idx) + self.language_combo.currentIndexChanged.connect(self.mark_profile_modified) + header_row.addWidget(self.language_combo) + mixer_layout.addLayout(header_row) # Error message self.error_label = QLabel( - "No voices selected or all weights are 0. Please select at least one voice and set its weight above 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) + mixer_layout.addWidget(self.error_label) # Voice weights display self.weighted_sums_container = QWidget() self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) self.weighted_sums_layout.setSpacing(5) self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5) - main_layout.addWidget(self.weighted_sums_container) + mixer_layout.addWidget(self.weighted_sums_container) # Separator separator = QFrame() separator.setFrameShadow(QFrame.Sunken) - main_layout.addWidget(separator) + mixer_layout.addWidget(separator) # Voice list scroll area self.scroll_area = QScrollArea() @@ -320,7 +450,7 @@ class VoiceFormulaDialog(QDialog): QSizePolicy.Expanding, QSizePolicy.Expanding ) self.scroll_area.setWidget(self.voice_list_widget) - main_layout.addWidget(self.scroll_area, stretch=1) + mixer_layout.addWidget(self.scroll_area, stretch=1) # Buttons button_layout = QHBoxLayout() @@ -341,13 +471,179 @@ class VoiceFormulaDialog(QDialog): button_layout.addWidget(clear_all_button) button_layout.addWidget(ok_button) button_layout.addWidget(cancel_button) - main_layout.addLayout(button_layout) + mixer_layout.addLayout(button_layout) - self.setLayout(main_layout) - - # Setup voices and display self.add_voices(initial_state or []) self.update_weighted_sums() + self.update_subtitle_combo_enabled() + + # assemble splitter + splitter.addWidget(profile_widget) + splitter.addWidget(mixer_widget) + splitter.setStretchFactor(1, 1) + # set as main layout + self.setLayout(QHBoxLayout()) + self.layout().addWidget(splitter) + + # Connect profile actions + self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed) + # Track initial profile for proper dirty-state saving + self.last_profile_row = self.profile_list.currentRow() + self.btn_new_profile.clicked.connect(self.new_profile) + self.btn_export_profiles.clicked.connect(self.export_all_profiles) + self.btn_import_profiles.clicked.connect(self.import_profiles_dialog) + # Detect modifications in voice mixers + for vm in self.voice_mixers: + vm.spin_box.valueChanged.connect(self.mark_profile_modified) + vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) + vm.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled) + vm.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled) + + def keyPressEvent(self, event): + # Bind Delete key to delete_profile when a profile is selected + if event.key() == Qt.Key_Delete and self.profile_list.hasFocus(): + item = self.profile_list.currentItem() + if item: + self.delete_profile(item) + return + super().keyPressEvent(event) + + def _has_unsaved_changes(self): + # Only return True if there are actually modified (yellow background) profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + # Only consider as unsaved if profile is marked dirty (yellow background) + if item.text().startswith("*"): + return True + return False + + def _prompt_save_changes(self): + dirty_indices = [ + i + for i in range(self.profile_list.count()) + if self.profile_list.item(i).text().startswith("*") + ] + parent = self.parent() + if len(dirty_indices) > 1: + msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?" + ret = QMessageBox.question( + self, + "Unsaved Changes", + msg, + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, + QMessageBox.Save, + ) + if ret == QMessageBox.Save: + # Save all using stored states + profiles = load_profiles() + for i in dirty_indices: + name = self.profile_list.item(i).text().lstrip("*") + state = self._profile_states.get(name) + if state is not None: + profiles[name] = state + self._profile_dirty[name] = False + save_profiles(profiles) + # clear states + for name in list(self._profile_states.keys()): + if name not in profiles: + continue + del self._profile_states[name] + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + # clear markers + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return True + elif ret == QMessageBox.Discard: + # Discard all modifications + self._profile_states.clear() + for i in dirty_indices: + item = self.profile_list.item(i) + n = item.text().lstrip("*") + item.setText(n) + self._profile_dirty[n] = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + # reload current profile + profiles = load_profiles() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + else: + # Fallback to original logic for 0 or 1 dirty profile + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("Unsaved Changes") + box.setText( + "You have unsaved changes in your profile. Do you want to save the changes?" + ) + box.setStandardButtons( + QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel + ) + box.setDefaultButton(QMessageBox.Save) + ret = box.exec_() + if ret == QMessageBox.Save: + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if ( + self._profile_dirty.get(name, False) + or item.text().startswith("*") + or (name == self.current_profile) + ): + self.profile_list.setCurrentRow(i) + self.save_profile_by_name(name) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + elif ret == QMessageBox.Discard: + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + self._profile_dirty[name] = False + if item.text().startswith("*"): + item.setText(name) + self.update_profile_save_buttons() + self.update_profile_list_colors() + if self.current_profile in profiles: + self.load_profile_state(self.current_profile) + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + return True + else: + return False + + def on_profile_selection_changed(self, row): + # Save dirty state for previous profile + if hasattr(self, "last_profile_row") and self.last_profile_row is not None: + prev_item = self.profile_list.item(self.last_profile_row) + if prev_item: + prev_name = prev_item.text().lstrip("*") + self._profile_dirty[prev_name] = prev_item.text().startswith("*") + # Do NOT auto-save if modifications pending + # load new profile + item = self.profile_list.item(row) + if item: + name = item.text().lstrip("*") + self.load_profile_state(name) + # Restore dirty state for this profile + dirty = self._profile_dirty.get(name, False) + if dirty and not item.text().startswith("*"): + item.setText("*" + item.text()) + elif not dirty and item.text().startswith("*"): + item.setText(item.text().lstrip("*")) + self.last_profile_row = row + self.update_profile_save_buttons() + self.update_profile_list_colors() def add_voices(self, initial_state): first_enabled_voice = None @@ -358,7 +654,9 @@ class VoiceFormulaDialog(QDialog): ) initial_status = matching_voice is not None initial_weight = matching_voice[1] if matching_voice else 1.0 - voice_mixer = self.add_voice(voice, language_code, initial_status, initial_weight) + 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 @@ -379,6 +677,13 @@ class VoiceFormulaDialog(QDialog): lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) ) voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) + voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums) + voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified) + voice_mixer.checkbox.stateChanged.connect( + lambda *_: self.mark_profile_modified() + ) + voice_mixer.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled) + voice_mixer.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled) return voice_mixer def handle_voice_checkbox(self, voice_mixer, state): @@ -425,7 +730,10 @@ class VoiceFormulaDialog(QDialog): 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 = 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) @@ -435,6 +743,20 @@ class VoiceFormulaDialog(QDialog): self.error_label.show() self.weighted_sums_container.hide() + def update_subtitle_combo_enabled(self): + # Only enable subtitle_combo if at least one selected voice is from supported languages + selected_langs = set() + for vm in self.voice_mixers: + if vm.checkbox.isChecked() and vm.spin_box.value() > 0: + lang_code = vm.voice_name[0] + selected_langs.add(lang_code) + enable = any( + lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + for lang in selected_langs + ) + if self.subtitle_combo: + self.subtitle_combo.setEnabled(enable) + def disable_voice_by_name(self, voice_name): for mixer in self.voice_mixers: if mixer.voice_name == voice_name: @@ -461,7 +783,130 @@ class VoiceFormulaDialog(QDialog): return True return super().eventFilter(source, event) + def load_profile_state(self, profile_name): + name = profile_name.lstrip("*") + profiles = load_profiles() + # load voices and language from state or JSON + if name in self._profile_states: + state = self._profile_states[name] + else: + state = profiles.get(name, {}) + voices = state.get("voices") if isinstance(state, dict) else state + lang = state.get("language") if isinstance(state, dict) else None + # apply language selection + if lang: + i = self.language_combo.findData(lang) + if i >= 0: + self.language_combo.blockSignals(True) + self.language_combo.setCurrentIndex(i) + self.language_combo.blockSignals(False) + self.current_profile = name + weights = {n: w for n, w in voices} + for vm in self.voice_mixers: + weight = weights.get(vm.voice_name, 0.0) + # block signals to avoid triggering updates + vm.checkbox.blockSignals(True) + vm.spin_box.blockSignals(True) + vm.slider.blockSignals(True) + vm.checkbox.setChecked(weight > 0) + val = weight if weight > 0 else 1.0 + vm.spin_box.setValue(val) + vm.slider.setValue(int(val * 100)) + # restore signals + vm.checkbox.blockSignals(False) + vm.spin_box.blockSignals(False) + vm.slider.blockSignals(False) + # sync enabled state + vm.toggle_inputs() + self.update_weighted_sums() + self.update_subtitle_combo_enabled() + + def save_profile_by_name(self, name): + profiles = load_profiles() + state = self._profile_states.get(name, None) + if state is not None: + # ensure dict format + if isinstance(state, dict): + entry = state + else: + entry = {"voices": state, "language": self.language_combo.currentData()} + profiles[name] = entry + save_profiles(profiles) + self._profile_dirty[name] = False + del self._profile_states[name] + self._virtual_new_profile = False + # Remove * marker + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + if item.text().lstrip("*") == name: + item.setText(name) + break + self.update_profile_list_colors() + self.update_profile_save_buttons() + self.update_weighted_sums() + + def _handle_zero_weight_profiles(self): + if self.profile_list.count() <= 1: + return False + zero = [] + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + zero.append((i, name)) + if not zero: + return False + msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?" + # Use Delete instead of Ignore + reply = QMessageBox.question( + self, + "Invalid Profiles", + msg, + QMessageBox.Yes | QMessageBox.Cancel, + QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + for i, name in reversed(zero): + self.profile_list.takeItem(i) + delete_profile(name) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_list_colors() + self.update_profile_save_buttons() + return False + else: + idx, _ = zero[0] + self.profile_list.setCurrentRow(idx) + return True + def accept(self): + # If no profiles, treat as cancel + if self.profile_list.count() == 0: + # Update subtitle_mode to match combo before closing + if self.subtitle_combo: + parent = self.parent() + if parent is not None: + parent.subtitle_mode = self.subtitle_combo.currentText() + self.reject() + return + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return selected_voices = self.get_selected_voices() total_weight = sum(weight for _, weight in selected_voices) if total_weight == 0: @@ -470,5 +915,375 @@ class VoiceFormulaDialog(QDialog): "Invalid Weights", "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", ) + self.update_weighted_sums() return + # Save weights to current profile + profiles = load_profiles() + profiles[self.current_profile] = { + "voices": selected_voices, + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + # Mark this profile as not dirty + self._profile_dirty[self.current_profile] = False super().accept() + + def reject(self): + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + return + if self._handle_zero_weight_profiles(): + return + super().reject() + + def closeEvent(self, event): + # Prompt to save if unsaved changes, then check for zero-weight error after save + if self._has_unsaved_changes(): + if not self._prompt_save_changes(): + event.ignore() + return + if self._handle_zero_weight_profiles(): + event.ignore() + return + super().closeEvent(event) + + def mark_profile_modified(self): + item = self.profile_list.currentItem() + if item and not item.text().startswith("*"): + item.setText("*" + item.text()) + # Flag profile as dirty and store unsaved state + name = self.current_profile + self._profile_dirty[name] = True + self._profile_states[name] = { + "voices": self.get_selected_voices(), + "language": self.language_combo.currentData(), + } + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def new_profile(self): + name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:") + if ok and name: + profiles = load_profiles() + # Remove 'New profile' placeholder if not persisted in JSON + if ( + self.profile_list.count() == 1 + and self.profile_list.item(0).text() == "New profile" + and "New profile" not in profiles + ): + self.profile_list.takeItem(0) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + if name in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + return + profiles[name] = { + "voices": [], + "language": self.language_combo.currentData(), + } + save_profiles(profiles) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), name + ) + ) + self.profile_list.setCurrentRow(self.profile_list.count() - 1) + # reset UI mixers + for vm in self.voice_mixers: + vm.checkbox.setChecked(False) + vm.spin_box.setValue(1.0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + self.update_weighted_sums() + + def export_all_profiles(self): + # Prevent export if any profile has total weight 0 + profiles = load_profiles() + for name, weights in profiles.items(): + total = 0 + voices = weights.get("voices", []) + if isinstance(voices, list): + for entry in voices: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profiles", "voice_profiles", "JSON Files (*.json)" + ) + if path: + export_profiles(path) + + def import_profiles_dialog(self): + path, _ = QFileDialog.getOpenFileName( + self, "Import Profiles", "", "JSON Files (*.json)" + ) + if path: + from voice_profiles import load_profiles, save_profiles + + # Try to read the file and count profiles + try: + import json + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + # always expect abogen_voice_profiles wrapper + if not (isinstance(data, dict) and "abogen_voice_profiles" in data): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + imported_profiles = data["abogen_voice_profiles"] + if not isinstance(imported_profiles, dict): + QMessageBox.warning( + self, + "Invalid File", + "This file is not a valid abogen voice profiles file.", + ) + return + count = len(imported_profiles) + except Exception: + QMessageBox.warning( + self, "Import Error", "Could not read the selected file." + ) + return + if count == 0: + QMessageBox.information( + self, "No Profiles", "No profiles found in the selected file." + ) + return + profiles = load_profiles() + collisions = [name for name in imported_profiles if name in profiles] + # Combine prompts: show both import count and overwrite count if any + if count == 1: + orig_name = next(iter(imported_profiles.keys())) + msg = f"Profile '{orig_name}' will be imported." + if collisions: + msg += f"\nThis will overwrite an existing profile." + msg += "\nContinue?" + reply = QMessageBox.question( + self, "Import Profile", msg, QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profile Imported", + f"Profile '{orig_name}' imported successfully.", + ) + else: + msg = f"{count} profiles will be imported." + if collisions: + msg += f"\n{len(collisions)} profile(s) will be overwritten." + msg += "\nContinue?" + reply = QMessageBox.question( + self, "Import Profiles", msg, QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + profiles.update(imported_profiles) + save_profiles(profiles) + QMessageBox.information( + self, + "Profiles Imported", + f"{count} profiles imported successfully.", + ) + # Refresh list + self.profile_list.clear() + profiles = load_profiles() + for nm in profiles: + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), nm + ) + ) + if self.profile_list.count() > 0: + self.profile_list.setCurrentRow(0) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self._virtual_new_profile = False + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def show_profile_context_menu(self, pos): + item = self.profile_list.itemAt(pos) + if not item: + return + name = item.text().lstrip("*") + menu = QMenu(self) + rename_act = QAction("Rename", self) + delete_act = QAction("Delete", self) + dup_act = QAction("Duplicate", self) + export_act = QAction("Export this profile", self) + menu.addAction(rename_act) + menu.addAction(dup_act) + menu.addAction(export_act) + menu.addAction(delete_act) + act = menu.exec_(self.profile_list.viewport().mapToGlobal(pos)) + if act == rename_act: + self.rename_profile(item) + elif act == delete_act: + self.delete_profile(item) + elif act == dup_act: + self.duplicate_profile(item) + elif act == export_act: + self.export_selected_profile_item(item) + + def export_selected_profile_item(self, item): + if not item: + return + name = item.text().lstrip("*") + profiles = load_profiles() + weights = profiles.get(name, {}).get("voices", []) + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + QMessageBox.warning( + self, + "Export Blocked", + f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.", + ) + return + path, _ = QFileDialog.getSaveFileName( + self, "Export Profile", f"{name}.json", "JSON Files (*.json)" + ) + if path: + # Use abogen_voice_profiles wrapper for single profile export + with open(path, "w", encoding="utf-8") as f: + json.dump( + {"abogen_voice_profiles": {name: profiles.get(name, {})}}, + f, + indent=2, + ) + + def rename_profile(self, item): + old = item.text().lstrip("*") + new, ok = QInputDialog.getText( + self, "Rename Profile", f"Profile name:", text=old + ) + if ok and new and new != old: + profiles = load_profiles() + if new in profiles: + QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") + return + profiles[new] = profiles.pop(old) + save_profiles(profiles) + item.setText(new) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def delete_profile(self, item): + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + self._virtual_new_profile = False + self._profile_dirty.pop("New profile", None) + self.update_profile_save_buttons() + self.update_profile_list_colors() + return + reply = QMessageBox.question( + self, + "Delete Profile", + f"Delete profile '{name}'?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply == QMessageBox.Yes: + delete_profile(name) + row = self.profile_list.row(item) + self.profile_list.takeItem(row) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def duplicate_profile(self, item): + src = item.text().lstrip("*") + profiles = load_profiles() + base = f"{src}_duplicate" + new = base + i = 1 + while new in profiles: + new = f"{base}{i}" + i += 1 + duplicate_profile(src, new) + self.profile_list.addItem( + QListWidgetItem( + QIcon(get_resource_path("abogen.assets", "profile.png")), new + ) + ) + parent = self.parent() + if hasattr(parent, "populate_profiles_in_voice_combo"): + parent.populate_profiles_in_voice_combo() + self.update_profile_save_buttons() + self.update_profile_list_colors() + + def update_profile_save_buttons(self): + # Remove all save buttons first + for i in range(self.profile_list.count()): + self.profile_list.setItemWidget(self.profile_list.item(i), None) + # Add save button to dirty profiles + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if item.text().startswith("*"): + widget = SaveButtonWidget( + self.profile_list, name, self.save_profile_by_name + ) + self.profile_list.setItemWidget(item, widget) + + def update_profile_list_colors(self): + profiles = load_profiles() + for i in range(self.profile_list.count()): + item = self.profile_list.item(i) + name = item.text().lstrip("*") + if self._virtual_new_profile and name == "New profile": + item.setBackground(QColor("#fff59d")) # yellow + elif item.text().startswith("*"): + item.setBackground(QColor("#fff59d")) # yellow + else: + weights = profiles.get(name, {}).get("voices", []) + # Defensive: only sum if weights is a list of (voice, weight) pairs + total = 0 + if isinstance(weights, list): + for entry in weights: + if ( + isinstance(entry, (list, tuple)) + and len(entry) == 2 + and isinstance(entry[1], (int, float)) + ): + total += entry[1] + if total == 0: + item.setBackground(QColor("#ffcdd2")) # light red + else: + item.setBackground(QColor("white")) + self.update_profile_save_buttons() diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index fa867cd..c28dfbe 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -29,8 +29,8 @@ def parse_voice_formula(pipeline, formula): 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 @@ -52,6 +52,6 @@ def parse_voice_formula(pipeline, formula): 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 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/voice_mixer.png b/demo/voice_mixer.png new file mode 100644 index 0000000000000000000000000000000000000000..6b40823bf8afc39fec288250d9585eddbce55be2 GIT binary patch literal 28897 zcmbrl2UL?=*DlK5Y{Y_KLzJolqDWDCM+K<@QbR{Yx^yBn1VljuK|nxiq<2Dx1On2P zP^5+)dP#uLLTE|O3;X;3aqhX}j&aX98Og}ok~PojrH|Mo+1sW01sD z$xB+7c#Cq$4Hs+B#^F&y_SjnUiw>uTboLf1d#j97?2 zQwj|_fA7kzpnrZ%9aFxiq#{H}N$S1omVIG6U(GRyBx#sunlubi>KUaO3wpJ%uwcKt z(%;qFYh-JiJLys@eGZ_QhUSw#2$U!zD?2#H#kgaZ7 z_A>bV`SW7d9SqFZuB~jNg=@7>7(zpH#qD_CU0g!qj8&gJDK&~yzHn!`FYGx>Y`#_C z!D6z6bFxv1X^svAr+@qE-zPr^8afz;2j+nBBdasbA$|lD>HVUMI+Y(9+xX`975VL7 z+(^nHzGUa$hp#=jckhpuJjm@$ljWoH8&Zv8FK~jRjHqNRwM0GcZqk3A z7nAne&8KW}QFY;shlRZ`ML2_cvY10GK*Y0H#*^_MD~ftQSMNT5n76t((_Fh7!ljVG9fplD7ef3ke1F$9QBx>!Ay(4^u>c=o20j0i z1~V&O9n4FPIOX6=HGhByUigyQE<*!@g{f+%L=!AJ{A%8AG|Vc8gmvBFofS$nzj@Q+ zx7r_3bBf3TWqq5xe$pSF}JLM(K)AqD{Z+}$d^o|P_J+3(|2v=e7 zUR4Cy790~Q#9r44$m1y=6r>=v2>X+N5Blo0H|jrCXql8V zseZFLyKw@qIUa(-Tfz~$mm*k%M-rQkZ7Pjp4Xmu-x>+i#e=?M84TUk5{w3FiE#F?x zYXgS%TCc_=x8GLz!pdAh@oMBcp-n9wdXNb1#|;-%Lyq?H;lo8BycK29s(XxMv6-H0 zh-y~N?^w|Y5P1kKGFnZ+9_O0GUw7xjSyIJU<>hfxreZQ?B+Hnr!LYE^dZ<07^DaW1 z_Tf>S(3Cf}q(I*q zD@_>&Sq&!$nsv{8llIyCfy3i_UaRZb*yQADrCirb*dWoKP+Z_P+d|iqBB{egUQx|z zTk zmv*Klw0rGT8CQt6ltP8rVcms*Bt{Y^LddxX+bIb%lC-ndi9;ueE$(@w8! zwRLBdW$Pv_p)xz0CwuZRu=~|!aNq0ojM_#W{PI}_Dil|cHx~#$VPnv*?l+Jhv0l8f z`SdR5S?W~CkB`pDj}fB+2iy&?()DJ8qId7Xt)nE^9R|A|WepE~E2tK>7=lH6<5-}L z_gq1V4V2E>`|r|A5uVF?tdvH7lFJl?G!?68f?7&;+MV?5TU{Hgl=0syR@e&H+0_^+ z`TLyn%R?^8W>_=wM2T7Lx3IhiZ}W}xKTnr4k(~01OAkC;{kq-B1vR %7Wbo}Xy5 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 Date: Fri, 2 May 2025 17:33:15 +0300 Subject: [PATCH 25/26] Better EPUB handling, improvements in code and docs --- CHANGELOG.md | 1 + README.md | 5 +- abogen/VERSION | 2 +- abogen/book_handler.py | 1255 ++++++++++++++++++++++++++-------------- abogen/gui.py | 7 +- demo/abogen.png | Bin 22311 -> 23118 bytes pyproject.toml | 2 +- 7 files changed, 819 insertions(+), 453 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a987142..37a0963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Added profile system to voice mixer, allowing users to create and manage multiple voice profiles. - Improvements in the voice mixer, mostly for organizing controls and enhancing user experience. - Added icons for flags and genders in the GUI, making it easier to identify different options. +- Improved the content and chapter extraction process for EPUB files, ensuring better handling of various structures. - Switched to platformdirs for determining the correct desktop path, instead of using old methods. - Fixed preview voices was not using GPU acceleration, which was causing performance issues. - Improvements in code and documentation. \ No newline at end of file diff --git a/README.md b/README.md index 55abb0c..4ee7998 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex ## `Voice Mixer` -With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to @jborza for making this possible through his contributions in #5) +With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5)) ## `Supported Languages` ``` @@ -168,8 +168,9 @@ Feel free to explore the code and make any changes you like. ## `Credits` - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. +- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. -- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id), [Person](https://icons8.com/icon/34105/person) icons by [Icons8](https://icons8.com/). +- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/). ## `License` This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. diff --git a/abogen/VERSION b/abogen/VERSION index e6d5cb8..e4c0d46 100644 --- a/abogen/VERSION +++ b/abogen/VERSION @@ -1 +1 @@ -1.0.2 \ No newline at end of file +1.0.3 \ No newline at end of file diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 3248f3d..fb79b9d 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -4,7 +4,7 @@ import ebooklib import base64 import fitz # PyMuPDF for PDF support from ebooklib import epub -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, NavigableString from PyQt5.QtWidgets import ( QDialog, QTreeWidget, @@ -24,6 +24,13 @@ from PyQt5.QtWidgets import ( from PyQt5.QtCore import Qt from utils import clean_text, calculate_text_length import os +import logging # Add logging +import urllib.parse + +# Setup logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) class HandlerDialog(QDialog): @@ -59,7 +66,37 @@ class HandlerDialog(QDialog): self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end # Load the book based on file type - self.book = epub.read_epub(book_path) if self.file_type == "epub" else None + try: + self.book = epub.read_epub(book_path) if self.file_type == "epub" else None + except KeyError as e: + logging.error( + f"EPUB file is missing a referenced file: {e}. Skipping missing file." + ) + # Try to patch ebooklib to skip missing files (monkey-patch read_file) + import types + + orig_read_file = None + try: + from ebooklib import epub as _epub_module + + reader_class = _epub_module.EpubReader + orig_read_file = reader_class.read_file + + def safe_read_file(self, name): + try: + return orig_read_file(self, name) + except KeyError: + logging.warning( + f"Missing file in EPUB: {name}. Returning empty bytes." + ) + return b"" + + reader_class.read_file = safe_read_file + self.book = epub.read_epub(book_path) + reader_class.read_file = orig_read_file # Restore + except Exception as patch_e: + logging.error(f"Failed to patch ebooklib for missing files: {patch_e}") + raise e self.pdf_doc = fitz.open(book_path) if self.file_type == "pdf" else None # Extract book metadata @@ -129,250 +166,18 @@ class HandlerDialog(QDialog): def _preprocess_content(self): """Pre-process content from the document""" if self.file_type == "epub": - # Always process EPUB content using the anchor-based approach - self._process_epub_content() + try: + self._process_epub_content_nav() # Use the new navigation-based method + except Exception as e: + logging.error( + f"Error processing EPUB with navigation: {e}. Falling back to TOC/spine.", + exc_info=True, + ) + # Fallback to a simpler spine-based processing if nav fails + self._process_epub_content_spine_fallback() else: self._preprocess_pdf_content() - def _process_epub_content(self, split_anchors=True): - """ - Process EPUB content by globally ordering TOC entries and slicing content between them. - Ensures all content between defined TOC start points is captured. - """ - # split_anchors parameter kept for compatibility but always treated as True - book = self.book - - # 1. Cache all document HTML and determine spine order - self.doc_content = {} - # Correctly get hrefs from spine items - spine_docs = [] - for spine_item_tuple in book.spine: - item_id = spine_item_tuple[0] - item = book.get_item_with_id(item_id) - if item: - spine_docs.append(item.get_name()) # Use get_name() for href - else: - print( - f"Warning: Spine item with id '{item_id}' not found in book items." - ) - - doc_order = {href: i for i, href in enumerate(spine_docs)} - - for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT): - href = item.get_name() - if href in doc_order: # Only process docs in spine - try: - html_content = item.get_content().decode("utf-8", errors="ignore") - self.doc_content[href] = html_content - except Exception: - self.doc_content[href] = "" # Handle decoding errors - - # 2. Get all TOC entries with hrefs and determine their positions - toc_entries_with_pos = [] - - def find_position(doc_href, fragment_id): - if doc_href not in self.doc_content: - return -1 - html_content = self.doc_content[doc_href] - if not fragment_id: # No fragment, position is 0 - return 0 - - # Find position of fragment identifier (id= or name=) - id_match_str = f'id="{fragment_id}"' - name_match_str = f'name="{fragment_id}"' - - id_pos = html_content.find(id_match_str) - name_pos = html_content.find(name_match_str) - - pos = -1 - if id_pos != -1 and name_pos != -1: - pos = min(id_pos, name_pos) - elif id_pos != -1: - pos = id_pos - elif name_pos != -1: - pos = name_pos - else: - return -1 # Anchor not found by simple string search - - # Backtrack to the start of the tag '<' - tag_start_pos = html_content.rfind("<", 0, pos) - return ( - tag_start_pos if tag_start_pos != -1 else 0 - ) # Default to 0 if '<' not found - - def collect_toc_entries(entries): - collected = [] - for entry in entries: - href, title = None, "Unknown" - children = [] - entry_obj = None # Store the original entry object - - if isinstance(entry, ebooklib.epub.Link): - href, title = entry.href, entry.title or entry.href - entry_obj = entry - elif isinstance(entry, tuple) and len(entry) >= 1: - section_or_link = entry[0] - entry_obj = section_or_link - if isinstance(section_or_link, ebooklib.epub.Section): - title = section_or_link.title - href = getattr(section_or_link, "href", None) - elif isinstance(section_or_link, ebooklib.epub.Link): - href, title = ( - section_or_link.href, - section_or_link.title or section_or_link.href, - ) - - if len(entry) > 1 and isinstance(entry[1], list): - children = entry[1] - - if href: - base_href, fragment = ( - href.split("#", 1) if "#" in href else (href, None) - ) - if ( - base_href in doc_order - ): # Only consider entries pointing to spine documents - position = find_position(base_href, fragment) - if position != -1: # Only add if position is valid - collected.append( - { - "href": href, # Use the original href from TOC as the key - "title": title, - "doc_href": base_href, - "position": position, - "doc_order": doc_order[base_href], - } - ) - - if children: - collected.extend(collect_toc_entries(children)) - return collected - - all_toc_entries = collect_toc_entries(self.book.toc) - - # Handle case where book has no TOC or empty TOC - if not all_toc_entries: - # Create a synthetic TOC entry for the first spine document - if spine_docs: - # Process all content as a single chapter - all_content_html = "" - for doc_href in spine_docs: - all_content_html += self.doc_content.get(doc_href, "") - - if all_content_html: - soup = BeautifulSoup(all_content_html, "html.parser") - text = clean_text(soup.get_text()).strip() - - # Use the first spine document as the identifier - first_doc = spine_docs[0] - self.content_texts[first_doc] = text - self.content_lengths[first_doc] = len(text) - - # Create a synthetic TOC entry for tree building - self.book.toc = [(epub.Link(first_doc, "Main Content", first_doc),)] - return - - # 3. Sort TOC entries globally - all_toc_entries.sort(key=lambda x: (x["doc_order"], x["position"])) - - # 4. Slice content between sorted entries - self.content_texts = {} - self.content_lengths = {} - num_entries = len(all_toc_entries) - - for i in range(num_entries): - current_entry = all_toc_entries[i] - current_href = current_entry["href"] - current_doc = current_entry["doc_href"] - current_pos = current_entry["position"] - current_doc_html = self.doc_content.get(current_doc, "") - - start_slice_pos = current_pos - slice_html = "" - - # Find the start of the next TOC entry - next_entry = all_toc_entries[i + 1] if (i + 1) < num_entries else None - - if next_entry: - next_doc = next_entry["doc_href"] - next_pos = next_entry["position"] - - if current_doc == next_doc: - # Next entry is in the same document - slice_html = current_doc_html[start_slice_pos:next_pos] - else: - # Next entry is in a different document - # Take content from current position to end of current document - slice_html = current_doc_html[start_slice_pos:] - # Include content from intermediate documents in the spine - current_doc_index = current_entry["doc_order"] - next_doc_index = next_entry["doc_order"] - for doc_idx in range(current_doc_index + 1, next_doc_index): - intermediate_doc_href = spine_docs[doc_idx] - slice_html += self.doc_content.get(intermediate_doc_href, "") - # Add content from the beginning of the next document up to the next entry's position - next_doc_html = self.doc_content.get(next_doc, "") - slice_html += next_doc_html[:next_pos] - else: - # This is the last TOC entry - # Take content from current position to end of current document - slice_html = current_doc_html[start_slice_pos:] - # Include content from all remaining documents in the spine - current_doc_index = current_entry["doc_order"] - for doc_idx in range(current_doc_index + 1, len(spine_docs)): - intermediate_doc_href = spine_docs[doc_idx] - slice_html += self.doc_content.get(intermediate_doc_href, "") - - # 5. Extract text and store - slice_soup = BeautifulSoup(slice_html, "html.parser") - - # Remove sup and sub tags from the HTML before extracting text - for tag in slice_soup.find_all(["sup", "sub"]): - tag.decompose() - - text = clean_text(slice_soup.get_text()).strip() - self.content_texts[current_href] = text # Store using the original TOC href - self.content_lengths[current_href] = len(text) - - # 6. Handle content BEFORE the first TOC entry - if all_toc_entries: - first_entry = all_toc_entries[0] - first_doc_href = first_entry["doc_href"] - first_pos = first_entry["position"] - first_doc_order = first_entry["doc_order"] - prefix_html = "" - # Include content from documents before the first entry's document - for doc_idx in range(first_doc_order): - intermediate_doc_href = spine_docs[doc_idx] - prefix_html += self.doc_content.get(intermediate_doc_href, "") - # Include content from the start of the first entry's document up to its position - first_doc_html = self.doc_content.get(first_doc_href, "") - prefix_html += first_doc_html[:first_pos] - - if prefix_html.strip(): - prefix_soup = BeautifulSoup(prefix_html, "html.parser") - # Remove sup and sub tags - for tag in prefix_soup.find_all(["sup", "sub"]): - tag.decompose() - prefix_text = clean_text(prefix_soup.get_text()).strip() - - if prefix_text: - # Create a new chapter for content before the first TOC entry - # Use a synthetic href to avoid collision with real TOC entries - prefix_chapter_href = "prefix_content_chapter" - self.content_texts[prefix_chapter_href] = prefix_text - self.content_lengths[prefix_chapter_href] = len(prefix_text) - - # Add a new entry to the TOC for the prefix content - prefix_link = epub.Link( - prefix_chapter_href, "Introduction", prefix_chapter_href - ) - # Insert at beginning of TOC - if isinstance(self.book.toc, list): - self.book.toc.insert(0, (prefix_link,)) - else: - self.book.toc = [(prefix_link,)] + (self.book.toc or []) - def _preprocess_pdf_content(self): """Pre-process all page contents from PDF document""" for page_num in range(len(self.pdf_doc)): @@ -395,16 +200,624 @@ class HandlerDialog(QDialog): self.content_texts[page_id] = text self.content_lengths[page_id] = calculate_text_length(text) - def _build_tree(self): - """Build tree based on file type""" - if self.file_type == "epub": - self._build_epub_tree() - else: - self._build_pdf_tree() + def _process_epub_content_spine_fallback(self): + """Fallback EPUB processing based purely on spine order.""" + logging.info("Using spine fallback for EPUB processing.") + self.doc_content = {} + spine_docs = [] + for spine_item_tuple in self.book.spine: + item_id = spine_item_tuple[0] + item = self.book.get_item_with_id(item_id) + if item: + spine_docs.append(item.get_name()) + else: + logging.warning(f"Spine item with id '{item_id}' not found.") - def _build_epub_tree(self): - """Build the tree for EPUB files from TOC""" + # Cache content + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href in spine_docs: + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + self.doc_content[href] = html_content + except Exception as e: + logging.error(f"Error decoding content for {href}: {e}") + self.doc_content[href] = "" + + # Create a simple TOC based on spine order + synthetic_toc = [] + self.content_texts = {} + self.content_lengths = {} + for i, doc_href in enumerate(spine_docs): + html_content = self.doc_content.get(doc_href, "") + if html_content: + soup = BeautifulSoup(html_content, "html.parser") + # Remove sup and sub tags + for tag in soup.find_all(["sup", "sub"]): + tag.decompose() + text = clean_text(soup.get_text()).strip() + if text: + # Use doc_href as the identifier + self.content_texts[doc_href] = text + self.content_lengths[doc_href] = len(text) + # Create a synthetic TOC entry + title = f"Chapter {i+1}: {doc_href}" + # Try to get a better title from

or + h1 = soup.find("h1") + if h1 and h1.get_text(strip=True): + title = h1.get_text(strip=True) + else: + title_tag = soup.find("title") + if title_tag and title_tag.get_text(strip=True): + title = title_tag.get_text(strip=True) + + synthetic_toc.append( + (epub.Link(doc_href, title, doc_href), []) + ) # Wrap in tuple and empty list for compatibility + + # Replace book.toc with the synthetic one if it was empty or fallback was triggered + if not self.book.toc or not hasattr( + self, "processed_nav_structure" + ): # Check if nav processing failed + self.book.toc = synthetic_toc + logging.info(f"Generated synthetic TOC with {len(synthetic_toc)} entries.") + + def _process_epub_content_nav(self): + """ + Process EPUB content using ITEM_NAVIGATION (NAV HTML) or ITEM_NCX. + Globally orders navigation entries and slices content between them. + """ + logging.info( + "Attempting to process EPUB using navigation document (NAV/NCX)..." + ) + nav_item = None + nav_type = None + + # 1. Check ITEM_NAVIGATION for actual NAV HTML (.xhtml/.html) + nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) + if nav_items: + # Prefer files explicitly named 'nav.xhtml' or similar + preferred_nav = next( + ( + item + for item in nav_items + if "nav" in item.get_name().lower() + and item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if preferred_nav: + nav_item = preferred_nav + nav_type = "html" + logging.info(f"Found preferred NAV HTML item: {nav_item.get_name()}") + else: + # Check if any ITEM_NAVIGATION is actually HTML + html_nav = next( + ( + item + for item in nav_items + if item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if html_nav: + nav_item = html_nav + nav_type = "html" + logging.info( + f"Found NAV HTML item in ITEM_NAVIGATION: {html_nav.get_name()}" + ) + + # 2. If no NAV HTML found via ITEM_NAVIGATION, check if ITEM_NAVIGATION points to NCX + if not nav_item and nav_items: + ncx_in_nav = next( + ( + item + for item in nav_items + if item.get_name().lower().endswith(".ncx") + ), + None, + ) + if ncx_in_nav: + nav_item = ncx_in_nav + nav_type = "ncx" + logging.info( + f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}" + ) + + # 3. If still no nav_item, check for ITEM_NCX directly + if not nav_item: + ncx_items = list(self.book.get_items_of_type(ebooklib.ITEM_NCX)) + if ncx_items: + nav_item = ncx_items[0] # Take the first one + nav_type = "ncx" + logging.info(f"Found NCX item via ITEM_NCX: {ncx_items[0].get_name()}") + + # 4. If no navigation item found by any method, trigger fallback + if not nav_item or not nav_type: + logging.warning( + "No suitable EPUB navigation document (NAV HTML or NCX) found. Falling back." + ) + raise ValueError("No navigation document found") # Trigger fallback + + # Determine parser based on the confirmed nav_type + parser_type = "html.parser" if nav_type == "html" else "xml" + logging.info(f"Using parser: '{parser_type}' for {nav_item.get_name()}") + try: + nav_content = nav_item.get_content().decode("utf-8", errors="ignore") + nav_soup = BeautifulSoup(nav_content, parser_type) + except Exception as e: + logging.error( + f"Failed to parse navigation content ({nav_item.get_name()}) using {parser_type}: {e}", + exc_info=True, + ) + raise ValueError( + f"Failed to parse navigation content: {e}" + ) # Trigger fallback + + # --- Rest of the processing logic --- + # 1. Cache all document HTML and determine spine order (no changes needed here) + self.doc_content = {} + spine_docs = [] + for spine_item_tuple in self.book.spine: + item_id = spine_item_tuple[0] + item = self.book.get_item_with_id(item_id) + if item: + spine_docs.append(item.get_name()) + else: + logging.warning(f"Spine item with id '{item_id}' not found.") + doc_order = {href: i for i, href in enumerate(spine_docs)} + # Add a mapping for unquoted (decoded) hrefs as well + doc_order_decoded = { + urllib.parse.unquote(href): i for href, i in doc_order.items() + } + + # Clear previous content/lengths before processing + self.content_texts = {} + self.content_lengths = {} + + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href in doc_order or any( + href in nav_point.get("src", "") + for nav_point in nav_soup.find_all(["content", "a"]) + ): + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + self.doc_content[href] = html_content + except Exception as e: + logging.error(f"Error decoding content for {href}: {e}") + self.doc_content[href] = "" + + # 2. Extract and order navigation entries globally + ordered_nav_entries = [] + + # Define find_position locally or ensure self._find_position_robust is used correctly + # Using self._find_position_robust is preferred as it's a method of the class + find_position_func = self._find_position_robust + + # Store the parsed structure for tree building later + self.processed_nav_structure = [] + + # Call the correct parsing function based on confirmed nav_type + parse_successful = False + if nav_type == "ncx": + nav_map = nav_soup.find("navMap") + if nav_map: + logging.info("Parsing NCX <navMap>...") + for nav_point in nav_map.find_all("navPoint", recursive=False): + self._parse_ncx_navpoint( + nav_point, + ordered_nav_entries, + doc_order, + doc_order_decoded, + self.processed_nav_structure, + find_position_func, + ) + parse_successful = bool( + ordered_nav_entries + ) # Success if entries were added + else: + logging.warning("Could not find <navMap> in NCX file.") + elif nav_type == "html": + logging.info("Parsing NAV HTML...") + toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"}) + if not toc_nav: + # Fallback: look for any <nav> element containing an <ol> + all_navs = nav_soup.find_all("nav") + for nav in all_navs: + if nav.find("ol"): + toc_nav = nav + logging.info("Found fallback TOC structure in <nav> with <ol>.") + break + if toc_nav: + top_ol = toc_nav.find("ol", recursive=False) + if top_ol: + for li in top_ol.find_all("li", recursive=False): + self._parse_html_nav_li( + li, + ordered_nav_entries, + doc_order, + doc_order_decoded, + self.processed_nav_structure, + find_position_func, + ) + parse_successful = bool( + ordered_nav_entries + ) # Success if entries were added + else: + logging.warning("Found <nav> for TOC but no top-level <ol> inside.") + else: + logging.warning( + "Could not find TOC structure (<nav epub:type='toc'> or <nav><ol>) in NAV HTML." + ) + + # Handle case where parsing ran but found no valid entries OR parsing failed + if not parse_successful: + logging.warning( + "Navigation parsing completed but found no valid entries, or parsing failed. Falling back." + ) + raise ValueError("No valid navigation entries found after parsing") + + # Sort entries globally by document order and position within the document + ordered_nav_entries.sort(key=lambda x: (x["doc_order"], x["position"])) + logging.info(f"Sorted {len(ordered_nav_entries)} navigation entries.") + + # 3. Slice content ONLY between sorted TOC entries + num_entries = len(ordered_nav_entries) + for i in range(num_entries): + current_entry = ordered_nav_entries[i] + current_src = current_entry["src"] + current_doc = current_entry["doc_href"] + current_pos = current_entry["position"] + current_doc_html = self.doc_content.get(current_doc, "") + + start_slice_pos = current_pos + slice_html = "" + + next_entry = ordered_nav_entries[i + 1] if (i + 1) < num_entries else None + + if next_entry: + next_doc = next_entry["doc_href"] + next_pos = next_entry["position"] + + # Always include all content from current position to next position, even if next_doc is before current_doc + if current_doc == next_doc: + slice_html = current_doc_html[start_slice_pos:next_pos] + else: + # Collect all content from current_doc (from start_slice_pos to end), + # then all intermediate docs (in spine order), + # then up to next_pos in next_doc (even if next_doc is before current_doc in spine) + slice_html = current_doc_html[start_slice_pos:] + docs_between = [] + try: + idx_current = spine_docs.index(current_doc) + idx_next = spine_docs.index(next_doc) + if idx_current < idx_next: + for doc_idx in range(idx_current + 1, idx_next): + docs_between.append(spine_docs[doc_idx]) + elif idx_current > idx_next: + for doc_idx in range(idx_current + 1, len(spine_docs)): + docs_between.append(spine_docs[doc_idx]) + for doc_idx in range(0, idx_next): + docs_between.append(spine_docs[doc_idx]) + except Exception: + pass + for doc_href in docs_between: + slice_html += self.doc_content.get(doc_href, "") + next_doc_html = self.doc_content.get(next_doc, "") + slice_html += next_doc_html[:next_pos] + else: + # Last TOC entry: include all content from current position to end of book + slice_html = current_doc_html[start_slice_pos:] + try: + idx_current = spine_docs.index(current_doc) + for doc_idx in range(idx_current + 1, len(spine_docs)): + intermediate_doc_href = spine_docs[doc_idx] + slice_html += self.doc_content.get(intermediate_doc_href, "") + except Exception: + pass + + if slice_html.strip(): + slice_soup = BeautifulSoup(slice_html, "html.parser") + # Add double newlines after <p> and <div> tags + for tag in slice_soup.find_all(["p", "div"]): + tag.append("\n\n") + for tag in slice_soup.find_all(["sup", "sub"]): + tag.decompose() + text = clean_text(slice_soup.get_text()).strip() + if text: + self.content_texts[current_src] = text + self.content_lengths[current_src] = len(text) + else: + self.content_texts[current_src] = "" + self.content_lengths[current_src] = 0 + else: + self.content_texts[current_src] = "" + self.content_lengths[current_src] = 0 + + # 4. Extract text and store using the original TOC entry src as the key + if ordered_nav_entries: + first_entry = ordered_nav_entries[0] + first_doc_href = first_entry["doc_href"] + first_pos = first_entry["position"] + first_doc_order = first_entry["doc_order"] + prefix_html = "" + + for doc_idx in range(first_doc_order): + if doc_idx < len(spine_docs): + intermediate_doc_href = spine_docs[doc_idx] + prefix_html += self.doc_content.get(intermediate_doc_href, "") + else: + logging.warning( + f"Document index {doc_idx} out of bounds for spine (length {len(spine_docs)})." + ) + + first_doc_html = self.doc_content.get(first_doc_href, "") + prefix_html += first_doc_html[:first_pos] + + if prefix_html.strip(): + prefix_soup = BeautifulSoup(prefix_html, "html.parser") + for tag in prefix_soup.find_all(["sup", "sub"]): + tag.decompose() + prefix_text = clean_text(prefix_soup.get_text()).strip() + + if prefix_text: + prefix_chapter_src = "internal:prefix_content" + self.content_texts[prefix_chapter_src] = prefix_text + self.content_lengths[prefix_chapter_src] = len(prefix_text) + self.processed_nav_structure.insert( + 0, + { + "src": prefix_chapter_src, + "title": "Introduction", + "children": [], + }, + ) + logging.info( + f"Added prefix content chapter '{prefix_chapter_src}'." + ) + + logging.info( + f"Finished processing EPUB navigation. Found {len(self.content_texts)} content sections linked to TOC." + ) + + def _parse_ncx_navpoint( + self, + nav_point, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + nav_label = nav_point.find("navLabel") + content = nav_point.find("content") + title = ( + nav_label.find("text").get_text(strip=True) + if nav_label and nav_label.find("text") + else "Untitled Section" + ) + src = content["src"] if content and "src" in content.attrs else None + + current_entry_node = {"title": title, "src": src, "children": []} + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + # Try both original and decoded hrefs + doc_key = None + if base_href in doc_order: + doc_key = base_href + doc_idx = doc_order[base_href] + elif urllib.parse.unquote(base_href) in doc_order: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order[doc_key] + elif base_href in doc_order_decoded: + doc_key = base_href + doc_idx = doc_order_decoded[base_href] + elif urllib.parse.unquote(base_href) in doc_order_decoded: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order_decoded[doc_key] + else: + logging.warning( + f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list." + ) + current_entry_node["has_content"] = False + doc_key = None + if doc_key is not None: + position = find_position_func(doc_key, fragment) + entry_data = { + "src": src, + "title": title, + "doc_href": doc_key, + "position": position, + "doc_order": doc_idx, + } + ordered_entries.append(entry_data) + current_entry_node["has_content"] = True + else: + logging.warning(f"Navigation entry '{title}' has no 'src' attribute.") + current_entry_node["has_content"] = False + + child_navpoints = nav_point.find_all("navPoint", recursive=False) + if child_navpoints: + for child_np in child_navpoints: + # Pass find_position_func down recursively + self._parse_ncx_navpoint( + child_np, + ordered_entries, + doc_order, + doc_order_decoded, + current_entry_node["children"], + find_position_func, + ) + + if title and ( + current_entry_node.get("has_content", False) + or current_entry_node["children"] + ): + tree_structure_list.append(current_entry_node) + + def _parse_html_nav_li( + self, + li_element, + ordered_entries, + doc_order, + doc_order_decoded, + tree_structure_list, + find_position_func, + ): + link = li_element.find("a", recursive=False) + span_text = li_element.find("span", recursive=False) + title = "Untitled Section" + src = None + current_entry_node = {"children": []} + + if link and "href" in link.attrs: + src = link["href"] + title = link.get_text(strip=True) or title + if not title.strip() and span_text: + title = span_text.get_text(strip=True) or title + if not title.strip(): + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + elif span_text: + title = span_text.get_text(strip=True) or title + if not title.strip(): + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + else: + li_text = "".join( + t for t in li_element.contents if isinstance(t, NavigableString) + ).strip() + title = li_text or title + + current_entry_node["title"] = title + current_entry_node["src"] = src + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + # Try both original and decoded hrefs + doc_key = None + if base_href in doc_order: + doc_key = base_href + doc_idx = doc_order[base_href] + elif urllib.parse.unquote(base_href) in doc_order: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order[doc_key] + elif base_href in doc_order_decoded: + doc_key = base_href + doc_idx = doc_order_decoded[base_href] + elif urllib.parse.unquote(base_href) in doc_order_decoded: + doc_key = urllib.parse.unquote(base_href) + doc_idx = doc_order_decoded[doc_key] + else: + logging.warning( + f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list." + ) + current_entry_node["has_content"] = False + doc_key = None + if doc_key is not None: + position = find_position_func(doc_key, fragment) + entry_data = { + "src": src, + "title": title, + "doc_href": doc_key, + "position": position, + "doc_order": doc_idx, + } + ordered_entries.append(entry_data) + current_entry_node["has_content"] = True + else: + current_entry_node["has_content"] = False + + child_ol = li_element.find("ol", recursive=False) + if child_ol: + for child_li in child_ol.find_all("li", recursive=False): + # Pass find_position_func down recursively + self._parse_html_nav_li( + child_li, + ordered_entries, + doc_order, + doc_order_decoded, + current_entry_node["children"], + find_position_func, + ) + + if title and ( + current_entry_node.get("has_content", False) + or current_entry_node["children"] + ): + tree_structure_list.append(current_entry_node) + + def _find_position_robust(self, doc_href, fragment_id): + if doc_href not in self.doc_content: + logging.warning(f"Document '{doc_href}' not found in cached content.") + return 0 + html_content = self.doc_content[doc_href] + if not fragment_id: + return 0 + + try: + temp_soup = BeautifulSoup(f"<div>{html_content}</div>", "html.parser") + target_element = temp_soup.find(id=fragment_id) + if target_element: + tag_str = str(target_element) + pos = html_content.find(tag_str[: min(len(tag_str), 200)]) + if pos != -1: + logging.debug( + f"Found position for id='{fragment_id}' in {doc_href} using BeautifulSoup: {pos}" + ) + return pos + except Exception as e: + logging.warning( + f"BeautifulSoup failed to find id='{fragment_id}' in {doc_href}: {e}" + ) + + safe_fragment_id = re.escape(fragment_id) + id_name_pattern = re.compile( + f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']", re.IGNORECASE + ) + match = id_name_pattern.search(html_content) + if match: + pos = match.start() + logging.debug( + f"Found position for id/name='{fragment_id}' in {doc_href} using regex: {pos}" + ) + return pos + + id_match_str = f'id="{fragment_id}"' + name_match_str = f'name="{fragment_id}"' + id_pos = html_content.find(id_match_str) + name_pos = html_content.find(name_match_str) + + pos = -1 + if id_pos != -1 and name_pos != -1: + pos = min(id_pos, name_pos) + elif id_pos != -1: + pos = id_pos + elif name_pos != -1: + pos = name_pos + + if pos != -1: + tag_start_pos = html_content.rfind("<", 0, pos) + final_pos = tag_start_pos if tag_start_pos != -1 else 0 + logging.debug( + f"Found position for id/name='{fragment_id}' in {doc_href} using string search: {final_pos}" + ) + return final_pos + + logging.warning( + f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0." + ) + return 0 + + def _build_tree(self): self.treeWidget.clear() + info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) info_item.setData(0, Qt.UserRole, "info:bookinfo") info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable) @@ -412,76 +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] - else: - continue - - # Create tree item - item = QTreeWidgetItem(parent_item, [title]) - 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() + 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 ) - 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: + 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: - item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + seen_content_hashes.add(content_hash) - # Process children - if children: - build_tree(children, item) + 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) - build_tree(self.book.toc, self.treeWidget) + if children: + self._build_epub_tree_from_nav(children, item, seen_content_hashes) + + 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, + ) + + 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) @@ -489,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) @@ -517,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( @@ -532,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 @@ -550,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() @@ -569,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 ] @@ -592,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) @@ -603,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() @@ -618,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) @@ -629,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() @@ -644,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(): @@ -659,18 +1099,14 @@ 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, @@ -680,7 +1116,6 @@ class HandlerDialog(QDialog): "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) @@ -689,30 +1124,24 @@ class HandlerDialog(QDialog): 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) @@ -722,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) @@ -732,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) @@ -742,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" @@ -770,33 +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") @@ -806,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() @@ -823,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) @@ -835,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(): @@ -870,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(): @@ -882,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(): @@ -894,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(): @@ -906,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() @@ -930,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(): @@ -958,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() @@ -968,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 @@ -982,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(): @@ -992,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) @@ -1014,49 +1414,37 @@ 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)" ) @@ -1067,18 +1455,15 @@ class HandlerDialog(QDialog): self.previewEdit.setPlainText(text) def _display_book_info(self): - """Display book metadata and cover image in the preview panel""" self.previewEdit.clear() html_content = "<html><body style='font-family: Arial, sans-serif;'>" - # Add cover image if available if self.book_metadata["cover_image"]: try: image_data = base64.b64encode(self.book_metadata["cover_image"]).decode( "utf-8" ) - # Determine image type image_type = "jpeg" if self.book_metadata["cover_image"].startswith(b"\x89PNG"): image_type = "png" @@ -1095,7 +1480,6 @@ class HandlerDialog(QDialog): except Exception as e: html_content += f"<p>Error displaying cover image: {str(e)}</p>" - # Add title, authors, publisher if self.book_metadata["title"]: html_content += ( f"<h2 style='text-align: center;'>{self.book_metadata['title']}</h2>" @@ -1110,12 +1494,10 @@ class HandlerDialog(QDialog): html_content += "<hr/>" - # Add description if self.book_metadata["description"]: desc = re.sub(r"<[^>]+>", "", self.book_metadata["description"]) html_content += f"<h3>Description:</h3><p>{desc}</p>" - # Add file type and page count for PDFs if self.file_type == "pdf": page_count = len(self.pdf_doc) if self.pdf_doc else 0 html_content += f"<p>File type: PDF<br>Page count: {page_count}</p>" @@ -1124,7 +1506,6 @@ class HandlerDialog(QDialog): self.previewEdit.setHtml(html_content) def _extract_book_metadata(self): - """Extract book metadata""" metadata = { "title": None, "authors": [], @@ -1134,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] @@ -1151,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 @@ -1161,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) @@ -1182,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)) @@ -1193,54 +1570,52 @@ class HandlerDialog(QDialog): return metadata def get_selected_text(self): - """Get selected text and checked identifiers based on file type""" if self.file_type == "epub": return self._get_epub_selected_text() - else: # PDF + else: return self._get_pdf_selected_text() def _get_epub_selected_text(self): - """Get selected text from EPUB content""" - all_checked_hrefs = set() - chapter_titles = [] + all_checked_identifiers = set() + chapter_texts = [] - # Collect all checked hrefs in tree order to preserve chapter sequence + item_order_counter = 0 ordered_checked_items = [] + iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() + item_order_counter += 1 if item.checkState(0) == Qt.Checked: - href = item.data(0, Qt.UserRole) - if href and href != "info:bookinfo": - all_checked_hrefs.add(href) - ordered_checked_items.append((item, href)) + identifier = item.data(0, Qt.UserRole) + if identifier and identifier != "info:bookinfo": + all_checked_identifiers.add(identifier) + ordered_checked_items.append((item_order_counter, item, identifier)) iterator += 1 - # Process checked items in order - for item, href in ordered_checked_items: - # Always use the exact href (including fragment) from the TOC - text = self.content_texts.get(href) + ordered_checked_items.sort(key=lambda x: x[0]) + + for order, item, identifier in ordered_checked_items: + text = self.content_texts.get(identifier) if text and text.strip(): title = item.text(0) - title = re.sub(r"^\s*-\s*", "", title).strip() + title = re.sub(r"^\s*[-–—]\s*", "", title).strip() marker = f"<<CHAPTER_MARKER:{title}>>" - chapter_titles.append((title, marker + "\n" + text)) + chapter_texts.append(marker + "\n" + text) - return "\n\n".join([t[1] for t in chapter_titles]), all_checked_hrefs + full_text = "\n\n".join(chapter_texts) + return full_text, all_checked_identifiers def _get_pdf_selected_text(self): - """Get selected text from PDF content""" all_checked_identifiers = set() included_text_ids = set() section_titles = [] all_content = [] - # Check if PDF has no bookmarks pdf_has_no_bookmarks = ( hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks ) - # Collect all checked identifiers iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1250,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_")], @@ -1264,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() @@ -1273,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) @@ -1284,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: @@ -1297,7 +1667,6 @@ class HandlerDialog(QDialog): marker = f"<<CHAPTER_MARKER:{title}>>" section_titles.append((title, marker + "\n" + combined_text)) included_text_ids.add(parent_id) - # If only children are checked, use parent's name as marker at first child elif not parent_checked and checked_children: title = re.sub(r"^\s*-\s*", "", parent_title).strip() marker = f"<<CHAPTER_MARKER:{title}>>" @@ -1328,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() @@ -1347,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 @@ -1376,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/gui.py b/abogen/gui.py index a0c7ff6..3f7ceec 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -1445,10 +1445,10 @@ 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) @@ -1491,12 +1491,13 @@ 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) diff --git a/demo/abogen.png b/demo/abogen.png index 16adbaf1aa46c0fd4dff6e22bf1c5c362178cfea..b11c5dd040928f1d9bf391c4282719eef6c9c202 100644 GIT binary patch literal 23118 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 literal 22311 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 YDDooPlOp;R%x#shJDlvew&d790WPtzE&u=k diff --git a/pyproject.toml b/pyproject.toml index 7f626a6..3f5ff16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ 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", From 18f95c74e03fd7592abcff3854fbb5e7b80b394b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= <denizsafak98@gmail.com> Date: Fri, 2 May 2025 17:36:15 +0300 Subject: [PATCH 26/26] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a0963..1a7ab61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -– Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5. +- Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5. - Added profile system to voice mixer, allowing users to create and manage multiple voice profiles. - Improvements in the voice mixer, mostly for organizing controls and enhancing user experience. - Added icons for flags and genders in the GUI, making it easier to identify different options.