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
|
# 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.
|
||||||
|
|||||||
@@ -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 @@ Here’s 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.
|
||||||
|
|||||||
+20
-4
@@ -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,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
|
:: Check if torch is installed with CUDA support
|
||||||
echo Checking CUDA availability...
|
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...
|
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
|
%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.
|
echo.
|
||||||
@@ -276,8 +289,11 @@ if "%cuda_available%"=="False" (
|
|||||||
pause
|
pause
|
||||||
exit /b
|
exit /b
|
||||||
)
|
)
|
||||||
|
) else (
|
||||||
|
echo CUDA is available on NVIDIA GPU.
|
||||||
|
)
|
||||||
) else (
|
) else (
|
||||||
echo CUDA is available.
|
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
@@ -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"])
|
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
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user