diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6954003..ff8bb2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
-# v1.1.0 (pre-release)
+# v1.1.0
+- Added queue system for processing multiple items, allowing users to add multiple files and process them in a queue, mentioned by @jborza in #30 (Special thanks to @jborza for implementing this feature in PR #35)
- Added a feature that allows selecting multiple items in book handler (in right click menu) by @jborza in #31, that fixes #28
- Added dark theme support, allowing users to switch between light and dark themes in the settings.
+- Added auto-accept system to the chapter options dialog in conversion process, allowing the dialog to auto-accept after a certain time if no action is taken.
+- Added new option: `Configure max lines in log window` that allows configuring the maximum number of lines to display in the log window.
+- Improvements in documentation and code.
# v1.0.9
- Added chunking/segmenting system that fixes memory outage issues when processing large audio files.
diff --git a/README.md b/README.md
index 9c8ca1d..95cf045 100644
--- a/README.md
+++ b/README.md
@@ -119,34 +119,58 @@ abogen
Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware.
-## `Key Features`
-- **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor)
-- **Speed**: Adjust speech rate from `0.1x` to `2.0x`
-- **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female.
-- **Voice mixer**: Create custom voices by mixing different voice models with a profile system.
-- **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry)
-- **Output formats**: `.WAV`, `.FLAC`, `.MP3`, `.OPUS` and `M4B (with chapters)` (Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10))
-- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
-- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
-- **Save each chapter separately**: Save each chapter in e-books as a separate audio file.
-- **Project folder**: Save the converted items in a project folder with available metadata files.
-- **Options**:
- - **Replace single newlines with spaces**: Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks.
- - **Configure max words per subtitle**: Configures the maximum number of words per subtitle entry.
- - **Subtitle format**: Configures the subtitle format as `srt`, `ass (wide)`, `ass (narrow)`, `ass (centered wide)`, or `ass (centered narrow)`.
- - **Separate chapters audio format**: Configures the audio format for separate chapters as `wav`, `flac`, `mp3`, or `opus`.
- - **Create desktop shortcut**: Creates a shortcut on your desktop for easy access.
- - **Open config.json directory**: Opens the directory where the configuration file is stored.
- - **Open temp directory**: Opens the temporary directory where converted text files are stored.
- - **Clear temporary files**: Deletes temporary files created during the conversion or preview.
- - **Check for updates at startup**: Automatically checks for updates when the program starts.
-- **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`.
+## `Configuration`
+
+| Options | Description |
+|---------|-------------|
+| **Input Box** | Drag and drop `ePub`, `PDF`, or `.TXT` files (or use built-in text editor) |
+| **Queue options** | Add multiple files to a queue and process them in batch, with individual settings for each file. See [Queue mode](#queue-mode) for more details. |
+| **Speed** | Adjust speech rate from `0.1x` to `2.0x` |
+| **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. |
+| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. |
+| **Voice preview** | Listen to the selected voice before processing. |
+| **Generate subtitles** | `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
+| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` (Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10)) |
+| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. |
+| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. |
+| **Save location** | `Save next to input file`, `Save to desktop`, or `Choose output folder` |
+
+| Book handler options | Description |
+|---------|-------------|
+| **Chapter Control** | Select specific `chapters` from ePUBs or `chapters + pages` from PDFs. |
+| **Save each chapter separately** | Save each chapter in e-books as a separate audio file. |
+| **Create a merged version** | Create a single audio file that combines all chapters. (If `Save each chapter separately` is disabled, this option will be the default behavior.) |
+| **Save in a project folder with metadata** | Save the converted items in a project folder with available metadata files. |
+
+| Menu options | Description |
+|---------|-------------|
+| **Theme** | Change the application's theme using `System`, `Light`, or `Dark` options. |
+| **Configure max words per subtitle** | Configures the maximum number of words per subtitle entry. |
+| **Configure max lines in log window** | Configures the maximum number of lines to display in the log window. |
+| **Separate chapters audio format** | Configures the audio format for separate chapters as `wav`, `flac`, `mp3`, or `opus`. |
+| **Create desktop shortcut** | Creates a shortcut on your desktop for easy access. |
+| **Open config.json directory** | Opens the directory where the configuration file is stored. |
+| **Open temp directory** | Opens the temporary directory where converted text files are stored. |
+| **Clear temporary files** | Deletes temporary files created during the conversion or preview. |
+| **Check for updates at startup** | Automatically checks for updates when the program starts. |
## `Voice Mixer`
With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5))
+## `Queue Mode`
+
+
+Abogen supports **queue mode**, allowing you to add multiple files to a processing queue. This is useful if you want to convert several files in one batch.
+
+- You can add text files (`.txt`) directly using the **Add files** button in the Queue Manager. To add PDF or EPUB files, use the input box in the main window and click the **Add to Queue** button.
+- Each file in the queue keeps the configuration settings that were active when it was added. Changing the main window configuration afterward does **not** affect files already in the queue.
+- You can view each file's configuration by hovering over them.
+
+Abogen will process each item in the queue automatically, saving outputs as configured.
+> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35)
+
## `About Chapter Markers`
When you process ePUB or PDF files, Abogen converts them into text files stored in your temporary directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
@@ -257,12 +281,12 @@ Abogen is a standalone project, but it is inspired by and shares some similariti
- [ebook2audiobook](https://github.com/DrewThomasson/ebook2audiobook): Convert ebooks to audiobooks with chapters and metadata using dynamic AI models and voice cloning
## `Roadmap`
-- [ ] Add OCR scan feature for PDF files using docling.
+- [ ] Add OCR scan feature for PDF files using docling/teserract.
- [x] Add chapter metadata for .m4a files. (Issue [#9](https://github.com/denizsafak/abogen/issues/9), PR [#10](https://github.com/denizsafak/abogen/pull/10))
- [ ] Add support for different languages in GUI.
- [x] Add voice formula feature that enables mixing different voice models. (Issue [#1](https://github.com/denizsafak/abogen/issues/1), PR [#5](https://github.com/denizsafak/abogen/pull/5))
- [ ] Add support for kokoro-onnx (If it's necessary).
-- [ ] Add dark mode.
+- [x] Add dark mode.
## `Troubleshooting`
If you encounter any issues while running Abogen, try launching it from the command line with:
diff --git a/abogen/VERSION b/abogen/VERSION
index e5a4a5e..1cc5f65 100644
--- a/abogen/VERSION
+++ b/abogen/VERSION
@@ -1 +1 @@
-1.0.9
\ No newline at end of file
+1.1.0
\ No newline at end of file
diff --git a/abogen/constants.py b/abogen/constants.py
index fca17a0..a403302 100644
--- a/abogen/constants.py
+++ b/abogen/constants.py
@@ -1,4 +1,4 @@
-from utils import get_version
+from abogen.utils import get_version
# Program Information
PROGRAM_NAME = "abogen"
@@ -8,6 +8,9 @@ PROGRAM_DESCRIPTION = (
GITHUB_URL = "https://github.com/denizsafak/abogen"
VERSION = get_version()
+# Settings
+CHAPTER_OPTIONS_COUNTDOWN = 30 # Countdown seconds for chapter options
+
# Language description mapping
LANGUAGE_DESCRIPTIONS = {
"a": "American English",
@@ -106,9 +109,18 @@ SAMPLE_VOICE_TEXTS = {
COLORS = {
"BLUE": "#007dff",
"RED": "#c0392b",
+ "GREEN": "#42ad4a",
+ "GREEN_BG": "rgba(66, 173, 73, 0.1)",
+ "GREEN_BG_HOVER": "rgba(66, 173, 73, 0.15)",
+ "GREEN_BORDER": "#42ad4a",
+ "BLUE_BG": "rgba(0, 102, 255, 0.05)",
+ "BLUE_BG_HOVER": "rgba(0, 102, 255, 0.1)",
+ "BLUE_BORDER_HOVER": "#6ab0de",
"YELLOW_BACKGROUND": "rgba(255, 221, 51, 0.40)",
"GREY_BACKGROUND": "rgba(128, 128, 128, 0.15)",
"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)",
# Theme palette colors
"DARK_BG": "#202326",
"DARK_BASE": "#141618",
diff --git a/abogen/conversion.py b/abogen/conversion.py
index 1e6b262..0a31e60 100644
--- a/abogen/conversion.py
+++ b/abogen/conversion.py
@@ -6,18 +6,17 @@ import chardet
import charset_normalizer
import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir
-from PyQt5.QtCore import QThread, pyqtSignal, Qt
+from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf
-from utils import clean_text, create_process
-from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
-from voice_formulas import get_new_voice
-import hf_tracker
+from abogen.utils import clean_text, create_process
+from abogen.constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS, COLORS, CHAPTER_OPTIONS_COUNTDOWN
+from abogen.voice_formulas import get_new_voice
+import abogen.hf_tracker as hf_tracker
import static_ffmpeg
import threading # for efficient waiting
import subprocess
-
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
@@ -61,7 +60,7 @@ class ChapterOptionsDialog(QDialog):
self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end")
# Set default states
- self.save_separately_checkbox.setChecked(True)
+ self.save_separately_checkbox.setChecked(False)
self.merge_at_end_checkbox.setChecked(True)
# Connect checkbox state change signal
@@ -72,14 +71,36 @@ class ChapterOptionsDialog(QDialog):
layout.addWidget(self.save_separately_checkbox)
layout.addWidget(self.merge_at_end_checkbox)
+ # Countdown label
+ self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN
+ self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...")
+ self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};")
+ layout.addWidget(self.countdown_label)
+
# Add OK button
button_box = QDialogButtonBox(QDialogButtonBox.Ok)
button_box.accepted.connect(self.accept)
layout.addWidget(button_box)
+ # Timer for countdown
+ self._timer = QTimer(self)
+ self._timer.timeout.connect(self._on_timer_tick)
+ self._timer.start(1000) # 1 second interval
+
+ # Store button_box for later use
+ self._button_box = button_box
+
# Initialize merge checkbox state
self.update_merge_checkbox_state()
+ def _on_timer_tick(self):
+ self.countdown_seconds -= 1
+ if self.countdown_seconds > 0:
+ self.countdown_label.setText(f"Auto-accepting in {self.countdown_seconds} seconds...")
+ else:
+ self._timer.stop()
+ self._button_box.accepted.emit() # Simulate OK click
+
def update_merge_checkbox_state(self):
# Enable merge checkbox only if save separately is checked
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
@@ -131,6 +152,7 @@ class ConversionThread(QThread):
start_time,
total_char_count,
use_gpu=True,
+ from_queue=False
): # Add use_gpu parameter
super().__init__()
self._chapter_options_event = threading.Event()
@@ -144,7 +166,10 @@ class ConversionThread(QThread):
self.output_folder = output_folder
self.subtitle_mode = subtitle_mode
self.cancel_requested = False
+ self.should_cancel = False
+ self.process = None
self.output_format = output_format
+ self.from_queue = from_queue
self.start_time = start_time # Store start_time
self.total_char_count = total_char_count # Use passed total character count
self.processed_char_count = 0 # Initialize processed character count
@@ -316,15 +341,20 @@ class ConversionThread(QThread):
hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg))
# Show configuration
self.log_updated.emit("Configuration:")
- # Use display_path for logs if available, otherwise use the actual file name
- display_file = self.display_path if self.display_path else self.file_name
+
+ # Use file_name for logs if from_queue, otherwise use display_path if available
+ if getattr(self, "from_queue", False):
+ display_file = self.file_name
+ else:
+ display_file = self.display_path if self.display_path else self.file_name
+
self.log_updated.emit(f"- Input File: {display_file}")
# Use file size string passed from GUI
if hasattr(self, "file_size_str"):
self.log_updated.emit(f"- File size: {self.file_size_str}")
- self.log_updated.emit(f"- Total characters: {self.total_char_count:,}")
+ self.log_updated.emit(f"- Total characters: {int(self.total_char_count):,}")
self.log_updated.emit(
f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})"
@@ -447,7 +477,14 @@ class ConversionThread(QThread):
save_chapters_separately = getattr(self, "save_chapters_separately", False)
chapters_out_dir = None
suffix = ""
- base_path = self.display_path if self.display_path else self.file_name
+
+
+ # Use file_name for logs if from_queue, otherwise use display_path if available
+ if getattr(self, "from_queue", False):
+ base_path = self.file_name
+ else:
+ base_path = self.display_path if self.display_path else self.file_name
+
base_name = os.path.splitext(os.path.basename(base_path))[0]
if self.save_option == "Save to Desktop":
parent_dir = user_desktop_dir()
@@ -946,8 +983,13 @@ class ConversionThread(QThread):
genre_match = re.search(r"<]*)>>", text)
# Use display path or filename as fallback for title
- filename = os.path.splitext(os.path.basename(self.display_path if self.display_path else self.file_name))[0]
+ # Use file_name for logs if from_queue, otherwise use display_path if available
+ if getattr(self, "from_queue", False):
+ filename = os.path.splitext(os.path.basename(self.file_name))[0]
+ else:
+ filename = os.path.splitext(os.path.basename(self.display_path if self.display_path else self.file_name))[0]
+
if title_match:
metadata_options.extend(["-metadata", f"title={title_match.group(1)}"])
else:
@@ -1123,9 +1165,14 @@ class ConversionThread(QThread):
def cancel(self):
self.cancel_requested = True
- self.waiting_for_user_input = (
- False # Also release the wait if we're waiting for input
- )
+ self.should_cancel = True
+ self.waiting_for_user_input = False
+ # Terminate subprocess if running
+ if self.process:
+ try:
+ self.process.terminate()
+ except Exception:
+ pass
class VoicePreviewThread(QThread):
diff --git a/abogen/gui.py b/abogen/gui.py
index 322d5cc..ff22794 100644
--- a/abogen/gui.py
+++ b/abogen/gui.py
@@ -4,7 +4,9 @@ import tempfile
import platform
import base64
import re
-import hf_tracker
+from abogen.queue_manager_gui import QueueManager
+from abogen.queued_item import QueuedItem
+import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation
from PyQt5.QtWidgets import (
QApplication,
@@ -51,7 +53,7 @@ from PyQt5.QtGui import (
QColor,
QMovie,
)
-from utils import (
+from abogen.utils import (
load_config,
save_config,
get_gpu_acceleration,
@@ -62,9 +64,9 @@ from utils import (
get_resource_path,
LoadPipelineThread,
)
-from conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
-from book_handler import HandlerDialog
-from constants import (
+from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
+from abogen.book_handler import HandlerDialog
+from abogen.constants import (
PROGRAM_NAME,
VERSION,
GITHUB_URL,
@@ -74,9 +76,9 @@ from constants import (
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
COLORS
)
-from threading import Thread
-from voice_formula_gui import VoiceFormulaDialog
-from voice_profiles import load_profiles
+import threading
+from abogen.voice_formula_gui import VoiceFormulaDialog
+from abogen.voice_profiles import load_profiles
# Import ctypes for Windows-specific taskbar icon
if platform.system() == "Windows":
@@ -122,14 +124,14 @@ class IconProvider(QFileIconProvider):
class InputBox(QLabel):
# Define CSS styles as class constants
- STYLE_DEFAULT = "border:2px dashed #aaa; border-radius:5px; padding:20px; background:rgba(0, 102, 255, 0.05); min-height:100px;"
- STYLE_DEFAULT_HOVER = "background:rgba(0, 102, 255, 0.1); border-color:#6ab0de;"
+ STYLE_DEFAULT = f"border:2px dashed #aaa; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG']}; min-height:100px;"
+ STYLE_DEFAULT_HOVER = f"background:{COLORS['BLUE_BG_HOVER']}; border-color:{COLORS['BLUE_BORDER_HOVER']};"
- STYLE_ACTIVE = "border:2px dashed #42ad4a; border-radius:5px; padding:20px; background:rgba(66, 173, 73, 0.1); min-height:100px;"
- STYLE_ACTIVE_HOVER = "background:rgba(66, 173, 73, 0.15); border-color:#42ad4a;"
+ STYLE_ACTIVE = f"border:2px dashed {COLORS['GREEN']}; border-radius:5px; padding:20px; background:{COLORS['GREEN_BG']}; min-height:100px;"
+ STYLE_ACTIVE_HOVER = f"background:{COLORS['GREEN_BG_HOVER']}; border-color:{COLORS['GREEN_BORDER']};"
- STYLE_ERROR = "border:2px dashed #e74c3c; border-radius:5px; padding:20px; background:rgba(232, 78, 60, 0.10); min-height:100px; color:#c0392b;"
- STYLE_ERROR_HOVER = "background:rgba(232, 78, 60, 0.15); border-color:#e74c3c;"
+ STYLE_ERROR = f"border:2px dashed {COLORS['RED']}; border-radius:5px; padding:20px; background:{COLORS['RED_BG']}; min-height:100px; color:{COLORS['RED']};"
+ STYLE_ERROR_HOVER = f"background:{COLORS['RED_BG_HOVER']}; border-color:{COLORS['RED']};"
def __init__(self, parent=None):
super().__init__(parent)
@@ -141,11 +143,16 @@ class InputBox(QLabel):
self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}")
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setCursor(Qt.PointingHandCursor)
+
+ # Add clear button
self.clear_btn = QPushButton("✕", self)
self.clear_btn.setFixedSize(28, 28)
self.clear_btn.hide()
self.clear_btn.clicked.connect(self.clear_input)
+
+ # Add Chapters button
self.chapters_btn = QPushButton("Chapters", self)
+ self.chapters_btn.setStyleSheet("QPushButton { padding: 6px 10px; }")
self.chapters_btn.hide()
self.chapters_btn.clicked.connect(self.on_chapters_clicked)
@@ -154,6 +161,7 @@ class InputBox(QLabel):
self.textbox_btn.setStyleSheet("QPushButton { padding: 6px 10px; }")
self.textbox_btn.setToolTip("Input text directly instead of using a file")
self.textbox_btn.clicked.connect(self.on_textbox_clicked)
+
# Add Edit button matching the textbox button
self.edit_btn = QPushButton("Edit", self)
self.edit_btn.setStyleSheet("QPushButton { padding: 6px 10px; }")
@@ -178,9 +186,9 @@ class InputBox(QLabel):
# Position textbox button at top left
self.textbox_btn.move(margin, margin)
self.edit_btn.move(margin, margin)
- # Position go to folder button at bottom right
+ # Position go to folder button at bottom right with correct margins
self.go_to_folder_btn.move(
- self.width() - self.go_to_folder_btn.width(),
+ self.width() - self.go_to_folder_btn.width() - margin,
self.height() - self.go_to_folder_btn.height() - margin,
)
@@ -198,12 +206,24 @@ class InputBox(QLabel):
size_str = self._human_readable_size(os.path.getsize(file_path))
name = os.path.basename(file_path)
- char_count = "N/A"
+ char_count = 0
+
+ def parse_size(size_str):
+ # Use regex to extract the numeric part
+ match = re.match(r"([\d.]+)", size_str)
+ if match:
+ return float(match.group(1))
+ raise ValueError(f"Invalid size format: {size_str}")
+
# Format numbers with commas
def format_num(n):
try:
- return f"{int(n):,}"
+ if isinstance(n, str):
+ size = int(parse_size(n))
+ return f"{size:,}"
+ else:
+ return f"{n:,}"
except Exception:
return str(n)
@@ -241,7 +261,7 @@ class InputBox(QLabel):
self.window().char_count = 0
# embed icon at native size with word-wrap for the filename
self.setText(
- f'
{name}
Size: {format_num(size_str)}
Characters: {format_num(char_count)}'
+ f'
{name}
Size: {size_str}
Characters: {format_num(char_count)}'
)
# Set fixed width to force wrapping
self.setWordWrap(True)
@@ -258,6 +278,7 @@ class InputBox(QLabel):
else: # PDF - always use Pages
self.chapters_btn.setText(f"Pages ({chapter_count})")
+
# Hide textbox and show edit only for .txt files
self.textbox_btn.hide()
# Show edit button for txt files directly
@@ -273,12 +294,24 @@ class InputBox(QLabel):
self.edit_btn.setVisible(should_show_edit)
self.go_to_folder_btn.show()
+ # Enable add to queue button only when file is accepted (input box is green)
+ self.resizeEvent(None)
+ if hasattr(self.window(), 'btn_add_to_queue'):
+ self.window().btn_add_to_queue.setEnabled(True)
+
+ self.chapters_btn.adjustSize()
+ # Reset the input_box_cleared_by_queue flag after setting file info
+ if hasattr(self.window(), 'input_box_cleared_by_queue'):
+ self.window().input_box_cleared_by_queue = False
def set_error(self, message):
self.setText(message)
self.setStyleSheet(f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}")
# Show textbox button in error state as well
self.textbox_btn.show()
+ # Disable add to queue button on error
+ if hasattr(self.window(), 'btn_add_to_queue'):
+ self.window().btn_add_to_queue.setEnabled(False)
def clear_input(self):
self.window().selected_file = None
@@ -296,6 +329,12 @@ class InputBox(QLabel):
self.textbox_btn.show()
self.edit_btn.hide()
self.go_to_folder_btn.hide()
+ # Disable add to queue button when input is cleared
+ if hasattr(self.window(), 'btn_add_to_queue'):
+ self.window().btn_add_to_queue.setEnabled(False)
+ # Reset the input_box_cleared_by_queue flag after setting file info
+ if hasattr(self.window(), 'input_box_cleared_by_queue'):
+ self.window().input_box_cleared_by_queue = True
def _human_readable_size(self, size, decimal_places=2):
for unit in ["B", "KB", "MB", "GB", "TB"]:
@@ -310,15 +349,38 @@ class InputBox(QLabel):
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
- ext = event.mimeData().urls()[0].toLocalFile().lower()
- if ext.endswith(".txt") or ext.endswith(".epub") or ext.endswith(".pdf"):
- event.acceptProposedAction()
- return
+ urls = event.mimeData().urls()
+ if urls:
+ ext = urls[0].toLocalFile().lower()
+ if ext.endswith(".txt") or ext.endswith(".epub") or ext.endswith(".pdf"):
+ event.acceptProposedAction()
+ # Set hover style based on current state
+ if self.styleSheet().find(self.STYLE_ACTIVE) != -1:
+ self.setStyleSheet(f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }} {self.STYLE_ACTIVE_HOVER}")
+ elif self.styleSheet().find(self.STYLE_ERROR) != -1:
+ self.setStyleSheet(f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }} {self.STYLE_ERROR_HOVER}")
+ else:
+ self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }} {self.STYLE_DEFAULT_HOVER}")
+ return
event.ignore()
+ def dragLeaveEvent(self, event):
+ # Restore the style based on current state
+ if self.styleSheet().find(self.STYLE_ACTIVE) != -1:
+ self.setStyleSheet(f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}")
+ elif self.styleSheet().find(self.STYLE_ERROR) != -1:
+ self.setStyleSheet(f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}")
+ else:
+ self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}")
+ event.accept()
+
def dropEvent(self, event):
if event.mimeData().hasUrls():
- file_path = event.mimeData().urls()[0].toLocalFile()
+ urls = event.mimeData().urls()
+ if not urls:
+ event.ignore()
+ return
+ file_path = urls[0].toLocalFile()
win = self.window()
if file_path.lower().endswith(".txt"):
win.selected_file, win.selected_file_type = file_path, "txt"
@@ -515,7 +577,7 @@ class TextboxDialog(QDialog):
def insert_chapter_marker(self):
# Insert a fixed chapter marker without prompting
cursor = self.text_edit.textCursor()
- cursor.insertText("<>")
+ cursor.insertText("\n<>\n")
self.text_edit.setTextCursor(cursor)
self.update_char_count()
self.text_edit.setFocus()
@@ -547,6 +609,8 @@ class abogen(QWidget):
self.displayed_file_path = (
None # Add new variable to track the displayed file path
)
+ # Max log lines
+ self.log_window_max_lines = self.config.get("log_window_max_lines", 5000)
self.selected_chapters = set()
self.last_opened_book_path = None # Track the last opened book path
self.last_output_path = None
@@ -592,6 +656,13 @@ class abogen(QWidget):
if platform.system() == "Windows":
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("abogen")
+ # Queued items list
+ self.queued_items = []
+ self.current_queue_index = 0
+
+ # Log lines
+ self._log_lines = []
+
self.initUI()
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
self.update_speed_label()
@@ -605,7 +676,7 @@ class abogen(QWidget):
self.voice_combo.setCurrentIndex(idx)
# If a profile is selected at startup, load voices and language
if self.selected_profile_name:
- from voice_profiles import load_profiles
+ from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
if isinstance(entry, dict):
@@ -616,7 +687,7 @@ class abogen(QWidget):
self.selected_lang = entry[0][0] if entry and entry[0] else None
if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder)
- self.save_path_label.show()
+ self.save_path_row_widget.show()
self.subtitle_combo.setCurrentText(self.subtitle_mode)
# Enable/disable subtitle options based on selected language (profile or voice)
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
@@ -652,6 +723,26 @@ class abogen(QWidget):
container_layout.setSpacing(15)
self.input_box = InputBox(self)
container_layout.addWidget(self.input_box, 1)
+ # Manage queue button, start queue button
+ self.queue_row_widget = QWidget(self) # Make queue_row a QWidget
+ queue_row = QHBoxLayout(self.queue_row_widget)
+ queue_row.setContentsMargins(0, 0, 0, 0)
+ self.btn_add_to_queue = QPushButton("Add to Queue", self)
+ self.btn_add_to_queue.setFixedHeight(40)
+ self.btn_add_to_queue.setEnabled(False)
+ self.btn_add_to_queue.clicked.connect(self.add_to_queue)
+ queue_row.addWidget(self.btn_add_to_queue)
+ self.btn_manage_queue = QPushButton("Manage Queue", self)
+ self.btn_manage_queue.setFixedHeight(40)
+ self.btn_manage_queue.setEnabled(True)
+ self.btn_manage_queue.clicked.connect(self.manage_queue)
+ queue_row.addWidget(self.btn_manage_queue)
+ self.btn_clear_queue = QPushButton("Clear Queue", self)
+ self.btn_clear_queue.setFixedHeight(40)
+ self.btn_clear_queue.setEnabled(False)
+ self.btn_clear_queue.clicked.connect(self.clear_queue)
+ queue_row.addWidget(self.btn_clear_queue)
+ container_layout.addWidget(self.queue_row_widget)
self.log_text = QTextEdit(self)
self.log_text.setReadOnly(True)
self.log_text.setFrameStyle(QTextEdit.NoFrame)
@@ -678,10 +769,10 @@ class abogen(QWidget):
controls_layout.addLayout(speed_layout)
self.speed_slider.valueChanged.connect(self.update_speed_label)
# Voice selection
- voice_layout = QVBoxLayout()
+ voice_layout = QHBoxLayout()
voice_layout.setSpacing(7)
- voice_layout.addWidget(QLabel("Select Voice:", self))
- voice_row = QHBoxLayout()
+ voice_label = QLabel("Select voice:", self)
+ voice_layout.addWidget(voice_label)
self.voice_combo = QComboBox(self)
self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed)
self.voice_combo.setStyleSheet(
@@ -691,8 +782,8 @@ class abogen(QWidget):
"The first character represents the language:\n"
'"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)
-
+ self.voice_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ voice_layout.addWidget(self.voice_combo)
# Voice formula button
self.btn_voice_formula_mixer = QPushButton(self)
mixer_icon_path = get_resource_path("abogen.assets", "voice_mixer.png")
@@ -701,8 +792,7 @@ class abogen(QWidget):
self.btn_voice_formula_mixer.setFixedSize(40, 36)
self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }")
self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog)
- voice_row.addWidget(self.btn_voice_formula_mixer)
-
+ voice_layout.addWidget(self.btn_voice_formula_mixer)
# Play/Stop icons
def make_icon(color, shape):
pix = QPixmap(20, 20)
@@ -732,14 +822,16 @@ class abogen(QWidget):
self.btn_preview.setFixedSize(40, 36)
self.btn_preview.setStyleSheet("QPushButton { padding: 6px 12px; }")
self.btn_preview.clicked.connect(self.preview_voice)
- voice_row.addWidget(self.btn_preview)
+ voice_layout.addWidget(self.btn_preview)
self.preview_playing = False
self.play_audio_thread = None # Keep track of audio playing thread
- voice_layout.addLayout(voice_row)
controls_layout.addLayout(voice_layout)
- # Subtitle mode
- subtitle_layout = QVBoxLayout()
- subtitle_layout.addWidget(QLabel("Generate subtitles:", self))
+
+ # Generate subtitles
+ subtitle_layout = QHBoxLayout()
+ subtitle_layout.setSpacing(7)
+ subtitle_label = QLabel("Generate subtitles:", self)
+ subtitle_layout.addWidget(subtitle_label)
self.subtitle_combo = QComboBox(self)
self.subtitle_combo.setToolTip(
"Choose how subtitles will be generated:\n"
@@ -760,6 +852,7 @@ class abogen(QWidget):
self.subtitle_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }"
)
+ self.subtitle_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.subtitle_combo.setCurrentText(self.subtitle_mode)
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
# Enable/disable subtitle options based on selected language (profile or voice)
@@ -767,13 +860,17 @@ class abogen(QWidget):
self.subtitle_combo.setEnabled(enable)
subtitle_layout.addWidget(self.subtitle_combo)
controls_layout.addLayout(subtitle_layout)
- # Output format
- format_layout = QVBoxLayout()
- format_layout.addWidget(QLabel("Output Format:", self))
+
+ # Output voice format
+ format_layout = QHBoxLayout()
+ format_layout.setSpacing(7)
+ format_label = QLabel("Output voice format:", self)
+ format_layout.addWidget(format_label)
self.format_combo = QComboBox(self)
self.format_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }"
)
+ self.format_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
# Add items with display labels and underlying keys
for key, label in [
("wav", "wav"),
@@ -793,9 +890,61 @@ class abogen(QWidget):
)
format_layout.addWidget(self.format_combo)
controls_layout.addLayout(format_layout)
+
+ # Output subtitle format
+ subtitle_format_layout = QHBoxLayout()
+ subtitle_format_layout.setSpacing(7)
+ subtitle_format_label = QLabel("Output subtitle format:", self)
+ subtitle_format_layout.addWidget(subtitle_format_label)
+ self.subtitle_format_combo = QComboBox(self)
+ self.subtitle_format_combo.setStyleSheet(
+ "QComboBox { min-height: 20px; padding: 6px 12px; }"
+ )
+ self.subtitle_format_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ subtitle_formats = [
+ ("srt", "SRT (standard)"),
+ ("ass_wide", "ASS (wide)"),
+ ("ass_narrow", "ASS (narrow)"),
+ ("ass_centered_wide", "ASS (centered wide)"),
+ ("ass_centered_narrow", "ASS (centered narrow)")
+ ]
+ for value, text in subtitle_formats:
+ self.subtitle_format_combo.addItem(text, value)
+ subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow")
+ idx = self.subtitle_format_combo.findData(subtitle_format)
+ if idx >= 0:
+ self.subtitle_format_combo.setCurrentIndex(idx)
+ self.subtitle_format_combo.currentIndexChanged.connect(
+ lambda i: self.set_subtitle_format(self.subtitle_format_combo.itemData(i))
+ )
+ subtitle_format_layout.addWidget(self.subtitle_format_combo)
+ controls_layout.addLayout(subtitle_format_layout)
+
+ # Replace single newlines dropdown (acts like checkbox)
+ replace_newlines_layout = QHBoxLayout()
+ replace_newlines_layout.setSpacing(7)
+ replace_newlines_label = QLabel("Replace single newlines:", self)
+ replace_newlines_layout.addWidget(replace_newlines_label)
+ self.replace_newlines_combo = QComboBox(self)
+ self.replace_newlines_combo.addItems(["Disabled", "Enabled"])
+ self.replace_newlines_combo.setToolTip("Replace single newlines in the input text with spaces before processing.")
+ self.replace_newlines_combo.setStyleSheet(
+ "QComboBox { min-height: 20px; padding: 6px 12px; }"
+ )
+ self.replace_newlines_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
+ # Set initial value based on config
+ self.replace_newlines_combo.setCurrentIndex(1 if self.replace_single_newlines else 0)
+ self.replace_newlines_combo.currentIndexChanged.connect(
+ lambda idx: self.toggle_replace_single_newlines(idx == 1)
+ )
+ replace_newlines_layout.addWidget(self.replace_newlines_combo)
+ controls_layout.addLayout(replace_newlines_layout)
+
# Save location
- save_layout = QVBoxLayout()
- save_layout.addWidget(QLabel("Save Location:", self))
+ save_layout = QHBoxLayout()
+ save_layout.setSpacing(7)
+ save_label = QLabel("Save location:", self)
+ save_layout.addWidget(save_label)
self.save_combo = QComboBox(self)
save_options = [
"Save next to input file",
@@ -806,13 +955,28 @@ class abogen(QWidget):
self.save_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }"
)
+ self.save_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.save_combo.setCurrentText(self.save_option)
self.save_combo.currentTextChanged.connect(self.on_save_option_changed)
save_layout.addWidget(self.save_combo)
- self.save_path_label = QLabel("", self)
- self.save_path_label.hide()
- save_layout.addWidget(self.save_path_label)
controls_layout.addLayout(save_layout)
+
+ # Save path label
+ self.save_path_row_widget = QWidget(self)
+ save_path_row = QHBoxLayout(self.save_path_row_widget)
+ save_path_row.setSpacing(7)
+ save_path_row.setContentsMargins(0, 0, 0, 0)
+ selected_folder_label = QLabel("Selected folder:", self.save_path_row_widget)
+ save_path_row.addWidget(selected_folder_label)
+ self.save_path_label = QLabel("", self.save_path_row_widget)
+ self.save_path_label.setStyleSheet(
+ f"QLabel {{ color: {COLORS['GREEN']}; }}"
+ )
+ self.save_path_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+ save_path_row.addWidget(self.save_path_label)
+ self.save_path_row_widget.hide() # Hide the whole row by default
+ controls_layout.addWidget(self.save_path_row_widget)
+
# GPU Acceleration Checkbox with Settings button
gpu_layout = QHBoxLayout()
gpu_checkbox_layout = QVBoxLayout()
@@ -825,6 +989,12 @@ class abogen(QWidget):
gpu_checkbox_layout.addWidget(self.gpu_checkbox)
gpu_layout.addLayout(gpu_checkbox_layout)
+ # Set initial enabled state for subtitle format combo
+ if self.subtitle_mode == "Disabled":
+ self.subtitle_format_combo.setEnabled(False)
+ else:
+ self.subtitle_format_combo.setEnabled(True)
+
# Settings button with icon
settings_icon_path = get_resource_path("abogen.assets", "settings.svg")
self.settings_btn = QPushButton(self)
@@ -901,6 +1071,9 @@ class abogen(QWidget):
self.setLayout(outer_layout)
self.populate_profiles_in_voice_combo()
+ # Initialize flag to track if input box was cleared by queue
+ self.input_box_cleared_by_queue = False
+
def open_file_dialog(self):
if self.is_converting:
return
@@ -1014,7 +1187,7 @@ class abogen(QWidget):
else:
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
os.makedirs(temp_dir, exist_ok=True)
-
+
fd, tmp = tempfile.mkstemp(
prefix=f"{base_name}_", suffix=".txt", dir=temp_dir
)
@@ -1130,7 +1303,7 @@ class abogen(QWidget):
if isinstance(data, str) and data.startswith("profile:"):
pname = data.split(":", 1)[1]
self.selected_profile_name = pname
- from voice_profiles import load_profiles
+ from abogen.voice_profiles import load_profiles
entry = load_profiles().get(pname, {})
# set mixed voices and language
@@ -1163,7 +1336,7 @@ class abogen(QWidget):
self.subtitle_combo.setEnabled(False)
def update_subtitle_combo_for_profile(self, profile_name):
- from voice_profiles import load_profiles
+ from abogen.voice_profiles import load_profiles
entry = load_profiles().get(profile_name, {})
lang = entry.get("language") if isinstance(entry, dict) else None
@@ -1210,6 +1383,7 @@ class abogen(QWidget):
self.input_box.hide()
self.log_text.show()
self.log_text.clear()
+ self._log_lines = []
QApplication.processEvents()
def restore_input_box(self):
@@ -1230,35 +1404,61 @@ class abogen(QWidget):
self._update_log_main_thread(message)
def _update_log_main_thread(self, message):
- sb = self.log_text.verticalScrollBar()
- prev_val = sb.value()
- at_bottom = prev_val == sb.maximum()
- # save current text cursor to preserve selection
- old_cursor = self.log_text.textCursor()
- # prepare html
+ if not hasattr(self, '_log_lines'):
+ self._log_lines = []
+
+ # Always produce a single HTML line (with color if tuple)
if isinstance(message, tuple):
text, spec = message
color = "green" if spec is True else ("red" if spec is False else spec)
- html = self.color_text(text, color)
+ html_line = self.color_text(text, color)
else:
- html = str(message).replace("\n", "
") + "
"
- # move cursor to end for insertion
- insert_cursor = self.log_text.textCursor()
- insert_cursor.movePosition(QTextCursor.End)
- self.log_text.setTextCursor(insert_cursor)
- self.log_text.insertHtml(html)
- # restore original cursor/selection
- self.log_text.setTextCursor(old_cursor)
- # restore scroll position
- sb.setValue(sb.maximum() if at_bottom else prev_val)
+ html_line = f"{str(message).replace(chr(10), '
')}
"
+
+ self._log_lines.append(html_line)
+
+ # Check if user is at bottom BEFORE inserting
+ sb = self.log_text.verticalScrollBar()
+ at_bottom = sb.value() >= sb.maximum() - 2
+
+ # Trim buffer if needed
+ if len(self._log_lines) > self.log_window_max_lines:
+ self._log_lines = self._log_lines[-self.log_window_max_lines:]
+ self.log_text.clear()
+ for line in self._log_lines:
+ self.log_text.insertHtml(line)
+ else:
+ self.log_text.insertHtml(html_line)
+
+ # If user was at bottom before, scroll to new bottom
+ if at_bottom:
+ sb.setValue(sb.maximum())
+
QApplication.processEvents()
+ def _get_queue_progress_format(self, value=None):
+ """Return the progress bar format string for queue mode."""
+ if hasattr(self, 'queued_items') and self.queued_items and hasattr(self, 'current_queue_index'):
+ N = self.current_queue_index + 1
+ M = len(self.queued_items)
+ percent = value if value is not None else self.progress_bar.value()
+ return f"{percent}% ({N}/{M})"
+ else:
+ percent = value if value is not None else self.progress_bar.value()
+ return f"{percent}%"
+
def update_progress(self, value, etr_str): # Add etr_str parameter
# Ensure progress doesn't exceed 99%
if value >= 100:
value = 99
self.progress_bar.setValue(value)
- self.progress_bar.setFormat("%p%") # Keep format as percentage only
+ # Show queue progress if in queue mode
+ if hasattr(self, 'queued_items') and self.queued_items and hasattr(self, 'current_queue_index'):
+ N = self.current_queue_index + 1
+ M = len(self.queued_items)
+ self.progress_bar.setFormat(f"{value}% ({N}/{M})")
+ else:
+ self.progress_bar.setFormat(f"{value}%")
self.etr_label.setText(
f"Estimated time remaining: {etr_str}"
) # Update ETR label
@@ -1271,7 +1471,178 @@ class abogen(QWidget):
self.progress_bar.repaint()
QApplication.processEvents()
- def start_conversion(self):
+ def enable_disable_queue_buttons(self):
+ enabled = bool(self.queued_items)
+ self.btn_clear_queue.setEnabled(enabled)
+ # Update Manage Queue button text with count
+ if enabled:
+ self.btn_manage_queue.setText(f"Manage Queue ({len(self.queued_items)})")
+ self.btn_manage_queue.setStyleSheet(
+ f"QPushButton {{ color: {COLORS['GREEN']}; }}"
+ )
+ else:
+ self.btn_manage_queue.setText("Manage Queue")
+ self.btn_manage_queue.setStyleSheet("")
+ # Change main Start button to 'Start queue' if queue has items
+ if enabled:
+ self.btn_start.setText(f"Start queue ({len(self.queued_items)})")
+ try:
+ self.btn_start.clicked.disconnect()
+ except Exception:
+ pass
+ self.btn_start.clicked.connect(self.start_queue)
+ else:
+ self.btn_start.setText("Start")
+ try:
+ self.btn_start.clicked.disconnect()
+ except Exception:
+ pass
+ self.btn_start.clicked.connect(self.start_conversion)
+
+ def enqueue(self, item : QueuedItem):
+ self.queued_items.append(item)
+ #self.update_log((f"Enqueued: {item.file_name}", True))
+ # enable start queue button, manage queue button
+ self.enable_disable_queue_buttons()
+
+ def get_queue(self):
+ return self.queued_items
+
+ def add_to_queue(self):
+ # Use the file currently displayed in the input box
+ file_to_queue = self.displayed_file_path if self.displayed_file_path else self.selected_file
+
+ if not file_to_queue:
+ self.input_box.set_error("Please add a file.")
+ return
+ actual_subtitle_mode = self.get_actual_subtitle_mode()
+ voice_formula = self.get_voice_formula()
+ selected_lang = self.get_selected_lang(voice_formula)
+
+ item_queue = QueuedItem(
+ file_name=file_to_queue,
+ lang_code=selected_lang,
+ speed=self.speed_slider.value() / 100.0,
+ voice=voice_formula,
+ save_option=self.save_option,
+ output_folder=self.selected_output_folder,
+ subtitle_mode=actual_subtitle_mode,
+ output_format=self.selected_format,
+ total_char_count=self.char_count,
+ replace_single_newlines=self.replace_single_newlines
+ )
+
+ # Prevent adding duplicate items to the queue
+ for queued_item in self.queued_items:
+ if (
+ queued_item.file_name == item_queue.file_name and
+ queued_item.lang_code == item_queue.lang_code and
+ queued_item.speed == item_queue.speed and
+ queued_item.voice == item_queue.voice and
+ queued_item.save_option == item_queue.save_option and
+ queued_item.output_folder == item_queue.output_folder and
+ queued_item.subtitle_mode == item_queue.subtitle_mode and
+ queued_item.output_format == item_queue.output_format and
+ getattr(queued_item, "replace_single_newlines", False) == item_queue.replace_single_newlines
+ ):
+ QMessageBox.warning(self, "Duplicate Item", "This item is already in the queue.")
+ return
+
+ self.enqueue(item_queue)
+ # Clear input after adding to queue
+ self.input_box.clear_input()
+ self.input_box_cleared_by_queue = True # Set flag
+ self.enable_disable_queue_buttons()
+
+ def clear_queue(self):
+ # Warn user if more than 1 item in the queue before clearing
+ if len(self.queued_items) > 1:
+ reply = QMessageBox.question(
+ self,
+ "Confirm Clear Queue",
+ f"Are you sure you want to clear {len(self.queued_items)} items from the queue?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if reply != QMessageBox.Yes:
+ return
+ self.queued_items = []
+ self.enable_disable_queue_buttons()
+
+ def manage_queue(self):
+ # show a dialog to manage the queue
+ dialog = QueueManager(self, self.queued_items)
+ if dialog.exec_() == QDialog.Accepted:
+ self.queued_items = dialog.get_queue()
+ # re-enable/disable buttons based on queue state
+ self.enable_disable_queue_buttons()
+
+ def start_queue(self):
+ self.current_queue_index = 0 # Start from the first item
+ # Set progress bar to 0% (1/M) immediately
+ if self.queued_items:
+ self.progress_bar.setValue(0)
+ self.progress_bar.setFormat(f"0% (1/{len(self.queued_items)})")
+ self.progress_bar.show()
+ self.start_next_queued_item()
+
+ def start_next_queued_item(self):
+ if self.current_queue_index < len(self.queued_items):
+ queued_item = self.queued_items[self.current_queue_index]
+ self.selected_file = queued_item.file_name
+ self.selected_lang = queued_item.lang_code
+ self.speed_slider.setValue(int(queued_item.speed * 100))
+ self.selected_voice = queued_item.voice
+ self.save_option = queued_item.save_option
+ self.selected_output_folder = queued_item.output_folder
+ self.subtitle_mode = queued_item.subtitle_mode
+ self.selected_format = queued_item.output_format
+ self.char_count = queued_item.total_char_count
+ self.replace_single_newlines = getattr(queued_item, "replace_single_newlines", False)
+ self.start_conversion(from_queue=True)
+ else:
+ # Queue finished, reset index
+ self.current_queue_index = 0
+
+ def queue_item_conversion_finished(self):
+ # Called after each conversion finishes
+ self.current_queue_index += 1
+ if self.current_queue_index < len(self.queued_items):
+ self.start_next_queued_item()
+ else:
+ self.current_queue_index = 0 # Reset for next time
+
+
+ def get_voice_formula(self) -> str:
+ if self.mixed_voice_state:
+ formula_components = [
+ f"{name}*{weight}" for name, weight in self.mixed_voice_state
+ ]
+ return " + ".join(filter(None, formula_components))
+ else:
+ return self.selected_voice
+
+ def get_selected_lang(self, voice_formula) -> str:
+ if self.selected_profile_name:
+ from abogen.voice_profiles import load_profiles
+ entry = load_profiles().get(self.selected_profile_name, {})
+ selected_lang = entry.get("language")
+ else:
+ selected_lang = self.selected_voice[0] if self.selected_voice else None
+ # fallback: extract from formula if missing
+ if not selected_lang:
+ m = re.search(r"\b([a-z])", voice_formula)
+ selected_lang = m.group(1) if m else None
+ return selected_lang
+
+ def get_actual_subtitle_mode(self) -> str:
+ return (
+ "Disabled"
+ if not self.subtitle_combo.isEnabled()
+ else self.subtitle_mode
+ )
+
+ def start_conversion(self, from_queue=False):
if not self.selected_file:
self.input_box.set_error("Please add a file.")
return
@@ -1279,9 +1650,16 @@ class abogen(QWidget):
self.is_converting = True
self.convert_input_box_to_log()
self.progress_bar.setValue(0)
- self.progress_bar.setFormat("%p%") # Reset format initially
+ # Show queue progress if in queue mode
+ if from_queue and hasattr(self, 'queued_items') and self.queued_items and hasattr(self, 'current_queue_index'):
+ N = self.current_queue_index + 1
+ M = len(self.queued_items)
+ self.progress_bar.setFormat(f"0% ({N}/{M})")
+ else:
+ self.progress_bar.setFormat("%p%") # Reset format initially
self.etr_label.hide() # Hide ETR label initially
self.controls_widget.hide()
+ self.queue_row_widget.hide() # Hide queue row when process starts
self.progress_bar.show()
self.btn_cancel.show()
QApplication.processEvents()
@@ -1313,32 +1691,12 @@ class abogen(QWidget):
self.btn_cancel.setEnabled(True)
# Override subtitle_mode to "Disabled" if subtitle_combo is disabled
- actual_subtitle_mode = (
- "Disabled"
- if not self.subtitle_combo.isEnabled()
- else self.subtitle_mode
- )
+ actual_subtitle_mode = self.get_actual_subtitle_mode()
# if voice formula is not None, use the selected voice
- if self.mixed_voice_state:
- formula_components = [
- f"{name}*{weight}" for name, weight in self.mixed_voice_state
- ]
- voice_formula = " + ".join(filter(None, formula_components))
- else:
- voice_formula = self.selected_voice
+ voice_formula = self.get_voice_formula()
# determine selected language: use profile setting if profile selected, else voice code
- if self.selected_profile_name:
- from voice_profiles import load_profiles
-
- entry = load_profiles().get(self.selected_profile_name, {})
- selected_lang = entry.get("language")
- else:
- selected_lang = self.selected_voice[0] if self.selected_voice else None
- # fallback: extract from formula if missing
- if not selected_lang:
- m = re.search(r"\b([a-z])", voice_formula)
- selected_lang = m.group(1) if m else None
+ selected_lang = self.get_selected_lang(voice_formula)
self.conversion_thread = ConversionThread(
self.selected_file,
@@ -1354,6 +1712,7 @@ class abogen(QWidget):
start_time=self.start_time,
total_char_count=self.char_count,
use_gpu=self.gpu_ok,
+ from_queue=from_queue,
) # Use gpu_ok status
# Pass the displayed file path to the log_updated signal handler in ConversionThread
self.conversion_thread.display_path = display_path
@@ -1408,7 +1767,56 @@ class abogen(QWidget):
load_thread = LoadPipelineThread(pipeline_loaded_callback)
load_thread.start()
- Thread(target=gpu_and_load, daemon=True).start()
+ threading.Thread(target=gpu_and_load, daemon=True).start()
+
+ def show_queue_summary(self):
+ """Show a styled, resizable summary dialog after queue finishes."""
+ if not self.queued_items:
+ return
+
+ # Build HTML summary for better styling
+ summary_html = ""
+ summary_html += (
+ f"Queue finished
"
+ f"Processed {len(self.queued_items)} items:
"
+ )
+
+
+ for idx, item in enumerate(self.queued_items, 1):
+ output = getattr(item, "output_path", None)
+ if not output:
+ output = "Unknown"
+ summary_html += (
+ f"{idx}) {os.path.basename(item.file_name)}
"
+ f"Language: {item.lang_code}
"
+ f"Voice: {item.voice}
"
+ f"Speed: {item.speed * 100:.1f}%
"
+ f"Characters: {item.total_char_count}
"
+ f"Input: {item.file_name}
"
+ f"Output: {output}"
+ f"
"
+ )
+ summary_html += ""
+
+ dialog = QDialog(self)
+ dialog.setWindowTitle("Queue Summary")
+ dialog.resize(550, 650) # Make window resizable and larger
+
+ layout = QVBoxLayout(dialog)
+ text_edit = QTextEdit(dialog)
+ text_edit.setReadOnly(True)
+ text_edit.setHtml(summary_html)
+ layout.addWidget(text_edit)
+
+ close_btn = QPushButton("Close", dialog)
+ close_btn.setFixedHeight(36)
+ close_btn.clicked.connect(dialog.accept)
+ layout.addWidget(close_btn)
+
+ dialog.setLayout(layout)
+ dialog.setMinimumSize(400, 300)
+ dialog.setSizeGripEnabled(True) # Allow resizing
+ dialog.exec_()
def on_conversion_finished(self, message, output_path):
prevent_sleep_end()
@@ -1425,17 +1833,23 @@ class abogen(QWidget):
if self.displayed_file_path
else self.selected_file
)
- # Check if the file exists before trying to set file info
- if display_path and os.path.exists(display_path):
- self.input_box.set_file_info(display_path)
+ # Only repopulate if not cleared by queue
+ if not getattr(self, 'input_box_cleared_by_queue', False):
+ if display_path and os.path.exists(display_path):
+ self.input_box.set_file_info(display_path)
+ else:
+ self.input_box.clear_input()
else:
- # Clear the input if the file no longer exists
self.input_box.clear_input()
return
self.update_log(message)
if output_path:
self.last_output_path = output_path
+ # Store output_path in the current queued item if in queue mode
+ if self.queued_items and self.current_queue_index < len(self.queued_items):
+ self.queued_items[self.current_queue_index].output_path = output_path
+
self.etr_label.hide() # Hide ETR label
self.progress_bar.setValue(100)
self.progress_bar.hide()
@@ -1458,16 +1872,28 @@ class abogen(QWidget):
if self.open_file_btn:
self.open_file_btn.setVisible(show_open_file_button)
- self.controls_widget.hide()
- self.finish_widget.show()
- self.log_text.moveCursor(QTextCursor.End)
- self.log_text.ensureCursorVisible()
- save_config(self.config)
+ # Only show finish_widget if queue is done
+ if self.current_queue_index + 1 >= len(self.queued_items) or not self.queued_items:
+ # Queue finished, show finish screen
+ self.controls_widget.hide()
+ self.finish_widget.show()
+ sb = self.log_text.verticalScrollBar()
+ sb.setValue(sb.maximum())
+ save_config(self.config)
+ # Show queue summary if more than one item
+ if len(self.queued_items) > 1:
+ self.show_queue_summary()
+ else:
+ # More items in queue: clear log and reload for next item
+ self.log_text.clear()
+ self._log_lines = []
+ QApplication.processEvents()
+
+ # Start new queued item, if we're using a queued conversion
+ self.queue_item_conversion_finished()
def reset_ui(self):
try:
- self.restore_input_box()
- self.input_box.clear_input() # Reset text and style
self.etr_label.hide() # Hide ETR label
self.progress_bar.setValue(0)
self.progress_bar.hide()
@@ -1480,6 +1906,7 @@ class abogen(QWidget):
if self.open_file_btn:
self.open_file_btn.show()
self.controls_widget.show()
+ self.queue_row_widget.show() # Show queue row on reset
self.finish_widget.hide()
self.btn_start.setText("Start")
# Disconnect only if connected, then reconnect
@@ -1488,25 +1915,35 @@ class abogen(QWidget):
except TypeError:
pass # Ignore error if not connected
self.btn_start.clicked.connect(self.start_conversion)
+ self.enable_disable_queue_buttons()
+ self.restore_input_box()
+ self.input_box.clear_input() # Reset text and style
+ # Trigger the "Clear Queue" button (simulate user click)
+ self.btn_clear_queue.click()
except Exception as e:
self._show_error_message_box("Reset Error", f"Could not reset UI:\n{e}")
def go_back_ui(self):
self.finish_widget.hide()
self.controls_widget.show()
+ self.queue_row_widget.show() # Show queue row on go back
self.progress_bar.hide()
self.restore_input_box()
+ self.log_text.clear()
+ self._log_lines = []
# Use displayed_file_path instead of selected_file for EPUBs or PDFs
display_path = (
self.displayed_file_path if self.displayed_file_path else self.selected_file
)
- # Check if the file exists before trying to set file info
- if display_path and os.path.exists(display_path):
- self.input_box.set_file_info(display_path)
+ # Only repopulate if not cleared by queue
+ if not getattr(self, 'input_box_cleared_by_queue', False):
+ if display_path and os.path.exists(display_path):
+ self.input_box.set_file_info(display_path)
+ else:
+ self.input_box.clear_input()
else:
- # Clear the input if the file no longer exists
self.input_box.clear_input()
# Ensure open file button is visible when going back
@@ -1524,7 +1961,7 @@ class abogen(QWidget):
if folder:
self.selected_output_folder = folder
self.save_path_label.setText(folder)
- self.save_path_label.show()
+ self.save_path_row_widget.show()
self.config["selected_output_folder"] = folder
else:
self.save_option = "Save next to input file"
@@ -1538,7 +1975,7 @@ class abogen(QWidget):
self.save_combo.setCurrentText(self.save_option)
self.config["save_option"] = self.save_option
else:
- self.save_path_label.hide()
+ self.save_path_row_widget.hide()
self.selected_output_folder = None
self.config["selected_output_folder"] = None
save_config(self.config)
@@ -1593,7 +2030,7 @@ class abogen(QWidget):
voice_formula = " + ".join(filter(None, components))
voice_to_cache = voice_formula
if self.selected_profile_name:
- from voice_profiles import load_profiles
+ from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
lang_to_cache = entry.get("language")
else:
@@ -1727,7 +2164,7 @@ class abogen(QWidget):
voice = " + ".join(filter(None, components))
# determine language: use profile setting, else explicit mixer selection, else fallback to first voice code
if self.selected_profile_name:
- from voice_profiles import load_profiles
+ from abogen.voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
lang = entry.get("language")
@@ -1762,7 +2199,7 @@ class abogen(QWidget):
if cached_path and os.path.exists(cached_path):
temp_wav = cached_path
else: # Should not happen if cache check was done
- self._show_error_message_box("Preview Error", "Cache file expected but not found.")
+ self._show_error_message_box("Preview Error", "Cache file expected but not found, please try again.")
self._preview_cleanup()
return
else: # Should have temp_wav from preview_thread or handled by cache check
@@ -1862,30 +2299,37 @@ class abogen(QWidget):
hasattr(self, "conversion_thread")
and self.conversion_thread.isRunning()
):
- self.conversion_thread.cancel()
+ if not hasattr(self, "_conversion_lock"):
+ self._conversion_lock = threading.Lock()
+ def _cancel():
+ with self._conversion_lock:
+ self.conversion_thread.cancel() # <-- Use cancel() method
+ self.conversion_thread.wait()
+ threading.Thread(target=_cancel, daemon=True).start()
+
self.is_converting = False
self.etr_label.hide() # Hide ETR label
self.progress_bar.hide()
self.btn_cancel.hide()
self.controls_widget.show()
+ self.queue_row_widget.show() # Show queue row on cancel
self.finish_widget.hide()
self.restore_input_box()
+ self.log_text.clear()
+ self._log_lines = []
display_path = (
self.displayed_file_path
if self.displayed_file_path
else self.selected_file
)
- # Check if the file exists before trying to set file info
- if display_path and os.path.exists(display_path):
- self.input_box.set_file_info(display_path)
+ # Only repopulate if not cleared by queue
+ if not getattr(self, 'input_box_cleared_by_queue', False):
+ if display_path and os.path.exists(display_path):
+ self.input_box.set_file_info(display_path)
+ else:
+ self.input_box.clear_input()
else:
- # Clear the input if the file no longer exists
self.input_box.clear_input()
- if (
- hasattr(self, "conversion_thread")
- and self.conversion_thread.isRunning()
- ):
- self.conversion_thread.wait()
prevent_sleep_end()
except Exception as e:
self._show_error_message_box(
@@ -1896,6 +2340,11 @@ class abogen(QWidget):
self.subtitle_mode = mode
self.config["subtitle_mode"] = mode
save_config(self.config)
+ # Disable subtitle format combo if subtitles are disabled
+ if mode == "Disabled":
+ self.subtitle_format_combo.setEnabled(False)
+ else:
+ self.subtitle_format_combo.setEnabled(True)
def on_format_changed(self, fmt):
self.selected_format = fmt
@@ -1932,7 +2381,7 @@ class abogen(QWidget):
def show_chapter_options_dialog(self, chapter_count):
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
- from conversion import ChapterOptionsDialog
+ from abogen.conversion import ChapterOptionsDialog
dialog = ChapterOptionsDialog(chapter_count, parent=self)
dialog.setWindowModality(Qt.ApplicationModal)
@@ -2103,45 +2552,6 @@ class abogen(QWidget):
menu.addMenu(theme_menu)
- # Add replace single newlines option
- newline_action = QAction("Replace single newlines with spaces", self)
- newline_action.setCheckable(True)
- newline_action.setChecked(self.replace_single_newlines)
- newline_action.triggered.connect(self.toggle_replace_single_newlines)
- menu.addAction(newline_action)
-
- # Add max words per subtitle option
- max_words_action = QAction("Configure max words per subtitle", self)
- max_words_action.triggered.connect(self.set_max_subtitle_words)
- menu.addAction(max_words_action)
-
- # Add subtitle format option
- subtitle_format_menu = QMenu("Subtitle format", self)
- subtitle_format_menu.setToolTip("Choose the format for generated subtitles")
-
- subtitle_format_group = QActionGroup(self)
- subtitle_format_group.setExclusive(True)
-
- # Define mapping: (internal_value, display_text)
- subtitle_formats = [
- ("srt", "SRT (standard)"),
- ("ass_wide", "ASS (wide)"),
- ("ass_narrow", "ASS (narrow)"),
- ("ass_centered_wide", "ASS (centered wide)"),
- ("ass_centered_narrow", "ASS (centered narrow)"),
- ]
-
- subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow")
- for value, text in subtitle_formats:
- format_action = QAction(text, self)
- format_action.setCheckable(True)
- format_action.setChecked(subtitle_format == value)
- format_action.triggered.connect(lambda checked, fmt=value: self.set_subtitle_format(fmt))
- subtitle_format_group.addAction(format_action)
- subtitle_format_menu.addAction(format_action)
-
- menu.addMenu(subtitle_format_menu)
-
# Add separate chapters format option
separate_chapters_format_menu = QMenu("Separate chapters audio format", self)
separate_chapters_format_menu.setToolTip("Choose the format for individual chapter files")
@@ -2159,6 +2569,15 @@ class abogen(QWidget):
menu.addMenu(separate_chapters_format_menu)
+ # Add max words per subtitle option
+ max_words_action = QAction("Configure max words per subtitle", self)
+ max_words_action.triggered.connect(self.set_max_subtitle_words)
+ menu.addAction(max_words_action)
+
+ max_lines_action = QAction("Configure max lines in log window", self)
+ max_lines_action.triggered.connect(self.set_max_log_lines)
+ menu.addAction(max_lines_action)
+
# Add separator
menu.addSeparator()
@@ -2202,14 +2621,14 @@ class abogen(QWidget):
menu.exec_(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height())))
- def toggle_replace_single_newlines(self, checked):
- self.replace_single_newlines = checked
- self.config["replace_single_newlines"] = checked
+ def toggle_replace_single_newlines(self, enabled):
+ self.replace_single_newlines = enabled
+ self.config["replace_single_newlines"] = enabled
save_config(self.config)
def reveal_config_in_explorer(self):
"""Open the configuration file location in file explorer."""
- from utils import get_user_config_path
+ from abogen.utils import get_user_config_path
try:
config_path = get_user_config_path()
@@ -2241,7 +2660,7 @@ class abogen(QWidget):
"""Create a desktop shortcut to this program using PowerShell."""
import sys
from platformdirs import user_desktop_dir
- from utils import create_process
+ from abogen.utils import create_process
try:
if platform.system() == "Windows":
@@ -2376,7 +2795,7 @@ Categories=AudioVideo;Audio;Utility;
save_config(self.config)
def show_voice_formula_dialog(self):
- from voice_profiles import load_profiles
+ from abogen.voice_profiles import load_profiles
profiles = load_profiles()
initial_state = None
@@ -2401,8 +2820,6 @@ Categories=AudioVideo;Audio;Utility;
initial_state = entry
else:
initial_state = []
- else:
- initial_state = []
dialog = VoiceFormulaDialog(
self, initial_state=initial_state, selected_profile=selected_profile
)
@@ -2657,6 +3074,29 @@ Categories=AudioVideo;Audio;Utility;
self, "Error", f"An error occurred while clearing temporary files:\n{e}"
)
+ def set_max_log_lines(self):
+ """Open a dialog to set the maximum lines in the log window."""
+ from PyQt5.QtWidgets import QInputDialog
+
+ value, ok = QInputDialog.getInt(
+ self,
+ "Max Lines in Log Window",
+ "Enter the maximum number of lines to display in the log window:",
+ self.log_window_max_lines,
+ 10, # min value
+ 999999999, # max value
+ 1, # step
+ )
+ if ok:
+ self.log_window_max_lines = value
+ self.config["log_window_max_lines"] = value
+ save_config(self.config)
+ QMessageBox.information(
+ self,
+ "Setting Saved",
+ f"Maximum lines in log window set to {value}.",
+ )
+
def set_max_subtitle_words(self):
"""Open a dialog to set the maximum words per subtitle"""
from PyQt5.QtWidgets import QInputDialog
diff --git a/abogen/main.py b/abogen/main.py
index 2a311a9..d1d4a41 100644
--- a/abogen/main.py
+++ b/abogen/main.py
@@ -7,9 +7,9 @@ from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
# Add the directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
-from gui import abogen
-from utils import get_resource_path
-from constants import PROGRAM_NAME, VERSION
+from abogen.gui import abogen
+from abogen.utils import get_resource_path
+from abogen.constants import PROGRAM_NAME, VERSION
# Set environment variables for AMD ROCm
os.environ["MIOPEN_FIND_MODE"] = "FAST"
diff --git a/abogen/queue_manager_gui.py b/abogen/queue_manager_gui.py
new file mode 100644
index 0000000..8e9ab1d
--- /dev/null
+++ b/abogen/queue_manager_gui.py
@@ -0,0 +1,491 @@
+# a simple window with a list of items in the queue, no checkboxes
+# button to remove an item from the queue
+# button to clear the queue
+
+from PyQt5.QtWidgets import (
+ QDialog,
+ QVBoxLayout,
+ QHBoxLayout,
+ QDialogButtonBox,
+ QPushButton,
+ QListWidget,
+ QListWidgetItem,
+ QFileIconProvider,
+ QLabel,
+ QWidget,
+ QSizePolicy,
+)
+from PyQt5.QtCore import QFileInfo, Qt
+from abogen.constants import COLORS
+from copy import deepcopy
+from PyQt5.QtGui import QFontMetrics
+
+class ElidedLabel(QLabel):
+ def __init__(self, text, parent=None):
+ super().__init__(text, parent)
+ self._full_text = text
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
+
+ def setText(self, text):
+ self._full_text = text
+ super().setText(text)
+ self.update()
+
+ def resizeEvent(self, event):
+ metrics = QFontMetrics(self.font())
+ elided = metrics.elidedText(self._full_text, Qt.ElideRight, self.width())
+ super().setText(elided)
+ super().resizeEvent(event)
+
+ def fullText(self):
+ return self._full_text
+
+class QueueListItemWidget(QWidget):
+ def __init__(self, file_name, char_count):
+ super().__init__()
+ layout = QHBoxLayout()
+ layout.setContentsMargins(12, 0, 6, 0)
+ layout.setSpacing(0)
+ import os
+ 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)
+ layout.addWidget(name_label, 1)
+ layout.addWidget(char_label, 0)
+ self.setLayout(layout)
+
+class DroppableQueueListWidget(QListWidget):
+ def __init__(self, parent_dialog):
+ super().__init__()
+ self.parent_dialog = parent_dialog
+ self.setAcceptDrops(True)
+ # Overlay for drag hover
+ self.drag_overlay = QLabel("", self)
+ self.drag_overlay.setAlignment(Qt.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)
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ for url in event.mimeData().urls():
+ if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'):
+ self.drag_overlay.resize(self.size())
+ self.drag_overlay.setVisible(True)
+ event.acceptProposedAction()
+ return
+ self.drag_overlay.setVisible(False)
+ event.ignore()
+
+ def dragMoveEvent(self, event):
+ if event.mimeData().hasUrls():
+ for url in event.mimeData().urls():
+ if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'):
+ event.acceptProposedAction()
+ return
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ self.drag_overlay.setVisible(False)
+ event.accept()
+
+ def dropEvent(self, event):
+ self.drag_overlay.setVisible(False)
+ if event.mimeData().hasUrls():
+ file_paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt')]
+ if file_paths:
+ self.parent_dialog.add_files_from_paths(file_paths)
+ event.acceptProposedAction()
+ else:
+ event.ignore()
+ else:
+ event.ignore()
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ if hasattr(self, 'drag_overlay'):
+ self.drag_overlay.resize(self.size())
+
+class QueueManager(QDialog):
+ def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)):
+ super().__init__()
+ self.queue = queue
+ self._original_queue = deepcopy(queue) # Store a deep copy of the original queue
+ self.parent = parent
+ layout = QVBoxLayout()
+ layout.setContentsMargins(15, 15, 15, 15) # set main layout margins
+ 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.setAlternatingRowColors(True)
+ self.listwidget.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.listwidget.customContextMenuRequested.connect(self.show_context_menu)
+ # Add informative instructions at the top
+ instructions = QLabel(
+ "How Queue Works?
"
+ "You can add text files (.txt) directly using the 'Add files' button below. "
+ "To add PDF or EPUB files, use the input box in the main window and click the 'Add to Queue' button. "
+ "Each file in the queue keeps the configuration settings active when it was added. "
+ "Changing the main window configuration afterward does not affect files already in the queue. "
+ "You can view each file's configuration by hovering over them."
+ )
+ instructions.setAlignment(Qt.AlignLeft)
+ instructions.setWordWrap(True)
+ instructions.setStyleSheet("margin-bottom: 8px;")
+ layout.addWidget(instructions)
+ # Overlay label for empty queue
+ self.empty_overlay = QLabel(
+ "Drag and drop your text files here or use the 'Add files' button.",
+ self.listwidget
+ )
+ self.empty_overlay.setAlignment(Qt.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.hide()
+ # add queue items to the list
+ self.process_queue()
+
+ button_row = QHBoxLayout()
+ button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row
+ button_row.setSpacing(7) # set spacing between buttons
+ # Add files button
+ add_files_button = QPushButton("Add files")
+ add_files_button.setFixedHeight(40)
+ add_files_button.clicked.connect(self.add_more_files)
+ button_row.addWidget(add_files_button)
+
+ # Remove button
+ self.remove_button = QPushButton("Remove selected")
+ self.remove_button.setFixedHeight(40)
+ self.remove_button.clicked.connect(self.remove_item)
+ button_row.addWidget(self.remove_button)
+
+ # Clear button
+ self.clear_button = QPushButton("Clear Queue")
+ self.clear_button.setFixedHeight(40)
+ self.clear_button.clicked.connect(self.clear_queue)
+ button_row.addWidget(self.clear_button)
+
+ layout.addLayout(button_row)
+ layout.addWidget(self.listwidget)
+
+ # Connect selection change to update button state
+ self.listwidget.currentItemChanged.connect(self.update_button_states)
+ self.listwidget.itemSelectionChanged.connect(self.update_button_states)
+
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+
+ layout.addWidget(buttons)
+
+ self.setLayout(layout)
+
+ self.setWindowTitle(title)
+ self.resize(*size)
+
+ self.update_button_states()
+
+ def process_queue(self):
+ """Process the queue items."""
+ self.listwidget.clear()
+ if not self.queue:
+ self.empty_overlay.resize(self.listwidget.size())
+ self.empty_overlay.show()
+ self.update_button_states()
+ return
+ else:
+ self.empty_overlay.hide()
+ icon_provider = QFileIconProvider()
+ for item in self.queue:
+ # Only show the file name, not the full path
+ file_name = item.file_name
+ display_name = file_name
+ import os
+ if os.path.sep in file_name:
+ display_name = os.path.basename(file_name)
+ # Get icon for the file
+ icon = icon_provider.icon(QFileInfo(file_name))
+ list_item = QListWidgetItem()
+ # Set tooltip with detailed info
+ output_folder = getattr(item, 'output_folder', '')
+ tooltip = (
+ f"Path: {file_name}
"
+ f"Language: {getattr(item, 'lang_code', '')}
"
+ f"Speed: {getattr(item, 'speed', '')}
"
+ f"Voice: {getattr(item, 'voice', '')}
"
+ f"Save Option: {getattr(item, 'save_option', '')}
"
+ )
+ if output_folder not in (None, '', 'None'):
+ tooltip += f"Output Folder: {output_folder}
"
+ tooltip += (
+ f"Subtitle Mode: {getattr(item, 'subtitle_mode', '')}
"
+ f"Output Format: {getattr(item, 'output_format', '')}
"
+ f"Characters: {getattr(item, 'total_char_count', '')}
"
+ f"Replace Single Newlines: {getattr(item, 'replace_single_newlines', False)}"
+ )
+ list_item.setToolTip(tooltip)
+ list_item.setIcon(icon)
+ # Use custom widget for display
+ char_count = getattr(item, 'total_char_count', 0)
+ widget = QueueListItemWidget(file_name, char_count)
+ self.listwidget.addItem(list_item)
+ self.listwidget.setItemWidget(list_item, widget)
+ self.update_button_states()
+
+ def remove_item(self):
+ items = self.listwidget.selectedItems()
+ if not items:
+ return
+ from PyQt5.QtWidgets import QMessageBox
+ # Remove by index to ensure correct mapping
+ rows = sorted([self.listwidget.row(item) for item in items], reverse=True)
+ # Warn user if removing multiple files
+ if len(rows) > 1:
+ reply = QMessageBox.question(
+ self,
+ "Confirm Remove",
+ f"Are you sure you want to remove {len(rows)} selected items from the queue?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if reply != QMessageBox.Yes:
+ return
+ for row in rows:
+ if 0 <= row < len(self.queue):
+ del self.queue[row]
+ self.process_queue()
+ self.update_button_states()
+
+ def clear_queue(self):
+ from PyQt5.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
+ )
+ if reply != QMessageBox.Yes:
+ return
+ self.queue.clear()
+ self.listwidget.clear()
+ self.empty_overlay.resize(self.listwidget.size()) # Ensure overlay is sized correctly
+ self.empty_overlay.show() # Show the overlay when queue is empty
+ self.update_button_states()
+
+ def get_queue(self):
+ return self.queue
+
+ def get_current_attributes(self):
+ # Fetch current attribute values from the parent abogen GUI
+ attrs = {}
+ parent = self.parent
+ if parent is not None:
+ # lang_code: use parent's get_voice_formula and get_selected_lang
+ if hasattr(parent, 'get_voice_formula') and hasattr(parent, 'get_selected_lang'):
+ voice_formula = parent.get_voice_formula()
+ attrs['lang_code'] = parent.get_selected_lang(voice_formula)
+ attrs['voice'] = voice_formula
+ else:
+ attrs['lang_code'] = getattr(parent, 'selected_lang', '')
+ attrs['voice'] = getattr(parent, 'selected_voice', '')
+ # speed
+ if hasattr(parent, 'speed_slider'):
+ attrs['speed'] = parent.speed_slider.value() / 100.0
+ else:
+ attrs['speed'] = getattr(parent, 'speed', 1.0)
+ # save_option
+ attrs['save_option'] = getattr(parent, 'save_option', '')
+ # output_folder
+ attrs['output_folder'] = getattr(parent, 'selected_output_folder', '')
+ # subtitle_mode
+ if hasattr(parent, 'get_actual_subtitle_mode'):
+ attrs['subtitle_mode'] = parent.get_actual_subtitle_mode()
+ else:
+ attrs['subtitle_mode'] = getattr(parent, 'subtitle_mode', '')
+ # output_format
+ attrs['output_format'] = getattr(parent, 'selected_format', '')
+ # total_char_count
+ attrs['total_char_count'] = getattr(parent, 'char_count', '')
+ # replace_single_newlines
+ attrs['replace_single_newlines'] = getattr(parent, 'replace_single_newlines', False)
+ else:
+ # fallback: empty values
+ attrs = {k: '' for k in [
+ 'lang_code', 'speed', 'voice', 'save_option',
+ 'output_folder', 'subtitle_mode', 'output_format', 'total_char_count', 'replace_single_newlines']}
+ return attrs
+
+ def add_files_from_paths(self, file_paths):
+ from abogen.utils import calculate_text_length
+ from PyQt5.QtWidgets import QMessageBox
+ import os
+ current_attrs = self.get_current_attributes()
+ duplicates = []
+ for file_path in file_paths:
+ class QueueItem:
+ pass
+ item = QueueItem()
+ item.file_name = file_path
+ for attr, value in current_attrs.items():
+ setattr(item, attr, value)
+ # Read file content and calculate total_char_count using calculate_text_length
+ try:
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
+ file_content = f.read()
+ item.total_char_count = calculate_text_length(file_content)
+ except Exception:
+ item.total_char_count = 0
+ # Prevent adding duplicate items to the queue (check all attributes)
+ is_duplicate = False
+ for queued_item in self.queue:
+ if (
+ getattr(queued_item, 'file_name', None) == getattr(item, 'file_name', None) and
+ getattr(queued_item, 'lang_code', None) == getattr(item, 'lang_code', None) and
+ getattr(queued_item, 'speed', None) == getattr(item, 'speed', None) and
+ getattr(queued_item, 'voice', None) == getattr(item, 'voice', None) and
+ getattr(queued_item, 'save_option', None) == getattr(item, 'save_option', None) and
+ getattr(queued_item, 'output_folder', None) == getattr(item, 'output_folder', None) and
+ getattr(queued_item, 'subtitle_mode', None) == getattr(item, 'subtitle_mode', None) and
+ getattr(queued_item, 'output_format', None) == getattr(item, 'output_format', None) and
+ getattr(queued_item, 'total_char_count', None) == getattr(item, 'total_char_count', None) and
+ getattr(queued_item, 'replace_single_newlines', False) == getattr(item, 'replace_single_newlines', False)
+ ):
+ is_duplicate = True
+ break
+ if is_duplicate:
+ duplicates.append(os.path.basename(file_path))
+ continue
+ self.queue.append(item)
+ if duplicates:
+ QMessageBox.warning(
+ self,
+ "Duplicate Item(s)",
+ f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue."
+ )
+ self.process_queue()
+ self.update_button_states()
+
+ def add_more_files(self):
+ from PyQt5.QtWidgets import QFileDialog
+ from abogen.utils import calculate_text_length # import the function
+ # Only allow .txt files
+ files, _ = QFileDialog.getOpenFileNames(self, "Select .txt files", "", "Text Files (*.txt)")
+ if not files:
+ return
+ self.add_files_from_paths(files)
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ if hasattr(self, 'empty_overlay'):
+ self.empty_overlay.resize(self.listwidget.size())
+
+ def update_button_states(self):
+ # Enable Remove if at least one item is selected, else disable
+ if hasattr(self, 'remove_button'):
+ selected_count = len(self.listwidget.selectedItems())
+ self.remove_button.setEnabled(selected_count > 0)
+ if selected_count > 1:
+ self.remove_button.setText(f"Remove selected ({selected_count})")
+ else:
+ self.remove_button.setText("Remove selected")
+ # Disable Clear if queue is empty
+ if hasattr(self, 'clear_button'):
+ 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
+ import os
+ global_pos = self.listwidget.viewport().mapToGlobal(pos)
+ selected_items = self.listwidget.selectedItems()
+ menu = QMenu(self)
+ if len(selected_items) == 1:
+ # Add Remove action
+ remove_action = QAction("Remove this item", self)
+ 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]
+ display_name = item.text()
+ for q in self.queue:
+ if os.path.basename(q.file_name) == display_name:
+ if not os.path.exists(q.file_name):
+ QMessageBox.warning(self, "File Not Found", f"The file does not exist.")
+ return
+ QDesktopServices.openUrl(QUrl.fromLocalFile(q.file_name))
+ break
+ open_file_action.triggered.connect(open_file)
+ menu.addAction(open_file_action)
+
+ # Add Go to folder action
+ go_to_folder_action = QAction("Go to folder", self)
+ def go_to_folder():
+ from PyQt5.QtWidgets import QMessageBox
+ item = selected_items[0]
+ display_name = item.text()
+ for q in self.queue:
+ if os.path.basename(q.file_name) == display_name:
+ if not os.path.exists(q.file_name):
+ QMessageBox.warning(self, "File Not Found", f"The file does not exist.")
+ return
+ folder = os.path.dirname(q.file_name)
+ if os.path.exists(folder):
+ QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
+ break
+ go_to_folder_action.triggered.connect(go_to_folder)
+ menu.addAction(go_to_folder_action)
+
+ elif len(selected_items) > 1:
+ remove_action = QAction(f"Remove selected ({len(selected_items)})", self)
+ remove_action.triggered.connect(self.remove_item)
+ menu.addAction(remove_action)
+ # Always add Clear Queue
+ clear_action = QAction("Clear Queue", self)
+ clear_action.triggered.connect(self.clear_queue)
+ menu.addAction(clear_action)
+ menu.exec_(global_pos)
+
+ def accept(self):
+ # Accept: keep changes
+ super().accept()
+
+ def reject(self):
+ # Cancel: restore original queue
+ from PyQt5.QtWidgets import QMessageBox
+ # Warn if user changed a lot (e.g., more than 1 items difference)
+ original_count = len(self._original_queue)
+ current_count = len(self.queue)
+ if abs(original_count - current_count) > 1:
+ reply = QMessageBox.question(
+ self,
+ "Confirm Cancel",
+ f"Are you sure you want to cancel and discard all changes?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if reply != QMessageBox.Yes:
+ return
+ self.queue.clear()
+ self.queue.extend(deepcopy(self._original_queue))
+ super().reject()
+
+ def keyPressEvent(self, event):
+ from PyQt5.QtCore import Qt
+ if event.key() == Qt.Key_Delete:
+ self.remove_item()
+ else:
+ super().keyPressEvent(event)
\ No newline at end of file
diff --git a/abogen/queued_item.py b/abogen/queued_item.py
new file mode 100644
index 0000000..9e676a2
--- /dev/null
+++ b/abogen/queued_item.py
@@ -0,0 +1,15 @@
+# represents a queued item - book, chapters, voice, etc.
+from dataclasses import dataclass
+
+@dataclass
+class QueuedItem:
+ file_name: str
+ lang_code: str
+ speed: float
+ voice: str
+ save_option: str
+ output_folder: str
+ subtitle_mode: str
+ output_format: str
+ total_char_count: int
+ replace_single_newlines: bool = False
\ No newline at end of file
diff --git a/abogen/utils.py b/abogen/utils.py
index 46f77b9..fd24176 100644
--- a/abogen/utils.py
+++ b/abogen/utils.py
@@ -217,14 +217,22 @@ def calculate_text_length(text):
def get_gpu_acceleration(enabled):
- from torch.cuda import is_available
-
- if not enabled:
- return "CUDA GPU available but using CPU.", False
-
- if is_available():
- return "CUDA GPU available and enabled.", True
- return "CUDA GPU is not available. Using CPU.", False
+ try:
+ import torch
+ from torch.cuda import is_available
+ if not enabled:
+ return "CUDA GPU available but using CPU.", False
+ if is_available():
+ return "CUDA GPU available and enabled.", True
+ # Gather more diagnostic info if CUDA is not available
+ try:
+ cuda_devices = torch.cuda.device_count()
+ cuda_error = torch.cuda.get_device_name(0) if cuda_devices > 0 else "No devices found"
+ except Exception as e:
+ cuda_error = str(e)
+ return f"CUDA GPU is not available. Using CPU. ({cuda_error})", False
+ except Exception as e:
+ return f"Error checking CUDA GPU: {e}", False
def prevent_sleep_start():
diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py
index a9d0d70..a6e09aa 100644
--- a/abogen/voice_formula_gui.py
+++ b/abogen/voice_formula_gui.py
@@ -28,7 +28,7 @@ from PyQt5.QtWidgets import (
)
from PyQt5.QtCore import Qt, QTimer, QPoint, QRect, QSize
from PyQt5.QtGui import QPixmap, QIcon
-from constants import (
+from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
LANGUAGE_DESCRIPTIONS,
@@ -36,8 +36,8 @@ from constants import (
)
import re
import platform
-from utils import get_resource_path
-from voice_profiles import (
+from abogen.utils import get_resource_path
+from abogen.voice_profiles import (
load_profiles,
save_profiles,
delete_profile,
@@ -1084,7 +1084,7 @@ class VoiceFormulaDialog(QDialog):
self, "Import Profiles", "", "JSON Files (*.json)"
)
if path:
- from voice_profiles import load_profiles, save_profiles
+ from abogen.voice_profiles import load_profiles, save_profiles
# Try to read the file and count profiles
try:
diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py
index c28dfbe..af18c52 100644
--- a/abogen/voice_formulas.py
+++ b/abogen/voice_formulas.py
@@ -1,6 +1,5 @@
import re
-from constants import VOICES_INTERNAL
-
+from abogen.constants import VOICES_INTERNAL
# Calls parsing and loads the voice to gpu or cpu
def get_new_voice(pipeline, formula, use_gpu):
diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py
index 637ecce..77a07d0 100644
--- a/abogen/voice_profiles.py
+++ b/abogen/voice_profiles.py
@@ -1,6 +1,6 @@
import os
import json
-from utils import get_user_config_path
+from abogen.utils import get_user_config_path
def _get_profiles_path():
diff --git a/demo/abogen.png b/demo/abogen.png
index b11c5dd..6ece4fc 100644
Binary files a/demo/abogen.png and b/demo/abogen.png differ
diff --git a/demo/abogen2.png b/demo/abogen2.png
index db31b34..6857f92 100644
Binary files a/demo/abogen2.png and b/demo/abogen2.png differ
diff --git a/demo/chapter_marker.png b/demo/chapter_marker.png
index 5e676fb..b5d62a6 100644
Binary files a/demo/chapter_marker.png and b/demo/chapter_marker.png differ
diff --git a/demo/queue.png b/demo/queue.png
new file mode 100644
index 0000000..bc16a2e
Binary files /dev/null and b/demo/queue.png differ
diff --git a/demo/voice_mixer.png b/demo/voice_mixer.png
index 6b40823..3949ae3 100644
Binary files a/demo/voice_mixer.png and b/demo/voice_mixer.png differ