18 Commits
Author SHA1 Message Date
Deniz Şafak 395cd75038 v1.1.6 2025-07-30 02:33:52 +03:00
Deniz Şafak e60a1f7e42 Reformat using black 2025-07-30 02:31:49 +03:00
Deniz Şafak 1fe5a73c98 Improved EPUB chapter detection 2025-07-30 02:25:06 +03:00
Deniz Şafak 37db948d2f Include Mandarin Chinese (misaki[zh]) by default 2025-07-29 17:14:59 +03:00
Deniz Şafak 63679405a9 Ask user for CUDA installation in Windows installer script 2025-07-29 16:56:39 +03:00
Deniz Şafak 156fa75c76 Fixed SRT subtitle numbering issue in #41 2025-07-29 15:47:45 +03:00
Deniz Şafak c7631cc852 v1.1.5 2025-07-16 16:48:36 +03:00
Deniz Şafak 6f09efd018 Improved automatic filename suffixing 2025-07-16 16:47:18 +03:00
Deniz Şafak f1903eb6ac Fixed sleep prevention process not ending if program exited using Ctrl+C or kill 2025-07-16 15:35:45 +03:00
Deniz Şafak 977b2a736c Changed the temporary directory path to user's cache directory 2025-07-16 15:03:10 +03:00
Deniz Şafak 1f51e3edd3 Add extra metadata to m4b even there are no chapters 2025-07-16 08:07:03 +03:00
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
11 changed files with 359 additions and 251 deletions
+26 -1
View File
@@ -1,3 +1,28 @@
# 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.
- Fixed sleep prevention process not ending if program exited using Ctrl+C or kill.
- Improved automatic filename suffixing to better prevent overwriting files with the same name, even if they have different extensions.
- Improvements in code and documentation.
# 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 # v1.1.2
- Now you can play the audio files while they are processing. - 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. - Audio and subtitle files are now written directly to disk during generation, which significantly reduces memory usage.
@@ -5,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: `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. - 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) - 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. - Fixed `Open folder` and `Open file` buttons in the queue manager GUI.
- Improvements in code structure. - Improvements in code structure.
+5 -5
View File
@@ -149,9 +149,9 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
| **Configure max lines in log window** | Configures the maximum number of lines to display in the log window. | | **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`. | | **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. | | **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 config directory** | Opens the directory where the configuration file is stored. |
| **Open temp directory** | Opens the temporary directory where converted text files are stored. | | **Open cache directory** | Opens the cache directory where converted text files are stored. |
| **Clear temporary files** | Deletes temporary files created during the conversion or preview. | | **Clear cache files** | Deletes cache files created during the conversion or preview. |
| **Check for updates at startup** | Automatically checks for updates when the program starts. | | **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. | | **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. | | **Reset to default settings** | Resets all settings to their default values. |
@@ -174,7 +174,7 @@ Abogen will process each item in the queue automatically, saving outputs as conf
> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35) > 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` ## `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: When you process ePUB or PDF files, Abogen converts them into text files stored in your cache 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:
``` ```
<<CHAPTER_MARKER:Chapter Title>> <<CHAPTER_MARKER:Chapter Title>>
@@ -270,7 +270,7 @@ Abogen launches automatically inside the container.
Known issues: Known issues:
- Audio preview is not working inside container (ALSA error). - Audio preview is not working inside container (ALSA error).
- `Open temp directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen). - `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen).
(Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).) (Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).)
+22 -3
View File
@@ -1,5 +1,5 @@
@echo off @echo off
setlocal setlocal EnableDelayedExpansion
cd /d "%~dp0" cd /d "%~dp0"
:: Set misaki language :: Set misaki language
@@ -23,7 +23,6 @@ set CUDA_VERSION=128
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
set CURRENT_DIR="%CD%" set CURRENT_DIR="%CD%"
setlocal enabledelayedexpansion
set NAME=abogen set NAME=abogen
set PROJECTFOLDER=abogen set PROJECTFOLDER=abogen
set RUN=python_embedded\Scripts\abogen.exe 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 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 :: Check if torch is installed with CUDA support
echo.
echo Checking CUDA availability... echo Checking CUDA availability...
if /I "%IS_NVIDIA%"=="true" ( 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 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. echo CUDA is available on NVIDIA GPU.
) )
) else ( ) 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 :: Ask user if they want to create a desktop shortcut
+1 -1
View File
@@ -1 +1 @@
1.1.2 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()}" f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}"
) )
# 3. If still no nav_item, check for ITEM_NCX directly # 3. If still no nav_item, check for NCX or fallback to NAV HTML in all ITEM_DOCUMENTs
if not nav_item: ncx_constant = getattr(epub, "ITEM_NCX", None)
ncx_items = list(self.book.get_items_of_type(ebooklib.ITEM_NCX)) if not nav_item and ncx_constant is not None:
ncx_items = list(self.book.get_items_of_type(ncx_constant))
if ncx_items: if ncx_items:
nav_item = ncx_items[0] # Take the first one nav_item = ncx_items[0]
nav_type = "ncx" 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 # 4. If no navigation item found by any method, trigger fallback
if not nav_item or not nav_type: if not nav_item or not nav_type:
logging.warning( logging.warning(
@@ -516,10 +533,14 @@ class HandlerDialog(QDialog):
slice_html += self.doc_content.get(intermediate_doc_href, "") slice_html += self.doc_content.get(intermediate_doc_href, "")
except Exception: except Exception:
pass 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(): if slice_html.strip():
slice_soup = BeautifulSoup(slice_html, "html.parser") slice_soup = BeautifulSoup(slice_html, "html.parser")
# Add double newlines after <p> and <div> tags
for tag in slice_soup.find_all(["p", "div"]): for tag in slice_soup.find_all(["p", "div"]):
tag.append("\n\n") tag.append("\n\n")
for tag in slice_soup.find_all(["sup", "sub"]): 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." 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( def _parse_ncx_navpoint(
self, self,
nav_point, nav_point,
@@ -603,27 +641,15 @@ class HandlerDialog(QDialog):
if src: if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None) base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
# Try both original and decoded hrefs doc_key, doc_idx = self._find_doc_key(
doc_key = None base_href, doc_order, doc_order_decoded
if base_href in doc_order: )
doc_key = base_href if not doc_key:
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( 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 current_entry_node["has_content"] = False
doc_key = None else:
if doc_key is not None:
position = find_position_func(doc_key, fragment) position = find_position_func(doc_key, fragment)
entry_data = { entry_data = {
"src": src, "src": src,
@@ -698,28 +724,15 @@ class HandlerDialog(QDialog):
current_entry_node["title"] = title current_entry_node["title"] = title
current_entry_node["src"] = src current_entry_node["src"] = src
doc_key = None
doc_idx = None
position = 0
fragment = None
if src: if src:
base_href, fragment = src.split("#", 1) if "#" in src else (src, None) base_href, fragment = src.split("#", 1) if "#" in src else (src, None)
# Try both original and decoded hrefs doc_key, doc_idx = self._find_doc_key(
doc_key = None base_href, doc_order, doc_order_decoded
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
if doc_key is not None: if doc_key is not None:
position = find_position_func(doc_key, fragment) position = find_position_func(doc_key, fragment)
entry_data = { entry_data = {
@@ -731,13 +744,16 @@ class HandlerDialog(QDialog):
} }
ordered_entries.append(entry_data) ordered_entries.append(entry_data)
current_entry_node["has_content"] = True 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: else:
current_entry_node["has_content"] = False current_entry_node["has_content"] = False
child_ol = li_element.find("ol", recursive=False) for child_ol in li_element.find_all("ol", recursive=False):
if child_ol:
for child_li in child_ol.find_all("li", recursive=False): for child_li in child_ol.find_all("li", recursive=False):
# Pass find_position_func down recursively
self._parse_html_nav_li( self._parse_html_nav_li(
child_li, child_li,
ordered_entries, ordered_entries,
@@ -746,11 +762,6 @@ class HandlerDialog(QDialog):
current_entry_node["children"], current_entry_node["children"],
find_position_func, 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): def _find_position_robust(self, doc_href, fragment_id):
+22
View File
@@ -31,6 +31,28 @@ LANGUAGE_DESCRIPTIONS = {
"z": "Mandarin Chinese", "z": "Mandarin Chinese",
} }
# Supported sound formats
SUPPORTED_SOUND_FORMATS = [
"wav",
"mp3",
"opus",
"m4b",
"flac",
]
# Supported subtitle formats
SUPPORTED_SUBTITLE_FORMATS = [
"srt",
"ass",
]
# Supported input formats
SUPPORTED_INPUT_FORMATS = [
"epub",
"pdf",
"txt",
]
# Supported languages for subtitle generation # Supported languages for subtitle generation
# Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation. # Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation.
# This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline. # This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline.
+88 -109
View File
@@ -1,6 +1,5 @@
import os import os
import re import re
import tempfile
import time import time
import chardet import chardet
import charset_normalizer import charset_normalizer
@@ -9,14 +8,15 @@ from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf import soundfile as sf
from abogen.utils import clean_text, create_process from abogen.utils import clean_text, create_process, get_user_cache_path
from abogen.constants import ( from abogen.constants import (
PROGRAM_NAME,
LANGUAGE_DESCRIPTIONS, LANGUAGE_DESCRIPTIONS,
SAMPLE_VOICE_TEXTS, SAMPLE_VOICE_TEXTS,
COLORS, COLORS,
CHAPTER_OPTIONS_COUNTDOWN, CHAPTER_OPTIONS_COUNTDOWN,
SUBTITLE_FORMATS, SUBTITLE_FORMATS,
SUPPORTED_SOUND_FORMATS,
SUPPORTED_SUBTITLE_FORMATS,
) )
from abogen.voice_formulas import get_new_voice from abogen.voice_formulas import get_new_voice
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
@@ -426,29 +426,19 @@ class ConversionThread(QThread):
) )
# Find a unique suffix for both folder and merged file, always # Find a unique suffix for both folder and merged file, always
counter = 1 counter = 1
allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS)
while True: while True:
suffix = f"_{counter}" if counter > 1 else "" suffix = f"_{counter}" if counter > 1 else ""
chapters_out_dir_candidate = os.path.join( chapters_out_dir_candidate = os.path.join(
parent_dir, f"{base_name}{suffix}_chapters" parent_dir, f"{base_name}{suffix}_chapters"
) )
merged_file_candidate = os.path.join( # Only check for files with allowed extensions (extension without dot, case-insensitive)
parent_dir, f"{base_name}{suffix}.{self.output_format}" clash = any(
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)
) )
merged_srt_candidate = ( if not os.path.exists(chapters_out_dir_candidate) and not clash:
os.path.splitext(merged_file_candidate)[0] + ".srt"
)
if (
not os.path.exists(chapters_out_dir_candidate)
and (
not merge_chapters_at_end
or not os.path.exists(merged_file_candidate)
)
and (
self.subtitle_mode == "Disabled"
or not merge_chapters_at_end
or not os.path.exists(merged_srt_candidate)
)
):
break break
counter += 1 counter += 1
if save_chapters_separately and total_chapters > 1: if save_chapters_separately and total_chapters > 1:
@@ -477,6 +467,8 @@ class ConversionThread(QThread):
{"chapter": chapter[0], "start": 0.0, "end": 0.0} {"chapter": chapter[0], "start": 0.0, "end": 0.0}
for chapter in chapters 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 # Prepare output file/ffmpeg process for merged output
if self.output_format in ["wav", "mp3", "flac"]: if self.output_format in ["wav", "mp3", "flac"]:
merged_out_file = sf.SoundFile( merged_out_file = sf.SoundFile(
@@ -488,13 +480,37 @@ class ConversionThread(QThread):
) )
ffmpeg_proc = None ffmpeg_proc = None
elif self.output_format == "m4b": elif self.output_format == "m4b":
temp_wav_path = os.path.join( # Real-time M4B generation using FFmpeg pipe
out_dir, f"temp_{base_name}{suffix}.wav" static_ffmpeg.add_paths()
) merged_out_file = None
merged_out_file = sf.SoundFile(
temp_wav_path, "w", samplerate=24000, channels=1, format="wav"
)
ffmpeg_proc = None ffmpeg_proc = None
metadata_options = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
)
# Prepare ffmpeg command for m4b output
cmd = [
"ffmpeg",
"-y",
"-thread_queue_size",
"32768",
"-f",
"f32le",
"-ar",
"24000",
"-ac",
"1",
"-i",
"pipe:0",
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
cmd += metadata_options
cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
elif self.output_format == "opus": elif self.output_format == "opus":
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
cmd = [ cmd = [
@@ -585,6 +601,7 @@ class ConversionThread(QThread):
{"chapter": chapter[0], "start": 0.0, "end": 0.0} {"chapter": chapter[0], "start": 0.0, "end": 0.0}
for chapter in chapters for chapter in chapters
] ]
srt_index = 1 # SRT numbering fix for chapter-only mode
# Instead of processing the whole text, process by chapter # Instead of processing the whole text, process by chapter
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
chapter_out_path = None chapter_out_path = None
@@ -663,6 +680,9 @@ class ConversionThread(QThread):
continue continue
# Open chapter subtitle file for incremental writing if needed # Open chapter subtitle file for incremental writing if needed
chapter_subtitle_file = None chapter_subtitle_file = None
chapter_srt_index = (
1 # Initialize SRT numbering for this chapter file
)
if self.subtitle_mode != "Disabled": if self.subtitle_mode != "Disabled":
subtitle_format = getattr(self, "subtitle_format", "srt") subtitle_format = getattr(self, "subtitle_format", "srt")
file_extension = "ass" if "ass" in subtitle_format else "srt" file_extension = "ass" if "ass" in subtitle_format else "srt"
@@ -793,6 +813,7 @@ class ConversionThread(QThread):
tokens_with_timestamps, tokens_with_timestamps,
new_entries, new_entries,
self.max_subtitle_words, self.max_subtitle_words,
fallback_end_time=chunk_start + chunk_dur,
) )
if merged_subtitle_file: if merged_subtitle_file:
subtitle_format = getattr( subtitle_format = getattr(
@@ -806,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" f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,,{merged_subtitle_alignment_tag}{text}\n"
) )
else: else:
for i, (start, end, text) in enumerate( for entry in new_entries:
new_entries, 1 start, end, text = entry
):
merged_subtitle_file.write( 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 # Per-chapter subtitle processing for both file and ffmpeg_proc
if chapter_out_file or chapter_ffmpeg_proc: if chapter_out_file or chapter_ffmpeg_proc:
new_chapter_entries = [] new_chapter_entries = []
@@ -819,6 +840,7 @@ class ConversionThread(QThread):
chapter_tokens_with_timestamps, chapter_tokens_with_timestamps,
new_chapter_entries, new_chapter_entries,
self.max_subtitle_words, self.max_subtitle_words,
fallback_end_time=chapter_current_time + chunk_dur,
) )
if chapter_subtitle_file: if chapter_subtitle_file:
subtitle_format = getattr( subtitle_format = getattr(
@@ -832,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" f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,,{chapter_subtitle_alignment_tag}{text}\n"
) )
else: else:
for i, (start, end, text) in enumerate( for entry in new_chapter_entries:
new_chapter_entries, 1 start, end, text = entry
):
chapter_subtitle_file.write( 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: if merge_chapters_at_end:
current_time += chunk_dur current_time += chunk_dur
if chapter_out_file or chapter_ffmpeg_proc: if chapter_out_file or chapter_ffmpeg_proc:
@@ -910,8 +932,9 @@ class ConversionThread(QThread):
if self.output_format in ["wav", "mp3", "flac"]: if self.output_format in ["wav", "mp3", "flac"]:
merged_out_file.close() merged_out_file.close()
elif self.output_format == "m4b": elif self.output_format == "m4b":
merged_out_file.close() ffmpeg_proc.stdin.close()
# Only generate chapters info if there are chapters ffmpeg_proc.wait()
# Add chapters via fast post-processing
if total_chapters > 1: if total_chapters > 1:
chapters_info_path = f"{base_filepath_no_ext}_chapters.txt" chapters_info_path = f"{base_filepath_no_ext}_chapters.txt"
with open(chapters_info_path, "w", encoding="utf-8") as f: with open(chapters_info_path, "w", encoding="utf-8") as f:
@@ -923,15 +946,19 @@ class ConversionThread(QThread):
f.write(f"START={int(chapter['start']*1000)}\n") f.write(f"START={int(chapter['start']*1000)}\n")
f.write(f"END={int(chapter['end']*1000)}\n") f.write(f"END={int(chapter['end']*1000)}\n")
f.write(f"title={chapter_title}\n\n") f.write(f"title={chapter_title}\n\n")
# Fast mux chapters into m4b (write to temp file, then replace original)
static_ffmpeg.add_paths()
orig_path = merged_out_path
root, ext = os.path.splitext(orig_path)
tmp_path = root + ".tmp" + ext
metadata_options = ( metadata_options = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
) )
static_ffmpeg.add_paths()
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
"-y", "-y",
"-i", "-i",
temp_wav_path, orig_path,
"-i", "-i",
chapters_info_path, chapters_info_path,
"-map", "-map",
@@ -940,46 +967,15 @@ class ConversionThread(QThread):
"1", "1",
"-map_chapters", "-map_chapters",
"1", "1",
*metadata_options,
"-c:a", "-c:a",
"aac", "copy",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
merged_out_path,
] ]
cmd += metadata_options
cmd.append(tmp_path)
proc = create_process(cmd) proc = create_process(cmd)
proc.wait() proc.wait()
self.log_updated.emit( os.replace(tmp_path, orig_path)
("\nCleaning up temporary files...", "grey")
)
if os.path.exists(chapters_info_path):
os.remove(chapters_info_path) os.remove(chapters_info_path)
else:
# No chapters: skip chapters info and related ffmpeg args
metadata_options = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
)
static_ffmpeg.add_paths()
cmd = [
"ffmpeg",
"-y",
"-i",
temp_wav_path,
*metadata_options,
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
merged_out_path,
]
proc = create_process(cmd)
proc.wait()
if os.path.exists(temp_wav_path):
os.remove(temp_wav_path)
elif self.output_format in ["opus"]: elif self.output_format in ["opus"]:
ffmpeg_proc.stdin.close() ffmpeg_proc.stdin.close()
ffmpeg_proc.wait() ffmpeg_proc.wait()
@@ -1146,7 +1142,11 @@ class ConversionThread(QThread):
return f"{h}:{m:02}:{s:02}.{cs:02}" return f"{h}:{m:02}:{s:02}.{cs:02}"
def _process_subtitle_tokens( def _process_subtitle_tokens(
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words self,
tokens_with_timestamps,
subtitle_entries,
max_subtitle_words,
fallback_end_time=None,
): ):
"""Helper function to process subtitle tokens according to the subtitle mode""" """Helper function to process subtitle tokens according to the subtitle mode"""
if not tokens_with_timestamps: if not tokens_with_timestamps:
@@ -1191,9 +1191,15 @@ class ConversionThread(QThread):
sentence_text = "" sentence_text = ""
for t in current_sentence: for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace", "") or "") sentence_text += t["text"] + (t.get("whitespace", "") or "")
subtitle_entries.append((start_time, end_time, sentence_text.strip())) subtitle_entries.append((start_time, end_time, sentence_text.strip()))
# Fallback for last entry
if subtitle_entries and fallback_end_time is not None:
last_entry = subtitle_entries[-1]
start, end, text = last_entry
if end is None or end <= start or end <= 0:
subtitle_entries[-1] = (start, fallback_end_time, text)
else: else:
# Word count-based grouping # Word count-based grouping
try: try:
@@ -1230,36 +1236,12 @@ class ConversionThread(QThread):
subtitle_entries.append( subtitle_entries.append(
(group[0]["start"], group[-1]["end"], text.strip()) (group[0]["start"], group[-1]["end"], text.strip())
) )
# Fallback for last entry
def _write_ass_subtitle( if subtitle_entries and fallback_end_time is not None:
self, file_path, subtitle_entries, is_centered=False, is_narrow=False last_entry = subtitle_entries[-1]
): start, end, text = last_entry
with open(file_path, "w", encoding="utf-8", errors="replace") as f: if end is None or end <= start or end <= 0:
# Minimal ASS header subtitle_entries[-1] = (start, fallback_end_time, text)
f.write("[Script Info]\n")
f.write("Title: Generated by Abogen\n")
f.write("ScriptType: v4.00+\n\n")
# Only events section, use override tags for positioning
f.write("[Events]\n")
f.write(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
# Set margin based on is_narrow parameter
margin = "90" if is_narrow else ""
alignment_tag = ""
if is_centered:
alignment = 5
alignment_tag = f"{{\\an{alignment}}}"
# Write each subtitle with override tag for alignment and margins
for i, (start, end, text) in enumerate(subtitle_entries, 1):
start_time = self._ass_time(start)
end_time = self._ass_time(end)
f.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{margin},{margin},0,,{alignment_tag}{text}\n"
)
def cancel(self): def cancel(self):
self.cancel_requested = True self.cancel_requested = True
@@ -1311,10 +1293,7 @@ class VoicePreviewThread(QThread):
self.use_gpu = use_gpu self.use_gpu = use_gpu
# Cache location for preview audio # Cache location for preview audio
self.cache_dir = os.path.join( self.cache_dir = get_user_cache_path("preview_cache")
tempfile.gettempdir(), PROGRAM_NAME, "preview_cache"
)
os.makedirs(self.cache_dir, exist_ok=True)
# Calculate cache path # Calculate cache path
self.cache_path = self._get_cache_path() self.cache_path = self._get_cache_path()
+83 -70
View File
@@ -62,6 +62,7 @@ from abogen.utils import (
prevent_sleep_end, prevent_sleep_end,
calculate_text_length, calculate_text_length,
get_resource_path, get_resource_path,
get_user_cache_path,
LoadPipelineThread, LoadPipelineThread,
) )
from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
@@ -564,12 +565,12 @@ class TextboxDialog(QDialog):
self.reject() self.reject()
else: else:
# Check if we need to warn about overwriting a non-temporary file # Check if we need to warn about overwriting a non-temporary file
if hasattr(self, "is_non_temp_file") and self.is_non_temp_file: if hasattr(self, "is_non_cache_file") and self.is_non_cache_file:
msg_box = QMessageBox(self) msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Warning) msg_box.setIcon(QMessageBox.Warning)
msg_box.setWindowTitle("File Overwrite Warning") msg_box.setWindowTitle("File Overwrite Warning")
msg_box.setText( msg_box.setText(
f"You are about to overwrite the original file:\n{self.non_temp_file_path}" f"You are about to overwrite the original file:\n{self.non_cache_file_path}"
) )
msg_box.setInformativeText("Do you want to continue?") msg_box.setInformativeText("Do you want to continue?")
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
@@ -591,11 +592,11 @@ class TextboxDialog(QDialog):
# Get default filename from original file if editing # Get default filename from original file if editing
initial_path = "" initial_path = ""
if hasattr(self, "non_temp_file_path") and self.non_temp_file_path: if hasattr(self, "non_cache_file_path") and self.non_cache_file_path:
initial_path = self.non_temp_file_path initial_path = self.non_cache_file_path
# For EPUB and PDF files, use the displayed_file_path from the main window # For EPUB and PDF files, use the displayed_file_path from the main window
# This gives a better filename instead of the temporary file path # This gives a better filename instead of the cache file path
main_window = self.parent() main_window = self.parent()
if ( if (
hasattr(main_window, "displayed_file_path") hasattr(main_window, "displayed_file_path")
@@ -763,7 +764,11 @@ class abogen(QWidget):
self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}") self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
screen = QApplication.primaryScreen().geometry() screen = QApplication.primaryScreen().geometry()
width, height = 500, 800 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) self.setGeometry(x, y, width, height)
outer_layout = QVBoxLayout() outer_layout = QVBoxLayout()
outer_layout.setContentsMargins(15, 15, 15, 15) outer_layout.setContentsMargins(15, 15, 15, 15)
@@ -1191,7 +1196,7 @@ class abogen(QWidget):
if book_path.lower().endswith(".pdf"): if book_path.lower().endswith(".pdf"):
self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False) self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False)
# Use "abogen" prefix for temporary files # Use "abogen" prefix for cache files
# Extract base name without extension # Extract base name without extension
base_name = os.path.splitext(os.path.basename(book_path))[0] base_name = os.path.splitext(os.path.basename(book_path))[0]
@@ -1201,15 +1206,15 @@ class abogen(QWidget):
self, "Select Project Folder", "", QFileDialog.ShowDirsOnly self, "Select Project Folder", "", QFileDialog.ShowDirsOnly
) )
if not project_dir: if not project_dir:
# User cancelled, fallback to temp # User cancelled, fallback to cache
self.save_as_project = False self.save_as_project = False
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) cache_dir = get_user_cache_path()
else: else:
# Create project folder structure # Create project folder structure
project_name = f"{base_name}_project" project_name = f"{base_name}_project"
project_dir = os.path.join(project_dir, project_name) project_dir = os.path.join(project_dir, project_name)
temp_dir = os.path.join(project_dir, "text") cache_dir = os.path.join(project_dir, "text")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
# Save metadata if available # Save metadata if available
meta_dir = os.path.join(project_dir, "metadata") meta_dir = os.path.join(project_dir, "metadata")
@@ -1264,11 +1269,10 @@ class abogen(QWidget):
with open(cover_path, "wb") as f: with open(cover_path, "wb") as f:
f.write(dialog.book_metadata["cover_image"]) f.write(dialog.book_metadata["cover_image"])
else: else:
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) cache_dir = get_user_cache_path()
os.makedirs(temp_dir, exist_ok=True)
fd, tmp = tempfile.mkstemp( fd, tmp = tempfile.mkstemp(
prefix=f"{base_name}_", suffix=".txt", dir=temp_dir prefix=f"{base_name}_", suffix=".txt", dir=cache_dir
) )
os.close(fd) os.close(fd)
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
@@ -1289,13 +1293,13 @@ class abogen(QWidget):
return return
editing = False editing = False
is_temp_file = False is_cache_file = False
# If path is explicitly provided, use it # If path is explicitly provided, use it
if file_path and os.path.exists(file_path): if file_path and os.path.exists(file_path):
editing = True editing = True
edit_file = file_path edit_file = file_path
# Check if this is a temporary file # Check if this is a cache file
is_temp_file = tempfile.gettempdir() in file_path is_cache_file = get_user_cache_path() in file_path
# Otherwise use selected_file if it's a txt file # Otherwise use selected_file if it's a txt file
elif ( elif (
self.selected_file_type == "txt" self.selected_file_type == "txt"
@@ -1304,8 +1308,8 @@ class abogen(QWidget):
): ):
editing = True editing = True
edit_file = self.selected_file edit_file = self.selected_file
# Check if this is a temporary file # Check if this is a cache file
is_temp_file = tempfile.gettempdir() in self.selected_file is_cache_file = get_user_cache_path() in self.selected_file
dialog = TextboxDialog(self) dialog = TextboxDialog(self)
if editing: if editing:
@@ -1317,10 +1321,10 @@ class abogen(QWidget):
dialog.text_edit.toPlainText() dialog.text_edit.toPlainText()
) # Store original text ) # Store original text
# If editing a non-temporary file, alert the user # If editing a non-cache file, alert the user
if not is_temp_file: if not is_cache_file:
dialog.is_non_temp_file = True dialog.is_non_cache_file = True
dialog.non_temp_file_path = edit_file dialog.non_cache_file_path = edit_file
except Exception: except Exception:
pass pass
if dialog.exec_() == QDialog.Accepted: if dialog.exec_() == QDialog.Accepted:
@@ -1338,10 +1342,9 @@ class abogen(QWidget):
# Hide chapters button since we're using custom text now # Hide chapters button since we're using custom text now
self.input_box.chapters_btn.hide() self.input_box.chapters_btn.hide()
else: else:
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) cache_dir = get_user_cache_path()
os.makedirs(temp_dir, exist_ok=True)
fd, tmp = tempfile.mkstemp( fd, tmp = tempfile.mkstemp(
prefix="abogen_", suffix=".txt", dir=temp_dir prefix="abogen_", suffix=".txt", dir=cache_dir
) )
os.close(fd) os.close(fd)
with open(tmp, "w", encoding="utf-8") as f: with open(tmp, "w", encoding="utf-8") as f:
@@ -2149,7 +2152,7 @@ class abogen(QWidget):
if not lang_to_cache or not voice_to_cache: # Not enough info if not lang_to_cache or not voice_to_cache: # Not enough info
return None return None
cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache") cache_dir = get_user_cache_path("preview_cache")
if "*" in voice_to_cache: # Voice formula if "*" in voice_to_cache: # Voice formula
voice_id = ( voice_id = (
@@ -2767,17 +2770,17 @@ class abogen(QWidget):
reveal_config_action.triggered.connect(self.reveal_config_in_explorer) reveal_config_action.triggered.connect(self.reveal_config_in_explorer)
menu.addAction(reveal_config_action) menu.addAction(reveal_config_action)
# Add open temp directory option # Add open cache directory option
open_temp_action = QAction("Open temp directory", self) open_cache_action = QAction("Open cache directory", self)
open_temp_action.triggered.connect(self.open_temp_directory) open_cache_action.triggered.connect(self.open_cache_directory)
menu.addAction(open_temp_action) menu.addAction(open_cache_action)
# Add clear temporary files option # Add clear cache files option
clear_temp_action = QAction("Clear temporary files", self) clear_cache_action = QAction("Clear cache files", self)
clear_temp_action.triggered.connect(self.clear_temp_files) clear_cache_action.triggered.connect(self.clear_cache_files)
menu.addAction(clear_temp_action) menu.addAction(clear_cache_action)
# Add seperator" # Add separator
menu.addSeparator() menu.addSeparator()
# Add "Disable Kokoro's internet access" option # Add "Disable Kokoro's internet access" option
@@ -2815,6 +2818,24 @@ class abogen(QWidget):
self.config["replace_single_newlines"] = enabled self.config["replace_single_newlines"] = enabled
save_config(self.config) 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): def toggle_kokoro_internet_access(self, disabled):
if disabled: if disabled:
message = ( message = (
@@ -2838,11 +2859,7 @@ class abogen(QWidget):
self.config["disable_kokoro_internet"] = disabled self.config["disable_kokoro_internet"] = disabled
save_config(self.config) save_config(self.config)
try: try:
from PyQt5.QtCore import QProcess self.restart_app()
import sys
QProcess.startDetached(sys.executable, sys.argv)
QApplication.quit()
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, "Restart Failed", f"Failed to restart the application:\n{e}" self, "Restart Failed", f"Failed to restart the application:\n{e}"
@@ -2858,16 +2875,12 @@ class abogen(QWidget):
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
from abogen.utils import get_user_config_path from abogen.utils import get_user_config_path
import sys
config_path = get_user_config_path() config_path = get_user_config_path()
try: try:
if os.path.exists(config_path): if os.path.exists(config_path):
os.remove(config_path) os.remove(config_path)
from PyQt5.QtCore import QProcess self.restart_app()
QProcess.startDetached(sys.executable, sys.argv)
QApplication.quit()
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, "Reset Error", f"Could not reset settings:\n{e}" self, "Reset Error", f"Could not reset settings:\n{e}"
@@ -2886,21 +2899,21 @@ class abogen(QWidget):
self, "Config Error", f"Could not open config location:\n{e}" self, "Config Error", f"Could not open config location:\n{e}"
) )
def open_temp_directory(self): def open_cache_directory(self):
"""Open the temporary directory used by the program.""" """Open the cache directory used by the program."""
try: try:
# Get the temp directory path # Get the cache directory path
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) cache_dir = get_user_cache_path()
# Create the directory if it doesn't exist # Create the directory if it doesn't exist
if not os.path.exists(temp_dir): if not os.path.exists(cache_dir):
os.makedirs(temp_dir) os.makedirs(cache_dir)
# Open the directory in file explorer # Open the directory in file explorer
QDesktopServices.openUrl(QUrl.fromLocalFile(temp_dir)) QDesktopServices.openUrl(QUrl.fromLocalFile(cache_dir))
except Exception as e: except Exception as e:
QMessageBox.critical( QMessageBox.critical(
self, "Temp Directory Error", f"Could not open temp directory:\n{e}" self, "Cache Directory Error", f"Could not open cache directory:\n{e}"
) )
def add_shortcut_to_desktop(self): def add_shortcut_to_desktop(self):
@@ -3240,23 +3253,23 @@ Categories=AudioVideo;Audio;Utility;
) )
pass pass
def clear_temp_files(self): def clear_cache_files(self):
"""Clear temporary files created by the program.""" """Clear cache files created by the program."""
import glob import glob
try: try:
# Get the abogen temp directory # Get the abogen cache directory
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) cache_dir = get_user_cache_path()
# Find all .txt files in the abogen temp directory # Find all .txt files in the abogen cache directory
pattern = os.path.join(temp_dir, "*.txt") pattern = os.path.join(cache_dir, "*.txt")
temp_files = glob.glob(pattern) cache_files = glob.glob(pattern)
# Count the files # Count the files
file_count = len(temp_files) file_count = len(cache_files)
# Check for preview cache files # Check for preview cache files
preview_cache_dir = os.path.join(temp_dir, "preview_cache") preview_cache_dir = os.path.join(cache_dir, "preview_cache")
preview_files = [] preview_files = []
if os.path.exists(preview_cache_dir): if os.path.exists(preview_cache_dir):
preview_pattern = os.path.join(preview_cache_dir, "*.wav") preview_pattern = os.path.join(preview_cache_dir, "*.wav")
@@ -3266,16 +3279,16 @@ Categories=AudioVideo;Audio;Utility;
if file_count == 0 and preview_count == 0: if file_count == 0 and preview_count == 0:
QMessageBox.information( QMessageBox.information(
self, "No Temporary Files", "No temporary files were found." self, "No Cache Files", "No cache files were found."
) )
return return
# Create a custom message box with checkbox # Create a custom message box with checkbox
msg_box = QMessageBox(self) msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Question) msg_box.setIcon(QMessageBox.Question)
msg_box.setWindowTitle("Clear Temporary Files") msg_box.setWindowTitle("Clear Cache Files")
msg_text = f"Found {file_count} temporary file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} temp folder." msg_text = f"Found {file_count} cache file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} cache folder."
if preview_count > 0: if preview_count > 0:
msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}." msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}."
@@ -3299,7 +3312,7 @@ Categories=AudioVideo;Audio;Utility;
# Delete the text files # Delete the text files
deleted_count = 0 deleted_count = 0
for file_path in temp_files: for file_path in cache_files:
try: try:
os.remove(file_path) os.remove(file_path)
deleted_count += 1 deleted_count += 1
@@ -3322,12 +3335,12 @@ Categories=AudioVideo;Audio;Utility;
result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}." result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}."
# Show results # Show results
QMessageBox.information(self, "Temporary Files Cleared", result_msg) QMessageBox.information(self, "Cache Files Cleared", result_msg)
# If currently selected file is in the temp directory, clear the UI # If currently selected file is in the cache directory, clear the UI
if ( if (
self.selected_file self.selected_file
and os.path.dirname(self.selected_file) == temp_dir and os.path.dirname(self.selected_file) == cache_dir
and self.selected_file.endswith(".txt") and self.selected_file.endswith(".txt")
): ):
self.input_box.clear_input() self.input_box.clear_input()
+16 -1
View File
@@ -2,6 +2,8 @@ from json import load
import os import os
import sys import sys
import platform import platform
import atexit
import signal
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon from PyQt5.QtGui import QIcon
from PyQt5.QtCore import qInstallMessageHandler, QtMsgType from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
@@ -9,7 +11,7 @@ from PyQt5.QtCore import qInstallMessageHandler, QtMsgType
# Add the directory to Python path # Add the directory to Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__))) sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
from abogen.utils import get_resource_path, load_config from abogen.utils import get_resource_path, load_config, prevent_sleep_end
# Set Hugging Face Hub environment variables # Set Hugging Face Hub environment variables
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable Hugging Face telemetry
@@ -27,6 +29,19 @@ from abogen.constants import PROGRAM_NAME, VERSION
os.environ["MIOPEN_FIND_MODE"] = "FAST" os.environ["MIOPEN_FIND_MODE"] = "FAST"
os.environ["MIOPEN_CONV_PRECISE_ROCM_TUNING"] = "0" 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)
# Ensure sys.stdout and sys.stderr are valid in GUI mode # Ensure sys.stdout and sys.stderr are valid in GUI mode
if sys.stdout is None: if sys.stdout is None:
sys.stdout = open(os.devnull, "w") sys.stdout = open(os.devnull, "w")
+28 -5
View File
@@ -74,15 +74,32 @@ def get_user_config_path():
if os.path.exists(custom_dir): if os.path.exists(custom_dir):
config_dir = custom_dir config_dir = custom_dir
else: else:
config_dir = user_config_dir("abogen", appauthor=False, roaming=True) config_dir = user_config_dir(
"abogen", appauthor=False, roaming=True, ensure_exists=True
)
else: else:
# Windows and fallback case # Windows and fallback case
config_dir = user_config_dir("abogen", appauthor=False, roaming=True) config_dir = user_config_dir(
"abogen", appauthor=False, roaming=True, ensure_exists=True
)
os.makedirs(config_dir, exist_ok=True)
return os.path.join(config_dir, "config.json") 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
)
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 _sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
@@ -244,6 +261,8 @@ def get_gpu_acceleration(enabled):
def prevent_sleep_start(): def prevent_sleep_start():
from abogen.constants import PROGRAM_NAME
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
import ctypes import ctypes
@@ -254,11 +273,15 @@ def prevent_sleep_start():
elif system == "Darwin": elif system == "Darwin":
_sleep_procs["Darwin"] = create_process(["caffeinate"]) _sleep_procs["Darwin"] = create_process(["caffeinate"])
elif system == "Linux": 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( _sleep_procs["Linux"] = create_process(
[ [
"systemd-inhibit", "systemd-inhibit",
"--what=handle-lid-switch:sleep", f"--who={program_name}",
f"--why={reason}",
"--what=sleep",
"--mode=block", "--mode=block",
"sleep", "sleep",
"infinity", "infinity",
+1
View File
@@ -15,6 +15,7 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko
dependencies = [ dependencies = [
"PyQt5>=5.15.11", "PyQt5>=5.15.11",
"kokoro>=0.9.4", "kokoro>=0.9.4",
"misaki[zh]>=0.9.4",
"ebooklib>=0.19", "ebooklib>=0.19",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
"PyMuPDF>=1.25.5", "PyMuPDF>=1.25.5",