Added profile system to voice mixer, improvements in code
@@ -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.
|
||||||
@@ -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 @@ 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)
|
- **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 @@ 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.
|
- **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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 442 B |
|
Before Width: | Height: | Size: 484 B After Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 531 B After Width: | Height: | Size: 786 B |
|
Before Width: | Height: | Size: 331 B After Width: | Height: | Size: 521 B |
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 372 B |
|
Before Width: | Height: | Size: 373 B After Width: | Height: | Size: 465 B |
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 314 B After Width: | Height: | Size: 441 B |
|
Before Width: | Height: | Size: 464 B After Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 317 B After Width: | Height: | Size: 431 B |
|
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 252 B After Width: | Height: | Size: 331 B |
@@ -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):
|
||||||
try:
|
print(
|
||||||
tts = self.kpipeline_class(
|
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
|
||||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M"
|
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
device = "cuda" if self.use_gpu else "cpu"
|
||||||
|
tts = self.kpipeline_class(
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
# 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_voice = self.config.get("selected_voice", "af_heart")
|
||||||
self.selected_lang = self.selected_voice[0]
|
self.selected_lang = self.selected_voice[0] if self.selected_voice else None
|
||||||
self.mixed_voice_state = None # Store the mixed voice state
|
|
||||||
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()
|
||||||
|
# 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)
|
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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
After Width: | Height: | Size: 28 KiB |