mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
Added profile system to voice mixer, improvements in code
This commit is contained in:
+843
-28
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
@@ -14,11 +16,31 @@ from PyQt5.QtWidgets import (
|
||||
QFrame,
|
||||
QLayout,
|
||||
QStyle,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QInputDialog,
|
||||
QFileDialog,
|
||||
QSplitter,
|
||||
QMenu,
|
||||
QAction,
|
||||
QComboBox,
|
||||
)
|
||||
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||||
from PyQt5.QtGui import QPixmap
|
||||
from constants import VOICES_INTERNAL
|
||||
from PyQt5.QtGui import QPixmap, QIcon, QColor
|
||||
from constants import (
|
||||
VOICES_INTERNAL,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
)
|
||||
from utils import get_resource_path
|
||||
from voice_profiles import (
|
||||
load_profiles,
|
||||
save_profiles,
|
||||
delete_profile,
|
||||
duplicate_profile,
|
||||
export_profiles,
|
||||
)
|
||||
|
||||
|
||||
# Constants
|
||||
VOICE_MIXER_WIDTH = 100
|
||||
@@ -27,7 +49,22 @@ MIN_WINDOW_WIDTH = 600
|
||||
MIN_WINDOW_HEIGHT = 400
|
||||
INITIAL_WINDOW_WIDTH = 1000
|
||||
INITIAL_WINDOW_HEIGHT = 500
|
||||
FEMALE, MALE = "👩🦰", "👨"
|
||||
|
||||
# Language options for the language selector loaded from constants
|
||||
LANGUAGE_OPTIONS = list(LANGUAGE_DESCRIPTIONS.items())
|
||||
|
||||
|
||||
class SaveButtonWidget(QWidget):
|
||||
def __init__(self, parent, profile_name, save_callback):
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.save_btn = QPushButton("Save", self)
|
||||
self.save_btn.setFixedWidth(48)
|
||||
self.save_btn.clicked.connect(lambda: save_callback(profile_name))
|
||||
layout.addStretch()
|
||||
layout.addWidget(self.save_btn)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
@@ -141,22 +178,30 @@ class VoiceMixer(QWidget):
|
||||
icons_layout = QHBoxLayout()
|
||||
icons_layout.setSpacing(3)
|
||||
icons_layout.setAlignment(Qt.AlignCenter) # Center the icons horizontally
|
||||
|
||||
|
||||
# Flag icon
|
||||
flag_icon_path = get_resource_path("abogen.assets.flags", f"{language_code}.png")
|
||||
gender_icon_path = get_resource_path("abogen.assets", "female.png" if is_female else "male.png")
|
||||
flag_icon_path = get_resource_path(
|
||||
"abogen.assets.flags", f"{language_code}.png"
|
||||
)
|
||||
gender_icon_path = get_resource_path(
|
||||
"abogen.assets", "female.png" if is_female else "male.png"
|
||||
)
|
||||
flag_label = QLabel()
|
||||
gender_label = QLabel()
|
||||
flag_pixmap = QPixmap(flag_icon_path)
|
||||
flag_label.setPixmap(flag_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
||||
flag_label.setPixmap(
|
||||
flag_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
)
|
||||
gender_pixmap = QPixmap(gender_icon_path)
|
||||
gender_label.setPixmap(gender_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
||||
gender_label.setPixmap(
|
||||
gender_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
)
|
||||
icons_layout.addWidget(flag_label)
|
||||
icons_layout.addWidget(gender_label)
|
||||
|
||||
|
||||
# Add icons layout
|
||||
layout.addLayout(icons_layout)
|
||||
|
||||
|
||||
# Checkbox (now below icons)
|
||||
self.checkbox = QCheckBox()
|
||||
self.checkbox.setChecked(initial_status)
|
||||
@@ -253,7 +298,7 @@ class HoverLabel(QLabel):
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
# Position the button in the top-right corner with a small margin
|
||||
self.delete_button.move(self.width() - 16, + 0)
|
||||
self.delete_button.move(self.width() - 16, +0)
|
||||
|
||||
def enterEvent(self, event):
|
||||
self.delete_button.show()
|
||||
@@ -263,8 +308,75 @@ class HoverLabel(QLabel):
|
||||
|
||||
|
||||
class VoiceFormulaDialog(QDialog):
|
||||
def __init__(self, parent=None, initial_state=None):
|
||||
def __init__(self, parent=None, initial_state=None, selected_profile=None):
|
||||
super().__init__(parent)
|
||||
profiles = load_profiles()
|
||||
self._virtual_new_profile = False
|
||||
if not profiles:
|
||||
# No profiles: show 'New profile' in the list, unsaved, not in JSON
|
||||
self.current_profile = "New profile"
|
||||
self._profile_dirty = {"New profile": True}
|
||||
self._virtual_new_profile = True
|
||||
profiles = {} # Do not add to JSON yet
|
||||
else:
|
||||
self.current_profile = (
|
||||
selected_profile
|
||||
if selected_profile in profiles
|
||||
else list(profiles.keys())[0]
|
||||
)
|
||||
self._profile_dirty = {name: False for name in profiles}
|
||||
# Track unsaved states per profile
|
||||
self._profile_states = {}
|
||||
# Add subtitle_combo reference if parent has it
|
||||
self.subtitle_combo = None
|
||||
if parent is not None and hasattr(parent, "subtitle_combo"):
|
||||
self.subtitle_combo = parent.subtitle_combo
|
||||
# Create main container layout with profile section and mixer section
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
# Profile section
|
||||
profile_widget = QWidget()
|
||||
profile_layout = QVBoxLayout(profile_widget)
|
||||
profile_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# Profile header and save/new buttons
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.addWidget(QLabel("Profiles:"))
|
||||
header_layout.addStretch()
|
||||
self.btn_new_profile = QPushButton("New profile")
|
||||
header_layout.addWidget(self.btn_new_profile)
|
||||
profile_layout.addLayout(header_layout)
|
||||
# Profile list
|
||||
self.profile_list = QListWidget()
|
||||
icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
|
||||
if self._virtual_new_profile:
|
||||
item = QListWidgetItem(icon, "New profile")
|
||||
self.profile_list.addItem(item)
|
||||
self.profile_list.setCurrentRow(0)
|
||||
else:
|
||||
for name in profiles:
|
||||
item = QListWidgetItem(icon, name)
|
||||
self.profile_list.addItem(item)
|
||||
idx = list(profiles.keys()).index(self.current_profile)
|
||||
self.profile_list.setCurrentRow(idx)
|
||||
profile_layout.addWidget(self.profile_list)
|
||||
self.profile_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.profile_list.customContextMenuRequested.connect(
|
||||
self.show_profile_context_menu
|
||||
)
|
||||
self.profile_list.setItemWidget = (
|
||||
self.profile_list.setItemWidget
|
||||
) # for type hints
|
||||
# Save and management buttons
|
||||
mgmt_layout = QVBoxLayout()
|
||||
self.btn_import_profiles = QPushButton("Import profile(s)")
|
||||
mgmt_layout.addWidget(self.btn_import_profiles)
|
||||
self.btn_export_profiles = QPushButton("Export profiles")
|
||||
mgmt_layout.addWidget(self.btn_export_profiles)
|
||||
profile_layout.addLayout(mgmt_layout)
|
||||
# prepare mixer widget
|
||||
mixer_widget = QWidget()
|
||||
mixer_layout = QVBoxLayout(mixer_widget)
|
||||
mixer_layout.setContentsMargins(5, 0, 0, 0)
|
||||
|
||||
self.setWindowTitle("Voice Mixer")
|
||||
self.setWindowFlags(
|
||||
Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint
|
||||
@@ -274,37 +386,55 @@ class VoiceFormulaDialog(QDialog):
|
||||
self.voice_mixers = []
|
||||
self.last_enabled_voice = None
|
||||
|
||||
# Main Layout
|
||||
main_layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
# Header label and language selector
|
||||
self.header_label = QLabel(
|
||||
"Adjust voice weights to create your preferred voice mix."
|
||||
)
|
||||
self.header_label.setStyleSheet("font-size: 13px;")
|
||||
self.header_label.setWordWrap(True)
|
||||
main_layout.addWidget(self.header_label)
|
||||
header_row = QHBoxLayout()
|
||||
header_row.addWidget(self.header_label, 1)
|
||||
header_row.addStretch()
|
||||
header_row.addWidget(QLabel("Language:"))
|
||||
self.language_combo = QComboBox()
|
||||
for code, desc in LANGUAGE_OPTIONS:
|
||||
flag = get_resource_path("abogen.assets.flags", f"{code}.png")
|
||||
if flag and os.path.exists(flag):
|
||||
self.language_combo.addItem(QIcon(flag), desc, code)
|
||||
else:
|
||||
self.language_combo.addItem(desc, code)
|
||||
# set current language for profile
|
||||
prof = profiles.get(self.current_profile, {})
|
||||
lang = prof.get("language") if isinstance(prof, dict) else None
|
||||
if not lang:
|
||||
lang = list(LANGUAGE_DESCRIPTIONS.keys())[0]
|
||||
idx = self.language_combo.findData(lang)
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
self.language_combo.currentIndexChanged.connect(self.mark_profile_modified)
|
||||
header_row.addWidget(self.language_combo)
|
||||
mixer_layout.addLayout(header_row)
|
||||
|
||||
# Error message
|
||||
self.error_label = QLabel(
|
||||
"No voices selected or all weights are 0. Please select at least one voice and set its weight above 0."
|
||||
"Please select at least one voice and set its weight above 0."
|
||||
)
|
||||
self.error_label.setStyleSheet("color: red; font-weight: bold;")
|
||||
self.error_label.setWordWrap(True)
|
||||
self.error_label.hide()
|
||||
main_layout.addWidget(self.error_label)
|
||||
mixer_layout.addWidget(self.error_label)
|
||||
|
||||
# Voice weights display
|
||||
self.weighted_sums_container = QWidget()
|
||||
self.weighted_sums_layout = FlowLayout(self.weighted_sums_container)
|
||||
self.weighted_sums_layout.setSpacing(5)
|
||||
self.weighted_sums_layout.setContentsMargins(5, 5, 5, 5)
|
||||
main_layout.addWidget(self.weighted_sums_container)
|
||||
mixer_layout.addWidget(self.weighted_sums_container)
|
||||
|
||||
# Separator
|
||||
separator = QFrame()
|
||||
separator.setFrameShadow(QFrame.Sunken)
|
||||
main_layout.addWidget(separator)
|
||||
mixer_layout.addWidget(separator)
|
||||
|
||||
# Voice list scroll area
|
||||
self.scroll_area = QScrollArea()
|
||||
@@ -320,7 +450,7 @@ class VoiceFormulaDialog(QDialog):
|
||||
QSizePolicy.Expanding, QSizePolicy.Expanding
|
||||
)
|
||||
self.scroll_area.setWidget(self.voice_list_widget)
|
||||
main_layout.addWidget(self.scroll_area, stretch=1)
|
||||
mixer_layout.addWidget(self.scroll_area, stretch=1)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
@@ -341,13 +471,179 @@ class VoiceFormulaDialog(QDialog):
|
||||
button_layout.addWidget(clear_all_button)
|
||||
button_layout.addWidget(ok_button)
|
||||
button_layout.addWidget(cancel_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
mixer_layout.addLayout(button_layout)
|
||||
|
||||
self.setLayout(main_layout)
|
||||
|
||||
# Setup voices and display
|
||||
self.add_voices(initial_state or [])
|
||||
self.update_weighted_sums()
|
||||
self.update_subtitle_combo_enabled()
|
||||
|
||||
# assemble splitter
|
||||
splitter.addWidget(profile_widget)
|
||||
splitter.addWidget(mixer_widget)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
# set as main layout
|
||||
self.setLayout(QHBoxLayout())
|
||||
self.layout().addWidget(splitter)
|
||||
|
||||
# Connect profile actions
|
||||
self.profile_list.currentRowChanged.connect(self.on_profile_selection_changed)
|
||||
# Track initial profile for proper dirty-state saving
|
||||
self.last_profile_row = self.profile_list.currentRow()
|
||||
self.btn_new_profile.clicked.connect(self.new_profile)
|
||||
self.btn_export_profiles.clicked.connect(self.export_all_profiles)
|
||||
self.btn_import_profiles.clicked.connect(self.import_profiles_dialog)
|
||||
# Detect modifications in voice mixers
|
||||
for vm in self.voice_mixers:
|
||||
vm.spin_box.valueChanged.connect(self.mark_profile_modified)
|
||||
vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified())
|
||||
vm.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled)
|
||||
vm.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
# Bind Delete key to delete_profile when a profile is selected
|
||||
if event.key() == Qt.Key_Delete and self.profile_list.hasFocus():
|
||||
item = self.profile_list.currentItem()
|
||||
if item:
|
||||
self.delete_profile(item)
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def _has_unsaved_changes(self):
|
||||
# Only return True if there are actually modified (yellow background) profiles
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
# Only consider as unsaved if profile is marked dirty (yellow background)
|
||||
if item.text().startswith("*"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _prompt_save_changes(self):
|
||||
dirty_indices = [
|
||||
i
|
||||
for i in range(self.profile_list.count())
|
||||
if self.profile_list.item(i).text().startswith("*")
|
||||
]
|
||||
parent = self.parent()
|
||||
if len(dirty_indices) > 1:
|
||||
msg = f"You have unsaved changes in {len(dirty_indices)} profiles. Do you want to save all?"
|
||||
ret = QMessageBox.question(
|
||||
self,
|
||||
"Unsaved Changes",
|
||||
msg,
|
||||
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
|
||||
QMessageBox.Save,
|
||||
)
|
||||
if ret == QMessageBox.Save:
|
||||
# Save all using stored states
|
||||
profiles = load_profiles()
|
||||
for i in dirty_indices:
|
||||
name = self.profile_list.item(i).text().lstrip("*")
|
||||
state = self._profile_states.get(name)
|
||||
if state is not None:
|
||||
profiles[name] = state
|
||||
self._profile_dirty[name] = False
|
||||
save_profiles(profiles)
|
||||
# clear states
|
||||
for name in list(self._profile_states.keys()):
|
||||
if name not in profiles:
|
||||
continue
|
||||
del self._profile_states[name]
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
# clear markers
|
||||
for i in dirty_indices:
|
||||
item = self.profile_list.item(i)
|
||||
n = item.text().lstrip("*")
|
||||
item.setText(n)
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
return True
|
||||
elif ret == QMessageBox.Discard:
|
||||
# Discard all modifications
|
||||
self._profile_states.clear()
|
||||
for i in dirty_indices:
|
||||
item = self.profile_list.item(i)
|
||||
n = item.text().lstrip("*")
|
||||
item.setText(n)
|
||||
self._profile_dirty[n] = False
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
# reload current profile
|
||||
profiles = load_profiles()
|
||||
if self.current_profile in profiles:
|
||||
self.load_profile_state(self.current_profile)
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
# Fallback to original logic for 0 or 1 dirty profile
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Warning)
|
||||
box.setWindowTitle("Unsaved Changes")
|
||||
box.setText(
|
||||
"You have unsaved changes in your profile. Do you want to save the changes?"
|
||||
)
|
||||
box.setStandardButtons(
|
||||
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
|
||||
)
|
||||
box.setDefaultButton(QMessageBox.Save)
|
||||
ret = box.exec_()
|
||||
if ret == QMessageBox.Save:
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
name = item.text().lstrip("*")
|
||||
if (
|
||||
self._profile_dirty.get(name, False)
|
||||
or item.text().startswith("*")
|
||||
or (name == self.current_profile)
|
||||
):
|
||||
self.profile_list.setCurrentRow(i)
|
||||
self.save_profile_by_name(name)
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
return True
|
||||
elif ret == QMessageBox.Discard:
|
||||
profiles = load_profiles()
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
name = item.text().lstrip("*")
|
||||
self._profile_dirty[name] = False
|
||||
if item.text().startswith("*"):
|
||||
item.setText(name)
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
if self.current_profile in profiles:
|
||||
self.load_profile_state(self.current_profile)
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def on_profile_selection_changed(self, row):
|
||||
# Save dirty state for previous profile
|
||||
if hasattr(self, "last_profile_row") and self.last_profile_row is not None:
|
||||
prev_item = self.profile_list.item(self.last_profile_row)
|
||||
if prev_item:
|
||||
prev_name = prev_item.text().lstrip("*")
|
||||
self._profile_dirty[prev_name] = prev_item.text().startswith("*")
|
||||
# Do NOT auto-save if modifications pending
|
||||
# load new profile
|
||||
item = self.profile_list.item(row)
|
||||
if item:
|
||||
name = item.text().lstrip("*")
|
||||
self.load_profile_state(name)
|
||||
# Restore dirty state for this profile
|
||||
dirty = self._profile_dirty.get(name, False)
|
||||
if dirty and not item.text().startswith("*"):
|
||||
item.setText("*" + item.text())
|
||||
elif not dirty and item.text().startswith("*"):
|
||||
item.setText(item.text().lstrip("*"))
|
||||
self.last_profile_row = row
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
|
||||
def add_voices(self, initial_state):
|
||||
first_enabled_voice = None
|
||||
@@ -358,7 +654,9 @@ class VoiceFormulaDialog(QDialog):
|
||||
)
|
||||
initial_status = matching_voice is not None
|
||||
initial_weight = matching_voice[1] if matching_voice else 1.0
|
||||
voice_mixer = self.add_voice(voice, language_code, initial_status, initial_weight)
|
||||
voice_mixer = self.add_voice(
|
||||
voice, language_code, initial_status, initial_weight
|
||||
)
|
||||
if initial_status and first_enabled_voice is None:
|
||||
first_enabled_voice = voice_mixer
|
||||
|
||||
@@ -379,6 +677,13 @@ class VoiceFormulaDialog(QDialog):
|
||||
lambda state, vm=voice_mixer: self.handle_voice_checkbox(vm, state)
|
||||
)
|
||||
voice_mixer.spin_box.valueChanged.connect(self.update_weighted_sums)
|
||||
voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums)
|
||||
voice_mixer.spin_box.valueChanged.connect(self.mark_profile_modified)
|
||||
voice_mixer.checkbox.stateChanged.connect(
|
||||
lambda *_: self.mark_profile_modified()
|
||||
)
|
||||
voice_mixer.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled)
|
||||
voice_mixer.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled)
|
||||
return voice_mixer
|
||||
|
||||
def handle_voice_checkbox(self, voice_mixer, state):
|
||||
@@ -425,7 +730,10 @@ class VoiceFormulaDialog(QDialog):
|
||||
for name, weight in selected:
|
||||
percentage = weight / total * 100
|
||||
# Make the voice name bold and include percentage
|
||||
voice_label = HoverLabel(f"<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.delete_button.clicked.connect(
|
||||
lambda _, vn=name: self.disable_voice_by_name(vn)
|
||||
@@ -435,6 +743,20 @@ class VoiceFormulaDialog(QDialog):
|
||||
self.error_label.show()
|
||||
self.weighted_sums_container.hide()
|
||||
|
||||
def update_subtitle_combo_enabled(self):
|
||||
# Only enable subtitle_combo if at least one selected voice is from supported languages
|
||||
selected_langs = set()
|
||||
for vm in self.voice_mixers:
|
||||
if vm.checkbox.isChecked() and vm.spin_box.value() > 0:
|
||||
lang_code = vm.voice_name[0]
|
||||
selected_langs.add(lang_code)
|
||||
enable = any(
|
||||
lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||
for lang in selected_langs
|
||||
)
|
||||
if self.subtitle_combo:
|
||||
self.subtitle_combo.setEnabled(enable)
|
||||
|
||||
def disable_voice_by_name(self, voice_name):
|
||||
for mixer in self.voice_mixers:
|
||||
if mixer.voice_name == voice_name:
|
||||
@@ -461,7 +783,130 @@ class VoiceFormulaDialog(QDialog):
|
||||
return True
|
||||
return super().eventFilter(source, event)
|
||||
|
||||
def load_profile_state(self, profile_name):
|
||||
name = profile_name.lstrip("*")
|
||||
profiles = load_profiles()
|
||||
# load voices and language from state or JSON
|
||||
if name in self._profile_states:
|
||||
state = self._profile_states[name]
|
||||
else:
|
||||
state = profiles.get(name, {})
|
||||
voices = state.get("voices") if isinstance(state, dict) else state
|
||||
lang = state.get("language") if isinstance(state, dict) else None
|
||||
# apply language selection
|
||||
if lang:
|
||||
i = self.language_combo.findData(lang)
|
||||
if i >= 0:
|
||||
self.language_combo.blockSignals(True)
|
||||
self.language_combo.setCurrentIndex(i)
|
||||
self.language_combo.blockSignals(False)
|
||||
self.current_profile = name
|
||||
weights = {n: w for n, w in voices}
|
||||
for vm in self.voice_mixers:
|
||||
weight = weights.get(vm.voice_name, 0.0)
|
||||
# block signals to avoid triggering updates
|
||||
vm.checkbox.blockSignals(True)
|
||||
vm.spin_box.blockSignals(True)
|
||||
vm.slider.blockSignals(True)
|
||||
vm.checkbox.setChecked(weight > 0)
|
||||
val = weight if weight > 0 else 1.0
|
||||
vm.spin_box.setValue(val)
|
||||
vm.slider.setValue(int(val * 100))
|
||||
# restore signals
|
||||
vm.checkbox.blockSignals(False)
|
||||
vm.spin_box.blockSignals(False)
|
||||
vm.slider.blockSignals(False)
|
||||
# sync enabled state
|
||||
vm.toggle_inputs()
|
||||
self.update_weighted_sums()
|
||||
self.update_subtitle_combo_enabled()
|
||||
|
||||
def save_profile_by_name(self, name):
|
||||
profiles = load_profiles()
|
||||
state = self._profile_states.get(name, None)
|
||||
if state is not None:
|
||||
# ensure dict format
|
||||
if isinstance(state, dict):
|
||||
entry = state
|
||||
else:
|
||||
entry = {"voices": state, "language": self.language_combo.currentData()}
|
||||
profiles[name] = entry
|
||||
save_profiles(profiles)
|
||||
self._profile_dirty[name] = False
|
||||
del self._profile_states[name]
|
||||
self._virtual_new_profile = False
|
||||
# Remove * marker
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
if item.text().lstrip("*") == name:
|
||||
item.setText(name)
|
||||
break
|
||||
self.update_profile_list_colors()
|
||||
self.update_profile_save_buttons()
|
||||
self.update_weighted_sums()
|
||||
|
||||
def _handle_zero_weight_profiles(self):
|
||||
if self.profile_list.count() <= 1:
|
||||
return False
|
||||
zero = []
|
||||
profiles = load_profiles()
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
name = item.text().lstrip("*")
|
||||
weights = profiles.get(name, {}).get("voices", [])
|
||||
total = 0
|
||||
if isinstance(weights, list):
|
||||
for entry in weights:
|
||||
if (
|
||||
isinstance(entry, (list, tuple))
|
||||
and len(entry) == 2
|
||||
and isinstance(entry[1], (int, float))
|
||||
):
|
||||
total += entry[1]
|
||||
if total == 0:
|
||||
zero.append((i, name))
|
||||
if not zero:
|
||||
return False
|
||||
msg = f"{len(zero)} invalid profile(s) with no voices selected or their total weights are 0. They will be ignored and deleted. Do you want to delete?"
|
||||
# Use Delete instead of Ignore
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Invalid Profiles",
|
||||
msg,
|
||||
QMessageBox.Yes | QMessageBox.Cancel,
|
||||
QMessageBox.Yes,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
for i, name in reversed(zero):
|
||||
self.profile_list.takeItem(i)
|
||||
delete_profile(name)
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
self.update_profile_list_colors()
|
||||
self.update_profile_save_buttons()
|
||||
return False
|
||||
else:
|
||||
idx, _ = zero[0]
|
||||
self.profile_list.setCurrentRow(idx)
|
||||
return True
|
||||
|
||||
def accept(self):
|
||||
# If no profiles, treat as cancel
|
||||
if self.profile_list.count() == 0:
|
||||
# Update subtitle_mode to match combo before closing
|
||||
if self.subtitle_combo:
|
||||
parent = self.parent()
|
||||
if parent is not None:
|
||||
parent.subtitle_mode = self.subtitle_combo.currentText()
|
||||
self.reject()
|
||||
return
|
||||
# Prompt to save if unsaved changes, then check for zero-weight error after save
|
||||
if self._has_unsaved_changes():
|
||||
if not self._prompt_save_changes():
|
||||
return
|
||||
if self._handle_zero_weight_profiles():
|
||||
return
|
||||
selected_voices = self.get_selected_voices()
|
||||
total_weight = sum(weight for _, weight in selected_voices)
|
||||
if total_weight == 0:
|
||||
@@ -470,5 +915,375 @@ class VoiceFormulaDialog(QDialog):
|
||||
"Invalid Weights",
|
||||
"The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.",
|
||||
)
|
||||
self.update_weighted_sums()
|
||||
return
|
||||
# Save weights to current profile
|
||||
profiles = load_profiles()
|
||||
profiles[self.current_profile] = {
|
||||
"voices": selected_voices,
|
||||
"language": self.language_combo.currentData(),
|
||||
}
|
||||
save_profiles(profiles)
|
||||
# Mark this profile as not dirty
|
||||
self._profile_dirty[self.current_profile] = False
|
||||
super().accept()
|
||||
|
||||
def reject(self):
|
||||
# Prompt to save if unsaved changes, then check for zero-weight error after save
|
||||
if self._has_unsaved_changes():
|
||||
if not self._prompt_save_changes():
|
||||
return
|
||||
if self._handle_zero_weight_profiles():
|
||||
return
|
||||
super().reject()
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Prompt to save if unsaved changes, then check for zero-weight error after save
|
||||
if self._has_unsaved_changes():
|
||||
if not self._prompt_save_changes():
|
||||
event.ignore()
|
||||
return
|
||||
if self._handle_zero_weight_profiles():
|
||||
event.ignore()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
|
||||
def mark_profile_modified(self):
|
||||
item = self.profile_list.currentItem()
|
||||
if item and not item.text().startswith("*"):
|
||||
item.setText("*" + item.text())
|
||||
# Flag profile as dirty and store unsaved state
|
||||
name = self.current_profile
|
||||
self._profile_dirty[name] = True
|
||||
self._profile_states[name] = {
|
||||
"voices": self.get_selected_voices(),
|
||||
"language": self.language_combo.currentData(),
|
||||
}
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
|
||||
def new_profile(self):
|
||||
name, ok = QInputDialog.getText(self, "New Profile", "Enter profile name:")
|
||||
if ok and name:
|
||||
profiles = load_profiles()
|
||||
# Remove 'New profile' placeholder if not persisted in JSON
|
||||
if (
|
||||
self.profile_list.count() == 1
|
||||
and self.profile_list.item(0).text() == "New profile"
|
||||
and "New profile" not in profiles
|
||||
):
|
||||
self.profile_list.takeItem(0)
|
||||
self._virtual_new_profile = False
|
||||
self._profile_dirty.pop("New profile", None)
|
||||
if name in profiles:
|
||||
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
||||
return
|
||||
profiles[name] = {
|
||||
"voices": [],
|
||||
"language": self.language_combo.currentData(),
|
||||
}
|
||||
save_profiles(profiles)
|
||||
self.profile_list.addItem(
|
||||
QListWidgetItem(
|
||||
QIcon(get_resource_path("abogen.assets", "profile.png")), name
|
||||
)
|
||||
)
|
||||
self.profile_list.setCurrentRow(self.profile_list.count() - 1)
|
||||
# reset UI mixers
|
||||
for vm in self.voice_mixers:
|
||||
vm.checkbox.setChecked(False)
|
||||
vm.spin_box.setValue(1.0)
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
self.update_weighted_sums()
|
||||
|
||||
def export_all_profiles(self):
|
||||
# Prevent export if any profile has total weight 0
|
||||
profiles = load_profiles()
|
||||
for name, weights in profiles.items():
|
||||
total = 0
|
||||
voices = weights.get("voices", [])
|
||||
if isinstance(voices, list):
|
||||
for entry in voices:
|
||||
if (
|
||||
isinstance(entry, (list, tuple))
|
||||
and len(entry) == 2
|
||||
and isinstance(entry[1], (int, float))
|
||||
):
|
||||
total += entry[1]
|
||||
if total == 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Export Blocked",
|
||||
f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.",
|
||||
)
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export Profiles", "voice_profiles", "JSON Files (*.json)"
|
||||
)
|
||||
if path:
|
||||
export_profiles(path)
|
||||
|
||||
def import_profiles_dialog(self):
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import Profiles", "", "JSON Files (*.json)"
|
||||
)
|
||||
if path:
|
||||
from voice_profiles import load_profiles, save_profiles
|
||||
|
||||
# Try to read the file and count profiles
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# always expect abogen_voice_profiles wrapper
|
||||
if not (isinstance(data, dict) and "abogen_voice_profiles" in data):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Invalid File",
|
||||
"This file is not a valid abogen voice profiles file.",
|
||||
)
|
||||
return
|
||||
imported_profiles = data["abogen_voice_profiles"]
|
||||
if not isinstance(imported_profiles, dict):
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Invalid File",
|
||||
"This file is not a valid abogen voice profiles file.",
|
||||
)
|
||||
return
|
||||
count = len(imported_profiles)
|
||||
except Exception:
|
||||
QMessageBox.warning(
|
||||
self, "Import Error", "Could not read the selected file."
|
||||
)
|
||||
return
|
||||
if count == 0:
|
||||
QMessageBox.information(
|
||||
self, "No Profiles", "No profiles found in the selected file."
|
||||
)
|
||||
return
|
||||
profiles = load_profiles()
|
||||
collisions = [name for name in imported_profiles if name in profiles]
|
||||
# Combine prompts: show both import count and overwrite count if any
|
||||
if count == 1:
|
||||
orig_name = next(iter(imported_profiles.keys()))
|
||||
msg = f"Profile '{orig_name}' will be imported."
|
||||
if collisions:
|
||||
msg += f"\nThis will overwrite an existing profile."
|
||||
msg += "\nContinue?"
|
||||
reply = QMessageBox.question(
|
||||
self, "Import Profile", msg, QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
profiles.update(imported_profiles)
|
||||
save_profiles(profiles)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Profile Imported",
|
||||
f"Profile '{orig_name}' imported successfully.",
|
||||
)
|
||||
else:
|
||||
msg = f"{count} profiles will be imported."
|
||||
if collisions:
|
||||
msg += f"\n{len(collisions)} profile(s) will be overwritten."
|
||||
msg += "\nContinue?"
|
||||
reply = QMessageBox.question(
|
||||
self, "Import Profiles", msg, QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
profiles.update(imported_profiles)
|
||||
save_profiles(profiles)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Profiles Imported",
|
||||
f"{count} profiles imported successfully.",
|
||||
)
|
||||
# Refresh list
|
||||
self.profile_list.clear()
|
||||
profiles = load_profiles()
|
||||
for nm in profiles:
|
||||
self.profile_list.addItem(
|
||||
QListWidgetItem(
|
||||
QIcon(get_resource_path("abogen.assets", "profile.png")), nm
|
||||
)
|
||||
)
|
||||
if self.profile_list.count() > 0:
|
||||
self.profile_list.setCurrentRow(0)
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
self._virtual_new_profile = False
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
|
||||
def show_profile_context_menu(self, pos):
|
||||
item = self.profile_list.itemAt(pos)
|
||||
if not item:
|
||||
return
|
||||
name = item.text().lstrip("*")
|
||||
menu = QMenu(self)
|
||||
rename_act = QAction("Rename", self)
|
||||
delete_act = QAction("Delete", self)
|
||||
dup_act = QAction("Duplicate", self)
|
||||
export_act = QAction("Export this profile", self)
|
||||
menu.addAction(rename_act)
|
||||
menu.addAction(dup_act)
|
||||
menu.addAction(export_act)
|
||||
menu.addAction(delete_act)
|
||||
act = menu.exec_(self.profile_list.viewport().mapToGlobal(pos))
|
||||
if act == rename_act:
|
||||
self.rename_profile(item)
|
||||
elif act == delete_act:
|
||||
self.delete_profile(item)
|
||||
elif act == dup_act:
|
||||
self.duplicate_profile(item)
|
||||
elif act == export_act:
|
||||
self.export_selected_profile_item(item)
|
||||
|
||||
def export_selected_profile_item(self, item):
|
||||
if not item:
|
||||
return
|
||||
name = item.text().lstrip("*")
|
||||
profiles = load_profiles()
|
||||
weights = profiles.get(name, {}).get("voices", [])
|
||||
total = 0
|
||||
if isinstance(weights, list):
|
||||
for entry in weights:
|
||||
if (
|
||||
isinstance(entry, (list, tuple))
|
||||
and len(entry) == 2
|
||||
and isinstance(entry[1], (int, float))
|
||||
):
|
||||
total += entry[1]
|
||||
if total == 0:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Export Blocked",
|
||||
f"Profile '{name}' has no voices selected (total weight is 0). Please fix before exporting.",
|
||||
)
|
||||
return
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export Profile", f"{name}.json", "JSON Files (*.json)"
|
||||
)
|
||||
if path:
|
||||
# Use abogen_voice_profiles wrapper for single profile export
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"abogen_voice_profiles": {name: profiles.get(name, {})}},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def rename_profile(self, item):
|
||||
old = item.text().lstrip("*")
|
||||
new, ok = QInputDialog.getText(
|
||||
self, "Rename Profile", f"Profile name:", text=old
|
||||
)
|
||||
if ok and new and new != old:
|
||||
profiles = load_profiles()
|
||||
if new in profiles:
|
||||
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
||||
return
|
||||
profiles[new] = profiles.pop(old)
|
||||
save_profiles(profiles)
|
||||
item.setText(new)
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
|
||||
def delete_profile(self, item):
|
||||
name = item.text().lstrip("*")
|
||||
if self._virtual_new_profile and name == "New profile":
|
||||
row = self.profile_list.row(item)
|
||||
self.profile_list.takeItem(row)
|
||||
self._virtual_new_profile = False
|
||||
self._profile_dirty.pop("New profile", None)
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
return
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Profile",
|
||||
f"Delete profile '{name}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
delete_profile(name)
|
||||
row = self.profile_list.row(item)
|
||||
self.profile_list.takeItem(row)
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
|
||||
def duplicate_profile(self, item):
|
||||
src = item.text().lstrip("*")
|
||||
profiles = load_profiles()
|
||||
base = f"{src}_duplicate"
|
||||
new = base
|
||||
i = 1
|
||||
while new in profiles:
|
||||
new = f"{base}{i}"
|
||||
i += 1
|
||||
duplicate_profile(src, new)
|
||||
self.profile_list.addItem(
|
||||
QListWidgetItem(
|
||||
QIcon(get_resource_path("abogen.assets", "profile.png")), new
|
||||
)
|
||||
)
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||||
parent.populate_profiles_in_voice_combo()
|
||||
self.update_profile_save_buttons()
|
||||
self.update_profile_list_colors()
|
||||
|
||||
def update_profile_save_buttons(self):
|
||||
# Remove all save buttons first
|
||||
for i in range(self.profile_list.count()):
|
||||
self.profile_list.setItemWidget(self.profile_list.item(i), None)
|
||||
# Add save button to dirty profiles
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
name = item.text().lstrip("*")
|
||||
if item.text().startswith("*"):
|
||||
widget = SaveButtonWidget(
|
||||
self.profile_list, name, self.save_profile_by_name
|
||||
)
|
||||
self.profile_list.setItemWidget(item, widget)
|
||||
|
||||
def update_profile_list_colors(self):
|
||||
profiles = load_profiles()
|
||||
for i in range(self.profile_list.count()):
|
||||
item = self.profile_list.item(i)
|
||||
name = item.text().lstrip("*")
|
||||
if self._virtual_new_profile and name == "New profile":
|
||||
item.setBackground(QColor("#fff59d")) # yellow
|
||||
elif item.text().startswith("*"):
|
||||
item.setBackground(QColor("#fff59d")) # yellow
|
||||
else:
|
||||
weights = profiles.get(name, {}).get("voices", [])
|
||||
# Defensive: only sum if weights is a list of (voice, weight) pairs
|
||||
total = 0
|
||||
if isinstance(weights, list):
|
||||
for entry in weights:
|
||||
if (
|
||||
isinstance(entry, (list, tuple))
|
||||
and len(entry) == 2
|
||||
and isinstance(entry[1], (int, float))
|
||||
):
|
||||
total += entry[1]
|
||||
if total == 0:
|
||||
item.setBackground(QColor("#ffcdd2")) # light red
|
||||
else:
|
||||
item.setBackground(QColor("white"))
|
||||
self.update_profile_save_buttons()
|
||||
|
||||
Reference in New Issue
Block a user