Added profile system to voice mixer, improvements in code

This commit is contained in:
Deniz Şafak
2025-05-02 12:10:08 +03:00
parent 4acf70954e
commit 02b9990004
22 changed files with 1137 additions and 90 deletions
+5 -1
View File
@@ -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. - 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. - 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.
+11 -5
View File
@@ -68,7 +68,7 @@ abogen
1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor) 1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor)
2) Configure the settings: 2) Configure the settings:
- Set speech speed - 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 subtitle generation style (by sentence, word, etc.)
- Select output format - Select output format
- Select where to save the output - Select where to save the output
@@ -83,6 +83,7 @@ Heres 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) - **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor)
- **Speed**: Adjust speech rate from `0.1x` to `2.0x` - **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. - **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) - **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` - **Output formats**: `.WAV`, `.FLAC`, or `.MP3`
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder` - **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
@@ -93,10 +94,15 @@ Heres 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. - **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 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. - **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. - **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`. - **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`.
## `Voice Mixer`
<img title="Abogen Voice Mixer" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/voice_mixer.png'>
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` ## `Supported Languages`
``` ```
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English # 🇺🇸 '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. - [ ] Improve PDF support for better text extraction.
- [ ] Add chapter metadata for .m4a files using ffmpeg-bin. - [ ] Add chapter metadata for .m4a files using ffmpeg-bin.
- [ ] Add support for different languages in GUI. - [ ] 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 support for kokoro-onnx.
- [ ] Add dark mode. - [ ] Add dark mode.
@@ -154,7 +160,7 @@ If you'd like to modify the code and contribute to development, you can [downloa
```bash ```bash
# Go to the directory where you extracted the repository and run: # Go to the directory where you extracted the repository and run:
pip install -e . # Installs the package in editable mode 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 abogen # Opens the GUI
``` ```
Feel free to explore the code and make any changes you like. 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. - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Thanks to [@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. - 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` ## `License`
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 B

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 B

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 531 B

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 B

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 B

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 464 B

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 B

After

Width:  |  Height:  |  Size: 331 B

