From 572a3c728510f58f510a91b8dba084c2e526b6b0 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Sun, 27 Apr 2025 16:24:46 +0200 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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