mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507aedeae1 | ||
|
|
c5faab4bd7 | ||
|
|
3278447d6a | ||
|
|
a0e59564af | ||
|
|
6e96fb6405 | ||
|
|
f1cb92900c | ||
|
|
065eaa4745 | ||
|
|
082bb08e48 | ||
|
|
f286c4c6dc | ||
|
|
669ef26667 | ||
|
|
fd10aab6f3 | ||
|
|
0d7e746516 | ||
|
|
2cb2f3dfb1 | ||
|
|
578da66f34 | ||
|
|
814c171a90 | ||
|
|
b3b420beec | ||
|
|
93be50eda1 | ||
|
|
9c44001601 | ||
|
|
e03336836b | ||
|
|
b75277a3cb |
@@ -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
|
||||
- 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.
|
||||
|
||||
@@ -23,6 +23,9 @@ Go to [espeak-ng latest release](https://github.com/espeak-ng/espeak-ng/releases
|
||||
# For NVIDIA GPUs:
|
||||
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
|
||||
pip install abogen
|
||||
```
|
||||
@@ -54,6 +57,13 @@ sudo dnf install espeak-ng
|
||||
|
||||
# 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:
|
||||
>```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.
|
||||
|
||||
> 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?`
|
||||
If you installed using pip, you can simply run the following command to start Abogen:
|
||||
|
||||
@@ -99,10 +111,11 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
||||
- **Options**:
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
- [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
|
||||
- [ebook2audiobook](https://github.com/DrewThomasson/ebook2audiobook): Convert ebooks to audiobooks with chapters and metadata using dynamic AI models and voice cloning
|
||||
|
||||
## `Roadmap`
|
||||
- [ ] 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 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.
|
||||
- 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`
|
||||
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
|
||||
|
||||
+20
-4
@@ -230,13 +230,22 @@ if errorlevel 1 (
|
||||
|
||||
:: Install 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 (
|
||||
echo Failed to install setup requirements.
|
||||
pause
|
||||
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
|
||||
echo Checking and installing project dependencies...
|
||||
if exist %PYPROJECT_FILE% (
|
||||
@@ -263,11 +272,15 @@ 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
|
||||
echo Checking CUDA availability...
|
||||
for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
|
||||
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
|
||||
|
||||
if "%cuda_available%"=="False" (
|
||||
if "%cuda_available%"=="False" (
|
||||
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
|
||||
echo.
|
||||
@@ -276,8 +289,11 @@ if "%cuda_available%"=="False" (
|
||||
pause
|
||||
exit /b
|
||||
)
|
||||
) else (
|
||||
echo CUDA is available on NVIDIA GPU.
|
||||
)
|
||||
) else (
|
||||
echo CUDA is available.
|
||||
echo GPU is not NVIDIA. Skipping PyTorch CUDA installation.
|
||||
)
|
||||
|
||||
:: Ask user if they want to create a desktop shortcut
|
||||
|
||||
+1
-1
@@ -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
@@ -1494,8 +1494,13 @@ class HandlerDialog(QDialog):
|
||||
authors_text = ", ".join(self.book_metadata["authors"])
|
||||
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"]:
|
||||
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/>"
|
||||
|
||||
@@ -1517,24 +1522,51 @@ class HandlerDialog(QDialog):
|
||||
"description": None,
|
||||
"cover_image": None,
|
||||
"publisher": None,
|
||||
"publication_year": None,
|
||||
}
|
||||
|
||||
if self.file_type == "epub":
|
||||
try:
|
||||
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]
|
||||
except Exception as e:
|
||||
logging.warning(f"Error extracting title metadata: {e}")
|
||||
|
||||
try:
|
||||
author_items = self.book.get_metadata("DC", "creator")
|
||||
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")
|
||||
if desc_items:
|
||||
if desc_items and len(desc_items) > 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")
|
||||
if publisher_items:
|
||||
if publisher_items and len(publisher_items) > 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):
|
||||
metadata["cover_image"] = item.get_content()
|
||||
@@ -1565,6 +1597,18 @@ class HandlerDialog(QDialog):
|
||||
|
||||
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:
|
||||
try:
|
||||
pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
|
||||
@@ -1580,10 +1624,43 @@ class HandlerDialog(QDialog):
|
||||
else:
|
||||
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):
|
||||
all_checked_identifiers = set()
|
||||
chapter_texts = []
|
||||
|
||||
# Add metadata tags at the beginning
|
||||
metadata_tags = self._format_metadata_tags()
|
||||
|
||||
item_order_counter = 0
|
||||
ordered_checked_items = []
|
||||
|
||||
@@ -1608,7 +1685,7 @@ class HandlerDialog(QDialog):
|
||||
marker = f"<<CHAPTER_MARKER:{title}>>"
|
||||
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
|
||||
|
||||
def _get_pdf_selected_text(self):
|
||||
@@ -1617,6 +1694,9 @@ class HandlerDialog(QDialog):
|
||||
section_titles = []
|
||||
all_content = []
|
||||
|
||||
# Add metadata tags at the beginning
|
||||
metadata_tags = self._format_metadata_tags()
|
||||
|
||||
pdf_has_no_bookmarks = (
|
||||
hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks
|
||||
)
|
||||
@@ -1641,7 +1721,7 @@ class HandlerDialog(QDialog):
|
||||
if text:
|
||||
all_content.append(text)
|
||||
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)
|
||||
while iterator.value():
|
||||
@@ -1699,7 +1779,7 @@ class HandlerDialog(QDialog):
|
||||
included_text_ids.add(identifier)
|
||||
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):
|
||||
self.save_chapters_separately = bool(state)
|
||||
|
||||
+146
-21
@@ -4,6 +4,7 @@ import tempfile
|
||||
import time
|
||||
import chardet
|
||||
import charset_normalizer
|
||||
import hashlib # For generating unique cache filenames
|
||||
from platformdirs import user_desktop_dir
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt
|
||||
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 constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
|
||||
from voice_formulas import get_new_voice
|
||||
import hf_tracker
|
||||
import static_ffmpeg
|
||||
import threading # for efficient waiting
|
||||
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"
|
||||
)
|
||||
try:
|
||||
hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg))
|
||||
# Show configuration
|
||||
self.log_updated.emit("Configuration:")
|
||||
# Use display_path for logs if available, otherwise use the actual file name
|
||||
@@ -196,6 +199,9 @@ class ConversionThread(QThread):
|
||||
self.log_updated.emit(
|
||||
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":
|
||||
self.log_updated.emit(
|
||||
@@ -221,6 +227,10 @@ class ConversionThread(QThread):
|
||||
# Clean up text using utility function
|
||||
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_pattern = r"<<CHAPTER_MARKER:(.*?)>>"
|
||||
chapter_splits = list(re.finditer(chapter_pattern, text))
|
||||
@@ -500,18 +510,20 @@ class ConversionThread(QThread):
|
||||
|
||||
# Concatenate chapter audio and save
|
||||
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(
|
||||
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()
|
||||
proc = create_process(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-thread_queue_size", "1024", # Increased thread_queue_size for chapter opus
|
||||
"-thread_queue_size", "32768", # Increased thread_queue_size for chapter opus
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
@@ -537,7 +549,7 @@ class ConversionThread(QThread):
|
||||
chapter_out_path,
|
||||
chapter_audio,
|
||||
24000,
|
||||
format='wav' if self.output_format == 'm4b' else self.output_format,
|
||||
format=separate_format,
|
||||
)
|
||||
|
||||
# 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"
|
||||
ffmpeg_cmd_opus = [
|
||||
"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",
|
||||
"-c:a", "libopus", "-b:a", "24000", # Original bitrate
|
||||
opus_out_path,
|
||||
@@ -701,13 +713,20 @@ class ConversionThread(QThread):
|
||||
static_ffmpeg.add_paths()
|
||||
ffmpeg_cmd = [
|
||||
"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",
|
||||
"-i", chapters_info_path,
|
||||
"-map", "0:a",
|
||||
"-map_metadata", "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,
|
||||
]
|
||||
|
||||
@@ -742,6 +761,69 @@ class ConversionThread(QThread):
|
||||
except Exception as e_clean:
|
||||
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):
|
||||
"""Helper function to format time for SRT files"""
|
||||
h = int(t // 3600)
|
||||
@@ -865,10 +947,31 @@ class VoicePreviewThread(QThread):
|
||||
self.speed = speed
|
||||
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):
|
||||
print(
|
||||
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
|
||||
)
|
||||
|
||||
# Generate the preview and save to cache
|
||||
try:
|
||||
device = "cuda" if self.use_gpu else "cpu"
|
||||
tts = self.kpipeline_class(
|
||||
@@ -887,15 +990,9 @@ class VoicePreviewThread(QThread):
|
||||
audio_segments.append(result.audio)
|
||||
if audio_segments:
|
||||
audio = self.np_module.concatenate(audio_segments)
|
||||
# Create temp wav file in a folder in the system temp directory
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
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
|
||||
# Save directly to the cache path
|
||||
sf.write(self.cache_path, audio, 24000)
|
||||
self.temp_wav = self.cache_path
|
||||
self.finished.emit()
|
||||
except Exception as e:
|
||||
self.error.emit(f"Voice preview error: {str(e)}")
|
||||
@@ -908,6 +1005,7 @@ class PlayAudioThread(QThread):
|
||||
def __init__(self, wav_path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.wav_path = wav_path
|
||||
self.is_canceled = False
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
@@ -917,10 +1015,37 @@ class PlayAudioThread(QThread):
|
||||
pygame.mixer.init()
|
||||
pygame.mixer.music.load(self.wav_path)
|
||||
pygame.mixer.music.play()
|
||||
# Wait until playback is finished
|
||||
while pygame.mixer.music.get_busy():
|
||||
_time.sleep(0.1)
|
||||
# Wait until playback is finished or canceled
|
||||
while pygame.mixer.music.get_busy() and not self.is_canceled:
|
||||
_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.quit() # Quit the mixer
|
||||
except Exception:
|
||||
# Ignore any errors during cleanup
|
||||
pass
|
||||
|
||||
self.finished.emit()
|
||||
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)}")
|
||||
|
||||
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
@@ -4,6 +4,8 @@ import tempfile
|
||||
import platform
|
||||
import base64
|
||||
import re
|
||||
import hf_tracker
|
||||
import hashlib # Added for cache path generation
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
@@ -23,6 +25,7 @@ from PyQt5.QtWidgets import (
|
||||
QCheckBox,
|
||||
QMenu,
|
||||
QAction,
|
||||
QActionGroup,
|
||||
)
|
||||
from PyQt5.QtCore import (
|
||||
Qt,
|
||||
@@ -78,6 +81,13 @@ if platform.system() == "Windows":
|
||||
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):
|
||||
log_signal = pyqtSignal(object)
|
||||
|
||||
@@ -94,9 +104,9 @@ class IconProvider(QFileIconProvider):
|
||||
|
||||
|
||||
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_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_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 = """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: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: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):
|
||||
super().__init__(parent)
|
||||
@@ -519,6 +529,7 @@ class abogen(QWidget):
|
||||
"max_subtitle_words", 50
|
||||
) # Default max words per subtitle
|
||||
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(
|
||||
"use_gpu", True # Load GPU setting with default True
|
||||
)
|
||||
@@ -530,6 +541,11 @@ class abogen(QWidget):
|
||||
self.log_signal = ThreadSafeLogSignal()
|
||||
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
|
||||
icon_path = get_resource_path("abogen.assets", "icon.ico")
|
||||
if icon_path:
|
||||
@@ -581,6 +597,9 @@ class abogen(QWidget):
|
||||
if self.check_updates:
|
||||
QTimer.singleShot(1000, self.check_for_updates_startup)
|
||||
|
||||
# Set hf_tracker callbacks
|
||||
hf_tracker.set_log_callback(self.update_log)
|
||||
|
||||
def initUI(self):
|
||||
self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
|
||||
screen = QApplication.primaryScreen().geometry()
|
||||
@@ -677,6 +696,7 @@ class abogen(QWidget):
|
||||
self.btn_preview.clicked.connect(self.preview_voice)
|
||||
voice_row.addWidget(self.btn_preview)
|
||||
self.preview_playing = False
|
||||
self.play_audio_thread = None # Keep track of audio playing thread
|
||||
voice_layout.addLayout(voice_row)
|
||||
controls_layout.addLayout(voice_layout)
|
||||
# Subtitle mode
|
||||
@@ -938,10 +958,12 @@ class abogen(QWidget):
|
||||
title = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('title', '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'])]
|
||||
publication_year = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('publication_year', 'Unknown')))
|
||||
|
||||
f.write(f"Title: {title}\n")
|
||||
f.write(f"Authors: {', '.join(authors)}\n")
|
||||
f.write(f"Publisher: {publisher}\n")
|
||||
f.write(f"Publication Year: {publication_year}\n")
|
||||
if dialog.book_metadata.get('description'):
|
||||
description = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('description')))
|
||||
f.write(f"\nDescription:\n{description}\n")
|
||||
@@ -956,7 +978,7 @@ class abogen(QWidget):
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
|
||||
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)
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
@@ -1305,6 +1327,8 @@ class abogen(QWidget):
|
||||
self.conversion_thread.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
|
||||
if self.selected_file_type in ["epub", "pdf"] and hasattr(
|
||||
self, "selected_chapters"
|
||||
@@ -1518,20 +1542,100 @@ class abogen(QWidget):
|
||||
"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):
|
||||
if self.preview_playing:
|
||||
try:
|
||||
import pygame
|
||||
|
||||
pygame.mixer.music.stop()
|
||||
except Exception:
|
||||
pass
|
||||
if self.play_audio_thread and self.play_audio_thread.isRunning():
|
||||
# Call the stop method on PlayAudioThread to safely handle stopping
|
||||
self.play_audio_thread.stop()
|
||||
self.play_audio_thread.wait(500) # Wait a bit
|
||||
except Exception as e:
|
||||
print(f"Error stopping preview audio: {e}")
|
||||
self._preview_cleanup()
|
||||
return
|
||||
|
||||
if hasattr(self, "preview_thread") and self.preview_thread.isRunning():
|
||||
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.setToolTip("Loading...")
|
||||
self.voice_combo.setEnabled(False)
|
||||
@@ -1609,42 +1713,60 @@ class abogen(QWidget):
|
||||
self.preview_thread.error.connect(self._preview_error)
|
||||
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
|
||||
if not temp_wav:
|
||||
self.loading_movie.stop()
|
||||
elif from_cache: # This case is now handled before calling _play_preview_audio
|
||||
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(
|
||||
"Preview Error", "Preview error: No audio generated."
|
||||
)
|
||||
self.btn_preview.setIcon(self.play_icon)
|
||||
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)
|
||||
self._preview_cleanup()
|
||||
return
|
||||
|
||||
# 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.btn_preview.setIcon(self.stop_icon)
|
||||
self.btn_preview.setToolTip("Stop preview")
|
||||
self.btn_preview.setEnabled(True)
|
||||
|
||||
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:
|
||||
if os.path.exists(temp_wav): # Ensure it exists before trying to remove
|
||||
os.remove(temp_wav)
|
||||
except Exception:
|
||||
pass
|
||||
self._preview_cleanup()
|
||||
|
||||
try:
|
||||
self.audio_thread = PlayAudioThread(temp_wav)
|
||||
self.audio_thread.finished.connect(cleanup)
|
||||
self.audio_thread.error.connect(
|
||||
# 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(temp_wav)
|
||||
self.play_audio_thread.finished.connect(cleanup)
|
||||
self.play_audio_thread.error.connect(
|
||||
lambda msg: (self._show_preview_error_box(msg), cleanup())
|
||||
)
|
||||
self.audio_thread.start()
|
||||
self.play_audio_thread.start()
|
||||
except Exception as e:
|
||||
self._show_error_message_box(
|
||||
"Preview Error", f"Could not play preview audio:\n{e}"
|
||||
@@ -1667,9 +1789,9 @@ class abogen(QWidget):
|
||||
|
||||
def _preview_cleanup(self):
|
||||
self.preview_playing = False
|
||||
self.loading_movie.stop()
|
||||
if hasattr(self, 'loading_movie'): self.loading_movie.stop()
|
||||
try:
|
||||
self.loading_movie.frameChanged.disconnect()
|
||||
if hasattr(self, 'loading_movie'): self.loading_movie.frameChanged.disconnect()
|
||||
except Exception:
|
||||
pass # Ignore error if not connected
|
||||
self.btn_preview.setIcon(self.play_icon)
|
||||
@@ -1803,7 +1925,24 @@ class abogen(QWidget):
|
||||
max_words_action.triggered.connect(self.set_max_subtitle_words)
|
||||
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()
|
||||
|
||||
# Add shortcut to desktop (Windows or Linux)
|
||||
@@ -1825,7 +1964,7 @@ class abogen(QWidget):
|
||||
menu.addAction(open_temp_action)
|
||||
|
||||
# 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)
|
||||
menu.addAction(clear_temp_action)
|
||||
|
||||
@@ -2203,7 +2342,7 @@ Categories=AudioVideo;Audio;Utility;
|
||||
pass
|
||||
|
||||
def clear_temp_files(self):
|
||||
"""Clear all temporary files created by the program."""
|
||||
"""Clear temporary files created by the program."""
|
||||
import glob
|
||||
|
||||
try:
|
||||
@@ -2217,25 +2356,49 @@ Categories=AudioVideo;Audio;Utility;
|
||||
# Count the 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(
|
||||
self, "No Temporary Files", "No temporary files were found."
|
||||
)
|
||||
return
|
||||
|
||||
# Confirm with the user
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"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,
|
||||
)
|
||||
# Create a custom message box with checkbox
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Question)
|
||||
msg_box.setWindowTitle("Clear Temporary Files")
|
||||
|
||||
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
|
||||
|
||||
# Delete the files
|
||||
# Delete the text files
|
||||
deleted_count = 0
|
||||
for file_path in temp_files:
|
||||
try:
|
||||
@@ -2244,11 +2407,24 @@ Categories=AudioVideo;Audio;Utility;
|
||||
except Exception as 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
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Temporary Files Cleared",
|
||||
f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}.",
|
||||
self, "Temporary Files Cleared", result_msg
|
||||
)
|
||||
|
||||
# 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}.",
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -10,6 +10,10 @@ from gui import abogen
|
||||
from utils import get_resource_path
|
||||
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
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
@@ -48,6 +52,13 @@ def main():
|
||||
if 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.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
+21
-20
@@ -105,7 +105,7 @@ def clean_text(text, *args, **kwargs):
|
||||
|
||||
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
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -154,8 +154,8 @@ def create_process(cmd, stdin=None, text=True):
|
||||
|
||||
proc = subprocess.Popen(cmd, **kwargs)
|
||||
|
||||
# Stream output to console in real-time
|
||||
if proc.stdout:
|
||||
# Stream output to console in real-time if not capturing
|
||||
if proc.stdout and not capture_output:
|
||||
def _stream_output(stream):
|
||||
if text:
|
||||
# For text mode, read character by character for real-time output
|
||||
@@ -204,11 +204,15 @@ def save_config(config):
|
||||
|
||||
def calculate_text_length(text):
|
||||
# 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
|
||||
cleaned_text = text_no_markers.replace("\n", "")
|
||||
text = text.replace("\n", "")
|
||||
# Ignore leading/trailing spaces
|
||||
text = text.strip()
|
||||
# Calculate character count
|
||||
char_count = len(cleaned_text)
|
||||
char_count = len(text)
|
||||
return char_count
|
||||
|
||||
|
||||
@@ -227,29 +231,26 @@ def prevent_sleep_start():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
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:
|
||||
try:
|
||||
create_process("xdg-screensaver reset")
|
||||
except Exception:
|
||||
pass
|
||||
elif system == "Darwin":
|
||||
_sleep_procs["Darwin"] = create_process(["caffeinate"])
|
||||
elif system == "Linux":
|
||||
# use a sleep that never exits so inhibition stays active
|
||||
_sleep_procs["Linux"] = create_process([
|
||||
"systemd-inhibit",
|
||||
"--what=handle-lid-switch:sleep",
|
||||
"--mode=block",
|
||||
"sleep",
|
||||
"infinity",
|
||||
])
|
||||
|
||||
|
||||
def prevent_sleep_end():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
|
||||
elif system in ("Darwin", "Linux") and _sleep_procs[system]:
|
||||
try:
|
||||
|
||||
@@ -264,14 +264,12 @@ class HoverLabel(QLabel):
|
||||
self.voice_name = voice_name
|
||||
self.setMouseTracking(True)
|
||||
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
|
||||
self.delete_button = QPushButton("×", self)
|
||||
self.delete_button.setFixedSize(16, 16)
|
||||
self.delete_button = QPushButton("×", self)
|
||||
self.delete_button.setFixedSize(16, 16)
|
||||
self.delete_button.setStyleSheet(
|
||||
"""
|
||||
QPushButton {
|
||||
@@ -310,6 +308,12 @@ class HoverLabel(QLabel):
|
||||
class VoiceFormulaDialog(QDialog):
|
||||
def __init__(self, parent=None, initial_state=None, selected_profile=None):
|
||||
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()
|
||||
self._virtual_new_profile = False
|
||||
if not profiles:
|
||||
@@ -734,7 +738,7 @@ class VoiceFormulaDialog(QDialog):
|
||||
percentage = weight / total * 100
|
||||
# Make the voice name bold and include percentage
|
||||
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,
|
||||
)
|
||||
voice_label.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
@@ -916,6 +920,13 @@ class VoiceFormulaDialog(QDialog):
|
||||
super().accept()
|
||||
|
||||
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
|
||||
if self._has_unsaved_changes():
|
||||
if not self._prompt_save_changes():
|
||||
@@ -925,6 +936,13 @@ class VoiceFormulaDialog(QDialog):
|
||||
super().reject()
|
||||
|
||||
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
|
||||
if self._has_unsaved_changes():
|
||||
if not self._prompt_save_changes():
|
||||
@@ -1326,10 +1344,16 @@ class VoiceFormulaDialog(QDialog):
|
||||
item = self.profile_list.item(i)
|
||||
name = item.text().lstrip("*")
|
||||
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("*"):
|
||||
item.setBackground(QColor("#fff59d")) # yellow
|
||||
color = QColor("#ffff00") # yellow
|
||||
color.setAlpha(64) # Set transparency
|
||||
item.setBackground(color)
|
||||
else:
|
||||
item.setBackground(self.profile_list.palette().base().color()) # Use default list item background
|
||||
|
||||
weights = profiles.get(name, {}).get("voices", [])
|
||||
# Defensive: only sum if weights is a list of (voice, weight) pairs
|
||||
total = 0
|
||||
@@ -1342,9 +1366,9 @@ class VoiceFormulaDialog(QDialog):
|
||||
):
|
||||
total += entry[1]
|
||||
if total == 0:
|
||||
item.setBackground(QColor("#ffcdd2")) # light red
|
||||
else:
|
||||
item.setBackground(QColor("white"))
|
||||
color = QColor("#ff0000") # red
|
||||
color.setAlpha(64) # Set transparency
|
||||
item.setBackground(color)
|
||||
self.update_profile_save_buttons()
|
||||
|
||||
def preview_current_mix(self):
|
||||
|
||||
Reference in New Issue
Block a user