Improvements for vıice_formula gui, reformat with black

This commit is contained in:
Deniz Şafak
2025-04-30 06:57:34 +03:00
parent 7cf3884a4b
commit 854ad1adea
6 changed files with 417 additions and 203 deletions
+1 -6
View File
@@ -1,6 +1 @@
- Enhanced EPUB handling by treating all items in chapter list (including anchors) as chapters, improving navigation and organization for poorly structured books, mentioned by @Darthagnon in #4
- Fixed the issue with some chapters in EPUB files had missing content.
- Fixed the issue with some EPUB files only having one chapter caused the program to ignore the entire book.
- Fixed "utf-8' codec can't decode byte" error, mentioned by @nigelp in #3
- Added "Replace single newlines with spaces" option in the menu. This can be useful for texts that have imaginary line breaks.
- Improvements in code and documentation.
- Added voice mixing functionality that enables combining multiple voices into a single "Mixed voice", as suggested by @PulsarFTW in #1. (Special thanks to @jborza for making this feature possible with his contributions in #5)
+60 -32
View File
@@ -152,7 +152,9 @@ class HandlerDialog(QDialog):
if item:
spine_docs.append(item.get_name()) # Use get_name() for href
else:
print(f"Warning: Spine item with id '{item_id}' not found in book items.")
print(
f"Warning: Spine item with id '{item_id}' not found in book items."
)
doc_order = {href: i for i, href in enumerate(spine_docs)}
@@ -160,7 +162,7 @@ class HandlerDialog(QDialog):
href = item.get_name()
if href in doc_order: # Only process docs in spine
try:
html_content = item.get_content().decode('utf-8', errors='ignore')
html_content = item.get_content().decode("utf-8", errors="ignore")
self.doc_content[href] = html_content
except Exception:
self.doc_content[href] = "" # Handle decoding errors
@@ -193,8 +195,10 @@ class HandlerDialog(QDialog):
return -1 # Anchor not found by simple string search
# Backtrack to the start of the tag '<'
tag_start_pos = html_content.rfind('<', 0, pos)
return tag_start_pos if tag_start_pos != -1 else 0 # Default to 0 if '<' not found
tag_start_pos = html_content.rfind("<", 0, pos)
return (
tag_start_pos if tag_start_pos != -1 else 0
) # Default to 0 if '<' not found
def collect_toc_entries(entries):
collected = []
@@ -213,23 +217,32 @@ class HandlerDialog(QDialog):
title = section_or_link.title
href = getattr(section_or_link, "href", None)
elif isinstance(section_or_link, ebooklib.epub.Link):
href, title = section_or_link.href, section_or_link.title or section_or_link.href
href, title = (
section_or_link.href,
section_or_link.title or section_or_link.href,
)
if len(entry) > 1 and isinstance(entry[1], list):
children = entry[1]
if href:
base_href, fragment = href.split('#', 1) if '#' in href else (href, None)
if base_href in doc_order: # Only consider entries pointing to spine documents
base_href, fragment = (
href.split("#", 1) if "#" in href else (href, None)
)
if (
base_href in doc_order
): # Only consider entries pointing to spine documents
position = find_position(base_href, fragment)
if position != -1: # Only add if position is valid
collected.append({
"href": href, # Use the original href from TOC as the key
"title": title,
"doc_href": base_href,
"position": position,
"doc_order": doc_order[base_href]
})
collected.append(
{
"href": href, # Use the original href from TOC as the key
"title": title,
"doc_href": base_href,
"position": position,
"doc_order": doc_order[base_href],
}
)
if children:
collected.extend(collect_toc_entries(children))
@@ -247,14 +260,14 @@ class HandlerDialog(QDialog):
all_content_html += self.doc_content.get(doc_href, "")
if all_content_html:
soup = BeautifulSoup(all_content_html, 'html.parser')
soup = BeautifulSoup(all_content_html, "html.parser")
text = clean_text(soup.get_text()).strip()
# Use the first spine document as the identifier
first_doc = spine_docs[0]
self.content_texts[first_doc] = text
self.content_lengths[first_doc] = len(text)
# Create a synthetic TOC entry for tree building
self.book.toc = [(epub.Link(first_doc, "Main Content", first_doc),)]
return
@@ -311,12 +324,12 @@ class HandlerDialog(QDialog):
slice_html += self.doc_content.get(intermediate_doc_href, "")
# 5. Extract text and store
slice_soup = BeautifulSoup(slice_html, 'html.parser')
slice_soup = BeautifulSoup(slice_html, "html.parser")
# Remove sup and sub tags from the HTML before extracting text
for tag in slice_soup.find_all(['sup', 'sub']):
for tag in slice_soup.find_all(["sup", "sub"]):
tag.decompose()
text = clean_text(slice_soup.get_text()).strip()
self.content_texts[current_href] = text # Store using the original TOC href
self.content_lengths[current_href] = len(text)
@@ -337,21 +350,23 @@ class HandlerDialog(QDialog):
prefix_html += first_doc_html[:first_pos]
if prefix_html.strip():
prefix_soup = BeautifulSoup(prefix_html, 'html.parser')
prefix_soup = BeautifulSoup(prefix_html, "html.parser")
# Remove sup and sub tags
for tag in prefix_soup.find_all(['sup', 'sub']):
for tag in prefix_soup.find_all(["sup", "sub"]):
tag.decompose()
prefix_text = clean_text(prefix_soup.get_text()).strip()
if prefix_text:
# Create a new chapter for content before the first TOC entry
# Use a synthetic href to avoid collision with real TOC entries
prefix_chapter_href = "prefix_content_chapter"
self.content_texts[prefix_chapter_href] = prefix_text
self.content_lengths[prefix_chapter_href] = len(prefix_text)
# Add a new entry to the TOC for the prefix content
prefix_link = epub.Link(prefix_chapter_href, "Introduction", prefix_chapter_href)
prefix_link = epub.Link(
prefix_chapter_href, "Introduction", prefix_chapter_href
)
# Insert at beginning of TOC
if isinstance(self.book.toc, list):
self.book.toc.insert(0, (prefix_link,))
@@ -423,7 +438,11 @@ class HandlerDialog(QDialog):
item.setData(0, Qt.UserRole, href)
# Make item checkable if it has content
has_content = href and href in self.content_texts and self.content_texts[href].strip()
has_content = (
href
and href in self.content_texts
and self.content_texts[href].strip()
)
if has_content or children:
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
is_checked = href and href in self.checked_chapters
@@ -652,16 +671,21 @@ class HandlerDialog(QDialog):
self.previewEdit.setStyleSheet("QTextEdit { border: none; }")
# Create informative text label below preview
self.previewInfoLabel = QLabel("*Note: You can modify the content later using the \"Edit\" button in the input box or by accessing the temporary files directory through settings.", self)
self.previewInfoLabel = QLabel(
'*Note: You can modify the content later using the "Edit" button in the input box or by accessing the temporary files directory through settings.',
self,
)
self.previewInfoLabel.setWordWrap(True)
self.previewInfoLabel.setStyleSheet("QLabel { color: #666; font-style: italic; }")
self.previewInfoLabel.setStyleSheet(
"QLabel { color: #666; font-style: italic; }"
)
# Right panel layout (preview and info label)
previewLayout = QVBoxLayout()
previewLayout.setContentsMargins(0, 0, 0, 0)
previewLayout.addWidget(self.previewEdit, 1)
previewLayout.addWidget(self.previewInfoLabel, 0)
rightWidget = QWidget()
rightWidget.setLayout(previewLayout)
@@ -753,7 +777,9 @@ class HandlerDialog(QDialog):
# Create splitter for left panel and preview
self.splitter = QSplitter(Qt.Horizontal)
self.splitter.addWidget(leftWidget)
self.splitter.addWidget(rightWidget) # Now using rightWidget that includes preview and label
self.splitter.addWidget(
rightWidget
) # Now using rightWidget that includes preview and label
self.splitter.setSizes([280, 420])
# Set main layout
@@ -1031,7 +1057,9 @@ class HandlerDialog(QDialog):
if text is None:
title = current.text(0)
# Add title to preview even if no content
self.previewEdit.setPlainText(f"{title}\n\n(No content available for this item)")
self.previewEdit.setPlainText(
f"{title}\n\n(No content available for this item)"
)
elif not text.strip():
title = current.text(0)
self.previewEdit.setPlainText(f"{title}\n\n(This item is empty)")
+14 -9
View File
@@ -15,6 +15,7 @@ from voice_formulas import get_new_voice
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
def detect_encoding(file_path):
with open(file_path, "rb") as f:
raw_data = f.read()
@@ -172,9 +173,7 @@ class ConversionThread(QThread):
self.log_updated.emit(f"- Output format: {self.output_format}")
self.log_updated.emit(f"- Save option: {self.save_option}")
if self.replace_single_newlines:
self.log_updated.emit(
f"- Replace single newlines: Yes"
)
self.log_updated.emit(f"- Replace single newlines: Yes")
# Display save_chapters_separately flag if it's set
if hasattr(self, "save_chapters_separately"):
@@ -206,7 +205,9 @@ class ConversionThread(QThread):
text = self.file_name # Treat file_name as direct text input
else:
encoding = detect_encoding(self.file_name)
with open(self.file_name, "r", encoding=encoding, errors="replace") as file:
with open(
self.file_name, "r", encoding=encoding, errors="replace"
) as file:
text = file.read()
# Clean up text using utility function
@@ -351,13 +352,13 @@ class ConversionThread(QThread):
# Set split_pattern to \n+ which will split on one or more newlines
split_pattern = r"\n+"
# Check if the voice is a formula and load it if necessary
if '*' in self.voice:
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
for result in tts(
chapter_text,
voice=loaded_voice,
@@ -493,7 +494,9 @@ class ConversionThread(QThread):
chapter_srt_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.srt"
)
with open(chapter_srt_path, "w", encoding="utf-8", errors="replace") as srt_file:
with open(
chapter_srt_path, "w", encoding="utf-8", errors="replace"
) as srt_file:
for i, (start, end, text) in enumerate(
chapter_subtitle_entries, 1
):
@@ -534,7 +537,9 @@ class ConversionThread(QThread):
srt_path = os.path.splitext(out_path)[0] + ".srt"
sf.write(out_path, audio, 24000, format=self.output_format)
if self.subtitle_mode != "Disabled":
with open(srt_path, "w", encoding="utf-8", errors="replace") as srt_file:
with open(
srt_path, "w", encoding="utf-8", errors="replace"
) as srt_file:
for i, (start, end, text) in enumerate(subtitle_entries, 1):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
+13 -9
View File
@@ -571,10 +571,10 @@ class abogen(QWidget):
'"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
)
voice_row.addWidget(self.voice_combo)
# Voice formula button
self.btn_voice_formula_mixer = QPushButton(self)
self.btn_voice_formula_mixer.setText("🛠") # TODO add voice formula icon
self.btn_voice_formula_mixer.setText("🛠") # TODO add voice formula icon
self.btn_voice_formula_mixer.setToolTip("Mix and match voices")
self.btn_voice_formula_mixer.setFixedSize(40, 36)
self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }")
@@ -1050,15 +1050,17 @@ class abogen(QWidget):
if not self.subtitle_combo.isEnabled()
else self.subtitle_mode
)
# if voice formula is not None, use the selected voice
if self.mixed_voice_state:
formula_components = [f"{weight} * {name}" for name, weight in self.mixed_voice_state]
formula_components = [
f"{weight} * {name}" for name, weight in self.mixed_voice_state
]
voice_formula = " + ".join(filter(None, formula_components))
else:
voice_formula = self.selected_voice
# selected language - use the first voice of the mix
match = re.search(r'\b([a-z])', voice_formula)
match = re.search(r"\b([a-z])", voice_formula)
selected_lang = match.group(1)
self.conversion_thread = ConversionThread(
@@ -1083,7 +1085,9 @@ class abogen(QWidget):
# Pass max_subtitle_words from config
self.conversion_thread.max_subtitle_words = self.max_subtitle_words
# Pass replace_single_newlines setting
self.conversion_thread.replace_single_newlines = self.replace_single_newlines
self.conversion_thread.replace_single_newlines = (
self.replace_single_newlines
)
# Pass chapter count for EPUB or PDF files
if self.selected_file_type in ["epub", "pdf"] and hasattr(
self, "selected_chapters"
@@ -1678,17 +1682,17 @@ class abogen(QWidget):
def toggle_check_updates(self, checked):
self.config["check_updates"] = checked
save_config(self.config)
def show_voice_formula_dialog(self):
# get the current voice mix
if self.mixed_voice_state is None:
# if no voice mix is set, use the selected voice
self.mixed_voice_state = [(self.selected_voice, 1.0)]
dialog = VoiceFormulaDialog(self, initial_state=self.mixed_voice_state)
if dialog.exec_() == QDialog.Accepted:
self.mixed_voice_state = dialog.get_selected_voices()
def show_about_dialog(self):
"""Show an About dialog with program information including GitHub link."""
# Get application icon for dialog
+313 -134
View File
@@ -10,251 +10,430 @@ from PyQt5.QtWidgets import (
QWidget,
QPushButton,
QSizePolicy,
QMessageBox
)
from PyQt5.QtCore import (
Qt,
QTimer
)
from constants import (
VOICES_INTERNAL,
FLAGS
QMessageBox,
QFrame,
QLayout,
QStyle,
)
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
from constants import VOICES_INTERNAL, FLAGS
# Constants for voice names and flags
# Constants
VOICE_MIXER_WIDTH = 160
SLIDER_WIDTH = 32 # Added slider width
MIN_WINDOW_WIDTH = 600 # Minimum window width
MIN_WINDOW_HEIGHT = 400 # Minimum window height
INITIAL_WINDOW_WIDTH = 1000 # Initial window width
INITIAL_WINDOW_HEIGHT = 500 # Initial window height
FEMALE = "👩‍🦰"
MALE = "👨"
SLIDER_WIDTH = 32
MIN_WINDOW_WIDTH = 600
MIN_WINDOW_HEIGHT = 400
INITIAL_WINDOW_WIDTH = 1000
INITIAL_WINDOW_HEIGHT = 500
FEMALE, MALE = "👩‍🦰", "👨"
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_icon, initial_status=False, initial_weight=0.0):
def __init__(
self, voice_name, language_icon, initial_status=False, initial_weight=0.0
):
super().__init__()
self.voice_name = voice_name
# Set fixed width for this widget
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)
# Main Layout
layout = QVBoxLayout()
# Checkbox at the top
# Checkbox
self.checkbox = QCheckBox()
self.checkbox.setChecked(initial_status)
self.checkbox.stateChanged.connect(self.toggle_inputs)
layout.addWidget(self.checkbox, alignment=Qt.AlignCenter)
voice_gender = self.get_voice_gender()
# Voice name label
voice_gender = (
FEMALE
if self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
else MALE
)
name = voice_name[3:].capitalize()
name_label = QLabel(f"{language_icon} {voice_gender} {name}")
name_layout = QHBoxLayout()
name_layout.addWidget(name_label)
name_layout.setAlignment(name_label, Qt.AlignCenter)
name_layout.addWidget(
QLabel(f"{language_icon} {voice_gender} {name}"), alignment=Qt.AlignCenter
)
layout.addLayout(name_layout)
# Input and Slider
# 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) # Set slider orientation to vertical
self.slider = QSlider(Qt.Vertical)
self.slider.setRange(0, 100)
self.slider.setValue(int(initial_weight * 100))
self.slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # Fixed width, expanding height
self.slider.setFixedWidth(SLIDER_WIDTH) # Set fixed width for slider
self.slider.valueChanged.connect(
lambda val: self.spin_box.setValue(val / 100)
)
self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.slider.setFixedWidth(SLIDER_WIDTH)
# 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_layout.addWidget(self.slider, alignment=Qt.AlignCenter, stretch=1) # Use stretch to expand slider
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, 2) # Make slider take all available vertical space
slider_layout.setStretch(2, 1)
layout.addLayout(slider_layout, stretch=1) # Make slider layout expand
layout.addLayout(slider_layout, stretch=1)
self.setLayout(layout)
# Disable inputs initially if the checkbox is unchecked
self.toggle_inputs()
def toggle_inputs(self):
"""Enable or disable inputs based on the checkbox state."""
is_enabled = self.checkbox.isChecked()
self.spin_box.setEnabled(is_enabled)
self.slider.setEnabled(is_enabled)
def get_voice_gender(self):
if self.voice_name in VOICES_INTERNAL:
gender = self.voice_name[1]
return FEMALE if gender == "f" else MALE
return ""
def get_formula_component(self):
if self.checkbox.isChecked():
weight = self.spin_box.value()
return f"{weight:.3f} * {self.voice_name.lower().replace(' ', '_')}"
return ""
def get_voice_weight(self):
"""Return the voice and its weight if selected."""
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: #e0e0e0; 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(
"""
QPushButton {
background-color: red;
color: white;
border-radius: 8px;
font-weight: bold;
font-size: 12px;
border: none;
padding: 0px;
text-align: center;
}
QPushButton:hover {
background-color: #ff5555;
}
"""
)
self.delete_button.setCursor(Qt.PointingHandCursor)
self.delete_button.hide()
def resizeEvent(self, event):
super().resizeEvent(event)
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):
super().__init__(parent)
self.setWindowTitle("Voice Mixer")
self.setMinimumWidth(MIN_WINDOW_WIDTH)
self.setMinimumHeight(MIN_WINDOW_HEIGHT)
self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT)
self.voice_mixers = []
self.last_enabled_voice = None
# Main Layout
main_layout = QVBoxLayout()
# Header Label
self.header_label = QLabel("Select Voices For the Mix and Adjust Weights")
# Header
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)
# Weighted Sums Label
self.weighted_sums_label = QLabel()
self.weighted_sums_label.setAlignment(Qt.AlignCenter) # Center align the label
self.weighted_sums_label.setWordWrap(True) # Enable word wrap
main_layout.addWidget(self.weighted_sums_label)
# 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."
)
self.error_label.setStyleSheet("color: red; font-weight: bold;")
self.error_label.setWordWrap(True)
self.error_label.hide()
main_layout.addWidget(self.error_label)
# Scroll Area for Voice Panels
# 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)
# Separator
separator = QFrame()
separator.setFrameShadow(QFrame.Sunken)
main_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.ScrollBarAlwaysOff)
self.scroll_area.viewport().installEventFilter(self) # Install event filter for wheel events
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_layout = QHBoxLayout()
self.voice_list_widget.setLayout(self.voice_list_layout)
self.voice_list_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.voice_list_widget.setSizePolicy(
QSizePolicy.Expanding, QSizePolicy.Expanding
)
self.scroll_area.setWidget(self.voice_list_widget)
main_layout.addWidget(self.scroll_area)
main_layout.addWidget(self.scroll_area, stretch=1)
# Add buttons
# Buttons
button_layout = QHBoxLayout()
clear_all_button = QPushButton("Clear all")
ok_button = QPushButton("OK")
cancel_button = QPushButton("Cancel")
# Connect buttons to appropriate slots
ok_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject)
# Set OK button as default
ok_button.setDefault(True)
ok_button.setFocus()
button_layout.addStretch() # Push buttons to the right
# 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)
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
self.add_voices(initial_state)
# Setup voices and display
self.add_voices(initial_state or [])
self.update_weighted_sums()
def add_voices(self, initial_state):
"""Add voice mixers to the dialog based on the initial state and scroll to first enabled one."""
first_enabled_voice = None
first_enabled_voice = None
for voice in VOICES_INTERNAL:
flag = FLAGS.get(voice[0], "")
matching_voice = next((item for item in initial_state if item[0] == voice), None)
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 0.0
initial_weight = matching_voice[1] if matching_voice else 1.0
voice_mixer = self.add_voice(voice, flag, initial_status, initial_weight)
# remember the first enabled voice
if initial_status and first_enabled_voice is None:
first_enabled_voice = voice_mixer
if first_enabled_voice:
self.scroll_to_voice(first_enabled_voice)
def add_voice(self, voice_name, language_icon, initial_status=False, initial_weight=1.0):
voice_mixer = VoiceMixer(voice_name, language_icon, initial_status, initial_weight)
QTimer.singleShot(
0, lambda: self.scroll_area.ensureWidgetVisible(first_enabled_voice)
)
def add_voice(
self, voice_name, language_icon, initial_status=False, initial_weight=1.0
):
voice_mixer = VoiceMixer(
voice_name, language_icon, initial_status, initial_weight
)
self.voice_mixers.append(voice_mixer)
self.voice_list_layout.addWidget(voice_mixer)
# Connect signals to update weighted sums
voice_mixer.checkbox.stateChanged.connect(self.update_weighted_sums)
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)
return voice_mixer
def scroll_to_voice(self, voice_mixer):
"""Scroll the QScrollArea to ensure the given VoiceMixer is visible."""
QTimer.singleShot(0, lambda: self.scroll_area.ensureWidgetVisible(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 the list of selected voices and their weights."""
selected_voices = [
mixer.get_voice_weight() for mixer in self.voice_mixers
return [
v
for v in (m.get_voice_weight() for m in self.voice_mixers)
if v and v[1] > 0
]
return [voice for voice in selected_voices if voice] # Filter out None
def update_weighted_sums(self):
selected = [(m.voice_name, m.spin_box.value()) for m in self.voice_mixers if m.checkbox.isChecked()]
# 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)
if total > 0:
lines = [f"{name}: {weight/total*100:.1f}%" for name, weight in selected]
joined = " | ".join(lines)
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
voice_label = HoverLabel(f"{name}: {percentage:.1f}%", 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:
joined = ""
self.weighted_sums_label.setText(joined) # Remove the prefix
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):
"""Event filter to handle mouse wheel events for horizontal scrolling."""
if (source is self.scroll_area.viewport() and event.type() == event.Wheel):
# Check if the event is over a slider
# Check if mouse is over an enabled slider
if any(mixer.slider.underMouse() and mixer.slider.isEnabled() for mixer in self.voice_mixers):
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
# Convert vertical wheel movement to horizontal scrolling
# Horizontal scrolling
horiz_bar = self.scroll_area.horizontalScrollBar()
if event.angleDelta().y() > 0:
horiz_bar.setValue(horiz_bar.value() - 120) # Scroll left
else:
horiz_bar.setValue(horiz_bar.value() + 120) # Scroll right
delta = -120 if event.angleDelta().y() > 0 else 120
horiz_bar.setValue(horiz_bar.value() + delta)
return True
return super().eventFilter(source, event)
def resizeEvent(self, event):
"""Handle resize events to adjust slider heights"""
super().resizeEvent(event)
# Calculate available height for sliders
header_height = self.header_label.height() + self.weighted_sums_label.height()
button_area_height = 50 # Approximate height for button area
available_height = self.height() - header_height - button_area_height - 220 # Add more margin for safety
# Set slider height (don't make them too small)
slider_height = max(available_height, 100)
# Update all sliders
for mixer in self.voice_mixers:
mixer.slider.setFixedHeight(slider_height) # Use fixed height instead of minimum height
def accept(self):
selected_voices = self.get_selected_voices()
total_weight = sum(weight for _, weight in selected_voices)
@@ -262,7 +441,7 @@ class VoiceFormulaDialog(QDialog):
QMessageBox.warning(
self,
"Invalid Weights",
"The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights."
"The total weight of selected voices cannot be zero. Please select at least one voice or adjust the weights.",
)
return
super().accept()
+16 -13
View File
@@ -1,6 +1,7 @@
import re
from constants import VOICES_INTERNAL
# Calls parsing and loads the voice to gpu or cpu
def get_new_voice(pipeline, formula, use_gpu):
try:
@@ -12,43 +13,45 @@ def get_new_voice(pipeline, formula, use_gpu):
return weighted_voice.to(device)
except Exception as e:
raise ValueError(f"Failed to create voice: {str(e)}")
# Parse the formula and get the combined voice tensor
# Parse the formula and get the combined voice tensor
def parse_voice_formula(pipeline, formula):
if not formula.strip():
raise ValueError("Empty voice formula")
# Initialize the weighted sum
weighted_sum = None
total_weight = calculate_sum_from_formula(formula)
# Split the formula into terms
voices = formula.split('+')
voices = formula.split("+")
for term in voices:
# Parse each term (format: "0.333 * voice_name")
weight, voice_name = term.strip().split('*')
weight, voice_name = term.strip().split("*")
weight = float(weight.strip())
# normalize the weight
weight /= total_weight if total_weight > 0 else 1.0
voice_name = voice_name.strip()
# Get the voice tensor
if voice_name not in VOICES_INTERNAL:
raise ValueError(f"Unknown voice: {voice_name}")
voice_tensor = pipeline.load_single_voice(voice_name)
# Add to weighted sum
if weighted_sum is None:
weighted_sum = weight * voice_tensor
else:
weighted_sum += weight * voice_tensor
return weighted_sum
def calculate_sum_from_formula(formula):
weights = re.findall(r'([\d.]+) \*', formula)
weights = re.findall(r"([\d.]+) \*", formula)
total_sum = sum(float(weight) for weight in weights)
return total_sum
return total_sum