20 Commits
Author SHA1 Message Date
Deniz Şafak 507aedeae1 v1.0.8 2025-05-22 06:26:37 +03:00
Deniz Şafak c5faab4bd7 Add clearing preview cache option 2025-05-22 06:10:58 +03:00
Deniz Şafak 3278447d6a Added a download tracker that displays informative messages 2025-05-22 06:03:56 +03:00
Deniz Şafak a0e59564af Ignore metadata and leding/trailing spaces when counting text length 2025-05-22 02:54:29 +03:00
Deniz Şafak 6e96fb6405 Fix previewed voice states when cancel 2025-05-22 02:42:45 +03:00
Deniz Şafak f1cb92900c Updated profile and voice mixer icons, better visibility and aesthetics in voice mixer 2025-05-22 02:26:17 +03:00
Deniz Şafak 065eaa4745 Added voice preview caching system 2025-05-22 00:26:27 +03:00
Deniz Şafak 082bb08e48 Removed abogen_ prefix that was adding to converted books 2025-05-21 20:54:34 +03:00
Deniz Şafak f286c4c6dc Added new option: Separate chapters audio format 2025-05-21 20:03:36 +03:00
Deniz Şafak 669ef26667 Added extra metadata support for chaptered M4B files 2025-05-21 18:42:41 +03:00
Deniz Şafak fd10aab6f3 Fixed voice preview player keeps open at the background 2025-05-21 04:54:13 +03:00
Deniz Şafak 0d7e746516 Update CHANGELOG 2025-05-20 04:30:59 +03:00
Deniz Şafak 2cb2f3dfb1 Fix NVML Shared library error 2025-05-20 04:23:22 +03:00
Deniz Şafak 578da66f34 Update GPU check logic 2025-05-20 04:11:17 +03:00
Deniz Şafak 814c171a90 Update GPU detection logic 2025-05-20 03:32:29 +03:00
Deniz Şafak b3b420beec Do not install CUDA in Windows if GPU is AMD 2025-05-20 02:48:02 +03:00
Deniz Şafak 93be50eda1 Add support for AMD in Linux 2025-05-20 02:46:43 +03:00
Deniz Şafak 9c44001601 Fix app icon issue in Linux 2025-05-15 21:22:18 +03:00
Deniz Şafak e03336836b Better sleep state handling for Linux 2025-05-15 21:15:47 +03:00
Deniz Şafak b75277a3cb Improved input box background color handling 2025-05-15 21:13:00 +03:00
14 changed files with 653 additions and 130 deletions
+15
View File
@@ -1,3 +1,18 @@
# v1.0.8
- Added support for AMD GPUs in Linux (Special thanks to @hg000125 for his contribution in #23)
- Added voice preview caching system that stores generated previews in the cache folder, mentioned by @jborza in #22
- Added extra metadata support for chaptered M4B files, ensuring better compatibility with audiobook players.
- Added new option: `Separate chapters audio format`, allowing to choose between `wav`, `mp4`, `flac` and `opus` formats for chaptered audio files.
- Added a download tracker that displays informative messages while downloading Kokoro models or voices from HuggingFace.
- Skipping PyTorch CUDA installation if GPU is not NVIDIA in WINDOWS_INSTALL.bat script, preventing unnecessary installation of PyTorch.
- Removed `abogen_` prefix that was adding to converted books in temp directory.
- Fixed voice preview player keeps playing silently at the background after preview ends.
- Fixed not writing separate chapters audio when output is OPUS.
- Improved input box background color handling, fixed display issues in Linux.
- Updated profile and voice mixer icons, better visibility and aesthetics in voice mixer.
- Better sleep state handling for Linux.
- Improvements in documentation and code.
# v1.0.7 # v1.0.7
- Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`. - Improve chaptered audio generation by outputting directly as `m4b` instead of converting from `wav`.
- Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation. - Ignore chapter markers and single newlines when calculating text length, improving the accuracy of the text length calculation.
+16 -2
View File
@@ -23,6 +23,9 @@ Go to [espeak-ng latest release](https://github.com/espeak-ng/espeak-ng/releases
# For NVIDIA GPUs: # For NVIDIA GPUs:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
# For AMD GPUs:
# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU.
# Install abogen # Install abogen
pip install abogen pip install abogen
``` ```
@@ -54,6 +57,13 @@ sudo dnf install espeak-ng
# Install abogen # Install abogen
pip install abogen pip install abogen
# For NVIDIA GPUs:
# Already supported, no need to install CUDA separately.
# For AMD GPUs:
pip uninstall torch # After installing abogen, we need to uninstall the existing torch package
pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4
``` ```
> If you get `WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH.` error, run the following command to add it to your PATH: > If you get `WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH.` error, run the following command to add it to your PATH:
>```bash >```bash
@@ -62,6 +72,8 @@ pip install abogen
> If you get "No matching distribution found" error, try installing it on supported Python (3.10 to 3.12). You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide. > If you get "No matching distribution found" error, try installing it on supported Python (3.10 to 3.12). You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide.
> Special thanks to [@hg000125](https://github.com/hg000125) for his contribution in [#23](https://github.com/denizsafak/abogen/issues/23). AMD GPU support is possible thanks to his work.
## `How to run?` ## `How to run?`
If you installed using pip, you can simply run the following command to start Abogen: If you installed using pip, you can simply run the following command to start Abogen:
@@ -99,10 +111,11 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
- **Options**: - **Options**:
- **Replace single newlines with spaces**: Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. - **Replace single newlines with spaces**: Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks.
- **Configure max words per subtitle**: Configures the maximum number of words per subtitle entry. - **Configure max words per subtitle**: Configures the maximum number of words per subtitle entry.
- **Separate chapters audio format**: Configures the audio format for separate chapters (e.g., `WAV`, `FLAC`, `MP3`, `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.json directory**: Opens the directory where the configuration file is stored.
- **Open temp directory**: Opens the temporary directory where converted text files are stored. - **Open temp directory**: Opens the temporary directory where converted text files are stored.
- **Clear all temporary files**: Deletes all temporary files created during the conversion process. - **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. - **Check for updates at startup**: Automatically checks for updates when the program starts.
- **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`. - **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`.
@@ -205,6 +218,7 @@ Abogen is a standalone project, but it is inspired by and shares some similariti
- [autiobooks](https://github.com/plusuncold/autiobooks): Automatically convert epubs to audiobooks - [autiobooks](https://github.com/plusuncold/autiobooks): Automatically convert epubs to audiobooks
- [pdf-narrator](https://github.com/mateogon/pdf-narrator): Convert your PDFs and EPUBs into audiobooks effortlessly. - [pdf-narrator](https://github.com/mateogon/pdf-narrator): Convert your PDFs and EPUBs into audiobooks effortlessly.
- [epub_to_audiobook](https://github.com/p0n1/epub_to_audiobook): EPUB to audiobook converter, optimized for Audiobookshelf - [epub_to_audiobook](https://github.com/p0n1/epub_to_audiobook): EPUB to audiobook converter, optimized for Audiobookshelf
- [ebook2audiobook](https://github.com/DrewThomasson/ebook2audiobook): Convert ebooks to audiobooks with chapters and metadata using dynamic AI models and voice cloning
## `Roadmap` ## `Roadmap`
- [ ] Add OCR scan feature for PDF files using docling. - [ ] Add OCR scan feature for PDF files using docling.
@@ -239,7 +253,7 @@ Feel free to explore the code and make any changes you like.
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files. - Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files.
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/). - Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male), [Adjust](https://icons8.com/icon/21698/adjust) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/).
## `License` ## `License`
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details. This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
+18 -2
View File
@@ -230,13 +230,22 @@ if errorlevel 1 (
:: Install setup requirements :: Install setup requirements
echo Installing setup requirements... echo Installing setup requirements...
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools wheel sphinx hatchling --no-warn-script-location %PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools setuptools-scm wheel sphinx hatchling --no-warn-script-location
if errorlevel 1 ( if errorlevel 1 (
echo Failed to install setup requirements. echo Failed to install setup requirements.
pause pause
exit /b exit /b
) )
:: Install gpustat
echo Installing gpustat...
%PYTHON_CONSOLE_PATH% -m pip install gpustat --no-warn-script-location
if errorlevel 1 (
echo Failed to install gpustat.
pause
exit /b
)
:: Install project and dependencies from pyproject.toml :: Install project and dependencies from pyproject.toml
echo Checking and installing project dependencies... echo Checking and installing project dependencies...
if exist %PYPROJECT_FILE% ( if exist %PYPROJECT_FILE% (
@@ -263,8 +272,12 @@ if "%MISAKI_LANG%" NEQ "en" (
) )
) )
:: Check for NVIDIA GPU via is_nvidia()
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 Checking CUDA availability... 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 for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
if "%cuda_available%"=="False" ( if "%cuda_available%"=="False" (
@@ -277,7 +290,10 @@ if "%cuda_available%"=="False" (
exit /b exit /b
) )
) else ( ) else (
echo CUDA is available. echo CUDA is available on NVIDIA GPU.
)
) else (
echo GPU is not NVIDIA. Skipping PyTorch CUDA installation.
) )
:: 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.0.7 1.0.8
Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 B

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 331 B

After

Width:  |  Height:  |  Size: 376 B

+88 -8
View File
@@ -1494,8 +1494,13 @@ class HandlerDialog(QDialog):
authors_text = ", ".join(self.book_metadata["authors"]) authors_text = ", ".join(self.book_metadata["authors"])
html_content += f"<p style='text-align: center; font-style: italic;'>By {authors_text}</p>" html_content += f"<p style='text-align: center; font-style: italic;'>By {authors_text}</p>"
if self.book_metadata["publisher"] or self.book_metadata.get("publication_year"):
pub_info = []
if self.book_metadata["publisher"]: if self.book_metadata["publisher"]:
html_content += f"<p style='text-align: center;'>Published by {self.book_metadata['publisher']}</p>" pub_info.append(f"Published by {self.book_metadata['publisher']}")
if self.book_metadata.get("publication_year"):
pub_info.append(f"Year: {self.book_metadata['publication_year']}")
html_content += f"<p style='text-align: center;'>{' | '.join(pub_info)}</p>"
html_content += "<hr/>" html_content += "<hr/>"
@@ -1517,24 +1522,51 @@ class HandlerDialog(QDialog):
"description": None, "description": None,
"cover_image": None, "cover_image": None,
"publisher": None, "publisher": None,
"publication_year": None,
} }
if self.file_type == "epub": if self.file_type == "epub":
try:
title_items = self.book.get_metadata("DC", "title") title_items = self.book.get_metadata("DC", "title")
if title_items: if title_items and len(title_items) > 0:
metadata["title"] = title_items[0][0] metadata["title"] = title_items[0][0]
except Exception as e:
logging.warning(f"Error extracting title metadata: {e}")
try:
author_items = self.book.get_metadata("DC", "creator") author_items = self.book.get_metadata("DC", "creator")
if author_items: if author_items:
metadata["authors"] = [author[0] for author in author_items] metadata["authors"] = [author[0] for author in author_items if len(author) > 0]
except Exception as e:
logging.warning(f"Error extracting author metadata: {e}")
try:
desc_items = self.book.get_metadata("DC", "description") desc_items = self.book.get_metadata("DC", "description")
if desc_items: if desc_items and len(desc_items) > 0:
metadata["description"] = desc_items[0][0] metadata["description"] = desc_items[0][0]
except Exception as e:
logging.warning(f"Error extracting description metadata: {e}")
try:
publisher_items = self.book.get_metadata("DC", "publisher") publisher_items = self.book.get_metadata("DC", "publisher")
if publisher_items: if publisher_items and len(publisher_items) > 0:
metadata["publisher"] = publisher_items[0][0] metadata["publisher"] = publisher_items[0][0]
except Exception as e:
logging.warning(f"Error extracting publisher metadata: {e}")
# Try to extract publication year
try:
date_items = self.book.get_metadata("DC", "date")
if date_items and len(date_items) > 0:
date_str = date_items[0][0]
# Try to extract just the year from the date string
year_match = re.search(r'\b(19|20)\d{2}\b', date_str)
if year_match:
metadata["publication_year"] = year_match.group(0)
else:
metadata["publication_year"] = date_str
except Exception as e:
logging.warning(f"Error extracting publication date metadata: {e}")
for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
metadata["cover_image"] = item.get_content() metadata["cover_image"] = item.get_content()
@@ -1565,6 +1597,18 @@ class HandlerDialog(QDialog):
metadata["publisher"] = pdf_info.get("creator", None) metadata["publisher"] = pdf_info.get("creator", None)
# Try to extract publication date from PDF metadata
if "creationDate" in pdf_info:
date_str = pdf_info["creationDate"]
year_match = re.search(r'D:(\d{4})', date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
elif "modDate" in pdf_info:
date_str = pdf_info["modDate"]
year_match = re.search(r'D:(\d{4})', date_str)
if year_match:
metadata["publication_year"] = year_match.group(1)
if len(self.pdf_doc) > 0: if len(self.pdf_doc) > 0:
try: try:
pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2)) pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
@@ -1580,10 +1624,43 @@ class HandlerDialog(QDialog):
else: else:
return self._get_pdf_selected_text() return self._get_pdf_selected_text()
def _format_metadata_tags(self):
"""Format metadata tags for insertion at the beginning of the text"""
import datetime
metadata = self.book_metadata
filename = os.path.splitext(os.path.basename(self.book_path))[0]
current_year = str(datetime.datetime.now().year)
# Get values with fallbacks
title = metadata.get("title") or filename
authors = metadata.get("authors") or ["Unknown"]
authors_text = ", ".join(authors)
album_artist = authors_text or "Unknown"
year = metadata.get("publication_year") or current_year # Use publication year if available
# Count chapters/pages
total_chapters = len(self.checked_chapters)
chapter_text = f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}"
# Format metadata tags
metadata_tags = [
f"<<METADATA_TITLE:{title}>>",
f"<<METADATA_ARTIST:{authors_text}>>",
f"<<METADATA_ALBUM:{title} ({chapter_text})>>",
f"<<METADATA_YEAR:{year}>>",
f"<<METADATA_ALBUM_ARTIST:{album_artist}>>"
]
return "\n".join(metadata_tags)
def _get_epub_selected_text(self): def _get_epub_selected_text(self):
all_checked_identifiers = set() all_checked_identifiers = set()
chapter_texts = [] chapter_texts = []
# Add metadata tags at the beginning
metadata_tags = self._format_metadata_tags()
item_order_counter = 0 item_order_counter = 0
ordered_checked_items = [] ordered_checked_items = []
@@ -1608,7 +1685,7 @@ class HandlerDialog(QDialog):
marker = f"<<CHAPTER_MARKER:{title}>>" marker = f"<<CHAPTER_MARKER:{title}>>"
chapter_texts.append(marker + "\n" + text) chapter_texts.append(marker + "\n" + text)
full_text = "\n\n".join(chapter_texts) full_text = metadata_tags + "\n\n" + "\n\n".join(chapter_texts)
return full_text, all_checked_identifiers return full_text, all_checked_identifiers
def _get_pdf_selected_text(self): def _get_pdf_selected_text(self):
@@ -1617,6 +1694,9 @@ class HandlerDialog(QDialog):
section_titles = [] section_titles = []
all_content = [] all_content = []
# Add metadata tags at the beginning
metadata_tags = self._format_metadata_tags()
pdf_has_no_bookmarks = ( pdf_has_no_bookmarks = (
hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks
) )
@@ -1641,7 +1721,7 @@ class HandlerDialog(QDialog):
if text: if text:
all_content.append(text) all_content.append(text)
included_text_ids.add(page_id) included_text_ids.add(page_id)
return "\n\n".join(all_content), all_checked_identifiers return metadata_tags + "\n\n" + "\n\n".join(all_content), all_checked_identifiers
iterator = QTreeWidgetItemIterator(self.treeWidget) iterator = QTreeWidgetItemIterator(self.treeWidget)
while iterator.value(): while iterator.value():
@@ -1699,7 +1779,7 @@ class HandlerDialog(QDialog):
included_text_ids.add(identifier) included_text_ids.add(identifier)
iterator += 1 iterator += 1
return "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers return metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers
def on_save_chapters_changed(self, state): def on_save_chapters_changed(self, state):
self.save_chapters_separately = bool(state) self.save_chapters_separately = bool(state)
+146 -21
View File
@@ -4,6 +4,7 @@ import tempfile
import time import time
import chardet import chardet
import charset_normalizer import charset_normalizer
import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
@@ -11,6 +12,7 @@ import soundfile as sf
from utils import clean_text, create_process from utils import clean_text, create_process
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
from voice_formulas import get_new_voice from voice_formulas import get_new_voice
import hf_tracker
import static_ffmpeg import static_ffmpeg
import threading # for efficient waiting import threading # for efficient waiting
import subprocess import subprocess
@@ -160,6 +162,7 @@ class ConversionThread(QThread):
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n"
) )
try: try:
hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg))
# Show configuration # Show configuration
self.log_updated.emit("Configuration:") self.log_updated.emit("Configuration:")
# Use display_path for logs if available, otherwise use the actual file name # Use display_path for logs if available, otherwise use the actual file name
@@ -196,6 +199,9 @@ class ConversionThread(QThread):
self.log_updated.emit( self.log_updated.emit(
f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}" f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}"
) )
# Display the separate chapters format if it's set
separate_format = getattr(self, 'separate_chapters_format', 'wav')
self.log_updated.emit(f"- Separate chapters format: {separate_format}")
if self.save_option == "Choose output folder": if self.save_option == "Choose output folder":
self.log_updated.emit( self.log_updated.emit(
@@ -221,6 +227,10 @@ class ConversionThread(QThread):
# Clean up text using utility function # Clean up text using utility function
text = clean_text(text) text = clean_text(text)
# Remove metadata markers from the text to be processed
metadata_pattern = r"<<METADATA_[^:]+:[^>]*>>"
text = re.sub(metadata_pattern, "", text)
# --- Chapter splitting logic --- # --- Chapter splitting logic ---
chapter_pattern = r"<<CHAPTER_MARKER:(.*?)>>" chapter_pattern = r"<<CHAPTER_MARKER:(.*?)>>"
chapter_splits = list(re.finditer(chapter_pattern, text)) chapter_splits = list(re.finditer(chapter_pattern, text))
@@ -500,18 +510,20 @@ class ConversionThread(QThread):
# Concatenate chapter audio and save # Concatenate chapter audio and save
chapter_audio = self.np.concatenate(chapter_audio_segments) chapter_audio = self.np.concatenate(chapter_audio_segments)
# Determine chapter extension (.wav for m4b output)
chapter_ext = 'wav' if self.output_format == 'm4b' else self.output_format # Use separate_chapters_format
separate_format = getattr(self, 'separate_chapters_format', 'wav')
chapter_out_path = os.path.join( chapter_out_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{chapter_ext}" chapters_out_dir, f"{chapter_filename}.{separate_format}"
) )
if self.output_format == "opus": if separate_format == "opus":
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
proc = create_process( proc = create_process(
[ [
"ffmpeg", "ffmpeg",
"-y", "-y",
"-thread_queue_size", "1024", # Increased thread_queue_size for chapter opus "-thread_queue_size", "32768", # Increased thread_queue_size for chapter opus
"-f", "-f",
"f32le", "f32le",
"-ar", "-ar",
@@ -537,7 +549,7 @@ class ConversionThread(QThread):
chapter_out_path, chapter_out_path,
chapter_audio, chapter_audio,
24000, 24000,
format='wav' if self.output_format == 'm4b' else self.output_format, format=separate_format,
) )
# Generate .srt subtitle file for chapter if not Disabled # Generate .srt subtitle file for chapter if not Disabled
@@ -597,7 +609,7 @@ class ConversionThread(QThread):
opus_out_path = f"{base_filepath_no_ext}.opus" opus_out_path = f"{base_filepath_no_ext}.opus"
ffmpeg_cmd_opus = [ ffmpeg_cmd_opus = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-thread_queue_size", "1024", # Increased thread_queue_size "-thread_queue_size", "32768", # Increased thread_queue_size
"-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0", "-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0",
"-c:a", "libopus", "-b:a", "24000", # Original bitrate "-c:a", "libopus", "-b:a", "24000", # Original bitrate
opus_out_path, opus_out_path,
@@ -701,13 +713,20 @@ class ConversionThread(QThread):
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
ffmpeg_cmd = [ ffmpeg_cmd = [
"ffmpeg", "-y", "ffmpeg", "-y",
"-thread_queue_size", "1024", # Increased thread_queue_size "-thread_queue_size", "32768", # Increased thread_queue_size
"-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0", "-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0",
"-i", chapters_info_path, "-i", chapters_info_path,
"-map", "0:a", "-map", "0:a",
"-map_metadata", "1", "-map_metadata", "1",
"-map_chapters", "1", "-map_chapters", "1",
"-c:a", "aac", "-b:a", "128k", # Explicitly AAC with a common bitrate # Add metadata tags
"-metadata", "composer=Narrator",
"-metadata", "genre=Audiobook",
# Extract metadata from text markers if available
*self._extract_and_add_metadata_tags_to_ffmpeg_cmd(),
"-c:a", "aac",
"-q:a", "2", # Quality-based VBR for better quality control (lower = better, range 0.1-2)
"-movflags", "+faststart+use_metadata_tags", # Added for better compatibility
output_m4b_path, output_m4b_path,
] ]
@@ -742,6 +761,69 @@ class ConversionThread(QThread):
except Exception as e_clean: except Exception as e_clean:
self.log_updated.emit((f"Warning: Could not delete temporary chapter file {chapters_info_path}: {e_clean}", "orange")) self.log_updated.emit((f"Warning: Could not delete temporary chapter file {chapters_info_path}: {e_clean}", "orange"))
def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self):
"""Extract metadata tags from text content and add them to ffmpeg command"""
metadata_options = []
# Get the input text (either direct or from file)
text = ""
if self.is_direct_text:
text = self.file_name
else:
try:
encoding = detect_encoding(self.file_name)
with open(self.file_name, "r", encoding=encoding, errors="replace") as file:
text = file.read()
except Exception as e:
self.log_updated.emit(f"Warning: Could not read file for metadata extraction: {e}")
return []
# Extract metadata tags using regex
title_match = re.search(r"<<METADATA_TITLE:([^>]*)>>", text)
artist_match = re.search(r"<<METADATA_ARTIST:([^>]*)>>", text)
album_match = re.search(r"<<METADATA_ALBUM:([^>]*)>>", text)
year_match = re.search(r"<<METADATA_YEAR:([^>]*)>>", text)
album_artist_match = re.search(r"<<METADATA_ALBUM_ARTIST:([^>]*)>>", text)
# Use display path or filename as fallback for title
filename = os.path.splitext(os.path.basename(self.display_path if self.display_path else self.file_name))[0]
# Add title metadata
if title_match:
metadata_options.extend(["-metadata", f"title=Dinginliğin Gücü"])
else:
metadata_options.extend(["-metadata", f"title={filename}"])
# Add artist metadata
if artist_match:
metadata_options.extend(["-metadata", f"artist={artist_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"artist=Unknown"])
# Add album metadata
if album_match:
metadata_options.extend(["-metadata", f"album={album_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"album={filename}"])
# Add year metadata
if year_match:
metadata_options.extend(["-metadata", f"date={year_match.group(1)}"])
else:
# Use current year if year is not specified
import datetime
current_year = datetime.datetime.now().year
metadata_options.extend(["-metadata", f"date={current_year}"])
# Add album artist metadata
if album_artist_match:
metadata_options.extend(["-metadata", f"album_artist={album_artist_match.group(1)}"])
else:
metadata_options.extend(["-metadata", f"album_artist=Unknown"])
# Add these to ffmpeg command
return metadata_options
def _srt_time(self, t): def _srt_time(self, t):
"""Helper function to format time for SRT files""" """Helper function to format time for SRT files"""
h = int(t // 3600) h = int(t // 3600)
@@ -865,10 +947,31 @@ class VoicePreviewThread(QThread):
self.speed = speed self.speed = speed
self.use_gpu = use_gpu self.use_gpu = use_gpu
# Cache location for preview audio
self.cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache")
os.makedirs(self.cache_dir, exist_ok=True)
# Calculate cache path
self.cache_path = self._get_cache_path()
def _get_cache_path(self):
"""Generate a unique filename for the voice with its parameters"""
# For a voice formula, use a hash of the formula
if "*" in self.voice:
voice_id = f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}"
else:
voice_id = self.voice
# Create a unique filename based on voice_id, language, and speed
filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav"
return os.path.join(self.cache_dir, filename)
def run(self): def run(self):
print( print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
) )
# Generate the preview and save to cache
try: try:
device = "cuda" if self.use_gpu else "cpu" device = "cuda" if self.use_gpu else "cpu"
tts = self.kpipeline_class( tts = self.kpipeline_class(
@@ -887,15 +990,9 @@ class VoicePreviewThread(QThread):
audio_segments.append(result.audio) audio_segments.append(result.audio)
if audio_segments: if audio_segments:
audio = self.np_module.concatenate(audio_segments) audio = self.np_module.concatenate(audio_segments)
# Create temp wav file in a folder in the system temp directory # Save directly to the cache path
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) sf.write(self.cache_path, audio, 24000)
os.makedirs(temp_dir, exist_ok=True) self.temp_wav = self.cache_path
fd, temp_path = tempfile.mkstemp(
prefix="abogen_", suffix=".wav", dir=temp_dir
)
os.close(fd)
sf.write(temp_path, audio, 24000)
self.temp_wav = temp_path
self.finished.emit() self.finished.emit()
except Exception as e: except Exception as e:
self.error.emit(f"Voice preview error: {str(e)}") self.error.emit(f"Voice preview error: {str(e)}")
@@ -908,6 +1005,7 @@ class PlayAudioThread(QThread):
def __init__(self, wav_path, parent=None): def __init__(self, wav_path, parent=None):
super().__init__(parent) super().__init__(parent)
self.wav_path = wav_path self.wav_path = wav_path
self.is_canceled = False
def run(self): def run(self):
try: try:
@@ -917,10 +1015,37 @@ class PlayAudioThread(QThread):
pygame.mixer.init() pygame.mixer.init()
pygame.mixer.music.load(self.wav_path) pygame.mixer.music.load(self.wav_path)
pygame.mixer.music.play() pygame.mixer.music.play()
# Wait until playback is finished # Wait until playback is finished or canceled
while pygame.mixer.music.get_busy(): while pygame.mixer.music.get_busy() and not self.is_canceled:
_time.sleep(0.1) _time.sleep(0.2)
# Make sure to clean up regardless of how we exited the loop
try:
pygame.mixer.music.stop()
pygame.mixer.music.unload() pygame.mixer.music.unload()
pygame.mixer.quit() # Quit the mixer
except Exception:
# Ignore any errors during cleanup
pass
self.finished.emit() self.finished.emit()
except Exception as e: except Exception as e:
# Handle initialization errors separately to give better error messages
if "mixer not initialized" in str(e):
self.error.emit("Audio playback error: The audio system was not properly initialized")
else:
self.error.emit(f"Audio playback error: {str(e)}") self.error.emit(f"Audio playback error: {str(e)}")
def stop(self):
"""Safely stop playback"""
self.is_canceled = True
# Try to stop pygame if it's running, but catch all exceptions
try:
import pygame
if pygame.mixer.get_init():
if pygame.mixer.music.get_busy():
pygame.mixer.music.stop()
pygame.mixer.music.unload()
except Exception:
# Ignore all errors when stopping since mixer might not be initialized
pass
+227 -42
View File
@@ -4,6 +4,8 @@ import tempfile
import platform import platform
import base64 import base64
import re import re
import hf_tracker
import hashlib # Added for cache path generation
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QApplication,
QWidget, QWidget,
@@ -23,6 +25,7 @@ from PyQt5.QtWidgets import (
QCheckBox, QCheckBox,
QMenu, QMenu,
QAction, QAction,
QActionGroup,
) )
from PyQt5.QtCore import ( from PyQt5.QtCore import (
Qt, Qt,
@@ -78,6 +81,13 @@ if platform.system() == "Windows":
import ctypes import ctypes
class ShowWarningSignalEmitter(QObject): # New class to handle signal emission
show_warning_signal = pyqtSignal(str, str)
def emit(self, title, message):
self.show_warning_signal.emit(title, message)
class ThreadSafeLogSignal(QObject): class ThreadSafeLogSignal(QObject):
log_signal = pyqtSignal(object) log_signal = pyqtSignal(object)
@@ -94,9 +104,9 @@ class IconProvider(QFileIconProvider):
class InputBox(QLabel): class InputBox(QLabel):
LABEL_CSS = """QLabel { border:2px dashed #aaa; border-radius:5px; padding:20px; background:#f5f9fc; min-height:100px; } QLabel:hover { background:#e5f1fa; border-color:#6ab0de; }""" LABEL_CSS = """QLabel { border:2px dashed #aaa; border-radius:5px; padding:20px; background:rgba(0, 102, 255, 0.05); min-height:100px; } QLabel:hover { background:rgba(0, 102, 255, 0.1); border-color:#6ab0de; }"""
LABEL_CSS_ACTIVE = """QLabel { border:2px dashed #42ad4a; border-radius:5px; padding:20px; background:#e6f7e6;min-height:100px; } QLabel:hover { background:#e5f1fa; border-color:#6ab0de; }""" LABEL_CSS_ACTIVE = """QLabel { border:2px dashed #42ad4a; border-radius:5px; padding:20px; background:rgba(66, 173, 73, 0.1);min-height:100px; } QLabel:hover { background:rgba(66, 173, 73, 0.15); border-color:#42ad4a; }"""
LABEL_CSS_ERROR = """QLabel { border:2px dashed #e74c3c; border-radius:5px; padding:20px; background:#ffeaea; min-height:100px; color:#c0392b; } QLabel:hover { background:#ffeaea; border-color:#e74c3c; }""" LABEL_CSS_ERROR = """QLabel { border:2px dashed #e74c3c; border-radius:5px; padding:20px; background:rgba(232, 78, 60, 0.10); min-height:100px; color:#c0392b; } QLabel:hover { background:rgba(232, 78, 60, 0.15); border-color:#e74c3c; }"""
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -519,6 +529,7 @@ class abogen(QWidget):
"max_subtitle_words", 50 "max_subtitle_words", 50
) # Default max words per subtitle ) # Default max words per subtitle
self.selected_format = self.config.get("selected_format", "wav") self.selected_format = self.config.get("selected_format", "wav")
self.separate_chapters_format = self.config.get("separate_chapters_format", "wav") # Format for individual chapter files
self.use_gpu = self.config.get( self.use_gpu = self.config.get(
"use_gpu", True # Load GPU setting with default True "use_gpu", True # Load GPU setting with default True
) )
@@ -530,6 +541,11 @@ class abogen(QWidget):
self.log_signal = ThreadSafeLogSignal() self.log_signal = ThreadSafeLogSignal()
self.log_signal.log_signal.connect(self._update_log_main_thread) self.log_signal.log_signal.connect(self._update_log_main_thread)
# Create warning signal emitter
self.warning_signal_emitter = ShowWarningSignalEmitter()
self.warning_signal_emitter.show_warning_signal.connect(self.show_model_download_warning)
hf_tracker.set_show_warning_signal_emitter(self.warning_signal_emitter)
# Set application icon # Set application icon
icon_path = get_resource_path("abogen.assets", "icon.ico") icon_path = get_resource_path("abogen.assets", "icon.ico")
if icon_path: if icon_path:
@@ -581,6 +597,9 @@ class abogen(QWidget):
if self.check_updates: if self.check_updates:
QTimer.singleShot(1000, self.check_for_updates_startup) QTimer.singleShot(1000, self.check_for_updates_startup)
# Set hf_tracker callbacks
hf_tracker.set_log_callback(self.update_log)
def initUI(self): def initUI(self):
self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}") self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
screen = QApplication.primaryScreen().geometry() screen = QApplication.primaryScreen().geometry()
@@ -677,6 +696,7 @@ class abogen(QWidget):
self.btn_preview.clicked.connect(self.preview_voice) self.btn_preview.clicked.connect(self.preview_voice)
voice_row.addWidget(self.btn_preview) voice_row.addWidget(self.btn_preview)
self.preview_playing = False self.preview_playing = False
self.play_audio_thread = None # Keep track of audio playing thread
voice_layout.addLayout(voice_row) voice_layout.addLayout(voice_row)
controls_layout.addLayout(voice_layout) controls_layout.addLayout(voice_layout)
# Subtitle mode # Subtitle mode
@@ -938,10 +958,12 @@ class abogen(QWidget):
title = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('title', 'Unknown'))) title = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('title', 'Unknown')))
publisher = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('publisher', 'Unknown'))) publisher = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('publisher', 'Unknown')))
authors = [re.sub(r'<[^>]+>', '', str(author)) for author in dialog.book_metadata.get('authors', ['Unknown'])] authors = [re.sub(r'<[^>]+>', '', str(author)) for author in dialog.book_metadata.get('authors', ['Unknown'])]
publication_year = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('publication_year', 'Unknown')))
f.write(f"Title: {title}\n") f.write(f"Title: {title}\n")
f.write(f"Authors: {', '.join(authors)}\n") f.write(f"Authors: {', '.join(authors)}\n")
f.write(f"Publisher: {publisher}\n") f.write(f"Publisher: {publisher}\n")
f.write(f"Publication Year: {publication_year}\n")
if dialog.book_metadata.get('description'): if dialog.book_metadata.get('description'):
description = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('description'))) description = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('description')))
f.write(f"\nDescription:\n{description}\n") f.write(f"\nDescription:\n{description}\n")
@@ -956,7 +978,7 @@ class abogen(QWidget):
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
fd, tmp = tempfile.mkstemp( fd, tmp = tempfile.mkstemp(
prefix=f"abogen_{base_name}_", suffix=".txt", dir=temp_dir prefix=f"{base_name}_", suffix=".txt", dir=temp_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:
@@ -1305,6 +1327,8 @@ class abogen(QWidget):
self.conversion_thread.replace_single_newlines = ( self.conversion_thread.replace_single_newlines = (
self.replace_single_newlines self.replace_single_newlines
) )
# Pass separate_chapters_format setting
self.conversion_thread.separate_chapters_format = self.separate_chapters_format
# Pass chapter count for EPUB or PDF files # Pass chapter count for EPUB or PDF files
if self.selected_file_type in ["epub", "pdf"] and hasattr( if self.selected_file_type in ["epub", "pdf"] and hasattr(
self, "selected_chapters" self, "selected_chapters"
@@ -1518,20 +1542,100 @@ class abogen(QWidget):
"Open File Error", f"Could not open file:\n{e}" "Open File Error", f"Could not open file:\n{e}"
) )
def _get_preview_cache_path(self):
"""Generate the expected cache path for the current voice settings."""
speed = self.speed_slider.value() / 100.0
voice_to_cache = ""
lang_to_cache = ""
if self.mixed_voice_state:
components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
voice_formula = " + ".join(filter(None, components))
voice_to_cache = voice_formula
if self.selected_profile_name:
from voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
lang_to_cache = entry.get("language")
else:
lang_to_cache = self.selected_lang
if not lang_to_cache and self.mixed_voice_state:
lang_to_cache = (
self.mixed_voice_state[0][0][0]
if self.mixed_voice_state and self.mixed_voice_state[0][0]
else None
)
elif self.selected_voice:
lang_to_cache = self.selected_voice[0]
voice_to_cache = self.selected_voice
else: # No voice or profile selected
return None
if not lang_to_cache or not voice_to_cache: # Not enough info
return None
cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache")
if "*" in voice_to_cache: # Voice formula
voice_id = f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}"
else: # Single voice
voice_id = voice_to_cache
filename = f"{voice_id}_{lang_to_cache}_{speed:.2f}.wav"
return os.path.join(cache_dir, filename)
def preview_voice(self): def preview_voice(self):
if self.preview_playing: if self.preview_playing:
try: try:
import pygame if self.play_audio_thread and self.play_audio_thread.isRunning():
# Call the stop method on PlayAudioThread to safely handle stopping
pygame.mixer.music.stop() self.play_audio_thread.stop()
except Exception: self.play_audio_thread.wait(500) # Wait a bit
pass except Exception as e:
print(f"Error stopping preview audio: {e}")
self._preview_cleanup() self._preview_cleanup()
return return
if hasattr(self, "preview_thread") and self.preview_thread.isRunning(): if hasattr(self, "preview_thread") and self.preview_thread.isRunning():
return return
# Check for cache first
cached_path = self._get_preview_cache_path()
if cached_path and os.path.exists(cached_path):
print(f"Cache hit for {cached_path}")
self.btn_preview.setEnabled(False) # Disable button briefly
self.voice_combo.setEnabled(False)
self.btn_voice_formula_mixer.setEnabled(False)
self.btn_start.setEnabled(False)
# Directly play from cache
self.preview_playing = True
self.btn_preview.setIcon(self.stop_icon)
self.btn_preview.setToolTip("Stop preview")
self.btn_preview.setEnabled(True)
def cleanup_cached_play():
self._preview_cleanup()
try:
# Ensure pygame mixer is initialized for the audio thread
import pygame
if not pygame.mixer.get_init():
pygame.mixer.init()
self.play_audio_thread = PlayAudioThread(cached_path)
self.play_audio_thread.finished.connect(cleanup_cached_play)
self.play_audio_thread.error.connect(
lambda msg: (self._show_preview_error_box(msg), cleanup_cached_play())
)
self.play_audio_thread.start()
except Exception as e:
self._show_error_message_box(
"Preview Error", f"Could not play cached preview audio:\n{e}"
)
cleanup_cached_play()
return
# If no cache hit, proceed to load pipeline and generate
self.btn_preview.setEnabled(False) self.btn_preview.setEnabled(False)
self.btn_preview.setToolTip("Loading...") self.btn_preview.setToolTip("Loading...")
self.voice_combo.setEnabled(False) self.voice_combo.setEnabled(False)
@@ -1609,42 +1713,60 @@ class abogen(QWidget):
self.preview_thread.error.connect(self._preview_error) self.preview_thread.error.connect(self._preview_error)
self.preview_thread.start() self.preview_thread.start()
def _play_preview_audio(self): def _play_preview_audio(self, from_cache=True): # from_cache default is now False
# If preview_thread is the source, get temp_wav from it
if hasattr(self, 'preview_thread') and not from_cache:
temp_wav = self.preview_thread.temp_wav temp_wav = self.preview_thread.temp_wav
if not temp_wav: elif from_cache: # This case is now handled before calling _play_preview_audio
self.loading_movie.stop() cached_path = self._get_preview_cache_path()
if cached_path and os.path.exists(cached_path):
temp_wav = cached_path
else: # Should not happen if cache check was done
self._show_error_message_box("Preview Error", "Cache file expected but not found.")
self._preview_cleanup()
return
else: # Should have temp_wav from preview_thread or handled by cache check
self._show_error_message_box("Preview Error", "Preview audio path not found.")
self._preview_cleanup()
return
if not temp_wav:
if hasattr(self, 'loading_movie'): self.loading_movie.stop()
self._show_error_message_box( self._show_error_message_box(
"Preview Error", "Preview error: No audio generated." "Preview Error", "Preview error: No audio generated."
) )
self.btn_preview.setIcon(self.play_icon) self._preview_cleanup()
self.btn_preview.setToolTip("Preview selected voice")
self.btn_preview.setEnabled(True)
self.voice_combo.setEnabled(True)
self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
self.btn_start.setEnabled(True)
return return
# stop loading animation, switch to stop icon # stop loading animation, switch to stop icon
self.loading_movie.stop() if hasattr(self, 'loading_movie'): self.loading_movie.stop()
self.preview_playing = True self.preview_playing = True
self.btn_preview.setIcon(self.stop_icon) self.btn_preview.setIcon(self.stop_icon)
self.btn_preview.setToolTip("Stop preview") self.btn_preview.setToolTip("Stop preview")
self.btn_preview.setEnabled(True) self.btn_preview.setEnabled(True)
def cleanup(): def cleanup():
# Only remove if not from cache AND it's a temp file from VoicePreviewThread
if not from_cache and hasattr(self, 'preview_thread') and hasattr(self.preview_thread, 'temp_wav') and self.preview_thread.temp_wav == temp_wav:
try: try:
if os.path.exists(temp_wav): # Ensure it exists before trying to remove
os.remove(temp_wav) os.remove(temp_wav)
except Exception: except Exception:
pass pass
self._preview_cleanup() self._preview_cleanup()
try: try:
self.audio_thread = PlayAudioThread(temp_wav) # Ensure pygame mixer is initialized for the audio thread
self.audio_thread.finished.connect(cleanup) import pygame
self.audio_thread.error.connect( if not pygame.mixer.get_init():
pygame.mixer.init()
self.play_audio_thread = PlayAudioThread(temp_wav)
self.play_audio_thread.finished.connect(cleanup)
self.play_audio_thread.error.connect(
lambda msg: (self._show_preview_error_box(msg), cleanup()) lambda msg: (self._show_preview_error_box(msg), cleanup())
) )
self.audio_thread.start() self.play_audio_thread.start()
except Exception as e: except Exception as e:
self._show_error_message_box( self._show_error_message_box(
"Preview Error", f"Could not play preview audio:\n{e}" "Preview Error", f"Could not play preview audio:\n{e}"
@@ -1667,9 +1789,9 @@ class abogen(QWidget):
def _preview_cleanup(self): def _preview_cleanup(self):
self.preview_playing = False self.preview_playing = False
self.loading_movie.stop() if hasattr(self, 'loading_movie'): self.loading_movie.stop()
try: try:
self.loading_movie.frameChanged.disconnect() if hasattr(self, 'loading_movie'): self.loading_movie.frameChanged.disconnect()
except Exception: except Exception:
pass # Ignore error if not connected pass # Ignore error if not connected
self.btn_preview.setIcon(self.play_icon) self.btn_preview.setIcon(self.play_icon)
@@ -1803,7 +1925,24 @@ class abogen(QWidget):
max_words_action.triggered.connect(self.set_max_subtitle_words) max_words_action.triggered.connect(self.set_max_subtitle_words)
menu.addAction(max_words_action) menu.addAction(max_words_action)
# Add seperator # Add separate chapters format option
separate_chapters_format_menu = QMenu("Separate chapters audio format", self)
separate_chapters_format_menu.setToolTip("Choose the format for individual chapter files")
format_group = QActionGroup(self)
format_group.setExclusive(True)
for format_option in ["wav", "mp3", "flac", "opus"]:
format_action = QAction(format_option, self)
format_action.setCheckable(True)
format_action.setChecked(self.separate_chapters_format == format_option)
format_action.triggered.connect(lambda checked, fmt=format_option: self.set_separate_chapters_format(fmt))
format_group.addAction(format_action)
separate_chapters_format_menu.addAction(format_action)
menu.addMenu(separate_chapters_format_menu)
# Add separator
menu.addSeparator() menu.addSeparator()
# Add shortcut to desktop (Windows or Linux) # Add shortcut to desktop (Windows or Linux)
@@ -1825,7 +1964,7 @@ class abogen(QWidget):
menu.addAction(open_temp_action) menu.addAction(open_temp_action)
# Add clear temporary files option # Add clear temporary files option
clear_temp_action = QAction("Clear all temporary files", self) clear_temp_action = QAction("Clear temporary files", self)
clear_temp_action.triggered.connect(self.clear_temp_files) clear_temp_action.triggered.connect(self.clear_temp_files)
menu.addAction(clear_temp_action) menu.addAction(clear_temp_action)
@@ -2203,7 +2342,7 @@ Categories=AudioVideo;Audio;Utility;
pass pass
def clear_temp_files(self): def clear_temp_files(self):
"""Clear all temporary files created by the program.""" """Clear temporary files created by the program."""
import glob import glob
try: try:
@@ -2217,25 +2356,49 @@ Categories=AudioVideo;Audio;Utility;
# Count the files # Count the files
file_count = len(temp_files) file_count = len(temp_files)
if file_count == 0: # Check for preview cache files
preview_cache_dir = os.path.join(temp_dir, "preview_cache")
preview_files = []
if os.path.exists(preview_cache_dir):
preview_pattern = os.path.join(preview_cache_dir, "*.wav")
preview_files = glob.glob(preview_pattern)
preview_count = len(preview_files)
if file_count == 0 and preview_count == 0:
QMessageBox.information( QMessageBox.information(
self, "No Temporary Files", "No temporary files were found." self, "No Temporary Files", "No temporary files were found."
) )
return return
# Confirm with the user # Create a custom message box with checkbox
confirm = QMessageBox.question( msg_box = QMessageBox(self)
self, msg_box.setIcon(QMessageBox.Question)
"Clear Temporary Files", msg_box.setWindowTitle("Clear Temporary Files")
f"Found {file_count} temporary file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} temp folder.\n"
"Do you want to delete them?",
QMessageBox.Yes | QMessageBox.No,
)
if confirm != QMessageBox.Yes: msg_text = f"Found {file_count} temporary file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} temp folder."
if preview_count > 0:
msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}."
msg_box.setText(msg_text + "\nDo you want to delete them?")
# Add checkbox for preview cache
preview_cache_checkbox = QCheckBox("Clean preview cache", msg_box)
preview_cache_checkbox.setChecked(False)
# Only enable checkbox if preview files exist
preview_cache_checkbox.setEnabled(preview_count > 0)
# Add the checkbox to the layout
msg_box.setCheckBox(preview_cache_checkbox)
# Add buttons
msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
msg_box.setDefaultButton(QMessageBox.Yes)
if msg_box.exec_() != QMessageBox.Yes:
return return
# Delete the files # Delete the text files
deleted_count = 0 deleted_count = 0
for file_path in temp_files: for file_path in temp_files:
try: try:
@@ -2244,11 +2407,24 @@ Categories=AudioVideo;Audio;Utility;
except Exception as e: except Exception as e:
print(f"Error deleting {file_path}: {e}") print(f"Error deleting {file_path}: {e}")
# Delete preview cache files if checkbox is checked
deleted_preview_count = 0
if preview_cache_checkbox.isChecked() and preview_count > 0:
for file_path in preview_files:
try:
os.remove(file_path)
deleted_preview_count += 1
except Exception as e:
print(f"Error deleting preview cache {file_path}: {e}")
# Build result message
result_msg = f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}."
if preview_cache_checkbox.isChecked() and deleted_preview_count > 0:
result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}."
# Show results # Show results
QMessageBox.information( QMessageBox.information(
self, self, "Temporary Files Cleared", result_msg
"Temporary Files Cleared",
f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}.",
) )
# If currently selected file is in the temp directory, clear the UI # If currently selected file is in the temp directory, clear the UI
@@ -2293,3 +2469,12 @@ Categories=AudioVideo;Audio;Utility;
f"Maximum words per subtitle set to {value}.", f"Maximum words per subtitle set to {value}.",
) )
def set_separate_chapters_format(self, fmt):
"""Set the format for separate chapters audio files."""
self.separate_chapters_format = fmt
self.config["separate_chapters_format"] = fmt
save_config(self.config)
def show_model_download_warning(self, title, message):
QMessageBox.information(self, title, message)
+36
View File
@@ -0,0 +1,36 @@
log_callback = None
show_warning_signal_emitter = None # Renamed for clarity
def set_log_callback(cb):
global log_callback
log_callback = cb
def set_show_warning_signal_emitter(emitter): # Renamed for clarity
global show_warning_signal_emitter
show_warning_signal_emitter = emitter
from huggingface_hub import hf_hub_download
def tracked_hf_hub_download(*args, **kwargs):
try:
local_kwargs = dict(kwargs)
local_kwargs["local_files_only"] = True
hf_hub_download(*args, **local_kwargs)
except Exception:
repo_id = kwargs.get("repo_id", "<unknown repo>")
filename = kwargs.get("filename", "<unknown file>")
if filename.endswith(".pth"):
msg = f"\nDownloading model '{filename}' from Hugging Face ({repo_id}). This may take a while. Please wait..."
if show_warning_signal_emitter: # Check if the emitter is set
show_warning_signal_emitter.emit("Downloading Model", f"Downloading model '{filename}' from Hugging Face repository '{repo_id}'. This may take a while, please wait.")
else:
msg = f"\nDownloading '{filename}' from Hugging Face ({repo_id}). Please wait..."
if log_callback:
print(msg, flush=True)
log_callback(msg)
else:
print(msg, flush=True)
return hf_hub_download(*args, **kwargs)
import huggingface_hub
huggingface_hub.hf_hub_download = tracked_hf_hub_download
+16
View File
@@ -0,0 +1,16 @@
import gpustat
def check():
try:
stats = gpustat.new_query()
except Exception:
return False
for gpu in stats.gpus:
print(gpu.name)
if 'nvidia' in gpu.name.lower():
return True
return False
if __name__ == "__main__":
print(check())
+11
View File
@@ -10,6 +10,10 @@ from gui import abogen
from utils import get_resource_path from utils import get_resource_path
from constants import PROGRAM_NAME, VERSION from 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"
# 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")
@@ -48,6 +52,13 @@ def main():
if icon_path: if icon_path:
app.setWindowIcon(QIcon(icon_path)) app.setWindowIcon(QIcon(icon_path))
# Set the .desktop name on Linux
if platform.system() == "Linux":
try:
app.setDesktopFileName("abogen.desktop")
except AttributeError:
pass
ex = abogen() ex = abogen()
ex.show() ex.show()
sys.exit(app.exec_()) sys.exit(app.exec_())
+21 -20
View File
@@ -105,7 +105,7 @@ def clean_text(text, *args, **kwargs):
default_encoding = sys.getfilesystemencoding() default_encoding = sys.getfilesystemencoding()
def create_process(cmd, stdin=None, text=True): def create_process(cmd, stdin=None, text=True, capture_output=False):
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -154,8 +154,8 @@ def create_process(cmd, stdin=None, text=True):
proc = subprocess.Popen(cmd, **kwargs) proc = subprocess.Popen(cmd, **kwargs)
# Stream output to console in real-time # Stream output to console in real-time if not capturing
if proc.stdout: if proc.stdout and not capture_output:
def _stream_output(stream): def _stream_output(stream):
if text: if text:
# For text mode, read character by character for real-time output # For text mode, read character by character for real-time output
@@ -204,11 +204,15 @@ def save_config(config):
def calculate_text_length(text): def calculate_text_length(text):
# Ignore chapter markers # Ignore chapter markers
text_no_markers = re.sub(r"<<CHAPTER_MARKER:.*?>>", "", text) text = re.sub(r"<<CHAPTER_MARKER:.*?>>", "", text)
# Ignore metadata patterns
text = re.sub(r"<<METADATA_[^:]+:[^>]*>>", "", text)
# Ignore newlines # Ignore newlines
cleaned_text = text_no_markers.replace("\n", "") text = text.replace("\n", "")
# Ignore leading/trailing spaces
text = text.strip()
# Calculate character count # Calculate character count
char_count = len(cleaned_text) char_count = len(text)
return char_count return char_count
@@ -227,29 +231,26 @@ def prevent_sleep_start():
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
import ctypes import ctypes
ctypes.windll.kernel32.SetThreadExecutionState( ctypes.windll.kernel32.SetThreadExecutionState(
0x80000000 | 0x00000001 | 0x00000040 0x80000000 | 0x00000001 | 0x00000040
) # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED
elif system == "Darwin":
_sleep_procs["Darwin"] = create_process("caffeinate")
elif system == "Linux":
try:
_sleep_procs["Linux"] = create_process(
"systemd-inhibit --what=sleep --why=TextToAudiobook conversion sleep 999999"
) )
except Exception: elif system == "Darwin":
try: _sleep_procs["Darwin"] = create_process(["caffeinate"])
create_process("xdg-screensaver reset") elif system == "Linux":
except Exception: # use a sleep that never exits so inhibition stays active
pass _sleep_procs["Linux"] = create_process([
"systemd-inhibit",
"--what=handle-lid-switch:sleep",
"--mode=block",
"sleep",
"infinity",
])
def prevent_sleep_end(): def prevent_sleep_end():
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
import ctypes import ctypes
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
elif system in ("Darwin", "Linux") and _sleep_procs[system]: elif system in ("Darwin", "Linux") and _sleep_procs[system]:
try: try:
+33 -9
View File
@@ -264,14 +264,12 @@ class HoverLabel(QLabel):
self.voice_name = voice_name self.voice_name = voice_name
self.setMouseTracking(True) self.setMouseTracking(True)
self.setStyleSheet( self.setStyleSheet(
"background-color: #e0e0e0; border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;" "background-color: rgba(140, 140, 140, 0.15); border-radius: 4px; padding: 3px 6px 3px 6px; margin: 2px;"
) )
# Create delete button # Create delete button
self.delete_button = QPushButton("×", self) self.delete_button = QPushButton("×", self)
self.delete_button.setFixedSize(16, 16) self.delete_button.setFixedSize(16, 16)
self.delete_button = QPushButton("×", self)
self.delete_button.setFixedSize(16, 16)
self.delete_button.setStyleSheet( self.delete_button.setStyleSheet(
""" """
QPushButton { QPushButton {
@@ -310,6 +308,12 @@ class HoverLabel(QLabel):
class VoiceFormulaDialog(QDialog): class VoiceFormulaDialog(QDialog):
def __init__(self, parent=None, initial_state=None, selected_profile=None): def __init__(self, parent=None, initial_state=None, selected_profile=None):
super().__init__(parent) super().__init__(parent)
# Store original profile/mix state for restoration on cancel
self._original_profile_name = None
self._original_mixed_voice_state = None
if parent is not None:
self._original_profile_name = getattr(parent, "selected_profile_name", None)
self._original_mixed_voice_state = getattr(parent, "mixed_voice_state", None)
profiles = load_profiles() profiles = load_profiles()
self._virtual_new_profile = False self._virtual_new_profile = False
if not profiles: if not profiles:
@@ -734,7 +738,7 @@ class VoiceFormulaDialog(QDialog):
percentage = weight / total * 100 percentage = weight / total * 100
# Make the voice name bold and include percentage # Make the voice name bold and include percentage
voice_label = HoverLabel( voice_label = HoverLabel(
f'<b><span style="color:#1976d2">{name}: {percentage:.1f}%</span></b>', f'<b><span style="color:#007dff">{name}: {percentage:.1f}%</span></b>',
name, name,
) )
voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred) voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
@@ -916,6 +920,13 @@ class VoiceFormulaDialog(QDialog):
super().accept() super().accept()
def reject(self): def reject(self):
# Restore parent's profile/mix state on cancel
parent = self.parent()
if parent is not None:
if hasattr(self, "_original_profile_name"):
parent.selected_profile_name = self._original_profile_name
if hasattr(self, "_original_mixed_voice_state"):
parent.mixed_voice_state = self._original_mixed_voice_state
# Prompt to save if unsaved changes, then check for zero-weight error after save # Prompt to save if unsaved changes, then check for zero-weight error after save
if self._has_unsaved_changes(): if self._has_unsaved_changes():
if not self._prompt_save_changes(): if not self._prompt_save_changes():
@@ -925,6 +936,13 @@ class VoiceFormulaDialog(QDialog):
super().reject() super().reject()
def closeEvent(self, event): def closeEvent(self, event):
# Restore parent's profile/mix state on close
parent = self.parent()
if parent is not None:
if hasattr(self, "_original_profile_name"):
parent.selected_profile_name = self._original_profile_name
if hasattr(self, "_original_mixed_voice_state"):
parent.mixed_voice_state = self._original_mixed_voice_state
# Prompt to save if unsaved changes, then check for zero-weight error after save # Prompt to save if unsaved changes, then check for zero-weight error after save
if self._has_unsaved_changes(): if self._has_unsaved_changes():
if not self._prompt_save_changes(): if not self._prompt_save_changes():
@@ -1326,10 +1344,16 @@ class VoiceFormulaDialog(QDialog):
item = self.profile_list.item(i) item = self.profile_list.item(i)
name = item.text().lstrip("*") name = item.text().lstrip("*")
if self._virtual_new_profile and name == "New profile": if self._virtual_new_profile and name == "New profile":
item.setBackground(QColor("#fff59d")) # yellow color = QColor("#ffff00") # yellow
color.setAlpha(64) # Set transparency
item.setBackground(color)
elif item.text().startswith("*"): elif item.text().startswith("*"):
item.setBackground(QColor("#fff59d")) # yellow color = QColor("#ffff00") # yellow
color.setAlpha(64) # Set transparency
item.setBackground(color)
else: else:
item.setBackground(self.profile_list.palette().base().color()) # Use default list item background
weights = profiles.get(name, {}).get("voices", []) weights = profiles.get(name, {}).get("voices", [])
# Defensive: only sum if weights is a list of (voice, weight) pairs # Defensive: only sum if weights is a list of (voice, weight) pairs
total = 0 total = 0
@@ -1342,9 +1366,9 @@ class VoiceFormulaDialog(QDialog):
): ):
total += entry[1] total += entry[1]
if total == 0: if total == 0:
item.setBackground(QColor("#ffcdd2")) # light red color = QColor("#ff0000") # red
else: color.setAlpha(64) # Set transparency
item.setBackground(QColor("white")) item.setBackground(color)
self.update_profile_save_buttons() self.update_profile_save_buttons()
def preview_current_mix(self): def preview_current_mix(self):