16 Commits
Author SHA1 Message Date
Deniz Şafak 5f5dc85ae0 v1.1.4 - Fixed extra metadata information not being saved to M4B files 2025-07-16 07:14:10 +03:00
Deniz Şafak 0b15633995 v1.1.3 2025-07-16 06:27:22 +03:00
Deniz Şafak 8bc51dd270 App window now tries to fit the screen 2025-07-16 06:26:59 +03:00
Deniz Şafak d16c7603ce Better logic for M4B generation that makes it very fast 2025-07-16 05:15:08 +03:00
Deniz Şafak 970fc6c9c0 Better sleep state handling for Linux 2025-07-16 01:14:14 +03:00
Deniz Şafak d2f1c12de9 Fixed last sentence/subtitle entry timing in generated subtitles 2025-07-16 01:00:27 +03:00
Deniz Şafak 5d9409d391 Fix restart app in Windows 2025-07-15 12:23:15 +03:00
Deniz Şafak 0195788a54 v1.1.2 2025-07-15 11:59:27 +03:00
Deniz Şafak 1fd8fe21b7 Reformat using black 2025-07-15 11:57:43 +03:00
Deniz Şafak 89a7116c87 Generate subtitles dynamically like sound files, improvements in code 2025-07-15 11:50:22 +03:00
Deniz Şafak 6b85a08ccb Add new options and improvements for hfhub 2025-07-15 10:24:05 +03:00
Deniz Şafak b9c3fd7c6c Added a better logic for detecting chapters from the epub 2025-07-15 09:05:18 +03:00
Deniz Şafak a35bea9053 Directly write to audio file instead of RAM, improvements 2025-07-15 08:50:26 +03:00
Deniz Şafak e2802a2737 Potential fix for #37 and #38 2025-07-15 02:11:31 +03:00
Deniz Şafak e86ad435e5 Fixed Open folder and Open file buttons in the queue manager GUI 2025-07-14 12:09:44 +03:00
Deniz Şafak f2b42ef00e Windows installer build in editable mode 2025-07-12 01:52:17 +03:00
11 changed files with 734 additions and 625 deletions
+22
View File
@@ -1,3 +1,25 @@
# 1.1.4
- Fixed extra metadata information not being saved to M4B files, ensuring that all metadata is correctly written to the output file.
- Reformatted the code using Black for better readability and consistency.
# 1.1.3
- `M4B (with chapters)` generation is faster now, as it directly generates `m4b` files instead of converting from `wav`, which significantly reduces processing time, fixes the issue mentioned by @Milor123 in #39
- Better sleep state handling for Linux.
- The app window now tries to fit the screen if its height would exceed the available display area.
- Fixed issue where the app would not restart properly on Windows.
- Fixed last sentence/subtitle entry timing in generated subtitles, the end time of the final subtitle entry now correctly matches the end of the audio chunk, preventing zero or invalid timings at the end.
# v1.1.2
- Now you can play the audio files while they are processing.
- Audio and subtitle files are now written directly to disk during generation, which significantly reduces memory usage.
- Added a better logic for detecting chapters from the epub, mentioned by @jefro108 in #33
- Added a new option: `Reset to default settings`, allowing users to reset all settings to their default values.
- Added a new option: `Disable Kokoro's internet access`. This lets you prevent Kokoro from downloading models or voices from HuggingFace Hub, which can help avoid long waiting times if your computer is offline.
- HuggingFace Hub telemetry is now disabled by default for improved privacy. (HuggingFace Hub is used by Kokoro to download its models)
- Potential fix for #37 and #38, where the program was becoming slow while processing large files.
- Fixed `Open folder` and `Open file` buttons in the queue manager GUI.
- Improvements in code structure.
# v1.1.1
- Fixed adding wrong file in queue for EPUB and PDF files, ensuring the correct file is added to the queue.
- Reformatted the code using Black.
+2
View File
@@ -153,6 +153,8 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
| **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. |
| **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. |
| **Reset to default settings** | Resets all settings to their default values. |
## `Voice Mixer`
<img title="Abogen Voice Mixer" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/voice_mixer.png'>
+2 -2
View File
@@ -230,7 +230,7 @@ if errorlevel 1 (
:: Install setup requirements
echo Installing setup requirements...
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools setuptools-scm wheel sphinx hatchling --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools setuptools-scm wheel sphinx hatchling editables --no-warn-script-location
if errorlevel 1 (
echo Failed to install setup requirements.
pause
@@ -250,7 +250,7 @@ if errorlevel 1 (
echo Checking and installing project dependencies...
if exist %PYPROJECT_FILE% (
echo Installing project from pyproject.toml...
%PYTHON_CONSOLE_PATH% -m pip install . --no-warn-script-location
%PYTHON_CONSOLE_PATH% -m pip install -e . --no-warn-script-location
if errorlevel 1 (
echo Failed to install from pyproject.toml.
pause
+1 -1
View File
@@ -1 +1 @@
1.1.1
1.1.4
+18 -13
View File
@@ -22,7 +22,7 @@ from PyQt5.QtWidgets import (
QLabel,
)
from PyQt5.QtCore import Qt
from utils import clean_text, calculate_text_length
from abogen.utils import clean_text, calculate_text_length
import os
import logging # Add logging
import urllib.parse
@@ -240,20 +240,17 @@ class HandlerDialog(QDialog):
tag.decompose()
text = clean_text(soup.get_text()).strip()
if text:
# Use doc_href as the identifier
self.content_texts[doc_href] = text
self.content_lengths[doc_href] = len(text)
# Create a synthetic TOC entry
title = f"Chapter {i+1}: {doc_href}"
# Try to get a better title from <h1> or <title>
h1 = soup.find("h1")
if h1 and h1.get_text(strip=True):
title = h1.get_text(strip=True)
else:
title_tag = soup.find("title")
if title_tag and title_tag.get_text(strip=True):
title = title_tag.get_text(strip=True)
title = None
if soup.title and soup.title.string:
title = soup.title.string.strip()
elif (h1 := soup.find("h1")) and h1.get_text(strip=True):
title = h1.get_text(strip=True)
if not title:
title = f"Untitled Chapter {i+1}"
synthetic_toc.append(
(epub.Link(doc_href, title, doc_href), [])
) # Wrap in tuple and empty list for compatibility
@@ -1911,5 +1908,13 @@ class HandlerDialog(QDialog):
def closeEvent(self, event):
if self.pdf_doc is not None:
self.pdf_doc.close()
try:
if hasattr(self.pdf_doc, "is_closed"):
if not self.pdf_doc.is_closed:
self.pdf_doc.close()
else:
# Fallback: try/except close
self.pdf_doc.close()
except Exception:
pass
event.accept()
+8
View File
@@ -10,6 +10,13 @@ VERSION = get_version()
# Settings
CHAPTER_OPTIONS_COUNTDOWN = 30 # Countdown seconds for chapter options
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)"),
]
# Language description mapping
LANGUAGE_DESCRIPTIONS = {
@@ -109,6 +116,7 @@ SAMPLE_VOICE_TEXTS = {
COLORS = {
"BLUE": "#007dff",
"RED": "#c0392b",
"ORANGE": "#FFA500",
"GREEN": "#42ad4a",
"GREEN_BG": "rgba(66, 173, 73, 0.1)",
"GREEN_BG_HOVER": "rgba(66, 173, 73, 0.15)",
+512 -550
View File
File diff suppressed because it is too large Load Diff
+139 -50
View File
@@ -75,6 +75,7 @@ from abogen.constants import (
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
COLORS,
SUBTITLE_FORMATS,
)
import threading
from abogen.voice_formula_gui import VoiceFormulaDialog
@@ -124,6 +125,18 @@ class IconProvider(QFileIconProvider):
return super().icon(fileInfo)
LOG_COLOR_MAP = {
True: COLORS["GREEN"],
False: COLORS["RED"],
"red": COLORS["RED"],
"green": COLORS["GREEN"],
"orange": COLORS["ORANGE"],
"blue": COLORS["BLUE"],
"grey": COLORS["LIGHT_DISABLED"],
None: COLORS["LIGHT_DISABLED"],
}
class InputBox(QLabel):
# Define CSS styles as class constants
STYLE_DEFAULT = f"border:2px dashed #aaa; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG']}; min-height:100px;"
@@ -646,7 +659,7 @@ class abogen(QWidget):
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.log_window_max_lines = self.config.get("log_window_max_lines", 2000)
self.selected_chapters = set()
self.last_opened_book_path = None # Track the last opened book path
self.last_output_path = None
@@ -700,9 +713,6 @@ class abogen(QWidget):
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()
@@ -753,7 +763,11 @@ class abogen(QWidget):
self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
screen = QApplication.primaryScreen().geometry()
width, height = 500, 800
x, y = (screen.width() - width) // 2, (screen.height() - height) // 2
x = (screen.width() - width) // 2
# If desired height is larger than screen, fit to screen height
if height > screen.height() - 65:
height = screen.height() - 100 # Leave a margin for window borders
y = max((screen.height() - height) // 2, 0)
self.setGeometry(x, y, width, height)
outer_layout = QVBoxLayout()
outer_layout.setContentsMargins(15, 15, 15, 15)
@@ -785,6 +799,7 @@ class abogen(QWidget):
container_layout.addWidget(self.queue_row_widget)
self.log_text = QTextEdit(self)
self.log_text.setReadOnly(True)
self.log_text.setUndoRedoEnabled(False)
self.log_text.setFrameStyle(QTextEdit.NoFrame)
self.log_text.setStyleSheet("QTextEdit { border: none; }")
self.log_text.hide()
@@ -944,14 +959,7 @@ class abogen(QWidget):
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:
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)
@@ -1458,16 +1466,12 @@ 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):
self.log_text.hide()
self.input_box.show()
def color_text(self, message, color):
return f'<span style="color:{color};">{message.replace(chr(10), "<br>")}</span><br>'
def update_log(self, message):
# Use signal-based approach for thread-safe logging
if QThread.currentThread() != QApplication.instance().thread():
@@ -1479,38 +1483,36 @@ class abogen(QWidget):
self._update_log_main_thread(message)
def _update_log_main_thread(self, message):
if not hasattr(self, "_log_lines"):
self._log_lines = []
txt = self.log_text
sb = txt.verticalScrollBar()
at_bottom = sb.value() == sb.maximum()
# Always produce a single HTML line (with color if tuple)
cursor = txt.textCursor()
cursor.movePosition(QTextCursor.End)
fmt = cursor.charFormat()
if isinstance(message, tuple):
text, spec = message
color = "green" if spec is True else ("red" if spec is False else spec)
html_line = self.color_text(text, color)
fmt.setForeground(QColor(LOG_COLOR_MAP.get(spec, COLORS["LIGHT_DISABLED"])))
else:
html_line = f"{str(message).replace(chr(10), '<br>')}<br>"
text = str(message)
fmt.clearForeground()
cursor.setCharFormat(fmt)
cursor.insertText(text + "\n")
self._log_lines.append(html_line)
doc = txt.document()
excess = doc.blockCount() - self.log_window_max_lines
if excess > 0:
start = doc.findBlockByNumber(0).position()
end = doc.findBlockByNumber(excess).position()
trim_cursor = QTextCursor(doc)
trim_cursor.setPosition(start)
trim_cursor.setPosition(end, QTextCursor.KeepAnchor)
trim_cursor.removeSelectedText()
# 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 (
@@ -1889,7 +1891,7 @@ class abogen(QWidget):
f"<span style='color:{COLORS['GREEN']}; font-weight:bold;'>{idx}) {os.path.basename(item.file_name)}</span><br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Language:</span> {item.lang_code}<br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Voice:</span> {item.voice}<br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Speed:</span> {item.speed * 100:.1f}%<br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Speed:</span> {item.speed}<br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Characters:</span> {item.total_char_count}<br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Input:</span> {item.file_name}<br>"
f"<span style='color:{COLORS['LIGHT_DISABLED']};'>Output:</span> {output}</span>"
@@ -1988,7 +1990,6 @@ class abogen(QWidget):
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
@@ -2032,7 +2033,6 @@ class abogen(QWidget):
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 = (
@@ -2444,7 +2444,6 @@ class abogen(QWidget):
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
@@ -2484,6 +2483,16 @@ class abogen(QWidget):
self.config["use_gpu"] = self.use_gpu
save_config(self.config)
def cleanup_conversion_thread(self):
# Stop conversion thread
if (
hasattr(self, "conversion_thread")
and self.conversion_thread is not None
and self.conversion_thread.isRunning()
):
self.conversion_thread.cancel()
self.conversion_thread.wait()
def closeEvent(self, event):
if self.is_converting:
box = QMessageBox(self)
@@ -2495,16 +2504,12 @@ class abogen(QWidget):
box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
box.setDefaultButton(QMessageBox.No)
if box.exec_() == QMessageBox.Yes:
if (
hasattr(self, "conversion_thread")
and self.conversion_thread.isRunning()
):
self.conversion_thread.cancel()
self.conversion_thread.wait()
self.cleanup_conversion_thread()
event.accept()
else:
event.ignore()
else:
self.cleanup_conversion_thread()
event.accept()
def show_chapter_options_dialog(self, chapter_count):
@@ -2779,6 +2784,17 @@ class abogen(QWidget):
# Add seperator"
menu.addSeparator()
# Add "Disable Kokoro's internet access" option
disable_kokoro_action = QAction("Disable Kokoro's internet access", self)
disable_kokoro_action.setCheckable(True)
disable_kokoro_action.setChecked(
self.config.get("disable_kokoro_internet", False)
)
disable_kokoro_action.triggered.connect(
lambda checked: self.toggle_kokoro_internet_access(checked)
)
menu.addAction(disable_kokoro_action)
# Add check for updates option
check_updates_action = QAction("Check for updates at startup", self)
check_updates_action.setCheckable(True)
@@ -2786,6 +2802,11 @@ class abogen(QWidget):
check_updates_action.triggered.connect(self.toggle_check_updates)
menu.addAction(check_updates_action)
# Add "Reset to default settings" option
reset_defaults_action = QAction("Reset to default settings", self)
reset_defaults_action.triggered.connect(self.reset_to_default_settings)
menu.addAction(reset_defaults_action)
# Add about action
about_action = QAction("About", self)
about_action.triggered.connect(self.show_about_dialog)
@@ -2798,6 +2819,74 @@ class abogen(QWidget):
self.config["replace_single_newlines"] = enabled
save_config(self.config)
def restart_app(self):
from PyQt5.QtCore import QProcess
import sys
exe = sys.executable
args = sys.argv
# On Windows, use .exe if available
if platform.system() == "Windows":
script_path = args[0]
if not script_path.lower().endswith(".exe"):
exe_path = os.path.splitext(script_path)[0] + ".exe"
if os.path.exists(exe_path):
args[0] = exe_path
QProcess.startDetached(exe, args)
QApplication.quit()
def toggle_kokoro_internet_access(self, disabled):
if disabled:
message = (
"Disabling Kokoro's internet access will block downloads of models and voices from Hugging Face Hub. "
"This can make processing faster when there is no internet connection, since no requests will be made. "
"The app needs to restart to apply this change.\n\nDo you want to continue?"
)
else:
message = (
"Enabling Kokoro's internet access will allow it to download models and voices from Hugging Face Hub. "
"The app needs to restart to apply this change.\n\nDo you want to continue?"
)
reply = QMessageBox.question(
self,
"Restart Required",
message,
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
self.config["disable_kokoro_internet"] = disabled
save_config(self.config)
try:
self.restart_app()
except Exception as e:
QMessageBox.critical(
self, "Restart Failed", f"Failed to restart the application:\n{e}"
)
def reset_to_default_settings(self):
reply = QMessageBox.question(
self,
"Reset Settings",
"This will reset all settings to their default values and restart the application.\n\nDo you want to continue?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply == QMessageBox.Yes:
from abogen.utils import get_user_config_path
config_path = get_user_config_path()
try:
if os.path.exists(config_path):
os.remove(config_path)
self.restart_app()
except Exception as e:
QMessageBox.critical(
self, "Reset Error", f"Could not reset settings:\n{e}"
)
def reveal_config_in_explorer(self):
"""Open the configuration file location in file explorer."""
from abogen.utils import get_user_config_path
+17 -1
View File
@@ -1,20 +1,36 @@
from json import load
import os
import sys
import platform
import atexit
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
# Add the directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
from abogen.utils import get_resource_path, load_config, prevent_sleep_end
# Set Hugging Face Hub environment variables
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
os.environ["HF_HUB_ETAG_TIMEOUT"] = "10" # Metadata request timeout (seconds)
os.environ["HF_HUB_DOWNLOAD_TIMEOUT"] = "10" # File download timeout (seconds)
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1" # Disable symlinks warning
if load_config().get("disable_kokoro_internet", False):
print("INFO: Kokoro's internet access is disabled.")
os.environ["HF_HUB_OFFLINE"] = "1" # Disable Hugging Face Hub internet access
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"
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
# Reset sleep states
atexit.register(prevent_sleep_end)
# Ensure sys.stdout and sys.stderr are valid in GUI mode
if sys.stdout is None:
sys.stdout = open(os.devnull, "w")
+5 -4
View File
@@ -246,6 +246,7 @@ class QueueManager(QDialog):
)
list_item.setToolTip(tooltip)
list_item.setIcon(icon)
list_item.setData(Qt.UserRole, file_name)
# Use custom widget for display
char_count = getattr(item, "total_char_count", 0)
widget = QueueListItemWidget(file_name, char_count)
@@ -472,9 +473,9 @@ class QueueManager(QDialog):
from PyQt5.QtWidgets import QMessageBox
item = selected_items[0]
display_name = item.text()
file_path = item.data(Qt.UserRole)
for q in self.queue:
if os.path.basename(q.file_name) == display_name:
if q.file_name == file_path:
if not os.path.exists(q.file_name):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
@@ -493,9 +494,9 @@ class QueueManager(QDialog):
from PyQt5.QtWidgets import QMessageBox
item = selected_items[0]
display_name = item.text()
file_path = item.data(Qt.UserRole)
for q in self.queue:
if os.path.basename(q.file_name) == display_name:
if q.file_name == file_path:
if not os.path.exists(q.file_name):
QMessageBox.warning(
self, "File Not Found", f"The file does not exist."
+8 -4
View File
@@ -7,8 +7,6 @@ import subprocess
import re
from threading import Thread
# suppress warnings and disable HF hub symlink warnings
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
warnings.filterwarnings("ignore")
@@ -246,6 +244,8 @@ def get_gpu_acceleration(enabled):
def prevent_sleep_start():
from abogen.constants import PROGRAM_NAME
system = platform.system()
if system == "Windows":
import ctypes
@@ -256,11 +256,15 @@ def prevent_sleep_start():
elif system == "Darwin":
_sleep_procs["Darwin"] = create_process(["caffeinate"])
elif system == "Linux":
# use a sleep that never exits so inhibition stays active
# Add program name and reason for inhibition
program_name = PROGRAM_NAME
reason = "Prevent sleep during abogen process"
_sleep_procs["Linux"] = create_process(
[
"systemd-inhibit",
"--what=handle-lid-switch:sleep",
f"--who={program_name}",
f"--why={reason}",
"--what=sleep",
"--mode=block",
"sleep",
"infinity",