mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
395cd75038 | ||
|
|
e60a1f7e42 | ||
|
|
1fe5a73c98 | ||
|
|
37db948d2f | ||
|
|
63679405a9 | ||
|
|
156fa75c76 |
+8
-1
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
1.1.5
|
||||
1.1.6
|
||||
+66
-55
@@ -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."
|
||||
)
|
||||
current_entry_node["has_content"] = False
|
||||
doc_key = None
|
||||
doc_key, doc_idx = self._find_doc_key(
|
||||
base_href, doc_order, doc_order_decoded
|
||||
)
|
||||
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,12 +762,7 @@ 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)
|
||||
tree_structure_list.append(current_entry_node)
|
||||
|
||||
def _find_position_robust(self, doc_href, fragment_id):
|
||||
if doc_href not in self.doc_content:
|
||||
|
||||
+18
-12
@@ -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(
|
||||
@@ -483,8 +485,8 @@ class ConversionThread(QThread):
|
||||
merged_out_file = None
|
||||
ffmpeg_proc = None
|
||||
metadata_options = (
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
)
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
)
|
||||
# Prepare ffmpeg command for m4b output
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user