+23 -4
View File
@@ -152,6 +152,9 @@ class ConversionThread(QThread):
self.max_subtitle_words = 50 # Default value, will be overridden from GUI self.max_subtitle_words = 50 # Default value, will be overridden from GUI
def run(self): 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: try:
# Show configuration # Show configuration
self.log_updated.emit("Configuration:") self.log_updated.emit("Configuration:")
@@ -683,7 +686,14 @@ class VoicePreviewThread(QThread):
error = pyqtSignal(str) error = pyqtSignal(str)
def __init__( 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) super().__init__(parent)
self.np_module = np_module self.np_module = np_module
@@ -691,17 +701,26 @@ class VoicePreviewThread(QThread):
self.lang_code = lang_code self.lang_code = lang_code
self.voice = voice self.voice = voice
self.speed = speed self.speed = speed
self.temp_wav = None self.use_gpu = use_gpu
def run(self): def run(self):
print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
)
try: try:
device = "cuda" if self.use_gpu else "cpu"
tts = self.kpipeline_class( 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) sample_text = get_sample_voice_text(self.lang_code)
audio_segments = [] audio_segments = []
for result in tts( 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) audio_segments.append(result.audio)
if audio_segments: if audio_segments:
+187 -45
View File
@@ -72,6 +72,7 @@ from constants import (
from threading import Thread from threading import Thread
from voice_formula_gui import VoiceFormulaDialog from voice_formula_gui import VoiceFormulaDialog
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from voice_profiles import load_profiles
# Import ctypes for Windows-specific taskbar icon # Import ctypes for Windows-specific taskbar icon
if platform.system() == "Windows": if platform.system() == "Windows":
@@ -458,9 +459,17 @@ class abogen(QWidget):
self.selected_chapters = set() self.selected_chapters = set()
self.last_opened_book_path = None # Track the last opened book path self.last_opened_book_path = None # Track the last opened book path
self.last_output_path = None self.last_output_path = None
self.selected_voice = self.config.get("selected_voice", "af_heart") # Only one of selected_profile_name or selected_voice should be set
self.selected_lang = self.selected_voice[0] self.selected_profile_name = self.config.get("selected_profile_name")
self.mixed_voice_state = None # Store the mixed voice state 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.is_converting = False
self.subtitle_mode = self.config.get("subtitle_mode", "Sentence") self.subtitle_mode = self.config.get("subtitle_mode", "Sentence")
self.max_subtitle_words = self.config.get( self.max_subtitle_words = self.config.get(
@@ -468,8 +477,8 @@ class abogen(QWidget):
) # Default max words per subtitle ) # Default max words per subtitle
self.selected_format = self.config.get("selected_format", "wav") self.selected_format = self.config.get("selected_format", "wav")
self.use_gpu = self.config.get( self.use_gpu = self.config.get(
"use_gpu", True "use_gpu", True # Load GPU setting with default True
) # Load GPU setting with default True )
self.replace_single_newlines = self.config.get("replace_single_newlines", False) self.replace_single_newlines = self.config.get("replace_single_newlines", False)
self._pending_close_event = None self._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status self.gpu_ok = False # Initialize GPU availability status
@@ -489,17 +498,32 @@ class abogen(QWidget):
self.initUI() self.initUI()
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100)) self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
self.update_speed_label() 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: if idx >= 0:
self.voice_combo.setCurrentIndex(idx) 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: 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.setText(self.selected_output_folder)
self.save_path_label.show() self.save_path_label.show()
self.subtitle_combo.setCurrentText(self.subtitle_mode) self.subtitle_combo.setCurrentText(self.subtitle_mode)
# Enable/disable subtitle options based on selected language # Enable/disable subtitle options based on selected language (profile or voice)
self.subtitle_combo.setEnabled( enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION self.subtitle_combo.setEnabled(enable)
)
# loading gif for preview button # loading gif for preview button
loading_gif_path = get_resource_path("abogen.assets", "loading.gif") loading_gif_path = get_resource_path("abogen.assets", "loading.gif")
if loading_gif_path: if loading_gif_path:
@@ -559,23 +583,10 @@ class abogen(QWidget):
voice_layout.addWidget(QLabel("Select Voice:", self)) voice_layout.addWidget(QLabel("Select Voice:", self))
voice_row = QHBoxLayout() voice_row = QHBoxLayout()
self.voice_combo = QComboBox(self) self.voice_combo = QComboBox(self)
self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed)
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.setStyleSheet( self.voice_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }" "QComboBox { min-height: 20px; padding: 6px 12px; }"
) )
self.voice_combo.currentIndexChanged.connect(self.on_voice_changed)
self.voice_combo.setToolTip( self.voice_combo.setToolTip(
"The first character represents the language:\n" "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' '"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.setCurrentText(self.subtitle_mode)
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
# Enable/disable subtitle options based on selected language # Enable/disable subtitle options based on selected language (profile or voice)
self.subtitle_combo.setEnabled( enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION self.subtitle_combo.setEnabled(enable)
)
subtitle_layout.addWidget(self.subtitle_combo) subtitle_layout.addWidget(self.subtitle_combo)
controls_layout.addLayout(subtitle_layout) controls_layout.addLayout(subtitle_layout)
# Output format # Output format
@@ -775,6 +785,7 @@ class abogen(QWidget):
container_layout.addWidget(self.finish_widget) container_layout.addWidget(self.finish_widget)
outer_layout.addWidget(container) outer_layout.addWidget(container)
self.setLayout(outer_layout) self.setLayout(outer_layout)
self.populate_profiles_in_voice_combo()
def open_file_dialog(self): def open_file_dialog(self):
if self.is_converting: if self.is_converting:
@@ -957,6 +968,87 @@ class abogen(QWidget):
else: else:
self.subtitle_combo.setEnabled(False) 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): def convert_input_box_to_log(self):
self.input_box.hide() self.input_box.hide()
self.log_text.show() self.log_text.show()
@@ -1065,14 +1157,23 @@ class abogen(QWidget):
# if voice formula is not None, use the selected voice # if voice formula is not None, use the selected voice
if self.mixed_voice_state: if self.mixed_voice_state:
formula_components = [ 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)) voice_formula = " + ".join(filter(None, formula_components))
else: else:
voice_formula = self.selected_voice voice_formula = self.selected_voice
# selected language - use the first voice of the mix # determine selected language: use profile setting if profile selected, else voice code
match = re.search(r"\b([a-z])", voice_formula) if self.selected_profile_name:
selected_lang = match.group(1) 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.conversion_thread = ConversionThread(
self.selected_file, self.selected_file,
@@ -1355,13 +1456,32 @@ class abogen(QWidget):
self.btn_start.setEnabled(True) # Re-enable start button on error self.btn_start.setEnabled(True) # Re-enable start button on error
return return
lang, voice, speed = ( # Support preview for voice profiles
self.selected_voice[0], speed = self.speed_slider.value() / 100.0
self.selected_voice, if self.mixed_voice_state:
self.speed_slider.value() / 100.0, # 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( 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.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error) self.preview_thread.error.connect(self._preview_error)
@@ -1565,7 +1685,7 @@ class abogen(QWidget):
menu.addAction(add_shortcut_action) menu.addAction(add_shortcut_action)
# Add reveal config option # 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) reveal_config_action.triggered.connect(self.reveal_config_in_explorer)
menu.addAction(reveal_config_action) menu.addAction(reveal_config_action)
@@ -1699,13 +1819,35 @@ class abogen(QWidget):
save_config(self.config) save_config(self.config)
def show_voice_formula_dialog(self): def show_voice_formula_dialog(self):
# get the current voice mix # If a profile is selected in combo, open mixer with that profile
if self.mixed_voice_state is None: initial_state = None
# if no voice mix is set, use the selected voice selected_profile = self.selected_profile_name
self.mixed_voice_state = [(self.selected_voice, 1.0)] if selected_profile:
entry = load_profiles().get(selected_profile, {})
dialog = VoiceFormulaDialog(self, initial_state=self.mixed_voice_state) # 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: 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() self.mixed_voice_state = dialog.get_selected_voices()
def show_about_dialog(self): def show_about_dialog(self):
+5 -3
View File
@@ -33,8 +33,10 @@ def get_resource_path(package, resource):
pass pass
# Always try to resolve as a relative path from this file # Always try to resolve as a relative path from this file
parts = package.split('.') parts = package.split(".")
rel_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource) rel_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource
)
if os.path.exists(rel_path): if os.path.exists(rel_path):
return rel_path return rel_path
@@ -56,7 +58,7 @@ def get_resource_path(package, resource):
def get_version(): def get_version():
"""Return the current version of the application.""" """Return the current version of the application."""
try: try:
with open(get_resource_path("abogen", "VERSION"), "r") as f: with open(get_resource_path("/", "VERSION"), "r") as f:
return f.read().strip() return f.read().strip()
except Exception: except Exception:
return "Unknown" return "Unknown"
+840 -25
View File
@@ -1,3 +1,5 @@
import json
import os
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QDialog,
QVBoxLayout, QVBoxLayout,
@@ -14,11 +16,31 @@ from PyQt5.QtWidgets import (
QFrame, QFrame,
QLayout, QLayout,
QStyle, QStyle,
QListWidget,
QListWidgetItem,
QInputDialog,
QFileDialog,
QSplitter,
QMenu,
QAction,
QComboBox,
) )
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt5.QtGui import QPixmap from PyQt5.QtGui import QPixmap, QIcon, QColor
from constants import VOICES_INTERNAL from constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS,
)
from utils import get_resource_path from utils import get_resource_path
from voice_profiles import (
load_profiles,
save_profiles,
delete_profile,
duplicate_profile,
export_profiles,
)
# Constants # Constants
VOICE_MIXER_WIDTH = 100 VOICE_MIXER_WIDTH = 100
@@ -27,7 +49,22 @@ MIN_WINDOW_WIDTH = 600
MIN_WINDOW_HEIGHT = 400 MIN_WINDOW_HEIGHT = 400
INITIAL_WINDOW_WIDTH = 1000 INITIAL_WINDOW_WIDTH = 1000
INITIAL_WINDOW_HEIGHT = 500 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): class FlowLayout(QLayout):
@@ -143,14 +180,22 @@ class VoiceMixer(QWidget):
icons_layout.setAlignment(Qt.AlignCenter) # Center the icons horizontally icons_layout.setAlignment(Qt.AlignCenter) # Center the icons horizontally
# Flag icon # Flag icon
flag_icon_path = get_resource_path("abogen.assets.flags", f"{language_code}.png") flag_icon_path = get_resource_path(
gender_icon_path = get_resource_path("abogen.assets", "female.png" if is_female else "male.png") "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() flag_label = QLabel()
gender_label = QLabel() gender_label = QLabel()
flag_pixmap = QPixmap(flag_icon_path) 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_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(flag_label)
icons_layout.addWidget(gender_label) icons_layout.addWidget(gender_label)
@@ -253,7 +298,7 @@ class HoverLabel(QLabel):
def resizeEvent(self, event): def resizeEvent(self, event):
super().resizeEvent(event) super().resizeEvent(event)
# Position the button in the top-right corner with a small margin # 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): def enterEvent(self, event):
self.delete_button.show() self.delete_button.show()
@@ -263,8 +308,75 @@ class HoverLabel(QLabel):
class VoiceFormulaDialog(QDialog): 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) 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.setWindowTitle("Voice Mixer")
self.setWindowFlags( self.setWindowFlags(
Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint
@@ -274,37 +386,55 @@ class VoiceFormulaDialog(QDialog):
self.voice_mixers = [] self.voice_mixers = []
self.last_enabled_voice = None self.last_enabled_voice = None
# Main Layout # Header label and language selector
main_layout = QVBoxLayout()
# Header
self.header_label = QLabel( self.header_label = QLabel(
"Adjust voice weights to create your preferred voice mix." "Adjust voice weights to create your preferred voice mix."
) )
self.header_label.setStyleSheet("font-size: 13px;") self.header_label.setStyleSheet("font-size: 13px;")
self.header_label.setWordWrap(True) 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 # Error message
self.error_label = QLabel( 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.setStyleSheet("color: red; font-weight: bold;")
self.error_label.setWordWrap(True) self.error_label.setWordWrap(True)
self.error_label.hide() self.error_label.hide()
main_layout.addWidget(self.error_label) mixer_layout.addWidget(self.error_label)
# Voice weights display # Voice weights display
self.weighted_sums_container = QWidget() self.weighted_sums_container = QWidget()
self.weighted_sums_layout = FlowLayout(self.weighted_sums_container) self.weighted_sums_layout = FlowLayout(self.weighted_sums_container)
self.weighted_sums_layout.setSpacing(5) self.weighted_sums_layout.setSpacing(5)
self.weighted_sums_layout.setContentsMargins(5, 5, 5, 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
separator = QFrame() separator = QFrame()
separator.setFrameShadow(QFrame.Sunken) separator.setFrameShadow(QFrame.Sunken)
main_layout.addWidget(separator) mixer_layout.addWidget(separator)
# Voice list scroll area # Voice list scroll area
self.scroll_area = QScrollArea() self.scroll_area = QScrollArea()
@@ -320,7 +450,7 @@ class VoiceFormulaDialog(QDialog):
QSizePolicy.Expanding, QSizePolicy.Expanding QSizePolicy.Expanding, QSizePolicy.Expanding
) )
self.scroll_area.setWidget(self.voice_list_widget) 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 # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -341,13 +471,179 @@ class VoiceFormulaDialog(QDialog):
button_layout.addWidget(clear_all_button) button_layout.addWidget(clear_all_button)
button_layout.addWidget(ok_button) button_layout.addWidget(ok_button)
button_layout.addWidget(cancel_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.add_voices(initial_state or [])
self.update_weighted_sums() 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): def add_voices(self, initial_state):
first_enabled_voice = None first_enabled_voice = None
@@ -358,7 +654,9 @@ class VoiceFormulaDialog(QDialog):
) )
initial_status = matching_voice is not None initial_status = matching_voice is not None
initial_weight = matching_voice[1] if matching_voice else 1.0 initial_weight = matching_voice[1] if matching_voice else 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: if initial_status and first_enabled_voice is None:
first_enabled_voice = voice_mixer first_enabled_voice = voice_mixer
@@ -379,6 +677,13 @@ class VoiceFormulaDialog(QDialog):
lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state) lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state)
) )
voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums) 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 return voice_mixer
def handle_voice_checkbox(self, voice_mixer, state): def handle_voice_checkbox(self, voice_mixer, state):
@@ -425,7 +730,10 @@ class VoiceFormulaDialog(QDialog):
for name, weight in selected: for name, weight in selected:
percentage = weight / total * 100 percentage = weight / total * 100
# Make the voice name bold and include percentage # Make the voice name bold and include percentage
voice_label = HoverLabel(f"<b>{name}: {percentage:.1f}%</b>", name) voice_label = HoverLabel(
f'<b><span style="color:#1976d2">{name}: {percentage:.1f}%</span></b>',
name,
)
voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
voice_label.delete_button.clicked.connect( voice_label.delete_button.clicked.connect(
lambda _, vn=name: self.disable_voice_by_name(vn) lambda _, vn=name: self.disable_voice_by_name(vn)
@@ -435,6 +743,20 @@ class VoiceFormulaDialog(QDialog):
self.error_label.show() self.error_label.show()
self.weighted_sums_container.hide() 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): def disable_voice_by_name(self, voice_name):
for mixer in self.voice_mixers: for mixer in self.voice_mixers:
if mixer.voice_name == voice_name: if mixer.voice_name == voice_name:
@@ -461,7 +783,130 @@ class VoiceFormulaDialog(QDialog):
return True return True
return super().eventFilter(source, event) 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): 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() selected_voices = self.get_selected_voices()
total_weight = sum(weight for _, weight in selected_voices) total_weight = sum(weight for _, weight in selected_voices)
if total_weight == 0: if total_weight == 0:
@@ -470,5 +915,375 @@ class VoiceFormulaDialog(QDialog):
"Invalid Weights", "Invalid Weights",
"The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.", "The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.",
) )
self.update_weighted_sums()
return 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() 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()
+3 -3
View File
@@ -29,8 +29,8 @@ def parse_voice_formula(pipeline, formula):
voices = formula.split("+") voices = formula.split("+")
for term in voices: for term in voices:
# Parse each term (format: "0.333 * voice_name") # Parse each term (format: "voice_name*0.333")
weight, voice_name = term.strip().split("*") voice_name, weight = term.strip().split("*")
weight = float(weight.strip()) weight = float(weight.strip())
# normalize the weight # normalize the weight
weight /= total_weight if total_weight > 0 else 1.0 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): 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) total_sum = sum(float(weight) for weight in weights)
return total_sum return total_sum
+59
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB