Added profile system to voice mixer, improvements in code

This commit is contained in:
Deniz Şafak
2025-05-02 12:10:08 +03:00
parent 4acf70954e
commit 02b9990004
22 changed files with 1137 additions and 90 deletions
+843 -28
View File
@@ -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()