10 Commits
13 changed files with 1221 additions and 701 deletions
+9
View File
@@ -1,3 +1,12 @@
# 1.2.1
- Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support.
- Added tooltip indicators in queue manager to display book handler options (`Save chapters separately` and `Merge chapters at the end`) for queued items.
- Added `Open processed file` and `Open input file` options for items in the queue manager, instead of just `Open file` option.
- Added loading gif animation to book handler window.
- Fixed light theme slider colors in voice mixer for better visibility (for non-Windows users).
- Fixed subtitle word-count splitting logic for more accurate segmentation.
- Improvements in code and documentation.
# 1.2.0
- Added `Line` option to subtitle generation modes, allowing subtitles to be generated based on line breaks in the text, by @mleg in #94.
- Added a loading indicator to the book handler window for better user experience during book preprocessing.
+1 -1
View File
@@ -12,7 +12,7 @@ RUN apt-get update \
python3 \
python3-venv \
python3-pip \
python3-pyqt5 \
python3-pyqt6 \
espeak-ng \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.2.1
+389 -247
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -148,6 +148,7 @@ COLORS = {
"BLUE_BORDER_HOVER": "#6ab0de",
"YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)",
"GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)",
"GREY_BORDER": "#808080",
"RED_BACKGROUND": "rgba(232, 78, 60, 0.15)",
"RED_BG": "rgba(232, 78, 60, 0.10)",
"RED_BG_HOVER": "rgba(232, 78, 60, 0.15)",
+113 -48
View File
@@ -3,10 +3,15 @@ import re
import time
import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
from PyQt6.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt6.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf
from abogen.utils import clean_text, create_process, get_user_cache_path, detect_encoding
from abogen.utils import (
clean_text,
create_process,
get_user_cache_path,
detect_encoding,
)
from abogen.constants import (
LANGUAGE_DESCRIPTIONS,
SAMPLE_VOICE_TEXTS,
@@ -47,43 +52,45 @@ def sanitize_name_for_os(name, is_folder=True):
if system == "Windows":
# Windows illegal characters: < > : " / \ | ? *
# Also can't end with space or dot
sanitized = re.sub(r'[<>:"/\\|?*]', '_', name)
sanitized = re.sub(r'[<>:"/\\|?*]', "_", name)
# Remove control characters (0-31)
sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized)
sanitized = re.sub(r"[\x00-\x1f]", "_", sanitized)
# Remove trailing spaces and dots
sanitized = sanitized.rstrip('. ')
sanitized = sanitized.rstrip(". ")
# Windows reserved names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
reserved = ['CON', 'PRN', 'AUX', 'NUL'] + \
[f'COM{i}' for i in range(1, 10)] + \
[f'LPT{i}' for i in range(1, 10)]
if sanitized.upper() in reserved or sanitized.upper().split('.')[0] in reserved:
reserved = (
["CON", "PRN", "AUX", "NUL"]
+ [f"COM{i}" for i in range(1, 10)]
+ [f"LPT{i}" for i in range(1, 10)]
)
if sanitized.upper() in reserved or sanitized.upper().split(".")[0] in reserved:
sanitized = f"_{sanitized}"
elif system == "Darwin": # macOS
# macOS illegal characters: : (colon is converted to / by the system)
# Also can't start with dot (hidden file) for folders typically
sanitized = re.sub(r'[:]', '_', name)
sanitized = re.sub(r"[:]", "_", name)
# Remove control characters
sanitized = re.sub(r'[\x00-\x1f]', '_', sanitized)
sanitized = re.sub(r"[\x00-\x1f]", "_", sanitized)
# Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith('.'):
sanitized = '_' + sanitized[1:]
if is_folder and sanitized.startswith("."):
sanitized = "_" + sanitized[1:]
else: # Linux and others
# Linux illegal characters: / and null character
# Though / is illegal, most other chars are technically allowed
sanitized = re.sub(r'[/\x00]', '_', name)
sanitized = re.sub(r"[/\x00]", "_", name)
# Remove other control characters for safety
sanitized = re.sub(r'[\x01-\x1f]', '_', sanitized)
sanitized = re.sub(r"[\x01-\x1f]", "_", sanitized)
# Avoid leading dot for folders (creates hidden folders)
if is_folder and sanitized.startswith('.'):
sanitized = '_' + sanitized[1:]
if is_folder and sanitized.startswith("."):
sanitized = "_" + sanitized[1:]
# Ensure the name is not empty after sanitization
if not sanitized or sanitized.strip() == '':
if not sanitized or sanitized.strip() == "":
sanitized = "audiobook"
# Limit length to 255 characters (common limit across filesystems)
if len(sanitized) > 255:
sanitized = sanitized[:255].rstrip('. ')
sanitized = sanitized[:255].rstrip(". ")
return sanitized
@@ -96,8 +103,8 @@ class ChapterOptionsDialog(QDialog):
# Prevent closing with the X button and remove the help button
self.setWindowFlags(
self.windowFlags()
& ~Qt.WindowCloseButtonHint
& ~Qt.WindowContextHelpButtonHint
& ~Qt.WindowType.WindowCloseButtonHint
& ~Qt.WindowType.WindowContextHelpButtonHint
)
layout = QVBoxLayout(self)
@@ -131,7 +138,7 @@ class ChapterOptionsDialog(QDialog):
layout.addWidget(self.countdown_label)
# Add OK button
button_box = QDialogButtonBox(QDialogButtonBox.Ok)
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
button_box.accepted.connect(self.accept)
layout.addWidget(button_box)
@@ -180,7 +187,7 @@ class ChapterOptionsDialog(QDialog):
# Prevent escape key from closing the dialog
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
if event.key() == Qt.Key.Key_Escape:
event.ignore()
else:
super().keyPressEvent(event)
@@ -310,7 +317,11 @@ class ConversionThread(QThread):
# Normalize paths for consistent display (fixes Windows path separator issues)
input_file = os.path.normpath(input_file) if input_file else input_file
processing_file = os.path.normpath(processing_file) if processing_file else processing_file
processing_file = (
os.path.normpath(processing_file)
if processing_file
else processing_file
)
self.log_updated.emit(f"- Input File: {input_file}")
if input_file != processing_file:
@@ -318,7 +329,9 @@ class ConversionThread(QThread):
# Use file_name for logs if from_queue, otherwise use display_path if available
if getattr(self, "from_queue", False):
base_path = self.save_base_path or self.file_name # Use save_base_path if available
base_path = (
self.save_base_path or self.file_name
) # Use save_base_path if available
else:
base_path = self.display_path if self.display_path else self.file_name
@@ -476,7 +489,9 @@ class ConversionThread(QThread):
# Use file_name for logs if from_queue, otherwise use display_path if available
if getattr(self, "from_queue", False):
base_path = self.save_base_path or self.file_name # Use save_base_path if available
base_path = (
self.save_base_path or self.file_name
) # Use save_base_path if available
else:
base_path = self.display_path if self.display_path else self.file_name
@@ -528,7 +543,9 @@ class ConversionThread(QThread):
# Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True
if merge_chapters_at_end:
out_dir = parent_dir
base_filepath_no_ext = os.path.join(out_dir, f"{sanitized_base_name}{suffix}")
base_filepath_no_ext = os.path.join(
out_dir, f"{sanitized_base_name}{suffix}"
)
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
subtitle_entries = []
current_time = 0.0
@@ -642,8 +659,12 @@ class ConversionThread(QThread):
# Add style definitions for karaoke highlighting
if self.subtitle_mode == "Sentence + Highlighting":
merged_subtitle_file.write("[V4+ Styles]\n")
merged_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n")
merged_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n")
merged_subtitle_file.write(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
merged_subtitle_file.write(
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
)
merged_subtitle_file.write("[Events]\n")
merged_subtitle_file.write(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
@@ -806,8 +827,12 @@ class ConversionThread(QThread):
# Add style definitions for karaoke highlighting
if self.subtitle_mode == "Sentence + Highlighting":
chapter_subtitle_file.write("[V4+ Styles]\n")
chapter_subtitle_file.write("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n")
chapter_subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n")
chapter_subtitle_file.write(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
chapter_subtitle_file.write(
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
)
chapter_subtitle_file.write("[Events]\n")
chapter_subtitle_file.write(
@@ -922,7 +947,12 @@ class ConversionThread(QThread):
start_time = self._ass_time(start)
end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else ""
effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
merged_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
)
@@ -951,7 +981,12 @@ class ConversionThread(QThread):
start_time = self._ass_time(start)
end_time = self._ass_time(end)
# Use karaoke effect for highlighting mode
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else ""
effect = (
"karaoke"
if self.subtitle_mode
== "Sentence + Highlighting"
else ""
)
chapter_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
)
@@ -997,7 +1032,9 @@ class ConversionThread(QThread):
# Add silence between chapters for merged output (except after the last chapter)
if merge_chapters_at_end and chapter_idx < total_chapters:
silence_samples = int(self.silence_duration * 24000) # Silence duration at 24,000 Hz
silence_samples = int(
self.silence_duration * 24000
) # Silence duration at 24,000 Hz
silence_audio = self.np.zeros(silence_samples, dtype="float32")
silence_bytes = silence_audio.tobytes()
@@ -1297,7 +1334,11 @@ class ConversionThread(QThread):
karaoke_text = ""
for t in current_sentence:
# Calculate duration in centiseconds
duration = t["end"] - t["start"] if t["end"] and t["start"] else 0.5
duration = (
t["end"] - t["start"]
if t["end"] and t["start"]
else 0.5
)
duration_cs = int(duration * 100)
# Add karaoke effect - relies on style's SecondaryColour for highlighting
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
@@ -1382,23 +1423,48 @@ class ConversionThread(QThread):
subtitle_entries[-1] = (start, fallback_end_time, text)
else:
# Word count-based grouping
# Word count-based grouping - simply count spaces and split after N spaces
try:
word_count = int(self.subtitle_mode.split()[0])
word_count = min(word_count, max_subtitle_words)
except (ValueError, IndexError):
word_count = 1
# Group words into subtitle entries (processed_tokens already has punctuation combined)
for i in range(0, len(processed_tokens), word_count):
group = processed_tokens[i : i + word_count]
if group:
text = "".join(
t["text"] + (t.get("whitespace", "") or "") for t in group
)
subtitle_entries.append(
(group[0]["start"], group[-1]["end"], text.strip())
)
current_group = []
space_count = 0
for token in processed_tokens:
current_group.append(token)
# Count spaces after tokens (in the whitespace field)
if token.get("whitespace", "") == " ":
space_count += 1
# Split after counting N spaces
if space_count >= word_count:
text = "".join(
t["text"] + (t.get("whitespace", "") or "")
for t in current_group
)
subtitle_entries.append(
(
current_group[0]["start"],
current_group[-1]["end"],
text.strip(),
)
)
current_group = []
space_count = 0
# Add any remaining tokens
if current_group:
text = "".join(
t["text"] + (t.get("whitespace", "") or "") for t in current_group
)
subtitle_entries.append(
(current_group[0]["start"], current_group[-1]["end"], text.strip())
)
# Fallback for last entry
if subtitle_entries and fallback_end_time is not None:
last_entry = subtitle_entries[-1]
@@ -1406,7 +1472,6 @@ class ConversionThread(QThread):
if end is None or end <= start or end <= 0:
subtitle_entries[-1] = (start, fallback_end_time, text)
def cancel(self):
self.cancel_requested = True
self.should_cancel = True
+280 -188
View File
File diff suppressed because it is too large Load Diff
+20 -9
View File
@@ -1,4 +1,3 @@
from json import load
import os
import sys
import platform
@@ -7,33 +6,43 @@ import signal
# Qt platform plugin detection (fixes #59)
try:
from PyQt5.QtCore import QLibraryInfo
plugins = QLibraryInfo.location(QLibraryInfo.PluginsPath)
from PyQt6.QtCore import QLibraryInfo
# Get the path to the plugins directory
plugins = QLibraryInfo.path(QLibraryInfo.LibraryPath.PluginsPath)
# Normalize path to use the OS-native separators and absolute path
platform_dir = os.path.normpath(os.path.join(plugins, "platforms"))
# Ensure we work with an absolute path for clarity
platform_dir = os.path.abspath(platform_dir)
if os.path.isdir(platform_dir):
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = platform_dir
print("QT_QPA_PLATFORM_PLUGIN_PATH set to:", platform_dir)
else:
print("PyQt5 platform plugins not found at", platform_dir)
print("PyQt6 platform plugins not found at", platform_dir)
except ImportError:
print("PyQt5 not installed.")
print("PyQt6 not installed.")
# Set application ID for Windows taskbar icon
if platform.system() == "Windows":
try:
from abogen.constants import PROGRAM_NAME, VERSION
import ctypes
app_id = f"{PROGRAM_NAME}.{VERSION}"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
except Exception as e:
print("Warning: failed to set AppUserModelID:", e)
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
from PyQt6.QtWidgets import QApplication
from PyQt6.QtGui import QIcon
from PyQt6.QtCore import (
QLibraryInfo,
qInstallMessageHandler,
QtMsgType,
)
# Add the directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
@@ -82,10 +91,12 @@ if platform.system() == "Darwin" and platform.processor() == "arm":
# Custom message handler to filter out specific Qt warnings
def qt_message_handler(mode, context, message):
# In PyQt6, the mode is an enum, so we compare with the enum members
if "Wayland does not support QWindow::requestActivate()" in message:
return # Suppress this specific message
if "setGrabPopup called with a parent, QtWaylandClient" in message:
return
if mode == QtMsgType.QtWarningMsg:
print(f"Qt Warning: {message}")
elif mode == QtMsgType.QtCriticalMsg:
@@ -129,7 +140,7 @@ def main():
ex = abogen()
ex.show()
sys.exit(app.exec_())
sys.exit(app.exec())
if __name__ == "__main__":
+224 -101
View File
@@ -2,7 +2,7 @@
# button to remove an item from the queue
# button to clear the queue
from PyQt5.QtWidgets import (
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
@@ -14,18 +14,20 @@ from PyQt5.QtWidgets import (
QLabel,
QWidget,
QSizePolicy,
QAbstractItemView,
)
from PyQt5.QtCore import QFileInfo, Qt
from PyQt6.QtCore import QFileInfo, Qt
from abogen.constants import COLORS
from copy import deepcopy
from PyQt5.QtGui import QFontMetrics
from PyQt6.QtGui import QFontMetrics
class ElidedLabel(QLabel):
def __init__(self, text, parent=None):
super().__init__(text, parent)
def __init__(self, text):
super().__init__(text)
self._full_text = text
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.setTextFormat(Qt.TextFormat.PlainText)
def setText(self, text):
self._full_text = text
@@ -34,7 +36,9 @@ class ElidedLabel(QLabel):
def resizeEvent(self, event):
metrics = QFontMetrics(self.font())
elided = metrics.elidedText(self._full_text, Qt.ElideRight, self.width())
elided = metrics.elidedText(
self._full_text, Qt.TextElideMode.ElideRight, self.width()
)
super().setText(elided)
super().resizeEvent(event)
@@ -53,8 +57,12 @@ class QueueListItemWidget(QWidget):
name_label = ElidedLabel(os.path.basename(file_name))
char_label = QLabel(f"Chars: {char_count}")
char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};")
char_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
char_label.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Preferred)
char_label.setAlignment(
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
)
char_label.setSizePolicy(
QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred
)
layout.addWidget(name_label, 1)
layout.addWidget(char_label, 0)
self.setLayout(layout)
@@ -67,12 +75,14 @@ class DroppableQueueListWidget(QListWidget):
self.setAcceptDrops(True)
# Overlay for drag hover
self.drag_overlay = QLabel("", self)
self.drag_overlay.setAlignment(Qt.AlignCenter)
self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.drag_overlay.setStyleSheet(
f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};"
)
self.drag_overlay.setVisible(False)
self.drag_overlay.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self.drag_overlay.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
@@ -132,9 +142,11 @@ class QueueManager(QDialog):
layout.setSpacing(12) # set spacing between widgets in main layout
# list of queued items
self.listwidget = DroppableQueueListWidget(self)
self.listwidget.setSelectionMode(QListWidget.ExtendedSelection)
self.listwidget.setSelectionMode(
QAbstractItemView.SelectionMode.ExtendedSelection
)
self.listwidget.setAlternatingRowColors(True)
self.listwidget.setContextMenuPolicy(Qt.CustomContextMenu)
self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
# Add informative instructions at the top
instructions = QLabel(
@@ -145,7 +157,7 @@ class QueueManager(QDialog):
"Changing the main window configuration afterward <b>does not</b> affect files already in the queue. "
"You can view each file's configuration by hovering over them."
)
instructions.setAlignment(Qt.AlignLeft)
instructions.setAlignment(Qt.AlignmentFlag.AlignLeft)
instructions.setWordWrap(True)
instructions.setStyleSheet("margin-bottom: 8px;")
layout.addWidget(instructions)
@@ -154,12 +166,14 @@ class QueueManager(QDialog):
"Drag and drop your text files here or use the 'Add files' button.",
self.listwidget,
)
self.empty_overlay.setAlignment(Qt.AlignCenter)
self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_overlay.setStyleSheet(
f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;"
)
self.empty_overlay.setWordWrap(True)
self.empty_overlay.setAttribute(Qt.WA_TransparentForMouseEvents, True)
self.empty_overlay.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
)
self.empty_overlay.hide()
# add queue items to the list
self.process_queue()
@@ -192,7 +206,10 @@ class QueueManager(QDialog):
self.listwidget.currentItemChanged.connect(self.update_button_states)
self.listwidget.itemSelectionChanged.connect(self.update_button_states)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
@@ -211,7 +228,6 @@ class QueueManager(QDialog):
self.listwidget.clear()
if not self.queue:
self.empty_overlay.resize(self.listwidget.size())
self.empty_overlay.show()
self.update_button_states()
return
@@ -224,8 +240,16 @@ class QueueManager(QDialog):
processing_file_path = item.file_name
# Normalize paths for consistent display (fixes Windows path separator issues)
display_file_path = os.path.normpath(display_file_path) if display_file_path else display_file_path
processing_file_path = os.path.normpath(processing_file_path) if processing_file_path else processing_file_path
display_file_path = (
os.path.normpath(display_file_path)
if display_file_path
else display_file_path
)
processing_file_path = (
os.path.normpath(processing_file_path)
if processing_file_path
else processing_file_path
)
# Only show the file name, not the full path
display_name = display_file_path
@@ -240,13 +264,19 @@ class QueueManager(QDialog):
# For plain .txt inputs we don't need to show a separate processing file
show_processing = True
try:
if isinstance(display_file_path, str) and display_file_path.lower().endswith('.txt'):
if isinstance(
display_file_path, str
) and display_file_path.lower().endswith(".txt"):
show_processing = False
except Exception:
show_processing = True
tooltip = f"<b>Input File:</b> {display_file_path}<br>"
if show_processing and processing_file_path and processing_file_path != display_file_path:
if (
show_processing
and processing_file_path
and processing_file_path != display_file_path
):
tooltip += f"<b>Processing File:</b> {processing_file_path}<br>"
tooltip += (
f"<b>Language:</b> {getattr(item, 'lang_code', '')}<br>"
@@ -262,13 +292,24 @@ class QueueManager(QDialog):
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>"
f"<b>Replace Single Newlines:</b> {getattr(item, 'replace_single_newlines', False)}"
)
# Add book handler options if present
save_chapters_separately = getattr(item, "save_chapters_separately", None)
merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None)
if save_chapters_separately is not None:
tooltip += f"<br><b>Save chapters separately:</b> {'Yes' if save_chapters_separately else 'No'}"
# Only show merge option if saving chapters separately
if save_chapters_separately and merge_chapters_at_end is not None:
tooltip += f"<br><b>Merge chapters at the end:</b> {'Yes' if merge_chapters_at_end else 'No'}"
list_item.setToolTip(tooltip)
list_item.setIcon(icon)
# Store both paths for context menu
list_item.setData(Qt.UserRole, {
'display_path': display_file_path,
'processing_path': processing_file_path
})
list_item.setData(
Qt.ItemDataRole.UserRole,
{
"display_path": display_file_path,
"processing_path": processing_file_path,
},
)
# Use custom widget for display
char_count = getattr(item, "total_char_count", 0)
widget = QueueListItemWidget(display_file_path, char_count)
@@ -280,7 +321,7 @@ class QueueManager(QDialog):
items = self.listwidget.selectedItems()
if not items:
return
from PyQt5.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QMessageBox
# Remove by index to ensure correct mapping
rows = sorted([self.listwidget.row(item) for item in items], reverse=True)
@@ -290,10 +331,10 @@ class QueueManager(QDialog):
self,
"Confirm Remove",
f"Are you sure you want to remove {len(rows)} selected items from the queue?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.Yes:
if reply != QMessageBox.StandardButton.Yes:
return
for row in rows:
if 0 <= row < len(self.queue):
@@ -302,17 +343,17 @@ class QueueManager(QDialog):
self.update_button_states()
def clear_queue(self):
from PyQt5.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QMessageBox
if len(self.queue) > 1:
reply = QMessageBox.question(
self,
"Confirm Clear Queue",
f"Are you sure you want to clear {len(self.queue)} items from the queue?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.Yes:
if reply != QMessageBox.StandardButton.Yes:
return
self.queue.clear()
self.listwidget.clear()
@@ -362,6 +403,13 @@ class QueueManager(QDialog):
attrs["replace_single_newlines"] = getattr(
parent, "replace_single_newlines", False
)
# book handler options
attrs["save_chapters_separately"] = getattr(
parent, "save_chapters_separately", None
)
attrs["merge_chapters_at_end"] = getattr(
parent, "merge_chapters_at_end", None
)
else:
# fallback: empty values
attrs = {
@@ -378,11 +426,13 @@ class QueueManager(QDialog):
"replace_single_newlines",
]
}
attrs["save_chapters_separately"] = None
attrs["merge_chapters_at_end"] = None
return attrs
def add_files_from_paths(self, file_paths):
from abogen.utils import calculate_text_length
from PyQt5.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QMessageBox
import os
current_attrs = self.get_current_attributes()
@@ -394,7 +444,9 @@ class QueueManager(QDialog):
item = QueueItem()
item.file_name = file_path
item.save_base_path = file_path # For .txt files, processing and save paths are the same
item.save_base_path = (
file_path # For .txt files, processing and save paths are the same
)
for attr, value in current_attrs.items():
setattr(item, attr, value)
# Read file content and calculate total_char_count using calculate_text_length
@@ -430,6 +482,10 @@ class QueueManager(QDialog):
== getattr(item, "replace_single_newlines", False)
and getattr(queued_item, "save_base_path", None)
== getattr(item, "save_base_path", None)
and getattr(queued_item, "save_chapters_separately", None)
== getattr(item, "save_chapters_separately", None)
and getattr(queued_item, "merge_chapters_at_end", None)
== getattr(item, "merge_chapters_at_end", None)
):
is_duplicate = True
break
@@ -447,7 +503,7 @@ class QueueManager(QDialog):
self.update_button_states()
def add_more_files(self):
from PyQt5.QtWidgets import QFileDialog
from PyQt6.QtWidgets import QFileDialog
from abogen.utils import calculate_text_length # import the function
# Only allow .txt files
@@ -477,9 +533,9 @@ class QueueManager(QDialog):
self.clear_button.setEnabled(bool(self.queue))
def show_context_menu(self, pos):
from PyQt5.QtWidgets import QMenu, QAction
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtCore import QUrl
from PyQt6.QtWidgets import QMenu
from PyQt6.QtGui import QAction, QDesktopServices
from PyQt6.QtCore import QUrl
import os
global_pos = self.listwidget.viewport().mapToGlobal(pos)
@@ -491,75 +547,129 @@ class QueueManager(QDialog):
remove_action.triggered.connect(self.remove_item)
menu.addAction(remove_action)
# Add Open file action
open_file_action = QAction("Open file", self)
def open_file():
from PyQt5.QtWidgets import QMessageBox
item = selected_items[0]
paths = item.data(Qt.UserRole)
if isinstance(paths, dict):
file_path = paths.get('display_path', paths.get('processing_path', ''))
else:
file_path = paths # Fallback for old format
# Find the queue item
for q in self.queue:
if (getattr(q, "save_base_path", None) == file_path or
q.file_name == file_path):
target_path = getattr(q, "save_base_path", None) or q.file_name
if not os.path.exists(target_path):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
)
return
QDesktopServices.openUrl(QUrl.fromLocalFile(target_path))
break
open_file_action.triggered.connect(open_file)
menu.addAction(open_file_action)
# Add Go to folder action
# If the queued item represents a converted document (markdown, pdf, epub)
# show two actions: Go to processed file (the cached .txt) and Go to input file (original source)
# Get paths for determining if it's a document input
item = selected_items[0]
paths = item.data(Qt.UserRole)
paths = item.data(Qt.ItemDataRole.UserRole)
if isinstance(paths, dict):
display_path = paths.get('display_path', '')
processing_path = paths.get('processing_path', '')
display_path = paths.get("display_path", "")
processing_path = paths.get("processing_path", "")
else:
display_path = paths
processing_path = paths
doc_exts = ('.md', '.markdown', '.pdf', '.epub')
doc_exts = (".md", ".markdown", ".pdf", ".epub")
is_document_input = (
isinstance(display_path, str) and display_path.lower().endswith(doc_exts)
isinstance(display_path, str)
and display_path.lower().endswith(doc_exts)
) or (
isinstance(processing_path, str) and processing_path.lower().endswith(doc_exts)
isinstance(processing_path, str)
and processing_path.lower().endswith(doc_exts)
)
from PyQt5.QtWidgets import QMessageBox
# Add Open file action(s)
def open_file_by_path(path_label: str):
from PyQt6.QtWidgets import QMessageBox
p = display_path if path_label == "display" else processing_path
if not p:
QMessageBox.warning(
self, "File Not Found", "Path is not available."
)
return
# Find the queue item and resolve the target path
target_path = None
for q in self.queue:
if (
getattr(q, "save_base_path", None) == display_path
or q.file_name == display_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else:
target_path = q.file_name
break
if (
getattr(q, "save_base_path", None) == processing_path
or q.file_name == processing_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else:
target_path = q.file_name
break
# Fallback to the raw path if resolution failed
if not target_path:
target_path = p
if not os.path.exists(target_path):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
)
return
QDesktopServices.openUrl(QUrl.fromLocalFile(target_path))
if is_document_input:
# For documents, show two open options
open_processed_action = QAction("Open processed file", self)
open_processed_action.triggered.connect(
lambda: open_file_by_path("processing")
)
menu.addAction(open_processed_action)
open_input_action = QAction("Open input file", self)
open_input_action.triggered.connect(
lambda: open_file_by_path("display")
)
menu.addAction(open_input_action)
else:
# For plain text files, show single open option
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(lambda: open_file_by_path("display"))
menu.addAction(open_file_action)
# Add Go to folder action
# If the queued item represents a converted document (markdown, pdf, epub)
# show two actions: Go to processed file (the cached .txt) and Go to input file (original source)
from PyQt6.QtWidgets import QMessageBox
def open_folder_for(path_label: str):
# path_label should be either 'display' or 'processing'
p = display_path if path_label == 'display' else processing_path
p = display_path if path_label == "display" else processing_path
if not p:
QMessageBox.warning(self, "File Not Found", "Path is not available.")
QMessageBox.warning(
self, "File Not Found", "Path is not available."
)
return
# If the stored path is the display path (original) but the actual file may be
# stored on the queue object differently, try to resolve via the queue entry.
target_path = None
for q in self.queue:
if getattr(q, 'save_base_path', None) == display_path or q.file_name == display_path:
if path_label == 'display':
target_path = getattr(q, 'save_base_path', None) or q.file_name
if (
getattr(q, "save_base_path", None) == display_path
or q.file_name == display_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else:
target_path = q.file_name
break
if getattr(q, 'save_base_path', None) == processing_path or q.file_name == processing_path:
if path_label == 'display':
target_path = getattr(q, 'save_base_path', None) or q.file_name
if (
getattr(q, "save_base_path", None) == processing_path
or q.file_name == processing_path
):
if path_label == "display":
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
else:
target_path = q.file_name
break
@@ -568,7 +678,11 @@ class QueueManager(QDialog):
target_path = p
if not os.path.exists(target_path):
QMessageBox.warning(self, "File Not Found", f"The file does not exist: {target_path}")
QMessageBox.warning(
self,
"File Not Found",
f"The file does not exist: {target_path}",
)
return
folder = os.path.dirname(target_path)
if os.path.exists(folder):
@@ -576,11 +690,13 @@ class QueueManager(QDialog):
if is_document_input:
processed_action = QAction("Go to processed file", self)
processed_action.triggered.connect(lambda: open_folder_for('processing'))
processed_action.triggered.connect(
lambda: open_folder_for("processing")
)
menu.addAction(processed_action)
input_action = QAction("Go to input file", self)
input_action.triggered.connect(lambda: open_folder_for('display'))
input_action.triggered.connect(lambda: open_folder_for("display"))
menu.addAction(input_action)
else:
# Default behavior for non-document inputs: single "Go to folder" action
@@ -588,15 +704,22 @@ class QueueManager(QDialog):
def go_to_folder():
item = selected_items[0]
paths = item.data(Qt.UserRole)
paths = item.data(Qt.ItemDataRole.UserRole)
if isinstance(paths, dict):
file_path = paths.get('display_path', paths.get('processing_path', ''))
file_path = paths.get(
"display_path", paths.get("processing_path", "")
)
else:
file_path = paths # Fallback for old format
# Find the queue item
for q in self.queue:
if (getattr(q, "save_base_path", None) == file_path or q.file_name == file_path):
target_path = getattr(q, "save_base_path", None) or q.file_name
if (
getattr(q, "save_base_path", None) == file_path
or q.file_name == file_path
):
target_path = (
getattr(q, "save_base_path", None) or q.file_name
)
if not os.path.exists(target_path):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
@@ -618,7 +741,7 @@ class QueueManager(QDialog):
clear_action = QAction("Clear Queue", self)
clear_action.triggered.connect(self.clear_queue)
menu.addAction(clear_action)
menu.exec_(global_pos)
menu.exec(global_pos)
def accept(self):
# Accept: keep changes
@@ -626,7 +749,7 @@ class QueueManager(QDialog):
def reject(self):
# Cancel: restore original queue
from PyQt5.QtWidgets import QMessageBox
from PyQt6.QtWidgets import QMessageBox
# Warn if user changed a lot (e.g., more than 1 items difference)
original_count = len(self._original_queue)
@@ -636,19 +759,19 @@ class QueueManager(QDialog):
self,
"Confirm Cancel",
f"Are you sure you want to cancel and discard all changes?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.Yes:
if reply != QMessageBox.StandardButton.Yes:
return
self.queue.clear()
self.queue.extend(deepcopy(self._original_queue))
super().reject()
def keyPressEvent(self, event):
from PyQt5.QtCore import Qt
from PyQt6.QtCore import Qt
if event.key() == Qt.Key_Delete:
if event.key() == Qt.Key.Key_Delete:
self.remove_item()
else:
super().keyPressEvent(event)
+2
View File
@@ -15,3 +15,5 @@ class QueuedItem:
total_char_count: int
replace_single_newlines: bool = False
save_base_path: str = None
save_chapters_separately: bool = None
merge_chapters_at_end: bool = None
+2
View File
@@ -14,6 +14,7 @@ warnings.filterwarnings("ignore")
def detect_encoding(file_path):
import chardet
import charset_normalizer
with open(file_path, "rb") as f:
raw_data = f.read()
detected_encoding = None
@@ -28,6 +29,7 @@ def detect_encoding(file_path):
encoding = detected_encoding if detected_encoding else "utf-8"
return encoding.lower()
def get_resource_path(package, resource):
"""
Get the path to a resource file, with fallback to local file system.
+149 -76
View File
@@ -1,6 +1,6 @@
import json
import os
from PyQt5.QtWidgets import (
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QCheckBox,
@@ -22,12 +22,11 @@ from PyQt5.QtWidgets import (
QFileDialog,
QSplitter,
QMenu,
QAction,
QComboBox,
QApplication,
QComboBox,
)
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt5.QtGui import QPixmap, QIcon
from PyQt6.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt6.QtGui import QPixmap, QIcon, QAction
from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
@@ -91,7 +90,7 @@ class FlowLayout(QLayout):
return len(self._item_list)
def expandingDirections(self):
return Qt.Orientations(Qt.Orientation(0))
return Qt.Orientation(0)
def hasHeightForWidth(self):
return True
@@ -132,10 +131,14 @@ class FlowLayout(QLayout):
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
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Horizontal,
)
layout_spacing_y = style.layoutSpacing(
QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Vertical,
)
space_x = spacing if spacing >= 0 else layout_spacing_x
space_y = spacing if spacing >= 0 else layout_spacing_y
@@ -163,7 +166,7 @@ class VoiceMixer(QWidget):
super().__init__()
self.voice_name = voice_name
self.setFixedWidth(VOICE_MIXER_WIDTH)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# TODO Set CSS for rounded corners
# self.setObjectName("VoiceMixer")
@@ -173,7 +176,7 @@ class VoiceMixer(QWidget):
# Name label at the top
name = voice_name
layout.addWidget(QLabel(name), alignment=Qt.AlignCenter)
layout.addWidget(QLabel(name), alignment=Qt.AlignmentFlag.AlignCenter)
# Voice name label with gender icon
is_female = self.voice_name in VOICES_INTERNAL and self.voice_name[1] == "f"
@@ -181,7 +184,9 @@ class VoiceMixer(QWidget):
# Icons layout (flag and gender)
icons_layout = QHBoxLayout()
icons_layout.setSpacing(3)
icons_layout.setAlignment(Qt.AlignCenter) # Center the icons horizontally
icons_layout.setAlignment(
Qt.AlignmentFlag.AlignCenter
) # Center the icons horizontally
# Flag icon
flag_icon_path = get_resource_path(
@@ -194,11 +199,21 @@ class VoiceMixer(QWidget):
gender_label = QLabel()
flag_pixmap = QPixmap(flag_icon_path)
flag_label.setPixmap(
flag_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)
flag_pixmap.scaled(
16,
16,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
gender_pixmap = QPixmap(gender_icon_path)
gender_label.setPixmap(
gender_pixmap.scaled(16, 16, Qt.KeepAspectRatio, Qt.SmoothTransformation)
gender_pixmap.scaled(
16,
16,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
icons_layout.addWidget(flag_label)
icons_layout.addWidget(gender_label)
@@ -210,7 +225,7 @@ class VoiceMixer(QWidget):
self.checkbox = QCheckBox()
self.checkbox.setChecked(initial_status)
self.checkbox.stateChanged.connect(self.toggle_inputs)
layout.addWidget(self.checkbox, alignment=Qt.AlignCenter)
layout.addWidget(self.checkbox, alignment=Qt.AlignmentFlag.AlignCenter)
# Spinbox and slider
self.spin_box = QDoubleSpinBox()
@@ -219,26 +234,16 @@ class VoiceMixer(QWidget):
self.spin_box.setDecimals(2)
self.spin_box.setValue(initial_weight)
self.slider = QSlider(Qt.Vertical)
self.slider = QSlider(Qt.Orientation.Vertical)
self.slider.setRange(0, 100)
self.slider.setValue(int(initial_weight * 100))
self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
self.slider.setSizePolicy(
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.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;
}}
"""
)
# Apply slider styling after widget is added to window (see showEvent)
self._slider_style_applied = False
# Connect controls
self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100))
@@ -249,23 +254,67 @@ class VoiceMixer(QWidget):
# 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(QLabel("1", alignment=Qt.AlignmentFlag.AlignCenter))
slider_center_layout = QHBoxLayout()
slider_center_layout.addWidget(self.slider, alignment=Qt.AlignHCenter)
slider_center_layout.addWidget(
self.slider, alignment=Qt.AlignmentFlag.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.addWidget(QLabel("0", alignment=Qt.AlignmentFlag.AlignCenter))
slider_layout.setStretch(2, 1)
layout.addLayout(slider_layout, stretch=1)
self.setLayout(layout)
self.toggle_inputs()
def showEvent(self, event):
super().showEvent(event)
# Apply slider styling once when widget is shown and has access to parent
if not self._slider_style_applied:
self._slider_style_applied = True
# 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;
}}
"""
)
else:
# Apply same fix for Light theme on non-Windows systems
# Get theme from parent window's config
parent_window = self.window()
theme = "system"
while parent_window:
if hasattr(parent_window, "config"):
theme = parent_window.config.get("theme", "system")
break
parent_window = parent_window.parent()
if theme == "light":
self.slider.setStyleSheet(
f"""
QSlider::groove:vertical:disabled {{
background: {COLORS.get("GREY_BACKGROUND")};
width: 4px;
border-radius: 4px;
}}
"""
)
def toggle_inputs(self):
is_enabled = self.checkbox.isChecked()
self.spin_box.setEnabled(is_enabled)
@@ -307,9 +356,11 @@ class HoverLabel(QLabel):
"""
)
# 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.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.delete_button.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, False
)
self.delete_button.setCursor(Qt.CursorShape.PointingHandCursor)
self.delete_button.hide()
def resizeEvent(self, event):
@@ -357,7 +408,7 @@ class VoiceFormulaDialog(QDialog):
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)
splitter = QSplitter(Qt.Orientation.Horizontal)
# Profile section
profile_widget = QWidget()
profile_layout = QVBoxLayout(profile_widget)
@@ -371,8 +422,8 @@ class VoiceFormulaDialog(QDialog):
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.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
self.profile_list.setSelectionBehavior(QListWidget.SelectionBehavior.SelectRows)
self.profile_list.setStyleSheet(
"QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"
)
@@ -388,7 +439,7 @@ class VoiceFormulaDialog(QDialog):
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.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.profile_list.customContextMenuRequested.connect(
self.show_profile_context_menu
)
@@ -409,7 +460,9 @@ class VoiceFormulaDialog(QDialog):
self.setWindowTitle("Voice Mixer")
self.setWindowFlags(
Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint
Qt.WindowType.Window
| Qt.WindowType.WindowCloseButtonHint
| Qt.WindowType.WindowMaximizeButtonHint
)
self.setMinimumSize(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT)
self.resize(INITIAL_WINDOW_WIDTH, INITIAL_WINDOW_HEIGHT)
@@ -468,21 +521,25 @@ class VoiceFormulaDialog(QDialog):
# Separator
separator = QFrame()
separator.setFrameShadow(QFrame.Sunken)
separator.setFrameShadow(QFrame.Shadow.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.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self.scroll_area.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.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
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self.scroll_area.setWidget(self.voice_list_widget)
mixer_layout.addWidget(self.scroll_area, stretch=1)
@@ -497,6 +554,9 @@ class VoiceFormulaDialog(QDialog):
ok_button.setDefault(True)
ok_button.setFocus()
# Connect buttons
clear_all_button.clicked.connect(self.clear_all_voices)
ok_button.clicked.connect(self.accept)
# Connect buttons
clear_all_button.clicked.connect(self.clear_all_voices)
ok_button.clicked.connect(self.accept)
@@ -533,7 +593,7 @@ class VoiceFormulaDialog(QDialog):
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():
if event.key() == Qt.Key.Key_Delete and self.profile_list.hasFocus():
item = self.profile_list.currentItem()
if item:
self.delete_profile(item)
@@ -562,10 +622,12 @@ class VoiceFormulaDialog(QDialog):
self,
"Unsaved Changes",
msg,
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel,
QMessageBox.Save,
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if ret == QMessageBox.Save:
if ret == QMessageBox.StandardButton.Save:
# Save all using stored states
profiles = load_profiles()
for i in dirty_indices:
@@ -590,7 +652,7 @@ class VoiceFormulaDialog(QDialog):
self.update_profile_save_buttons()
self.update_profile_list_colors()
return True
elif ret == QMessageBox.Discard:
elif ret == QMessageBox.StandardButton.Discard:
# Discard all modifications
self._profile_states.clear()
for i in dirty_indices:
@@ -612,17 +674,19 @@ class VoiceFormulaDialog(QDialog):
else:
# Fallback to original logic for 0 or 1 dirty profile
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setIcon(QMessageBox.Icon.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
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel
)
box.setDefaultButton(QMessageBox.Save)
ret = box.exec_()
if ret == QMessageBox.Save:
box.setDefaultButton(QMessageBox.StandardButton.Save)
ret = box.exec()
if ret == QMessageBox.StandardButton.Save:
for i in range(self.profile_list.count()):
item = self.profile_list.item(i)
name = item.text().lstrip("*")
@@ -636,7 +700,7 @@ class VoiceFormulaDialog(QDialog):
if hasattr(parent, "populate_profiles_in_voice_combo"):
parent.populate_profiles_in_voice_combo()
return True
elif ret == QMessageBox.Discard:
elif ret == QMessageBox.StandardButton.Discard:
profiles = load_profiles()
for i in range(self.profile_list.count()):
item = self.profile_list.item(i)
@@ -717,7 +781,7 @@ class VoiceFormulaDialog(QDialog):
return voice_mixer
def handle_voice_checkbox(self, voice_mixer, state):
if state == Qt.Checked:
if state == Qt.CheckState.Checked.value:
self.last_enabled_voice = voice_mixer.voice_name
self.update_weighted_sums()
@@ -767,7 +831,9 @@ class VoiceFormulaDialog(QDialog):
f'<b><span style="color:{COLORS.get("BLUE")}">{name}: {percentage:.1f}%</span></b>',
name,
)
voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
voice_label.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred
)
voice_label.delete_button.clicked.connect(
lambda _, vn=name: self.disable_voice_by_name(vn)
)
@@ -787,7 +853,7 @@ class VoiceFormulaDialog(QDialog):
mixer.checkbox.setChecked(False)
def eventFilter(self, source, event):
if source is self.scroll_area.viewport() and event.type() == event.Wheel:
if source is self.scroll_area.viewport() and event.type() == event.Type.Wheel:
# Skip if over an enabled slider
if any(
mixer.slider.underMouse() and mixer.slider.isEnabled()
@@ -890,10 +956,10 @@ class VoiceFormulaDialog(QDialog):
self,
"Invalid Profiles",
msg,
QMessageBox.Yes | QMessageBox.Cancel,
QMessageBox.Yes,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Yes,
)
if reply == QMessageBox.Yes:
if reply == QMessageBox.StandardButton.Yes:
for i, name in reversed(zero):
self.profile_list.takeItem(i)
delete_profile(name)
@@ -980,8 +1046,8 @@ class VoiceFormulaDialog(QDialog):
super().closeEvent(event)
def _parse_rgba_to_qcolor(self, rgba_str):
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor
from PyQt6.QtCore import Qt
from PyQt6.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)
@@ -1136,9 +1202,12 @@ class VoiceFormulaDialog(QDialog):
msg += f"\nThis will overwrite an existing profile."
msg += "\nContinue?"
reply = QMessageBox.question(
self, "Import Profile", msg, QMessageBox.Yes | QMessageBox.No
self,
"Import Profile",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.Yes:
if reply != QMessageBox.StandardButton.Yes:
return
profiles.update(imported_profiles)
save_profiles(profiles)
@@ -1153,9 +1222,12 @@ class VoiceFormulaDialog(QDialog):
msg += f"\n{len(collisions)} profile(s) will be overwritten."
msg += "\nContinue?"
reply = QMessageBox.question(
self, "Import Profiles", msg, QMessageBox.Yes | QMessageBox.No
self,
"Import Profiles",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.Yes:
if reply != QMessageBox.StandardButton.Yes:
return
profiles.update(imported_profiles)
save_profiles(profiles)
@@ -1196,7 +1268,7 @@ class VoiceFormulaDialog(QDialog):
menu.addAction(dup_act)
menu.addAction(export_act)
menu.addAction(delete_act)
act = menu.exec_(self.profile_list.viewport().mapToGlobal(pos))
act = menu.exec(self.profile_list.viewport().mapToGlobal(pos))
if act == rename_act:
self.rename_profile(item)
elif act == delete_act:
@@ -1323,9 +1395,9 @@ class VoiceFormulaDialog(QDialog):
self,
"Delete Profile",
f"Delete profile '{name}'?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.Yes:
if reply == QMessageBox.StandardButton.Yes:
delete_profile(name)
row = self.profile_list.row(item)
self.profile_list.takeItem(row)
@@ -1378,7 +1450,7 @@ class VoiceFormulaDialog(QDialog):
self.profile_list.setItemWidget(item, widget)
def update_profile_list_colors(self):
from PyQt5.QtCore import Qt
from PyQt6.QtCore import Qt
profiles = load_profiles()
for i in range(self.profile_list.count()):
@@ -1386,13 +1458,14 @@ class VoiceFormulaDialog(QDialog):
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)
item.setData(Qt.ItemDataRole.BackgroundRole, color)
elif item.text().startswith("*"):
color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND"))
item.setData(Qt.BackgroundRole, color)
item.setData(Qt.ItemDataRole.BackgroundRole, color)
else:
item.setData(
Qt.BackgroundRole, self.profile_list.palette().base().color()
Qt.ItemDataRole.BackgroundRole,
self.profile_list.palette().base().color(),
)
weights = profiles.get(name, {}).get("voices", [])
total = 0
@@ -1406,7 +1479,7 @@ class VoiceFormulaDialog(QDialog):
total += entry[1]
if total == 0:
color = self._parse_rgba_to_qcolor(COLORS.get("RED_BACKGROUND"))
item.setData(Qt.BackgroundRole, color)
item.setData(Qt.ItemDataRole.BackgroundRole, color)
self.update_profile_save_buttons()
def preview_current_mix(self):
+1 -1
View File
@@ -13,7 +13,7 @@ license = "MIT"
requires-python = ">=3.10, <3.13"
keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "kokoro", "accessibility", "book-converter", "voice-synthesis", "multilingual", "chapter-management", "subtitles", "content-creation", "media-generation"]
dependencies = [
"PyQt5>=5.15.11",
"PyQt6>=6.10.0",
"kokoro>=0.9.4",
"misaki[zh]>=0.9.4",
"ebooklib>=0.19",