mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f31f82fd | ||
|
|
d5388c62be | ||
|
|
07a2fe2d5b | ||
|
|
58d9073ace | ||
|
|
2cbb2f8aa7 | ||
|
|
f7d8d5ccd8 | ||
|
|
e7d479d846 | ||
|
|
036eed3142 | ||
|
|
0d86d107e9 | ||
|
|
56b336a529 |
+6
-8
@@ -1,8 +1,6 @@
|
|||||||
- 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.
|
- Merge pull request [#7](https://github.com/denizsafak/abogen/pull/7) by [@jborza](https://github.com/jborza) that improves voice preview and documentation.
|
||||||
- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles.
|
- Fixed the issue when a voice is selected, the voice mixer tries to pre-select that voice and ignores existing profiles.
|
||||||
- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience.
|
- Fixed the error while renaming the default "New profile" in the voice mixer.
|
||||||
- Added icons for flags and genders in the GUI, making it easier to identify different options.
|
- Fixed subtitle_combo enabling/disabling when a voice in the voice mixer is selected.
|
||||||
- Improved the content and chapter extraction process for EPUB files, ensuring better handling of various structures.
|
- Prevented using special characters in the profile name to avoid conflicts.
|
||||||
- Switched to platformdirs for determining the correct desktop path, instead of using old methods.
|
- Improved invalid profile handling in the voice mixer.
|
||||||
- Fixed preview voices was not using GPU acceleration, which was causing performance issues.
|
|
||||||
- Improvements in code and documentation.
|
|
||||||
@@ -160,6 +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
|
||||||
|
pip install build # Install the build package
|
||||||
python -m build # Builds the package in dist folder (optional)
|
python -m build # Builds the package in dist folder (optional)
|
||||||
abogen # Opens the GUI
|
abogen # Opens the GUI
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.3
|
1.0.4
|
||||||
+25
-6
@@ -1480,8 +1480,11 @@ class abogen(QWidget):
|
|||||||
lang = self.selected_voice[0]
|
lang = self.selected_voice[0]
|
||||||
voice = self.selected_voice
|
voice = self.selected_voice
|
||||||
|
|
||||||
|
# use same gpu/cpu logic as in conversion
|
||||||
|
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
|
||||||
|
|
||||||
self.preview_thread = VoicePreviewThread(
|
self.preview_thread = VoicePreviewThread(
|
||||||
np_module, kpipeline_class, lang, voice, speed, self.use_gpu
|
np_module, kpipeline_class, lang, voice, speed, gpu_ok
|
||||||
)
|
)
|
||||||
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)
|
||||||
@@ -1545,6 +1548,11 @@ class abogen(QWidget):
|
|||||||
|
|
||||||
def _preview_cleanup(self):
|
def _preview_cleanup(self):
|
||||||
self.preview_playing = False
|
self.preview_playing = False
|
||||||
|
self.loading_movie.stop()
|
||||||
|
try:
|
||||||
|
self.loading_movie.frameChanged.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore error if not connected
|
||||||
self.btn_preview.setIcon(self.play_icon)
|
self.btn_preview.setIcon(self.play_icon)
|
||||||
self.btn_preview.setToolTip("Preview selected voice")
|
self.btn_preview.setToolTip("Preview selected voice")
|
||||||
self.btn_preview.setEnabled(True)
|
self.btn_preview.setEnabled(True)
|
||||||
@@ -1820,25 +1828,36 @@ class abogen(QWidget):
|
|||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
|
|
||||||
def show_voice_formula_dialog(self):
|
def show_voice_formula_dialog(self):
|
||||||
# If a profile is selected in combo, open mixer with that profile
|
from voice_profiles import load_profiles
|
||||||
|
profiles = load_profiles()
|
||||||
initial_state = None
|
initial_state = None
|
||||||
selected_profile = self.selected_profile_name
|
selected_profile = self.selected_profile_name
|
||||||
if selected_profile:
|
if selected_profile:
|
||||||
entry = load_profiles().get(selected_profile, {})
|
entry = profiles.get(selected_profile, {})
|
||||||
# support new dict format
|
|
||||||
if isinstance(entry, dict):
|
if isinstance(entry, dict):
|
||||||
initial_state = entry.get("voices", [])
|
initial_state = entry.get("voices", [])
|
||||||
else:
|
else:
|
||||||
initial_state = entry
|
initial_state = entry
|
||||||
elif self.mixed_voice_state is not None:
|
elif self.mixed_voice_state is not None:
|
||||||
initial_state = self.mixed_voice_state
|
initial_state = self.mixed_voice_state
|
||||||
|
elif self.selected_voice:
|
||||||
|
# If a single voice is selected, default to first profile if available
|
||||||
|
if profiles:
|
||||||
|
first_profile = next(iter(profiles))
|
||||||
|
entry = profiles[first_profile]
|
||||||
|
selected_profile = first_profile
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
initial_state = entry.get("voices", [])
|
||||||
else:
|
else:
|
||||||
initial_state = [(self.selected_voice, 1.0)] if self.selected_voice else []
|
initial_state = entry
|
||||||
|
else:
|
||||||
|
initial_state = []
|
||||||
|
else:
|
||||||
|
initial_state = []
|
||||||
dialog = VoiceFormulaDialog(
|
dialog = VoiceFormulaDialog(
|
||||||
self, initial_state=initial_state, selected_profile=selected_profile
|
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:
|
if dialog.current_profile:
|
||||||
self.selected_profile_name = dialog.current_profile
|
self.selected_profile_name = dialog.current_profile
|
||||||
self.config["selected_profile_name"] = dialog.current_profile
|
self.config["selected_profile_name"] = dialog.current_profile
|
||||||
|
|||||||
+66
-27
@@ -475,7 +475,6 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
|
|
||||||
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
|
# assemble splitter
|
||||||
splitter.addWidget(profile_widget)
|
splitter.addWidget(profile_widget)
|
||||||
@@ -496,8 +495,6 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
for vm in self.voice_mixers:
|
for vm in self.voice_mixers:
|
||||||
vm.spin_box.valueChanged.connect(self.mark_profile_modified)
|
vm.spin_box.valueChanged.connect(self.mark_profile_modified)
|
||||||
vm.checkbox.stateChanged.connect(lambda *_: 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):
|
def keyPressEvent(self, event):
|
||||||
# Bind Delete key to delete_profile when a profile is selected
|
# Bind Delete key to delete_profile when a profile is selected
|
||||||
@@ -682,8 +679,6 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
voice_mixer.checkbox.stateChanged.connect(
|
voice_mixer.checkbox.stateChanged.connect(
|
||||||
lambda *_: self.mark_profile_modified()
|
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):
|
||||||
@@ -743,20 +738,6 @@ 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:
|
||||||
@@ -819,7 +800,6 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
# sync enabled state
|
# sync enabled state
|
||||||
vm.toggle_inputs()
|
vm.toggle_inputs()
|
||||||
self.update_weighted_sums()
|
self.update_weighted_sums()
|
||||||
self.update_subtitle_combo_enabled()
|
|
||||||
|
|
||||||
def save_profile_by_name(self, name):
|
def save_profile_by_name(self, name):
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
@@ -846,10 +826,10 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
self.update_weighted_sums()
|
self.update_weighted_sums()
|
||||||
|
|
||||||
def _handle_zero_weight_profiles(self):
|
def _handle_zero_weight_profiles(self):
|
||||||
if self.profile_list.count() <= 1:
|
profiles = load_profiles()
|
||||||
|
if len(profiles) < 1:
|
||||||
return False
|
return False
|
||||||
zero = []
|
zero = []
|
||||||
profiles = load_profiles()
|
|
||||||
for i in range(self.profile_list.count()):
|
for i in range(self.profile_list.count()):
|
||||||
item = self.profile_list.item(i)
|
item = self.profile_list.item(i)
|
||||||
name = item.text().lstrip("*")
|
name = item.text().lstrip("*")
|
||||||
@@ -868,7 +848,6 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
if not zero:
|
if not zero:
|
||||||
return False
|
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?"
|
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(
|
reply = QMessageBox.question(
|
||||||
self,
|
self,
|
||||||
"Invalid Profiles",
|
"Invalid Profiles",
|
||||||
@@ -963,8 +942,17 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
self.update_profile_list_colors()
|
self.update_profile_list_colors()
|
||||||
|
|
||||||
def new_profile(self):
|
def new_profile(self):
|
||||||
|
import re
|
||||||
|
while True:
|
||||||
name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:")
|
name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:")
|
||||||
if ok and name:
|
if not ok or not name:
|
||||||
|
break
|
||||||
|
name = name.strip() # Remove leading/trailing spaces
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if not re.match(r'^[\w\- ]+$', name):
|
||||||
|
QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.")
|
||||||
|
continue
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
# Remove 'New profile' placeholder if not persisted in JSON
|
# Remove 'New profile' placeholder if not persisted in JSON
|
||||||
if (
|
if (
|
||||||
@@ -977,7 +965,7 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
self._profile_dirty.pop("New profile", None)
|
self._profile_dirty.pop("New profile", None)
|
||||||
if name in profiles:
|
if name in profiles:
|
||||||
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
||||||
return
|
continue
|
||||||
profiles[name] = {
|
profiles[name] = {
|
||||||
"voices": [],
|
"voices": [],
|
||||||
"language": self.language_combo.currentData(),
|
"language": self.language_combo.currentData(),
|
||||||
@@ -996,6 +984,7 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
parent = self.parent()
|
parent = self.parent()
|
||||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||||
parent.populate_profiles_in_voice_combo()
|
parent.populate_profiles_in_voice_combo()
|
||||||
|
break
|
||||||
self.update_profile_save_buttons()
|
self.update_profile_save_buttons()
|
||||||
self.update_profile_list_colors()
|
self.update_profile_list_colors()
|
||||||
self.update_weighted_sums()
|
self.update_weighted_sums()
|
||||||
@@ -1182,21 +1171,64 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def rename_profile(self, item):
|
def rename_profile(self, item):
|
||||||
|
name = item.text().lstrip("*")
|
||||||
|
# block if profile has unsaved changes and it's not a virtual New profile
|
||||||
|
if self._profile_dirty.get(name, False) and not (self._virtual_new_profile and name == "New profile"):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Unsaved Changes", "Please save the profile before renaming."
|
||||||
|
)
|
||||||
|
return
|
||||||
old = item.text().lstrip("*")
|
old = item.text().lstrip("*")
|
||||||
|
import re
|
||||||
|
while True:
|
||||||
new, ok = QInputDialog.getText(
|
new, ok = QInputDialog.getText(
|
||||||
self, "Rename Profile", f"Profile name:", text=old
|
self, "Rename Profile", f"Profile name:", text=old
|
||||||
)
|
)
|
||||||
if ok and new and new != old:
|
if not ok or not new or new == old:
|
||||||
|
break
|
||||||
|
new = new.strip() # Remove leading/trailing spaces
|
||||||
|
if not new:
|
||||||
|
continue
|
||||||
|
if not re.match(r'^[\w\- ]+$', new):
|
||||||
|
QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.")
|
||||||
|
continue
|
||||||
|
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
if new in profiles:
|
if new in profiles:
|
||||||
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
||||||
return
|
continue
|
||||||
|
|
||||||
|
# Special case for renaming the virtual "New profile"
|
||||||
|
if self._virtual_new_profile and name == "New profile":
|
||||||
|
# Create the profile with the new name
|
||||||
|
profiles[new] = {
|
||||||
|
"voices": self.get_selected_voices(),
|
||||||
|
"language": self.language_combo.currentData(),
|
||||||
|
}
|
||||||
|
save_profiles(profiles)
|
||||||
|
|
||||||
|
# Update tracking properties
|
||||||
|
self._virtual_new_profile = False
|
||||||
|
self._profile_dirty.pop("New profile", None)
|
||||||
|
self._profile_dirty[new] = False
|
||||||
|
|
||||||
|
# Update the current profile name
|
||||||
|
self.current_profile = new
|
||||||
|
item.setText(new)
|
||||||
|
else:
|
||||||
|
# Standard renaming for regular profiles
|
||||||
profiles[new] = profiles.pop(old)
|
profiles[new] = profiles.pop(old)
|
||||||
save_profiles(profiles)
|
save_profiles(profiles)
|
||||||
item.setText(new)
|
item.setText(new)
|
||||||
|
|
||||||
|
# Update the current profile name if it was renamed
|
||||||
|
if self.current_profile == old:
|
||||||
|
self.current_profile = new
|
||||||
|
|
||||||
parent = self.parent()
|
parent = self.parent()
|
||||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||||
parent.populate_profiles_in_voice_combo()
|
parent.populate_profiles_in_voice_combo()
|
||||||
|
break
|
||||||
self.update_profile_save_buttons()
|
self.update_profile_save_buttons()
|
||||||
self.update_profile_list_colors()
|
self.update_profile_list_colors()
|
||||||
|
|
||||||
@@ -1227,6 +1259,13 @@ class VoiceFormulaDialog(QDialog):
|
|||||||
self.update_profile_list_colors()
|
self.update_profile_list_colors()
|
||||||
|
|
||||||
def duplicate_profile(self, item):
|
def duplicate_profile(self, item):
|
||||||
|
name = item.text().lstrip("*")
|
||||||
|
# block duplicating if profile has unsaved changes
|
||||||
|
if self._profile_dirty.get(name, False):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Unsaved Changes", "Please save the profile before duplicating."
|
||||||
|
)
|
||||||
|
return
|
||||||
src = item.text().lstrip("*")
|
src = item.text().lstrip("*")
|
||||||
profiles = load_profiles()
|
profiles = load_profiles()
|
||||||
base = f"{src}_duplicate"
|
base = f"{src}_duplicate"
|
||||||
|
|||||||
Reference in New Issue
Block a user