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