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 250dca3..f252cce 100644 Binary files a/abogen/assets/female.png and b/abogen/assets/female.png differ diff --git a/abogen/assets/flags/a.png b/abogen/assets/flags/a.png index a110f86..f770fcf 100644 Binary files a/abogen/assets/flags/a.png and b/abogen/assets/flags/a.png differ diff --git a/abogen/assets/flags/b.png b/abogen/assets/flags/b.png index 859e950..8e04f7a 100644 Binary files a/abogen/assets/flags/b.png and b/abogen/assets/flags/b.png differ diff --git a/abogen/assets/flags/e.png b/abogen/assets/flags/e.png index f647440..693cf2a 100644 Binary files a/abogen/assets/flags/e.png and b/abogen/assets/flags/e.png differ diff --git a/abogen/assets/flags/f.png b/abogen/assets/flags/f.png index f66e9cc..d80e7a7 100644 Binary files a/abogen/assets/flags/f.png and b/abogen/assets/flags/f.png differ diff --git a/abogen/assets/flags/h.png b/abogen/assets/flags/h.png index a3a4209..015ec29 100644 Binary files a/abogen/assets/flags/h.png and b/abogen/assets/flags/h.png differ diff --git a/abogen/assets/flags/i.png b/abogen/assets/flags/i.png index d602e54..6678202 100644 Binary files a/abogen/assets/flags/i.png and b/abogen/assets/flags/i.png differ diff --git a/abogen/assets/flags/j.png b/abogen/assets/flags/j.png index 4bcf2f9..ad5569e 100644 Binary files a/abogen/assets/flags/j.png and b/abogen/assets/flags/j.png differ diff --git a/abogen/assets/flags/p.png b/abogen/assets/flags/p.png index 9913de8..8abf47f 100644 Binary files a/abogen/assets/flags/p.png and b/abogen/assets/flags/p.png differ diff --git a/abogen/assets/flags/z.png b/abogen/assets/flags/z.png index 104dcbd..a0366c4 100644 Binary files a/abogen/assets/flags/z.png and b/abogen/assets/flags/z.png differ diff --git a/abogen/assets/male.png b/abogen/assets/male.png index 3455b92..65f2812 100644 Binary files a/abogen/assets/male.png and b/abogen/assets/male.png differ diff --git a/abogen/assets/profile.png b/abogen/assets/profile.png new file mode 100644 index 0000000..285ec1b Binary files /dev/null and b/abogen/assets/profile.png differ diff --git a/abogen/assets/voice_mixer.png b/abogen/assets/voice_mixer.png index 31a71d7..54393ff 100644 Binary files a/abogen/assets/voice_mixer.png and b/abogen/assets/voice_mixer.png differ diff --git a/abogen/conversion.py b/abogen/conversion.py index 7fa8c89..248306b 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -152,6 +152,9 @@ class ConversionThread(QThread): self.max_subtitle_words = 50 # Default value, will be overridden from GUI def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" + ) try: # Show configuration self.log_updated.emit("Configuration:") @@ -683,7 +686,14 @@ class VoicePreviewThread(QThread): error = pyqtSignal(str) def __init__( - self, np_module, kpipeline_class, lang_code, voice, speed, parent=None + self, + np_module, + kpipeline_class, + lang_code, + voice, + speed, + use_gpu=False, + parent=None, ): super().__init__(parent) self.np_module = np_module @@ -691,17 +701,26 @@ class VoicePreviewThread(QThread): self.lang_code = lang_code self.voice = voice self.speed = speed - self.temp_wav = None + self.use_gpu = use_gpu def run(self): + print( + f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" + ) try: + device = "cuda" if self.use_gpu else "cpu" tts = self.kpipeline_class( - lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M" + lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device ) + # Enable voice formula support for preview + if "*" in self.voice: + loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) + else: + loaded_voice = self.voice sample_text = get_sample_voice_text(self.lang_code) audio_segments = [] for result in tts( - sample_text, voice=self.voice, speed=self.speed, split_pattern=None + sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None ): audio_segments.append(result.audio) if audio_segments: diff --git a/abogen/gui.py b/abogen/gui.py index 0dc7f2a..a0c7ff6 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 +from voice_profiles import load_profiles # Import ctypes for Windows-specific taskbar icon if platform.system() == "Windows": @@ -458,9 +459,17 @@ class abogen(QWidget): self.selected_chapters = set() self.last_opened_book_path = None # Track the last opened book path self.last_output_path = None - self.selected_voice = self.config.get("selected_voice", "af_heart") - self.selected_lang = self.selected_voice[0] - self.mixed_voice_state = None # Store the mixed voice state + # Only one of selected_profile_name or selected_voice should be set + self.selected_profile_name = self.config.get("selected_profile_name") + self.selected_voice = None + self.selected_lang = None + self.mixed_voice_state = None + if self.selected_profile_name: + self.selected_voice = None + self.selected_lang = None + else: + self.selected_voice = self.config.get("selected_voice", "af_heart") + self.selected_lang = self.selected_voice[0] if self.selected_voice else None self.is_converting = False self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") self.max_subtitle_words = self.config.get( @@ -468,8 +477,8 @@ class abogen(QWidget): ) # Default max words per subtitle self.selected_format = self.config.get("selected_format", "wav") self.use_gpu = self.config.get( - "use_gpu", True - ) # Load GPU setting with default True + "use_gpu", True # Load GPU setting with default True + ) self.replace_single_newlines = self.config.get("replace_single_newlines", False) self._pending_close_event = None self.gpu_ok = False # Initialize GPU availability status @@ -489,17 +498,32 @@ class abogen(QWidget): self.initUI() self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) self.update_speed_label() - idx = self.voice_combo.findData(self.selected_voice) + # Set initial selection: prefer profile, else voice + idx = -1 + if self.selected_profile_name: + idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}") + elif self.selected_voice: + idx = self.voice_combo.findData(self.selected_voice) if idx >= 0: self.voice_combo.setCurrentIndex(idx) + # If a profile is selected at startup, load voices and language + if self.selected_profile_name: + from voice_profiles import load_profiles + + entry = load_profiles().get(self.selected_profile_name, {}) + if isinstance(entry, dict): + self.mixed_voice_state = entry.get("voices", []) + self.selected_lang = entry.get("language") + else: + self.mixed_voice_state = entry + self.selected_lang = entry[0][0] if entry and entry[0] else None if self.save_option == "Choose output folder" and self.selected_output_folder: self.save_path_label.setText(self.selected_output_folder) self.save_path_label.show() self.subtitle_combo.setCurrentText(self.subtitle_mode) - # Enable/disable subtitle options based on selected language - self.subtitle_combo.setEnabled( - self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION - ) + # Enable/disable subtitle options based on selected language (profile or voice) + enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION + self.subtitle_combo.setEnabled(enable) # loading gif for preview button loading_gif_path = get_resource_path("abogen.assets", "loading.gif") if loading_gif_path: @@ -559,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 0000000..6b40823 Binary files /dev/null and b/demo/voice_mixer.png differ