mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
1439 lines
56 KiB
Python
1439 lines
56 KiB
Python
import json
|
||
import os
|
||
from PyQt5.QtWidgets import (
|
||
QDialog,
|
||
QVBoxLayout,
|
||
QCheckBox,
|
||
QLabel,
|
||
QHBoxLayout,
|
||
QDoubleSpinBox,
|
||
QSlider,
|
||
QScrollArea,
|
||
QWidget,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QMessageBox,
|
||
QFrame,
|
||
QLayout,
|
||
QStyle,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QInputDialog,
|
||
QFileDialog,
|
||
QSplitter,
|
||
QMenu,
|
||
QAction,
|
||
QComboBox,
|
||
QApplication,
|
||
)
|
||
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
|
||
from PyQt5.QtGui import QPixmap, QIcon
|
||
from abogen.constants import (
|
||
VOICES_INTERNAL,
|
||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||
LANGUAGE_DESCRIPTIONS,
|
||
COLORS,
|
||
)
|
||
import re
|
||
import platform
|
||
from abogen.utils import get_resource_path
|
||
from abogen.voice_profiles import (
|
||
load_profiles,
|
||
save_profiles,
|
||
delete_profile,
|
||
duplicate_profile,
|
||
export_profiles,
|
||
)
|
||
|
||
|
||
# Constants
|
||
VOICE_MIXER_WIDTH = 100
|
||
SLIDER_WIDTH = 32
|
||
MIN_WINDOW_WIDTH = 600
|
||
MIN_WINDOW_HEIGHT = 400
|
||
INITIAL_WINDOW_WIDTH = 1200
|
||
INITIAL_WINDOW_HEIGHT = 500
|
||
|
||
# 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):
|
||
def __init__(self, parent=None, margin=0, spacing=-1):
|
||
super().__init__(parent)
|
||
if parent:
|
||
self.setContentsMargins(margin, margin, margin, margin)
|
||
self.setSpacing(spacing)
|
||
self._item_list = []
|
||
|
||
def __del__(self):
|
||
item = self.takeAt(0)
|
||
while item:
|
||
item = self.takeAt(0)
|
||
|
||
def addItem(self, item):
|
||
self._item_list.append(item)
|
||
|
||
def count(self):
|
||
return len(self._item_list)
|
||
|
||
def expandingDirections(self):
|
||
return Qt.Orientations(Qt.Orientation(0))
|
||
|
||
def hasHeightForWidth(self):
|
||
return True
|
||
|
||
def sizeHint(self):
|
||
return self.minimumSize()
|
||
|
||
def itemAt(self, index):
|
||
if 0 <= index < len(self._item_list):
|
||
return self._item_list[index]
|
||
return None
|
||
|
||
def takeAt(self, index):
|
||
if 0 <= index < len(self._item_list):
|
||
return self._item_list.pop(index)
|
||
return None
|
||
|
||
def heightForWidth(self, width):
|
||
return self._do_layout(QRect(0, 0, width, 0), True)
|
||
|
||
def setGeometry(self, rect):
|
||
super().setGeometry(rect)
|
||
self._do_layout(rect, False)
|
||
|
||
def minimumSize(self):
|
||
size = QSize()
|
||
for item in self._item_list:
|
||
size = size.expandedTo(item.minimumSize())
|
||
margin, _, _, _ = self.getContentsMargins()
|
||
size += QSize(2 * margin, 2 * margin)
|
||
return size
|
||
|
||
def _do_layout(self, rect, test_only):
|
||
x, y = rect.x(), rect.y()
|
||
line_height = 0
|
||
spacing = self.spacing()
|
||
|
||
for item in self._item_list:
|
||
style = self.parentWidget().style() if self.parentWidget() else QStyle()
|
||
layout_spacing_x = style.layoutSpacing(
|
||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal
|
||
)
|
||
layout_spacing_y = style.layoutSpacing(
|
||
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
|
||
)
|
||
space_x = spacing if spacing >= 0 else layout_spacing_x
|
||
space_y = spacing if spacing >= 0 else layout_spacing_y
|
||
|
||
next_x = x + item.sizeHint().width() + space_x
|
||
if next_x - space_x > rect.right() and line_height > 0:
|
||
x = rect.x()
|
||
y = y + line_height + space_y
|
||
next_x = x + item.sizeHint().width() + space_x
|
||
line_height = 0
|
||
|
||
if not test_only:
|
||
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
|
||
|
||
x = next_x
|
||
line_height = max(line_height, item.sizeHint().height())
|
||
|
||
return y + line_height - rect.y()
|
||
|
||
|
||
class VoiceMixer(QWidget):
|
||
def __init__(
|
||
self, voice_name, language_code, initial_status=False, initial_weight=0.0
|
||
):
|
||
super().__init__()
|
||
self.voice_name = voice_name
|
||
self.setFixedWidth(VOICE_MIXER_WIDTH)
|
||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
|
||
# TODO Set CSS for rounded corners
|
||
# self.setObjectName("VoiceMixer")
|
||
# self.setStyleSheet(self.ROUNDED_CSS)
|
||
|
||
layout = QVBoxLayout()
|
||
|
||
# Name label at the top
|
||
name = voice_name
|
||
layout.addWidget(QLabel(name), alignment=Qt.AlignCenter)
|
||
|
||
# Voice name label with gender icon
|
||
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
|
||
|
||
# Icons layout (flag and gender)
|
||
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_label = QLabel()
|
||
gender_label = QLabel()
|
||
flag_pixmap = QPixmap(flag_icon_path)
|
||
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)
|
||
)
|
||
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)
|
||
self.checkbox.stateChanged.connect(self.toggle_inputs)
|
||
layout.addWidget(self.checkbox, alignment=Qt.AlignCenter)
|
||
|
||
# Spinbox and slider
|
||
self.spin_box = QDoubleSpinBox()
|
||
self.spin_box.setRange(0, 1)
|
||
self.spin_box.setSingleStep(0.01)
|
||
self.spin_box.setDecimals(2)
|
||
self.spin_box.setValue(initial_weight)
|
||
|
||
self.slider = QSlider(Qt.Vertical)
|
||
self.slider.setRange(0, 100)
|
||
self.slider.setValue(int(initial_weight * 100))
|
||
self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||
self.slider.setFixedWidth(SLIDER_WIDTH)
|
||
|
||
# Fix slider in Windows
|
||
if platform.system() == "Windows":
|
||
appstyle = QApplication.instance().style().objectName().lower()
|
||
if appstyle != "windowsvista":
|
||
# Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"]
|
||
self.slider.setStyleSheet(f"""
|
||
QSlider::groove:vertical:disabled {{
|
||
background: {COLORS.get("GREY_BACKGROUND")};
|
||
width: 4px;
|
||
border-radius: 4px;
|
||
}}
|
||
""")
|
||
|
||
# Connect controls
|
||
self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100))
|
||
self.spin_box.valueChanged.connect(
|
||
lambda val: self.slider.setValue(int(val * 100))
|
||
)
|
||
|
||
# Layout for slider and labels
|
||
slider_layout = QVBoxLayout()
|
||
slider_layout.addWidget(self.spin_box)
|
||
slider_layout.addWidget(QLabel("1", alignment=Qt.AlignCenter))
|
||
|
||
slider_center_layout = QHBoxLayout()
|
||
slider_center_layout.addWidget(self.slider, alignment=Qt.AlignHCenter)
|
||
slider_center_layout.setContentsMargins(0, 0, 0, 0)
|
||
|
||
slider_center_widget = QWidget()
|
||
slider_center_widget.setLayout(slider_center_layout)
|
||
|
||
slider_layout.addWidget(slider_center_widget, stretch=1)
|
||
slider_layout.addWidget(QLabel("0", alignment=Qt.AlignCenter))
|
||
slider_layout.setStretch(2, 1)
|
||
|
||
layout.addLayout(slider_layout, stretch=1)
|
||
self.setLayout(layout)
|
||
self.toggle_inputs()
|
||
|
||
def toggle_inputs(self):
|
||
is_enabled = self.checkbox.isChecked()
|
||
self.spin_box.setEnabled(is_enabled)
|
||
self.slider.setEnabled(is_enabled)
|
||
|
||
def get_voice_weight(self):
|
||
if self.checkbox.isChecked():
|
||
return self.voice_name, self.spin_box.value()
|
||
return None
|
||
|
||
|
||
class HoverLabel(QLabel):
|
||
def __init__(self, text, voice_name, parent=None):
|
||
super().__init__(text, parent)
|
||
self.voice_name = voice_name
|
||
self.setMouseTracking(True)
|
||
self.setStyleSheet(
|
||
"background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;"
|
||
)
|
||
|
||
# Create delete button
|
||
self.delete_button = QPushButton("×", self)
|
||
self.delete_button.setFixedSize(16, 16)
|
||
self.delete_button.setStyleSheet(
|
||
f"""
|
||
QPushButton {{
|
||
background-color: {COLORS.get("RED")};
|
||
color: white;
|
||
border-radius: 7px;
|
||
font-weight: bold;
|
||
font-size: 12px;
|
||
border: none;
|
||
padding: 0px;
|
||
margin: 0px;
|
||
}}
|
||
QPushButton:hover {{
|
||
background-color: red;
|
||
}}
|
||
"""
|
||
)
|
||
# Make sure the entire button is clickable, not just the text
|
||
self.delete_button.setFocusPolicy(Qt.NoFocus)
|
||
self.delete_button.setAttribute(Qt.WA_TransparentForMouseEvents, False)
|
||
self.delete_button.setCursor(Qt.PointingHandCursor)
|
||
self.delete_button.hide()
|
||
|
||
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)
|
||
|
||
def enterEvent(self, event):
|
||
self.delete_button.show()
|
||
|
||
def leaveEvent(self, event):
|
||
self.delete_button.hide()
|
||
|
||
|
||
class VoiceFormulaDialog(QDialog):
|
||
def __init__(self, parent=None, initial_state=None, selected_profile=None):
|
||
super().__init__(parent)
|
||
# Store original profile/mix state for restoration on cancel
|
||
self._original_profile_name = None
|
||
self._original_mixed_voice_state = None
|
||
if parent is not None:
|
||
self._original_profile_name = getattr(parent, "selected_profile_name", None)
|
||
self._original_mixed_voice_state = getattr(parent, "mixed_voice_state", None)
|
||
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()
|
||
self.profile_list.setSelectionMode(QListWidget.SingleSelection)
|
||
self.profile_list.setSelectionBehavior(QListWidget.SelectRows)
|
||
self.profile_list.setStyleSheet("QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }")
|
||
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
|
||
)
|
||
self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
|
||
self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT)
|
||
self.voice_mixers = []
|
||
self.last_enabled_voice = None
|
||
|
||
# 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)
|
||
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)
|
||
# Preview current voice mix using main window's preview
|
||
self.btn_preview_mix = QPushButton("Preview", self)
|
||
self.btn_preview_mix.setToolTip("Preview current voice mix")
|
||
self.btn_preview_mix.clicked.connect(self.preview_current_mix)
|
||
header_row.addWidget(self.btn_preview_mix)
|
||
mixer_layout.addLayout(header_row)
|
||
|
||
# Error message
|
||
self.error_label = QLabel(
|
||
"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()
|
||
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)
|
||
mixer_layout.addWidget(self.weighted_sums_container)
|
||
|
||
# Separator
|
||
separator = QFrame()
|
||
separator.setFrameShadow(QFrame.Sunken)
|
||
mixer_layout.addWidget(separator)
|
||
|
||
# Voice list scroll area
|
||
self.scroll_area = QScrollArea()
|
||
self.scroll_area.setWidgetResizable(True)
|
||
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||
self.scroll_area.viewport().installEventFilter(self)
|
||
|
||
self.voice_list_widget = QWidget()
|
||
self.voice_list_layout = QHBoxLayout()
|
||
self.voice_list_widget.setLayout(self.voice_list_layout)
|
||
self.voice_list_widget.setSizePolicy(
|
||
QSizePolicy.Expanding, QSizePolicy.Expanding
|
||
)
|
||
self.scroll_area.setWidget(self.voice_list_widget)
|
||
mixer_layout.addWidget(self.scroll_area, stretch=1)
|
||
|
||
# Buttons
|
||
button_layout = QHBoxLayout()
|
||
clear_all_button = QPushButton("Clear all")
|
||
ok_button = QPushButton("OK")
|
||
cancel_button = QPushButton("Cancel")
|
||
|
||
# Set OK button as default
|
||
ok_button.setDefault(True)
|
||
ok_button.setFocus()
|
||
|
||
# Connect buttons
|
||
clear_all_button.clicked.connect(self.clear_all_voices)
|
||
ok_button.clicked.connect(self.accept)
|
||
cancel_button.clicked.connect(self.reject)
|
||
|
||
button_layout.addStretch()
|
||
button_layout.addWidget(clear_all_button)
|
||
button_layout.addWidget(ok_button)
|
||
button_layout.addWidget(cancel_button)
|
||
mixer_layout.addLayout(button_layout)
|
||
|
||
self.add_voices(initial_state or [])
|
||
self.update_weighted_sums()
|
||
|
||
# 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())
|
||
|
||
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
|
||
for voice in VOICES_INTERNAL:
|
||
language_code = voice[0] # First character is the language code
|
||
matching_voice = next(
|
||
(item for item in initial_state if item[0] == voice), None
|
||
)
|
||
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
|
||
)
|
||
if initial_status and first_enabled_voice is None:
|
||
first_enabled_voice = voice_mixer
|
||
|
||
if first_enabled_voice:
|
||
QTimer.singleShot(
|
||
0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice)
|
||
)
|
||
|
||
def add_voice(
|
||
self, voice_name, language_code, initial_status=False, initial_weight=1.0
|
||
):
|
||
voice_mixer = VoiceMixer(
|
||
voice_name, language_code, initial_status, initial_weight
|
||
)
|
||
self.voice_mixers.append(voice_mixer)
|
||
self.voice_list_layout.addWidget(voice_mixer)
|
||
voice_mixer.checkbox.stateChanged.connect(
|
||
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()
|
||
)
|
||
return voice_mixer
|
||
|
||
def handle_voice_checkbox(self, voice_mixer, state):
|
||
if state == Qt.Checked:
|
||
self.last_enabled_voice = voice_mixer.voice_name
|
||
self.update_weighted_sums()
|
||
|
||
def get_selected_voices(self):
|
||
return [
|
||
v
|
||
for v in (m.get_voice_weight() for m in self.voice_mixers)
|
||
if v and v[1] > 0
|
||
]
|
||
|
||
def update_weighted_sums(self):
|
||
# Clear previous labels
|
||
while self.weighted_sums_layout.count():
|
||
item = self.weighted_sums_layout.takeAt(0)
|
||
if item and item.widget():
|
||
item.widget().deleteLater()
|
||
|
||
# Get selected voices
|
||
selected = [
|
||
(m.voice_name, m.spin_box.value())
|
||
for m in self.voice_mixers
|
||
if m.checkbox.isChecked() and m.spin_box.value() > 0
|
||
]
|
||
|
||
total = sum(w for _, w in selected)
|
||
# disable Preview if no voices selected, but don't enable while loading
|
||
if not getattr(self, "_loading", False):
|
||
self.btn_preview_mix.setEnabled(total > 0)
|
||
|
||
if total > 0:
|
||
self.error_label.hide()
|
||
self.weighted_sums_container.show()
|
||
|
||
# Reorder so last enabled voice is at the end
|
||
if self.last_enabled_voice and any(
|
||
name == self.last_enabled_voice for name, _ in selected
|
||
):
|
||
others = [(n, w) for n, w in selected if n != self.last_enabled_voice]
|
||
last = [(n, w) for n, w in selected if n == self.last_enabled_voice]
|
||
selected = others + last
|
||
|
||
# Add voice labels
|
||
for name, weight in selected:
|
||
percentage = weight / total * 100
|
||
# Make the voice name bold and include percentage
|
||
voice_label = HoverLabel(
|
||
f'<b><span style="color:{COLORS.get("BLUE")}">{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)
|
||
)
|
||
self.weighted_sums_layout.addWidget(voice_label)
|
||
else:
|
||
self.error_label.show()
|
||
self.weighted_sums_container.hide()
|
||
|
||
def disable_voice_by_name(self, voice_name):
|
||
for mixer in self.voice_mixers:
|
||
if mixer.voice_name == voice_name:
|
||
mixer.checkbox.setChecked(False)
|
||
break
|
||
|
||
def clear_all_voices(self):
|
||
for mixer in self.voice_mixers:
|
||
mixer.checkbox.setChecked(False)
|
||
|
||
def eventFilter(self, source, event):
|
||
if source is self.scroll_area.viewport() and event.type() == event.Wheel:
|
||
# Skip if over an enabled slider
|
||
if any(
|
||
mixer.slider.underMouse() and mixer.slider.isEnabled()
|
||
for mixer in self.voice_mixers
|
||
):
|
||
return False
|
||
|
||
# Horizontal scrolling
|
||
horiz_bar = self.scroll_area.horizontalScrollBar()
|
||
delta = -120 if event.angleDelta().y() > 0 else 120
|
||
horiz_bar.setValue(horiz_bar.value() + delta)
|
||
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()
|
||
|
||
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):
|
||
profiles = load_profiles()
|
||
if len(profiles) < 1:
|
||
return False
|
||
zero = []
|
||
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?"
|
||
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:
|
||
QMessageBox.warning(
|
||
self,
|
||
"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):
|
||
# Restore parent's profile/mix state on cancel
|
||
parent = self.parent()
|
||
if parent is not None:
|
||
if hasattr(self, "_original_profile_name"):
|
||
parent.selected_profile_name = self._original_profile_name
|
||
if hasattr(self, "_original_mixed_voice_state"):
|
||
parent.mixed_voice_state = self._original_mixed_voice_state
|
||
# 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):
|
||
# Restore parent's profile/mix state on close
|
||
parent = self.parent()
|
||
if parent is not None:
|
||
if hasattr(self, "_original_profile_name"):
|
||
parent.selected_profile_name = self._original_profile_name
|
||
if hasattr(self, "_original_mixed_voice_state"):
|
||
parent.mixed_voice_state = self._original_mixed_voice_state
|
||
# 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 _parse_rgba_to_qcolor(self, rgba_str):
|
||
from PyQt5.QtCore import Qt
|
||
from PyQt5.QtGui import QColor
|
||
"""Helper to convert 'rgba(R,G,B,A_float)' string to QColor."""
|
||
match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str)
|
||
if match:
|
||
r, g, b = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
||
a_float = float(match.group(4))
|
||
a_int = int(a_float * 255)
|
||
return QColor(r, g, b, a_int)
|
||
return Qt.GlobalColor.transparent
|
||
|
||
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):
|
||
import re
|
||
|
||
while True:
|
||
name, ok = QInputDialog.getText(self, "New Profile", "Enter profile 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()
|
||
# 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.")
|
||
continue
|
||
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()
|
||
break
|
||
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 abogen.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):
|
||
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("*")
|
||
import re
|
||
|
||
while True:
|
||
new, ok = QInputDialog.getText(
|
||
self, "Rename Profile", f"Profile name:", text=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()
|
||
if new in profiles:
|
||
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
|
||
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)
|
||
save_profiles(profiles)
|
||
item.setText(new)
|
||
|
||
# Update the current profile name if it was renamed
|
||
if self.current_profile == old:
|
||
self.current_profile = new
|
||
|
||
parent = self.parent()
|
||
if hasattr(parent, "populate_profiles_in_voice_combo"):
|
||
parent.populate_profiles_in_voice_combo()
|
||
break
|
||
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):
|
||
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("*")
|
||
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):
|
||
from PyQt5.QtCore import Qt
|
||
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":
|
||
color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND"))
|
||
item.setData(Qt.BackgroundRole, color)
|
||
elif item.text().startswith("*"):
|
||
color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND"))
|
||
item.setData(Qt.BackgroundRole, color)
|
||
else:
|
||
item.setData(Qt.BackgroundRole, self.profile_list.palette().base().color())
|
||
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:
|
||
color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND"))
|
||
item.setData(Qt.BackgroundRole, color)
|
||
self.update_profile_save_buttons()
|
||
|
||
def preview_current_mix(self):
|
||
# Disable preview until playback completes
|
||
self.btn_preview_mix.setEnabled(False)
|
||
self.btn_preview_mix.setText("Loading...")
|
||
self._loading = True
|
||
parent = self.parent()
|
||
if parent and hasattr(parent, "preview_voice"):
|
||
# Apply mixed voices and selected language
|
||
parent.mixed_voice_state = self.get_selected_voices()
|
||
parent.selected_profile_name = None
|
||
lang = self.language_combo.currentData()
|
||
parent.selected_lang = lang
|
||
parent.subtitle_combo.setEnabled(
|
||
lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||
)
|
||
# Reset start flag and trigger preview
|
||
self._started = False
|
||
parent.preview_voice()
|
||
# Poll preview_playing: wait for start then end
|
||
self._preview_poll_timer = QTimer(self)
|
||
self._preview_poll_timer.timeout.connect(self._check_preview_done)
|
||
self._preview_poll_timer.start(200)
|
||
|
||
def _check_preview_done(self):
|
||
parent = self.parent()
|
||
if parent and hasattr(parent, "preview_playing"):
|
||
# Mark when playback starts
|
||
if parent.preview_playing:
|
||
self._started = True
|
||
# Update button text to "Playing..." when playback starts
|
||
self.btn_preview_mix.setText("Playing...")
|
||
# Once started and then stopped, re-enable
|
||
elif getattr(self, "_started", False):
|
||
self.btn_preview_mix.setEnabled(True)
|
||
self.btn_preview_mix.setText("Preview")
|
||
self._loading = False
|
||
self._preview_poll_timer.stop()
|