6 Commits
8 changed files with 130 additions and 75 deletions
+8 -1
View File
@@ -1,3 +1,10 @@
# 1.1.6
- Improved EPUB chapter detection: Now reliably detects chapters from NAV HTML (TOC) files, even in non-standard EPUBs, fixes the issue mentioned by @jefro108 in #33
- Fixed SRT subtitle numbering issue, mentioned by @page-muncher in #41
- Fixed missing chapter contents issue in some EPUB files.
- Windows installer script now prompts the user to install the CUDA version of PyTorch even if no NVIDIA GPU is detected.
- Abogen now includes Mandarin Chinese (misaki[zh]) by default; manual installation is no longer required.
# 1.1.5
- Changed the temporary directory path to user's cache directory, which is more appropriate for storing cache files and avoids issues with unintended cleanup.
- Fixed the isssue where extra metadata information was not being saved to M4B files when they have no chapters, ensuring that all metadata is correctly written to the output file.
@@ -23,7 +30,7 @@
- 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.
- cPotential 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.
+22 -3
View File
@@ -1,5 +1,5 @@
@echo off
setlocal
setlocal EnableDelayedExpansion
cd /d "%~dp0"
:: Set misaki language
@@ -23,7 +23,6 @@ set CUDA_VERSION=128
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
set CURRENT_DIR="%CD%"
setlocal enabledelayedexpansion
set NAME=abogen
set PROJECTFOLDER=abogen
set RUN=python_embedded\Scripts\abogen.exe
@@ -276,6 +275,7 @@ if "%MISAKI_LANG%" NEQ "en" (
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from abogen.is_nvidia import check; print(check())"') do set IS_NVIDIA=%%i
:: Check if torch is installed with CUDA support
echo.
echo Checking CUDA availability...
if /I "%IS_NVIDIA%"=="true" (
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
@@ -293,7 +293,26 @@ if /I "%IS_NVIDIA%"=="true" (
echo CUDA is available on NVIDIA GPU.
)
) else (
echo GPU is not NVIDIA. Skipping PyTorch CUDA installation.
echo.
echo Unable to detect an NVIDIA GPU in your system.
echo.
echo Do you want to install PyTorch anyway?
echo.
echo If you DO have an NVIDIA GPU, please press Y.
echo If you DO NOT have an NVIDIA GPU, please press N.
echo.
choice /C YN /M "Y=Yes, N=No"
if errorlevel 2 (
echo Skipping PyTorch installation.
) else (
echo Installing PyTorch with CUDA %CUDA_VERSION% support...
%PYTHON_CONSOLE_PATH% -m pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu%CUDA_VERSION% --no-warn-script-location
if errorlevel 1 (
echo Failed to install PyTorch.
pause
exit /b
)
)
)
:: Ask user if they want to create a desktop shortcut
+1 -1
View File
@@ -1 +1 @@
1.1.5
1.1.6
+64 -53
View File
@@ -324,14 +324,31 @@ class HandlerDialog(QDialog):
f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}"
)
# 3. If still no nav_item, check for ITEM_NCX directly
if not nav_item:
ncx_items = list(self.book.get_items_of_type(ebooklib.ITEM_NCX))
# 3. If still no nav_item, check for NCX or fallback to NAV HTML in all ITEM_DOCUMENTs
ncx_constant = getattr(epub, "ITEM_NCX", None)
if not nav_item and ncx_constant is not None:
ncx_items = list(self.book.get_items_of_type(ncx_constant))
if ncx_items:
nav_item = ncx_items[0] # Take the first one
nav_item = ncx_items[0]
nav_type = "ncx"
logging.info(f"Found NCX item via ITEM_NCX: {ncx_items[0].get_name()}")
logging.info(f"Found NCX item via ITEM_NCX: {nav_item.get_name()}")
# Fallback: search all ITEM_DOCUMENTs for a NAV HTML with <nav epub:type="toc">
if not nav_item:
for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
try:
html_content = item.get_content().decode("utf-8", errors="ignore")
if "<nav" in html_content and 'epub:type="toc"' in html_content:
soup = BeautifulSoup(html_content, "html.parser")
nav_tag = soup.find("nav", attrs={"epub:type": "toc"})
if nav_tag:
nav_item = item
nav_type = "html"
logging.info(
f"Found NAV HTML with TOC in: {item.get_name()}"
)
break
except Exception as e:
continue
# 4. If no navigation item found by any method, trigger fallback
if not nav_item or not nav_type:
logging.warning(
@@ -516,10 +533,14 @@ class HandlerDialog(QDialog):
slice_html += self.doc_content.get(intermediate_doc_href, "")
except Exception:
pass
# Fallback: if slice_html is empty, try to get the whole file's text
if not slice_html.strip() and current_doc_html:
logging.warning(
f"No content found for src '{current_src}', using full file as fallback."
)
slice_html = current_doc_html
if slice_html.strip():
slice_soup = BeautifulSoup(slice_html, "html.parser")
# Add double newlines after <p> and <div> tags
for tag in slice_soup.find_all(["p", "div"]):
tag.append("\n\n")
for tag in slice_soup.find_all(["sup", "sub"]):
@@ -581,6 +602,23 @@ class HandlerDialog(QDialog):
f"Finished processing EPUB navigation. Found {len(self.content_texts)} content sections linked to TOC."
)
def _find_doc_key(self, base_href, doc_order, doc_order_decoded):
"""Find the best matching doc_key for a given base_href using robust matching."""
candidates = [
base_href,
urllib.parse.unquote(base_href),
]
base_name = os.path.basename(base_href).lower()
for k in list(doc_order.keys()) + list(doc_order_decoded.keys()):
if os.path.basename(k).lower() == base_name:
candidates.append(k)
for candidate in candidates:
if candidate in doc_order:
return candidate, doc_order[candidate]
elif candidate in doc_order_decoded:
return candidate, doc_order_decoded[candidate]
return None, None
def _parse_ncx_navpoint(
self,
nav_point,
@@ -603,27 +641,15 @@ class HandlerDialog(QDialog):
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
# Try both original and decoded hrefs
doc_key = None
if base_href in doc_order:
doc_key = base_href
doc_idx = doc_order[base_href]
elif urllib.parse.unquote(base_href) in doc_order:
doc_key = urllib.parse.unquote(base_href)
doc_idx = doc_order[doc_key]
elif base_href in doc_order_decoded:
doc_key = base_href
doc_idx = doc_order_decoded[base_href]
elif urllib.parse.unquote(base_href) in doc_order_decoded:
doc_key = urllib.parse.unquote(base_href)
doc_idx = doc_order_decoded[doc_key]
else:
doc_key, doc_idx = self._find_doc_key(
base_href, doc_order, doc_order_decoded
)
if not doc_key:
logging.warning(
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list."
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list (even after basename fallback)."
)
current_entry_node["has_content"] = False
doc_key = None
if doc_key is not None:
else:
position = find_position_func(doc_key, fragment)
entry_data = {
"src": src,
@@ -698,28 +724,15 @@ class HandlerDialog(QDialog):
current_entry_node["title"] = title
current_entry_node["src"] = src
doc_key = None
doc_idx = None
position = 0
fragment = None
if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
# Try both original and decoded hrefs
doc_key = None
if base_href in doc_order:
doc_key = base_href
doc_idx = doc_order[base_href]
elif urllib.parse.unquote(base_href) in doc_order:
doc_key = urllib.parse.unquote(base_href)
doc_idx = doc_order[doc_key]
elif base_href in doc_order_decoded:
doc_key = base_href
doc_idx = doc_order_decoded[base_href]
elif urllib.parse.unquote(base_href) in doc_order_decoded:
doc_key = urllib.parse.unquote(base_href)
doc_idx = doc_order_decoded[doc_key]
else:
logging.warning(
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list."
doc_key, doc_idx = self._find_doc_key(
base_href, doc_order, doc_order_decoded
)
current_entry_node["has_content"] = False
doc_key = None
if doc_key is not None:
position = find_position_func(doc_key, fragment)
entry_data = {
@@ -731,13 +744,16 @@ class HandlerDialog(QDialog):
}
ordered_entries.append(entry_data)
current_entry_node["has_content"] = True
else:
logging.warning(
f"Navigation entry '{title}' points to '{base_href}', which is not in the spine or document list (even after basename fallback)."
)
current_entry_node["has_content"] = False
else:
current_entry_node["has_content"] = False
child_ol = li_element.find("ol", recursive=False)
if child_ol:
for child_ol in li_element.find_all("ol", recursive=False):
for child_li in child_ol.find_all("li", recursive=False):
# Pass find_position_func down recursively
self._parse_html_nav_li(
child_li,
ordered_entries,
@@ -746,11 +762,6 @@ class HandlerDialog(QDialog):
current_entry_node["children"],
find_position_func,
)
if title and (
current_entry_node.get("has_content", False)
or current_entry_node["children"]
):
tree_structure_list.append(current_entry_node)
def _find_position_robust(self, doc_href, fragment_id):
+16 -10
View File
@@ -434,8 +434,8 @@ class ConversionThread(QThread):
)
# Only check for files with allowed extensions (extension without dot, case-insensitive)
clash = any(
os.path.splitext(fname)[0] == f"{base_name}{suffix}" and
os.path.splitext(fname)[1][1:].lower() in allowed_exts
os.path.splitext(fname)[0] == f"{base_name}{suffix}"
and os.path.splitext(fname)[1][1:].lower() in allowed_exts
for fname in os.listdir(parent_dir)
)
if not os.path.exists(chapters_out_dir_candidate) and not clash:
@@ -467,6 +467,8 @@ class ConversionThread(QThread):
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
for chapter in chapters
]
# SRT numbering fix: use a global counter
merged_srt_index = 1 # SRT numbering for merged file
# Prepare output file/ffmpeg process for merged output
if self.output_format in ["wav", "mp3", "flac"]:
merged_out_file = sf.SoundFile(
@@ -599,6 +601,7 @@ class ConversionThread(QThread):
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
for chapter in chapters
]
srt_index = 1 # SRT numbering fix for chapter-only mode
# Instead of processing the whole text, process by chapter
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
chapter_out_path = None
@@ -677,6 +680,9 @@ class ConversionThread(QThread):
continue
# Open chapter subtitle file for incremental writing if needed
chapter_subtitle_file = None
chapter_srt_index = (
1 # Initialize SRT numbering for this chapter file
)
if self.subtitle_mode != "Disabled":
subtitle_format = getattr(self, "subtitle_format", "srt")
file_extension = "ass" if "ass" in subtitle_format else "srt"
@@ -821,12 +827,12 @@ class ConversionThread(QThread):
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,,{merged_subtitle_alignment_tag}{text}\n"
)
else:
for i, (start, end, text) in enumerate(
new_entries, 1
):
for entry in new_entries:
start, end, text = entry
merged_subtitle_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
merged_srt_index += 1
# Per-chapter subtitle processing for both file and ffmpeg_proc
if chapter_out_file or chapter_ffmpeg_proc:
new_chapter_entries = []
@@ -848,12 +854,12 @@ class ConversionThread(QThread):
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,,{chapter_subtitle_alignment_tag}{text}\n"
)
else:
for i, (start, end, text) in enumerate(
new_chapter_entries, 1
):
for entry in new_chapter_entries:
start, end, text = entry
chapter_subtitle_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
chapter_srt_index += 1
if merge_chapters_at_end:
current_time += chunk_dur
if chapter_out_file or chapter_ffmpeg_proc:
+3
View File
@@ -32,11 +32,13 @@ os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0"
# Reset sleep states
atexit.register(prevent_sleep_end)
# Also handle signals (Ctrl+C, kill, etc.)
def _cleanup_sleep(signum, frame):
prevent_sleep_end()
sys.exit(0)
signal.signal(signal.SIGINT, _cleanup_sleep)
signal.signal(signal.SIGTERM, _cleanup_sleep)
@@ -50,6 +52,7 @@ if sys.stderr is None:
if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
# Custom message handler to filter out specific Qt warnings
def qt_message_handler(mode, context, message):
if "Wayland does not support QWindow::requestActivate()" in message:
+11 -3
View File
@@ -74,24 +74,32 @@ def get_user_config_path():
if os.path.exists(custom_dir):
config_dir = custom_dir
else:
config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True)
config_dir = user_config_dir(
"abogen", appauthor=False, roaming=True, ensure_exists=True
)
else:
# Windows and fallback case
config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True)
config_dir = user_config_dir(
"abogen", appauthor=False, roaming=True, ensure_exists=True
)
return os.path.join(config_dir, "config.json")
# Define cache path
def get_user_cache_path(folder=None):
from platformdirs import user_cache_dir
cache_dir = user_cache_dir("abogen", appauthor=False, opinion=True, ensure_exists=True)
cache_dir = user_cache_dir(
"abogen", appauthor=False, opinion=True, ensure_exists=True
)
if folder:
cache_dir = os.path.join(cache_dir, folder)
# Ensure the directory exists
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
+1
View File
@@ -15,6 +15,7 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko
dependencies = [
"PyQt5>=5.15.11",
"kokoro>=0.9.4",
"misaki[zh]>=0.9.4",
"ebooklib>=0.19",
"beautifulsoup4>=4.13.4",
"PyMuPDF>=1.25.5",