Initial commit

This commit is contained in:
Deniz Şafak
2025-04-26 01:50:03 +03:00
parent a7afc52618
commit 20da35a137
25 changed files with 5476 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# System files
.DS_Store
._*
Thumbs.db
Desktop.ini
*.lnk
# Python artifacts
__pycache__/
**/__pycache__
*.py[cod]
*.so
*.exe
*.zip
*.tar.gz
*.egg-info
# Virtual environments
env/
venv/
.env/
.venv/
test/
# Python embedded
python_embedded/
# VS Code settings
.vscode/
# abogen
*config.json
build/
dist/
.old/
+134
View File
@@ -0,0 +1,134 @@
# abogen: Audiobook Generator <img width="30px" title="abogen icon" src="https://github.com/denizsafak/AutoSubSync/blob/main/abogen/assets/icon.ico" align="right" style="padding-left: 10px; padding-top:5px;">
[![Python Versions](https://img.shields.io/pypi/pyversions/abogen.svg)](https://pypi.org/project/abogen)
<a href="https://github.com/psf/black"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, or text files into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M).
<img title="Abogen Main" src='https://github.com/denizsafak/AutoSubSync/blob/main/demo/abogen.png' width="380">
<img title="Abogen Processing" src='https://github.com/denizsafak/AutoSubSync/blob/main/demo/abogen2.png' width="380">
## Demo
<video src="https://github.com/denizsafak/AutoSubSync/blob/main/demo/demo.webm" controls width="100%"></video>
> This demo was generated in just 5 seconds, producing 1 minute of audio with perfectly synced subtitles. To create a similar video, see [the demo guide](https://github.com/denizsafak/abogen/blob/main/demo/README.md).
## `How to install?`
### Windows
Go to [espeak-ng latest release](https://github.com/espeak-ng/espeak-ng/releases/latest) download and run the *.msi file.
```bash
# For NVIDIA GPUs:
pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
pip install abogen
```
Alternatively, for an easier setup on Windows:
1. [Download](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) the repository
2. Extract the ZIP file
3. Run `WINDOWS_INSTALL.bat` by double-clicking it
This method handles everything automatically - installing all dependencies including CUDA in a self-contained environment without requiring a separate Python installation. (You still need to install [espeak-ng](https://github.com/espeak-ng/espeak-ng/releases/latest).)
### Mac
```bash
brew install espeak-ng
pip install abogen # (I have not tested it)
```
### Linux
```bash
# Ubuntu/Debian
sudo apt install espeak-ng
# Arch Linux
sudo pacman -S espeak-ng
# Fedora
sudo dnf install espeak-ng
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.
Then simply run by typing:
```bash
abogen
```
## `How to use?`
1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor)
2) Configure the settings:
- Set speech speed
- Select a voice
- Select subtitle generation style (by sentence, word, etc.)
- Select output format
- Select where to save the output
3) Hit Start
## `In action`
<img title="Abogen in action" src='https://github.com/denizsafak/AutoSubSync/blob/main/demo/abogen.gif'>
Heres Abogen in action: in this demo, it processes 3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **GTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware.
## `Key Features`
- **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor)
- **Speed**: Adjust speech rate from `0.1x` to `2.0x`
- **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female.
- **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry)
- **Output formats**: `.WAV`, `.FLAC`, or `.MP3`
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
- **Options**:
- **Configure max words per subtitle**: Automatically configures the maximum number of words per subtitle entry.
- **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 teporary files**: Deletes all temporary files created during the conversion process.
- **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`.
## `Supported Languages`
```
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English
# 🇪🇸 'e' => Spanish es
# 🇫🇷 'f' => French fr-fr
# 🇮🇳 'h' => Hindi hi
# 🇮🇹 'i' => Italian it
# 🇯🇵 'j' => Japanese: pip install misaki[ja]
# 🇧🇷 'p' => Brazilian Portuguese pt-br
# 🇨🇳 'z' => Mandarin Chinese: pip install misaki[zh]
```
For a complete list of supported languages and voices, refer to Kokoro's [VOICES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md). To listen to sample audio outputs, see [SAMPLES.md](https://huggingface.co/hexgrad/Kokoro-82M/blob/main/SAMPLES.md).
## `Similar Projects`
Abogen is a standalone project, but it is inspired by and shares some similarities with other projects. Here are a few:
- [audiblez](https://github.com/santinic/audiblez): Generate audiobooks from e-books. **(Has CLI and GUI support)**
- [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.
## `Roadmap`
- [ ] Improve PDF support for better text extraction.
- [ ] Add chapter metadata for .m4a files using ffmpeg-bin.
- [ ] Add support for different languages in GUI.
- [ ] Add voice formula feature that enables mixing different voice models.
- [ ] Add support for kokoro-onnx.
## `Contributing`
I welcome contributions! If you have ideas for new features, improvements, or bug fixes, please fork the repository and submit a pull request.
### For developers and contributors
If you'd like to modify the code and contribute to development, you can [download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip), extract it and run the following commands to build **or** install the package:
```bash
# Go to the directory where you extracted the repository and run:
pip install -e . # Installs the package in editable mode
python -m build # Builds the package in dist folder
abogen # Opens the GUI
```
Feel free to explore the code and make any changes you like.
## `Credits`
Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro project and its contributors for making this possible.
## `License`
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
[Kokoro](https://github.com/hexgrad/kokoro) is licensed under [Apache-2.0](https://github.com/hexgrad/kokoro/blob/main/LICENSE) which allows commercial use, modification, distribution, and private use.
> [!IMPORTANT]
> Subtitle generation currently works only for English. This is because Kokoro provides timestamp tokens only for English text. If you want subtitles in other languages, please request this feature in the [Kokoro project](https://github.com/hexgrad/kokoro). For more technical details, see [this line](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383) in the Kokoro's code.
> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, content-creation, media-generation
+299
View File
@@ -0,0 +1,299 @@
@echo off
setlocal
cd /d "%~dp0"
:: Set misaki language
:: English: "en"
:: Chinese: "zh"
:: Japanese: "ja"
set MISAKI_LANG=en
:: Set PyTorch CUDA version
set CUDA_VERSION=128
:::
::: _ ____ ___ ____ _____ _ _
::: / \ | __ ) / _ \ / ___|| ____| \ | |
::: / _ \ | _ \| | | | | _ | _| | \| |
::: / ___ \| |_) | |_| | |_| || |___| |\ |
::: /_/ \_\____/ \___/ \____||_____|_| \_|
:::
:::
for /f "delims=: tokens=*" %%A in ('findstr /b ::: "%~f0"') do @echo(%%A
set CURRENT_DIR="%CD%"
setlocal enabledelayedexpansion
set NAME=abogen
set PROJECTFOLDER=abogen
set RUN=python_embedded\Scripts\abogen.exe
set PYPROJECT_FILE=pyproject.toml
set LAST_DIR_FILE=%PROJECTFOLDER%\last_known_directory.txt
set refrenv=%PROJECTFOLDER%\refrenv.bat
set PYTHON_PATH=python_embedded\pythonw.exe
set PYTHON_CONSOLE_PATH=python_embedded\python.exe
:: Check for updates
echo Checking for updates...
set VERSION_FILE=%PROJECTFOLDER%\VERSION
set VERSION_URL=https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION
set UPDATE_ZIP_URL=https://github.com/denizsafak/abogen/archive/refs/heads/main.zip
set UPDATE_ZIP=%PROJECTFOLDER%\update.zip
if exist "%VERSION_FILE%" (
set /p LOCAL_VERSION=<"%VERSION_FILE%"
:: Remove any dots from the version string
set "LOCAL_VERSION_CLEAN=!LOCAL_VERSION:.=!"
:: First verify GitHub is accessible by checking HTTP status code
curl -s -o nul -w "%%{http_code}" "%VERSION_URL%" > "%TEMP%\abogen_http_status.txt"
set /p abogen_http_status=<"%TEMP%\abogen_http_status.txt"
if not "!abogen_http_status!"=="200" (
echo Failed to access GitHub repository ^(HTTP status: !abogen_http_status!^). Continuing with current version.
goto continue_with_current_version
)
:: Get the remote version (only if GitHub is accessible)
curl -s -o "%TEMP%\abogen_remote_version.txt" "%VERSION_URL%"
findstr "^" "%TEMP%\abogen_remote_version.txt" >nul 2>&1
if errorlevel 1 (
echo Failed to retrieve version information. Continuing with current version.
goto continue_with_current_version
)
set /p abogen_remote_version=<"%TEMP%\abogen_remote_version.txt"
if "!abogen_remote_version!"=="" (
echo Empty version information received. Continuing with current version.
goto continue_with_current_version
)
:: Remove any dots from the remote version string
set "abogen_remote_version_CLEAN=!abogen_remote_version:.=!"
:: Double-verify that both version values are numeric
echo !LOCAL_VERSION_CLEAN!| findstr /r "^[0-9]*$" >nul
if errorlevel 1 (
echo Invalid local version format. Continuing with current version.
goto continue_with_current_version
)
echo !abogen_remote_version_CLEAN!| findstr /r "^[0-9]*$" >nul
if errorlevel 1 (
echo Invalid remote version format. Continuing with current version.
goto continue_with_current_version
)
if !abogen_remote_version_CLEAN! GTR !LOCAL_VERSION_CLEAN! (
echo Update available ^(!LOCAL_VERSION! -^> !abogen_remote_version!^).
echo Do you want to download the latest update?
choice /C YN /M "Y=Yes, N=No"
if errorlevel 2 (
echo Update skipped. Continuing with current version.
goto continue_with_current_version
)
echo Downloading the latest update...
:: Test the zip URL before downloading
curl -s -o nul -w "%%{http_code}" "%UPDATE_ZIP_URL%" > "%TEMP%\abogen_zip_status.txt"
set /p abogen_zip_status=<"%TEMP%\abogen_zip_status.txt"
if not "!abogen_zip_status!"=="302" (
echo Failed to access update zip file ^(HTTP status: !abogen_zip_status!^). Continuing with current version.
goto continue_with_current_version
)
curl -L -o "%UPDATE_ZIP%" "%UPDATE_ZIP_URL%"
if not exist "%UPDATE_ZIP%" (
echo Failed to download update with curl. Trying with PowerShell method...
powershell -Command "Invoke-WebRequest -Uri '%UPDATE_ZIP_URL%' -OutFile '%UPDATE_ZIP%'"
)
if exist "%UPDATE_ZIP%" (
echo Extracting update...
:: Create a temp directory for extraction
if not exist "%TEMP%\abogen_update" mkdir "%TEMP%\abogen_update"
powershell -Command "Expand-Archive -Path '%UPDATE_ZIP%' -DestinationPath '%TEMP%\abogen_update' -Force"
if exist "%TEMP%\abogen_update\abogen-main" (
:: Copy files from the extracted directory to the current directory
echo Installing update...
xcopy /E /Y /I "%TEMP%\abogen_update\abogen-main\*" "."
:: Clean up
rmdir /S /Q "%TEMP%\abogen_update"
del "%UPDATE_ZIP%"
echo Update completed successfully!
echo Restarting...
start "" "%~f0" %*
exit
) else (
echo Failed to extract update. Continuing with current version.
)
) else (
echo Failed to download update. Continuing with current version.
)
) else (
echo Current version: !LOCAL_VERSION!, Remote version: !abogen_remote_version!
echo You are usering the latest version.
)
) else (
echo VERSION file not found. Cannot check for updates.
)
:continue_with_current_version
REM Python embedded download configuration for different architectures
if "%PROCESSOR_ARCHITECTURE%"=="x86" (
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_win32.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-win32.zip
) else (
set PYTHON_EMBEDDED_FILE=%PROJECTFOLDER%\python_embedded_amd64.zip
set PYTHON_EMBEDDED_URL=https://github.com/wojiushixiaobai/Python-Embed-Win64/releases/download/3.12.8/python-3.12.8-embed-amd64.zip
)
:: Check if Python exists
%PYTHON_CONSOLE_PATH% -m pip --version >nul 2>&1 && (set python_installed=true) || (set python_installed=false)
if "%python_installed%"=="false" (
if not exist %PYTHON_EMBEDDED_FILE% (
echo Downloading embedded Python...
curl -L -o %PYTHON_EMBEDDED_FILE% %PYTHON_EMBEDDED_URL%
if errorlevel 1 (
echo Failed to download embedded Python with curl. Trying with PowerShell method...
powershell -Command "Invoke-WebRequest -Uri %PYTHON_EMBEDDED_URL% -OutFile %PYTHON_EMBEDDED_FILE%"
if errorlevel 1 (
echo Failed to download embedded Python.
pause
exit /b
)
)
)
if not exist "python_embedded" (
echo Creating python_embedded directory...
mkdir python_embedded
if errorlevel 1 (
echo Failed to create python_embedded directory.
pause
exit /b
)
)
echo Unzipping embedded Python...
tar -xf %PYTHON_EMBEDDED_FILE% -C python_embedded
if errorlevel 1 (
echo Failed to unzip embedded Python with tar. Trying with PowerShell method...
powershell -Command "Expand-Archive -Path %PYTHON_EMBEDDED_FILE% -DestinationPath python_embedded"
if errorlevel 1 (
echo Failed to unzip embedded Python.
pause
exit /b
)
)
::del %PYTHON_EMBEDDED_FILE%
echo Editing python312._pth file...
echo import site >> python_embedded\python312._pth
echo . >> python_embedded\python312._pth
if errorlevel 1 (
echo Failed to add import site and . to python312._pth file. Please edit the file manually and try again. You need to add 'import site' and '.' to the file. You can find the file in python_embedded directory. After editing, please run this script again.
pause
exit /b
)
)
:: Display provided argument if any
if not "%~1"=="" (
echo Open with: "%~1"
)
:: Update pip
echo Updating pip...
%PYTHON_CONSOLE_PATH% -m pip install --upgrade pip --no-warn-script-location
if errorlevel 1 (
echo Failed to update pip.
pause
exit /b
)
:: Install setuptools, wheel, and poetry-core
echo Installing setuptools...
%PYTHON_CONSOLE_PATH% -m pip install setuptools wheel hatchling editables --no-warn-script-location
if errorlevel 1 (
echo Failed to install setuptools.
pause
exit /b
)
:: Install project and dependencies from pyproject.toml
echo Checking and installing project dependencies...
if exist %PYPROJECT_FILE% (
echo Installing project from pyproject.toml...
%PYTHON_CONSOLE_PATH% -m pip install -e . --no-warn-script-location
if errorlevel 1 (
echo Failed to install from pyproject.toml.
pause
exit /b
)
) else (
echo Warning: pyproject.toml not found in current directory.
pause
)
:: Install misaki again if MISAKI_LANG is not set to "en"
if "%MISAKI_LANG%" NEQ "en" (
echo Configuring language pack: %MISAKI_LANG%
%PYTHON_CONSOLE_PATH% -m pip install misaki[lang] --upgrade --no-warn-script-location
if errorlevel 1 (
echo Failed to install misaki language pack.
pause
exit /b
)
)
:: 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 "%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.
if errorlevel 1 (
echo Failed to install PyTorch.
pause
exit /b
)
) else (
echo CUDA is available.
)
:: Ask user if they want to create a desktop shortcut
echo.
echo Do you want to create a desktop shortcut for %NAME%?
choice /C YN /M "Y=Yes, N=No"
if errorlevel 2 goto :skip_shortcut
if errorlevel 1 (
if exist "%PROJECTFOLDER%\assets\create_shortcuts.bat" (
call "%PROJECTFOLDER%\assets\create_shortcuts.bat"
) else (
echo Shortcut creation script not found: %PROJECTFOLDER%\assets\create_shortcuts.bat
)
goto :continue
)
:skip_shortcut
call "%PROJECTFOLDER%\assets\create_shortcuts.bat" --no-create-desktop-shortcut
echo Skipping desktop shortcut creation.
:continue
:: Run the program
echo Starting %NAME%...
start "" %RUN% %*
exit /b
+1
View File
@@ -0,0 +1 @@
# Add chapter metadata for .m4a files using ffmpeg.
+1
View File
@@ -0,0 +1 @@
1.0.0
+9
View File
@@ -0,0 +1,9 @@
from abogen.utils import get_version
# Program Information
PROGRAM_NAME = "abogen"
PROGRAM_DESCRIPTION = (
"Generate audiobooks from EPUBs, PDFs and text with synchronized captions."
)
GITHUB_URL = "https://github.com/denizsafak/abogen"
VERSION = get_version()
+94
View File
@@ -0,0 +1,94 @@
@echo off
setlocal
set "target=%~dp0..\..\python_embedded\Scripts\abogen.exe"
set "icon=%~dp0icon.ico"
set "shortcut=%USERPROFILE%\Desktop\abogen.lnk"
set "shortcutParent=%~dp0..\..\abogen.lnk"
set "create_desktop_shortcut=true"
:parse_args
if "%~1"=="" goto continue
if /i "%~1"=="--no-create-desktop-shortcut" (
set "create_desktop_shortcut=false"
) else if /i "%~1"=="true" (
set "create_desktop_shortcut=true"
) else if /i "%~1"=="false" (
set "create_desktop_shortcut=false"
)
shift
goto parse_args
:continue
if /i "%create_desktop_shortcut%"=="true" (
echo Creating desktop shortcut...
:: Try PowerShell method
powershell -NoProfile -Command ^
"$s = New-Object -ComObject WScript.Shell; " ^
"$sc = $s.CreateShortcut('%shortcut%'); " ^
"$sc.TargetPath = '%target%'; " ^
"$sc.IconLocation = '%icon%'; " ^
"$sc.Save()"
if errorlevel 1 (
echo PowerShell method failed. Trying another method...
goto vbscript
) else (
echo Shortcut created successfully.
goto createParent
)
:vbscript
echo Creating desktop shortcut...
echo Set oWS = WScript.CreateObject("WScript.Shell") > "%temp%\create_shortcut.vbs"
echo Set oLink = oWS.CreateShortcut("%shortcut%") >> "%temp%\create_shortcut.vbs"
echo oLink.TargetPath = "%target%" >> "%temp%\create_shortcut.vbs"
echo oLink.IconLocation = "%icon%" >> "%temp%\create_shortcut.vbs"
echo oLink.Save >> "%temp%\create_shortcut.vbs"
cscript //nologo "%temp%\create_shortcut.vbs"
del "%temp%\create_shortcut.vbs"
if exist "%shortcut%" (
echo Shortcut created successfully.
) else (
echo Failed to create shortcut.
)
) else (
echo Desktop shortcut creation skipped.
)
:createParent
echo Creating shortcut in parent parent folder...
:: Try PowerShell method
powershell -NoProfile -Command ^
"$s = New-Object -ComObject WScript.Shell; " ^
"$sc = $s.CreateShortcut('%shortcutParent%'); " ^
"$sc.TargetPath = '%target%'; " ^
"$sc.IconLocation = '%icon%'; " ^
"$sc.Save()"
if errorlevel 1 (
echo PowerShell method failed. Trying another method...
goto vbscriptParent
) else (
echo Shortcut created successfully.
goto end
)
:vbscriptParent
echo Creating shortcut in parent parent folder...
echo Set oWS = WScript.CreateObject("WScript.Shell") > "%temp%\create_shortcut_parent.vbs"
echo Set oLink = oWS.CreateShortcut("%shortcutParent%") >> "%temp%\create_shortcut_parent.vbs"
echo oLink.TargetPath = "%target%" >> "%temp%\create_shortcut_parent.vbs"
echo oLink.IconLocation = "%icon%" >> "%temp%\create_shortcut_parent.vbs"
echo oLink.Save >> "%temp%\create_shortcut_parent.vbs"
cscript //nologo "%temp%\create_shortcut_parent.vbs"
del "%temp%\create_shortcut_parent.vbs"
if exist "%shortcutParent%" (
echo Shortcut created successfully.
) else (
echo Failed to create shortcut.
)
:end
echo.
exit /b
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

File diff suppressed because it is too large Load Diff
+700
View File
@@ -0,0 +1,700 @@
import os
import re
import tempfile
import time
from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf
from abogen.utils import clean_text, SAMPLE_VOICE_TEXTS, LANGUAGE_DESCRIPTIONS
from abogen import PROGRAM_NAME
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
class ChapterOptionsDialog(QDialog):
def __init__(self, chapter_count, parent=None):
super().__init__(parent)
self.setWindowTitle("Chapter Options")
self.setMinimumWidth(350)
# Prevent closing with the X button and remove the help button
self.setWindowFlags(
self.windowFlags()
& ~Qt.WindowCloseButtonHint
& ~Qt.WindowContextHelpButtonHint
)
layout = QVBoxLayout(self)
# Add informational label
layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file."))
layout.addWidget(QLabel("How would you like to process these chapters?"))
# Add checkboxes
self.save_separately_checkbox = QCheckBox("Save each chapter separately")
self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end")
# Set default states
self.save_separately_checkbox.setChecked(True)
self.merge_at_end_checkbox.setChecked(True)
# Connect checkbox state change signal
self.save_separately_checkbox.stateChanged.connect(
self.update_merge_checkbox_state
)
layout.addWidget(self.save_separately_checkbox)
layout.addWidget(self.merge_at_end_checkbox)
# Add OK button
button_box = QDialogButtonBox(QDialogButtonBox.Ok)
button_box.accepted.connect(self.accept)
layout.addWidget(button_box)
# Initialize merge checkbox state
self.update_merge_checkbox_state()
def update_merge_checkbox_state(self):
# Enable merge checkbox only if save separately is checked
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
# Don't uncheck it, just leave it in its current state
def get_options(self):
save_separately = self.save_separately_checkbox.isChecked()
# Consider merge_at_end as false if the checkbox is disabled, regardless of its checked state
merge_at_end = (
self.merge_at_end_checkbox.isChecked()
and self.merge_at_end_checkbox.isEnabled()
)
return {
"save_chapters_separately": save_separately,
"merge_chapters_at_end": merge_at_end,
}
# Prevent closing by overriding the closeEvent
def closeEvent(self, event):
# Ignore all close events
event.ignore()
# Prevent escape key from closing the dialog
def keyPressEvent(self, event):
if event.key() == Qt.Key_Escape:
event.ignore()
else:
super().keyPressEvent(event)
class ConversionThread(QThread):
progress_updated = pyqtSignal(int, str) # Add str for ETR
conversion_finished = pyqtSignal(object, object) # Pass output path as second arg
log_updated = pyqtSignal(object) # Updated signal for log updates
chapters_detected = pyqtSignal(int) # Signal for chapter detection
def __init__(
self,
file_name,
lang_code,
speed,
voice,
save_option,
output_folder,
subtitle_mode,
output_format,
np_module,
kpipeline_class,
start_time,
total_char_count,
use_gpu=True,
): # Add use_gpu parameter
super().__init__()
self.np = np_module
self.KPipeline = kpipeline_class
self.file_name = file_name
self.lang_code = lang_code
self.speed = speed
self.voice = voice
self.save_option = save_option
self.output_folder = output_folder
self.subtitle_mode = subtitle_mode
self.cancel_requested = False
self.output_format = output_format
self.start_time = start_time # Store start_time
self.total_char_count = total_char_count # Use passed total character count
self.processed_char_count = 0 # Initialize processed character count
self.display_path = None # Add variable for display path
self.is_direct_text = (
False # Flag to indicate if input is from textbox rather than file
)
self.chapter_options_set = False
self.waiting_for_user_input = False
self.use_gpu = use_gpu # Store the GPU setting
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
def run(self):
try:
# Show configuration
self.log_updated.emit("Configuration:")
# Use display_path for logs if available, otherwise use the actual file name
display_file = self.display_path if self.display_path else self.file_name
self.log_updated.emit(f"- Input File: {display_file}")
# Use file size string passed from GUI
if hasattr(self, "file_size_str"):
self.log_updated.emit(f"- File size: {self.file_size_str}")
self.log_updated.emit(f"- Total characters: {self.total_char_count:,}")
self.log_updated.emit(
f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})"
)
self.log_updated.emit(f"- Voice: {self.voice}")
self.log_updated.emit(f"- Speed: {self.speed}")
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
self.log_updated.emit(f"- Output format: {self.output_format}")
self.log_updated.emit(f"- Save option: {self.save_option}")
# Display save_chapters_separately flag if it's set
if hasattr(self, "save_chapters_separately"):
self.log_updated.emit(
(
f"- Save chapters separately: {'Yes' if self.save_chapters_separately else 'No'}"
)
)
# Display merge_chapters_at_end flag if save_chapters_separately is True
if self.save_chapters_separately:
merge_at_end = getattr(self, "merge_chapters_at_end", True)
self.log_updated.emit(
f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}"
)
if self.save_option == "Choose output folder":
self.log_updated.emit(
f" - Output folder: {self.output_folder or os.getcwd()}"
)
self.log_updated.emit("\nInitializing TTS pipeline...")
# Set device based on use_gpu setting
device = "cuda" if self.use_gpu else "cpu"
tts = self.KPipeline(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
if self.is_direct_text:
text = self.file_name # Treat file_name as direct text input
else:
with open(self.file_name, "r", encoding="utf-8") as file:
text = file.read()
# Clean up text using utility function
text = clean_text(text)
# --- Chapter splitting logic ---
chapter_pattern = r"<<CHAPTER_MARKER:(.*?)>>"
chapter_splits = list(re.finditer(chapter_pattern, text))
chapters = []
if chapter_splits:
for idx, match in enumerate(chapter_splits):
start = match.end()
end = (
chapter_splits[idx + 1].start()
if idx + 1 < len(chapter_splits)
else len(text)
)
chapter_name = match.group(1).strip()
chapter_text = text[start:end].strip()
chapters.append((chapter_name, chapter_text))
else:
chapters = [("text", text)]
total_chapters = len(chapters)
# For text files with chapters, prompt user for options if not already set
is_txt_file = not self.is_direct_text and (
self.file_name.lower().endswith(".txt")
or (self.display_path and self.display_path.lower().endswith(".txt"))
)
if (
is_txt_file
and total_chapters > 1
and (
not hasattr(self, "save_chapters_separately")
or not hasattr(self, "merge_chapters_at_end")
)
and not self.chapter_options_set
):
self.waiting_for_user_input = True
# Emit signal to main thread to show dialog
self.chapters_detected.emit(total_chapters)
# Wait for chapter options to be set
while self.waiting_for_user_input and not self.cancel_requested:
time.sleep(0.1)
if self.cancel_requested:
self.conversion_finished.emit("Cancelled", None)
return
self.chapter_options_set = True
# Log all detected chapters at the beginning
if total_chapters > 1:
chapter_list = "\n".join(
[f"{i+1}) {c[0]}" for i, c in enumerate(chapters)]
)
self.log_updated.emit(
(f"\nDetected chapters ({total_chapters}):\n" + chapter_list)
)
else:
self.log_updated.emit((f"\nProcessing {chapters[0][0]}..."))
# If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file
save_chapters_separately = getattr(self, "save_chapters_separately", False)
chapters_out_dir = None
suffix = ""
base_path = self.display_path if self.display_path else self.file_name
base_name = os.path.splitext(os.path.basename(base_path))[0]
if self.save_option == "Save to Desktop":
parent_dir = os.path.join(os.path.expanduser("~"), "Desktop")
elif self.save_option == "Save next to input file":
parent_dir = os.path.dirname(base_path)
else:
parent_dir = self.output_folder or os.getcwd()
# Find a unique suffix for both folder and merged file, always
counter = 1
while True:
suffix = f"_{counter}" if counter > 1 else ""
chapters_out_dir_candidate = os.path.join(
parent_dir, f"{base_name}{suffix}_chapters"
)
merged_file_candidate = os.path.join(
parent_dir, f"{base_name}{suffix}.{self.output_format}"
)
merged_srt_candidate = (
os.path.splitext(merged_file_candidate)[0] + ".srt"
)
if (
not os.path.exists(chapters_out_dir_candidate)
and not os.path.exists(merged_file_candidate)
and (
self.subtitle_mode == "Disabled"
or not os.path.exists(merged_srt_candidate)
)
):
break
counter += 1
if save_chapters_separately and total_chapters > 1:
chapters_out_dir = chapters_out_dir_candidate
os.makedirs(chapters_out_dir, exist_ok=True)
self.log_updated.emit(f"\nChapters output folder: {chapters_out_dir}")
audio_segments = []
subtitle_entries = []
current_time = 0.0
rate = 24000
subtitle_mode = self.subtitle_mode
raw_tts_results = [] # Collect all raw tts Result objects
# ETR timing starts here, after model loading but before processing
self.etr_start_time = time.time()
self.processed_char_count = 0 # Initialize processed character count
# Initialize current segment counter
current_segment = 0
# Instead of processing the whole text, process by chapter
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
if total_chapters > 1:
self.log_updated.emit(
(
f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}",
"green",
)
)
# Variables for per-chapter processing when save_chapters_separately is enabled
chapter_audio_segments = []
chapter_subtitle_entries = []
chapter_current_time = 0.0
# Set split_pattern to \n+ which will split on one or more newlines
split_pattern = r"\n+"
for result in tts(
chapter_text,
voice=self.voice,
speed=self.speed,
split_pattern=split_pattern,
):
# Print the result for debugging
# print(f"Result: {result}")
if self.cancel_requested:
self.conversion_finished.emit("Cancelled", None)
return
current_segment += 1
grapheme_len = len(result.graphemes)
self.processed_char_count += grapheme_len
# Log progress with both character counts and the graphemes content
self.log_updated.emit(
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
)
raw_tts_results.append(result)
chunk_dur = len(result.audio) / rate
chunk_start = current_time
audio_segments.append(result.audio)
# For per-chapter output
if save_chapters_separately and total_chapters > 1:
chapter_audio_segments.append(result.audio)
chapter_chunk_start = chapter_current_time
# Process token timestamps for subtitle generation
if self.subtitle_mode != "Disabled":
tokens_list = getattr(result, "tokens", [])
tokens_with_timestamps = []
chapter_tokens_with_timestamps = []
# Process every token, regardless of text or timestamps
for tok in tokens_list:
tokens_with_timestamps.append(
{
"start": chunk_start + (tok.start_ts or 0),
"end": chunk_start + (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
}
)
if save_chapters_separately and total_chapters > 1:
chapter_tokens_with_timestamps.append(
{
"start": chapter_chunk_start
+ (tok.start_ts or 0),
"end": chapter_chunk_start + (tok.end_ts or 0),
"text": tok.text,
"whitespace": tok.whitespace,
}
)
# Process tokens according to subtitle mode
# Global subtitle processing
self._process_subtitle_tokens(
tokens_with_timestamps,
subtitle_entries,
self.max_subtitle_words,
)
# Per-chapter subtitle processing if enabled
if save_chapters_separately and total_chapters > 1:
self._process_subtitle_tokens(
chapter_tokens_with_timestamps,
chapter_subtitle_entries,
self.max_subtitle_words,
)
current_time += chunk_dur
# Update chapter_current_time for per-chapter output
if save_chapters_separately and total_chapters > 1:
chapter_current_time += chunk_dur
# Calculate percentage based on characters processed
percent = min(
int(self.processed_char_count / self.total_char_count * 100), 99
)
# Calculate ETR based on characters processed
etr_str = "Estimating..."
chars_done = self.processed_char_count
elapsed = time.time() - self.etr_start_time
# Calculate ETR if enough data is available
if (
chars_done > 0 and elapsed > 0.5
): # Check elapsed > 0.5 to avoid instability
avg_time_per_char = elapsed / chars_done
remaining = self.total_char_count - self.processed_char_count
if remaining > 0:
secs = avg_time_per_char * remaining
h = int(secs // 3600)
m = int((secs % 3600) // 60)
s = int(secs % 60)
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
# Update progress more frequently (after each result)
self.progress_updated.emit(percent, etr_str)
# Save the individual chapter output if save_chapters_separately is enabled
if (
save_chapters_separately
and total_chapters > 1
and chapters_out_dir
and chapter_audio_segments
):
# Sanitize chapter name for use in filenames
sanitized_chapter_name = re.sub(r"[^\w\-\. ]", "_", chapter_name)
sanitized_chapter_name = re.sub(
r"_+", "_", sanitized_chapter_name
) # Replace multiple underscores with one
chapter_filename = f"{chapter_idx:02d}_{sanitized_chapter_name}"
# Concatenate chapter audio and save
chapter_audio = self.np.concatenate(chapter_audio_segments)
chapter_out_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{self.output_format}"
)
sf.write(
chapter_out_path,
chapter_audio,
24000,
format=self.output_format,
)
# Generate .srt subtitle file for chapter if not Disabled
if self.subtitle_mode != "Disabled" and chapter_subtitle_entries:
chapter_srt_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.srt"
)
with open(chapter_srt_path, "w", encoding="utf-8") as srt_file:
for i, (start, end, text) in enumerate(
chapter_subtitle_entries, 1
):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
self.log_updated.emit(
(
f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nSubtitle saved to: {chapter_srt_path}",
"green",
)
)
else:
self.log_updated.emit(
(
f"\nChapter {chapter_idx} saved to: {chapter_out_path}",
"green",
)
)
# Set progress to 100% when processing is complete
self.progress_updated.emit(100, "00:00:00")
# Only generate the merged output file if merge_chapters_at_end is True or save_chapters_separately is False
merge_chapters = (
not hasattr(self, "save_chapters_separately")
or not self.save_chapters_separately
or getattr(self, "merge_chapters_at_end", True)
)
if audio_segments and merge_chapters:
self.log_updated.emit("\nFinalizing audio file...\n")
audio = self.np.concatenate(audio_segments)
out_dir = parent_dir
# Use the same suffix as above
out_filename = f"{base_name}{suffix}.{self.output_format}"
out_path = os.path.join(out_dir, out_filename)
srt_path = os.path.splitext(out_path)[0] + ".srt"
sf.write(out_path, audio, 24000, format=self.output_format)
if self.subtitle_mode != "Disabled":
with open(srt_path, "w", encoding="utf-8") as srt_file:
for i, (start, end, text) in enumerate(subtitle_entries, 1):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
self.conversion_finished.emit(
(
f"Audiobook saved to: {out_path}\n\nSubtitle saved to: {srt_path}",
"green",
),
out_path,
)
else:
self.conversion_finished.emit(
(f"Audiobook saved to: {out_path}", "green"), out_path
)
elif audio_segments and not merge_chapters:
self.conversion_finished.emit(
(
f"\nAll chapters processed successfully and saved to: {chapters_out_dir}",
"green",
),
chapters_out_dir,
)
else:
self.log_updated.emit(("No audio segments were generated.", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
except Exception as e:
self.log_updated.emit((f"Error occurred: {str(e)}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
def set_chapter_options(self, options):
"""Set chapter options from the dialog and resume processing"""
self.save_chapters_separately = options["save_chapters_separately"]
self.merge_chapters_at_end = options["merge_chapters_at_end"]
self.waiting_for_user_input = False
def _srt_time(self, t):
"""Helper function to format time for SRT files"""
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - int(t)) * 1000)
return f"{h:02}:{m:02}:{s:02},{ms:03}"
def _process_subtitle_tokens(
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words
):
"""Helper function to process subtitle tokens according to the subtitle mode"""
if not tokens_with_timestamps:
return
if self.subtitle_mode == "Sentence" or self.subtitle_mode == "Sentence + Comma":
# Define separator pattern based on mode
separator = r"[.!?]" if self.subtitle_mode == "Sentence" else r"[.!?,]"
current_sentence = []
word_count = 0
for token in tokens_with_timestamps:
current_sentence.append(token)
word_count += 1
# Split sentences based on separator or word count
if (
re.search(separator, token["text"]) and token["whitespace"] == " "
) or word_count >= max_subtitle_words:
if current_sentence:
# Create subtitle entry for this sentence
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
# Simplified text joining logic
sentence_text = ""
for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace", "") or "")
subtitle_entries.append(
(start_time, end_time, sentence_text.strip())
)
current_sentence = []
word_count = 0
# Add any remaining tokens as a sentence
if current_sentence:
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
# Simplified text joining logic
sentence_text = ""
for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace", "") or "")
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
else:
# Word count-based grouping
try:
word_count = int(self.subtitle_mode.split()[0])
word_count = min(word_count, max_subtitle_words)
except (ValueError, IndexError):
word_count = 1
# Combine punctuation with preceding words
processed_tokens = []
i = 0
while i < len(tokens_with_timestamps):
token = tokens_with_timestamps[i].copy()
# Look ahead for punctuation
while i + 1 < len(tokens_with_timestamps) and re.match(
r"^[^\w\s]+$", tokens_with_timestamps[i + 1]["text"]
):
token["text"] += tokens_with_timestamps[i + 1]["text"]
token["end"] = tokens_with_timestamps[i + 1]["end"]
token["whitespace"] = tokens_with_timestamps[i + 1]["whitespace"]
i += 1
processed_tokens.append(token)
i += 1
# Group words into subtitle entries
for i in range(0, len(processed_tokens), word_count):
group = processed_tokens[i : i + word_count]
if group:
text = "".join(
t["text"] + (t.get("whitespace", "") or "") for t in group
)
subtitle_entries.append(
(group[0]["start"], group[-1]["end"], text.strip())
)
def cancel(self):
self.cancel_requested = True
self.waiting_for_user_input = (
False # Also release the wait if we're waiting for input
)
class VoicePreviewThread(QThread):
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(
self, np_module, kpipeline_class, lang_code, voice, speed, parent=None
):
super().__init__(parent)
self.np_module = np_module
self.kpipeline_class = kpipeline_class
self.lang_code = lang_code
self.voice = voice
self.speed = speed
self.temp_wav = None
def run(self):
try:
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M"
)
sample_text = get_sample_voice_text(self.lang_code)
audio_segments = []
for result in tts(
sample_text, voice=self.voice, speed=self.speed, split_pattern=None
):
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
self.finished.emit()
except Exception as e:
self.error.emit(f"Voice preview error: {str(e)}")
class PlayAudioThread(QThread):
finished = pyqtSignal()
error = pyqtSignal(str)
def __init__(self, wav_path, parent=None):
super().__init__(parent)
self.wav_path = wav_path
def run(self):
try:
import pygame
import time as _time
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)
pygame.mixer.music.unload()
self.finished.emit()
except Exception as e:
self.error.emit(f"Audio playback error: {str(e)}")
+1865
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import os
import sys
import platform
from PyQt5.QtWidgets import QApplication
from PyQt5.QtGui import QIcon
from abogen.gui import abogen
from abogen.utils import get_resource_path
# Ensure sys.stdout and sys.stderr are valid in GUI mode
if sys.stdout is None:
sys.stdout = open(os.devnull, "w")
if sys.stderr is None:
sys.stderr = open(os.devnull, "w")
# Enable MPS GPU acceleration on Mac Apple Silicon
if platform.system() == "Darwin" and platform.processor() == "arm":
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
# Set application ID for Windows taskbar icon
if platform.system() == "Windows":
import ctypes
app_id = "abogen.v1.0.0"
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
# Handle Wayland on Linux GNOME
if platform.system() == "Linux":
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
if "gnome" in desktop and xdg_session == "wayland" and "QT_QPA_PLATFORM" not in os.environ:
os.environ["QT_QPA_PLATFORM"] = "wayland"
def main():
"""Main entry point for console usage."""
app = QApplication(sys.argv)
# Set application icon using get_resource_path from utils
icon_path = get_resource_path("abogen.assets", "icon.ico")
if icon_path:
app.setWindowIcon(QIcon(icon_path))
ex = abogen()
ex.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
+288
View File
@@ -0,0 +1,288 @@
import os
import json
import warnings
import platform
import subprocess
import re
from threading import Thread
# suppress warnings and disable HF hub symlink warnings
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
warnings.filterwarnings("ignore")
# Language description mapping
LANGUAGE_DESCRIPTIONS = {
"a": "American English",
"b": "British English",
"e": "Spanish",
"f": "French",
"h": "Hindi",
"i": "Italian",
"j": "Japanese",
"p": "Brazilian Portuguese",
"z": "Mandarin Chinese",
}
# Supported languages for subtitle generation
# Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation.
# This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline.
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
# 383 English processing (unchanged)
# 384 if self.lang_code in 'ab':
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [
"a",
"b",
]
# Voice and sample text constants
VOICES_INTERNAL = [
"af_alloy",
"af_aoede",
"af_bella",
"af_heart",
"af_jessica",
"af_kore",
"af_nicole",
"af_nova",
"af_river",
"af_sarah",
"af_sky",
"am_adam",
"am_echo",
"am_eric",
"am_fenrir",
"am_liam",
"am_michael",
"am_onyx",
"am_puck",
"am_santa",
"bf_alice",
"bf_emma",
"bf_isabella",
"bf_lily",
"bm_daniel",
"bm_fable",
"bm_george",
"bm_lewis",
"ef_dora",
"em_alex",
"em_santa",
"ff_siwis",
"hf_alpha",
"hf_beta",
"hm_omega",
"hm_psi",
"if_sara",
"im_nicola",
"jf_alpha",
"jf_gongitsune",
"jf_nezumi",
"jf_tebukuro",
"jm_kumo",
"pf_dora",
"pm_alex",
"pm_santa",
"zf_xiaobei",
"zf_xiaoni",
"zf_xiaoxiao",
"zf_xiaoyi",
"zm_yunjian",
"zm_yunxi",
"zm_yunxia",
"zm_yunyang",
]
# Voice and sample text mapping
SAMPLE_VOICE_TEXTS = {
"a": "This is a sample of the selected voice.",
"b": "This is a sample of the selected voice.",
"e": "Este es una muestra de la voz seleccionada.",
"f": "Ceci est un exemple de la voix sélectionnée.",
"h": "यह चयनित आवाज़ का एक नमूना है।",
"i": "Questo è un esempio della voce selezionata.",
"j": "これは選択した声のサンプルです。",
"p": "Este é um exemplo da voz selecionada.",
"z": "这是所选语音的示例。",
}
# flags mapping for voice display
FLAGS = {
"a": "🇺🇸",
"b": "🇬🇧",
"e": "🇪🇸",
"f": "🇫🇷",
"h": "🇮🇳",
"i": "🇮🇹",
"j": "🇯🇵",
"p": "🇧🇷",
"z": "🇨🇳",
}
def get_resource_path(package, resource):
"""
Get the path to a resource file, with fallback to local file system.
Args:
package (str): Package name containing the resource (e.g., 'abogen.assets')
resource (str): Resource filename (e.g., 'icon.ico')
Returns:
str: Path to the resource file, or None if not found
"""
from importlib import resources
# Try using importlib.resources first
try:
with resources.path(package, resource) as resource_path:
if os.path.exists(resource_path):
return str(resource_path)
except (ImportError, FileNotFoundError):
pass
# Fallback to local file system
try:
# Extract the subdirectory from package name (e.g., 'assets' from 'abogen.assets')
subdir = package.split(".")[-1] if "." in package else package
local_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), subdir, resource
)
if os.path.exists(local_path):
return local_path
except Exception:
pass
return None
def get_version():
"""Return the current version of the application."""
try:
with open(get_resource_path("abogen", "VERSION"), "r") as f:
return f.read().strip()
except Exception:
return "Unknown"
# Define config path
def get_user_config_path():
if os.name == "nt":
config_dir = os.path.join(os.environ["APPDATA"], "abogen")
else:
config_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
os.makedirs(config_dir, exist_ok=True)
return os.path.join(config_dir, "config.json")
CONFIG_PATH = get_user_config_path()
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
def clean_text(text):
# Trim spaces and tabs at the start and end of each line, preserving blank lines
text = "\n".join(line.strip() for line in text.splitlines())
# Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace
text = re.sub(r"\n{3,}", "\n\n", text).strip()
# Replace single newlines with spaces, but preserve double newlines
# text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
# Collapse multiple spaces and tabs into a single space
text = re.sub(r"[ \t]+", " ", text)
return text
def load_config():
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def save_config(config):
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
except Exception:
pass
def calculate_text_length(text):
# Remove double newlines (replace them with single newlines)
cleaned_text = text.replace("\n\n", "")
# Calculate character count
char_count = len(cleaned_text)
return char_count
def get_gpu_acceleration(enabled):
from torch.cuda import is_available
if not enabled:
return "CUDA GPU available but using CPU.", False
if is_available():
return "CUDA GPU available and enabled.", True
return "CUDA GPU is not available. Using CPU.", False
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"] = subprocess.Popen(["caffeinate"])
elif system == "Linux":
try:
_sleep_procs["Linux"] = subprocess.Popen(
[
"systemd-inhibit",
"--what=sleep",
"--why=TextToAudiobook conversion",
"sleep",
"999999",
]
)
except Exception:
try:
subprocess.Popen(["xdg-screensaver", "reset"])
except Exception:
pass
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:
_sleep_procs[system].terminate()
_sleep_procs[system] = None
except Exception:
pass
def load_numpy_kpipeline():
import numpy as np
from kokoro import KPipeline
return np, KPipeline
class LoadPipelineThread(Thread):
def __init__(self, callback):
super().__init__()
self.callback = callback
def run(self):
try:
np_module, kpipeline_class = load_numpy_kpipeline()
self.callback(np_module, kpipeline_class, None)
except Exception as e:
self.callback(None, None, str(e))
+65
View File
@@ -0,0 +1,65 @@
# How to Create Videos Like the Demo (52 seconds in just 736kB!)
The demo video showcases Abogen - an all-in-one tool for turning text into something you can see and hear. This guide explains how I created such a small yet effective demonstration video.
## About the Demo
The demo video shows how Abogen:
- Converts text files (ePub, PDF, text) into audio with synchronized subtitles
- Uses Kokoro (a powerful text-to-speech engine) to create natural voices
- Works completely on your computer for privacy and security
- Offers an easy interface for creating audiobooks and voiceovers
- Can be used for Instagram, YouTube, TikTok, or any content creation
And it does all this while being only **736kB** for a **52-second video**!
## How I Created This Tiny Video
### What You Need
- A background image (bg.jpg)
- The subtitle file (.srt) created by Abogen
- The audio recording (.wav) created by Abogen
- FFmpeg installed on your computer:
```bash
# Windows
winget install ffmpeg
# MacOS
brew install ffmpeg
# Linux
sudo apt install ffmpeg
```
### Step 1: Process the Subtitle File
Run this command to process Abogen's subtitle file:
```
python convert.py abogen_subtitles.srt
```
This creates a properly formatted subtitle file called "abogen_subtitles_demo.ass" with centered text and appropriate styling.
### Step 2: Create the Video
Run this FFmpeg command to create the tiny video:
```
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=abogen_subtitles_demo.ass" -c:v libvpx-vp9 -b:v 0 -crf 30 -c:a libopus -shortest demo.webm
```
That's it! The magic happens because:
- We use a single static background image instead of many frames
- The subtitles are stored as text (vector data), not as pixels
- VP9 video codec with Opus audio provides excellent compression
## For Higher Quality (But Larger) Video
If you need better quality for distribution, use this command instead:
```
ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=abogen_subtitles_demo.ass" -c:v libx264 -preset slow -crf 18 -movflags +faststart -c:a copy -shortest demo.mp4
```
This creates an MP4 file that's compatible with more devices but larger in size.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1
View File
@@ -0,0 +1 @@
Abogen is your all-in-one tool for turning text into something you can see and hear. Easily convert ePub, PDF, or text file into audio with subtitles that light up every word as its spoken. The voice you hear, and the subtitles you see are both created automatically by Abogen using Kokoro, an open-weight TTS model with 82 million parameters. Everything works right on your computer, so your files stay private and secure. The easy interface lets anyone create audiobooks, voiceovers for Instagram, YouTube or TikTok, or just high-quality text-to-speech in seconds. Whether you want to listen, create content, or bring your words to life, Abogen makes it fast, fun, and effortless. Experience your stories, scripts, and ideas in a whole new way with Abogen.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+205
View File
@@ -0,0 +1,205 @@
# FFmpeg commands for creating videos:
#
# For WebM (smaller filesize):
# ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=your_subtitle_demo.ass" -c:v libvpx-vp9 -b:v 0 -crf 30 -c:a libopus -shortest demo.webm
#
# For MP4 (higher quality):
# ffmpeg -loop 1 -framerate 24 -i bg.jpg -i audio.wav -vf "ass=your_subtitle_demo.ass" -c:v libx264 -preset slow -crf 18 -movflags +faststart -c:a copy -shortest demo.mp4
#
# Note: Replace 'audio.wav' with your audio file and 'your_subtitle_demo.ass' with your processed subtitle file
import re
import sys
from datetime import timedelta
def parse_time(s):
# For ASS format
if ':' in s and '.' in s:
h, m, s = s.split(':')
sec, cs = s.split('.')
return timedelta(hours=int(h), minutes=int(m), seconds=int(sec), milliseconds=int(cs) * 10)
# For SRT format (00:00:00,000)
elif ':' in s and ',' in s:
parts = s.split(':')
h = int(parts[0])
m = int(parts[1])
sec_parts = parts[2].split(',')
sec = int(sec_parts[0])
ms = int(sec_parts[1])
return timedelta(hours=h, minutes=m, seconds=sec, milliseconds=ms)
return None
def format_time(t):
total_seconds = int(t.total_seconds())
cs = int((t.total_seconds() - total_seconds) * 100)
h = total_seconds // 3600
m = (total_seconds % 3600) // 60
s = total_seconds % 60
return f"{h}:{m:02}:{s:02}.{cs:02}"
# Desired script info and style
DESIRED_SCRIPT_INFO = """[Script Info]
Title: Centered Subs
ScriptType: v4.00+
PlayResX: 1280
PlayResY: 720
"""
DESIRED_STYLE = """[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,60,&H00FFFFFF,&H000000FF,&H00000000,&H64000000,-1,0,0,0,100,100,0,0,1,2,0,5,10,10,10,1
"""
def process_subtitle_file(input_file):
output_file = input_file.replace('.ass', '_demo.ass').replace('.srt', '_demo.ass')
if output_file == input_file:
output_file = f"{input_file}_demo.ass"
try:
with open(input_file, "r", encoding="utf-8") as f:
lines = f.readlines()
except FileNotFoundError:
print(f"❌ Error: Input file '{input_file}' not found")
sys.exit(1)
except Exception as e:
print(f"❌ Error reading file: {e}")
sys.exit(1)
# Check if it's an SRT file
is_srt = input_file.lower().endswith('.srt')
if is_srt:
return convert_srt_to_ass(input_file, output_file, lines)
# Process ASS file
header = []
events = []
in_events = False
script_info_found = False
styles_found = False
for line in lines:
if line.strip().startswith("[Script Info]"):
script_info_found = True
header.append(DESIRED_SCRIPT_INFO)
continue
elif line.strip().startswith("[V4+ Styles]"):
styles_found = True
header.append(DESIRED_STYLE)
continue
elif line.strip().startswith("[Events]"):
in_events = True
header.append(line)
elif not in_events:
if not script_info_found and not styles_found:
header.append(line)
else:
if line.startswith("Dialogue:"):
events.append(line)
else:
header.append(line)
parsed_events = []
for line in events:
match = re.match(r"Dialogue: \d,([^,]+),([^,]+),", line)
if not match:
parsed_events.append((None, None, line))
continue
start = parse_time(match.group(1))
end = parse_time(match.group(2))
parsed_events.append((start, end, line))
# Cut overlaps
fixed_lines = []
for i in range(len(parsed_events)):
start, end, line = parsed_events[i]
if start is None:
fixed_lines.append(line)
continue
# Check for next subtitle
if i + 1 < len(parsed_events):
next_start, _, _ = parsed_events[i + 1]
if end > next_start:
end = next_start # cut current subtitle to stop at next one's start
fixed_line = re.sub(r"Dialogue: \d,[^,]+,[^,]+,",
f"Dialogue: 0,{format_time(start)},{format_time(end)},",
line, count=1)
fixed_lines.append(fixed_line)
try:
with open(output_file, "w", encoding="utf-8") as f:
f.writelines(header)
f.writelines(fixed_lines)
print(f"✅ Successfully processed. Output file: {output_file}")
except Exception as e:
print(f"❌ Error writing output file: {e}")
sys.exit(1)
def convert_srt_to_ass(input_file, output_file, lines):
"""Convert SRT format to ASS format."""
events = []
subtitle_blocks = []
current_block = []
# Parse SRT file
for line in lines:
line = line.strip()
if not line:
if current_block:
subtitle_blocks.append(current_block)
current_block = []
else:
current_block.append(line)
# Don't forget the last block
if current_block:
subtitle_blocks.append(current_block)
# Process subtitle blocks
for block in subtitle_blocks:
if len(block) < 3:
continue # Invalid block
# Skip the subtitle number
timing_line = block[1]
# Parse timing information
timing_match = re.match(r'(\d+:\d+:\d+,\d+)\s+-->\s+(\d+:\d+:\d+,\d+)', timing_line)
if not timing_match:
continue
start_time = parse_time(timing_match.group(1))
end_time = parse_time(timing_match.group(2))
# Combine text lines
text = "\\N".join(block[2:])
# Create ASS Dialogue line
dialogue_line = f"Dialogue: 0,{format_time(start_time)},{format_time(end_time)},Default,,0,0,0,,{text}\n"
events.append(dialogue_line)
# Create ASS file
try:
with open(output_file, "w", encoding="utf-8") as f:
f.write(DESIRED_SCRIPT_INFO)
f.write(DESIRED_STYLE)
f.write("[Events]\n")
f.write("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n")
f.writelines(events)
print(f"✅ Successfully converted SRT to ASS. Output file: {output_file}")
return True
except Exception as e:
print(f"❌ Error writing output file: {e}")
sys.exit(1)
if __name__ == "__main__":
if len(sys.argv) < 2:
print("❌ Error: No input file specified")
print(f"Usage: {sys.argv[0]} <input_file.ass|input_file.srt>")
sys.exit(1)
input_file = sys.argv[1]
process_subtitle_file(input_file)
+480
View File
@@ -0,0 +1,480 @@
1
00:00:00,350 --> 00:00:00,962
Abogen
2
00:00:00,962 --> 00:00:01,100
is
3
00:00:01,100 --> 00:00:01,300
your
4
00:00:01,300 --> 00:00:01,887
all-in-one
5
00:00:01,887 --> 00:00:02,250
tool
6
00:00:02,250 --> 00:00:02,375
for
7
00:00:02,375 --> 00:00:02,762
turning
8
00:00:02,762 --> 00:00:03,162
text
9
00:00:03,162 --> 00:00:03,375
into
10
00:00:03,375 --> 00:00:03,725
something
11
00:00:03,725 --> 00:00:03,825
you
12
00:00:03,825 --> 00:00:04,025
can
13
00:00:04,025 --> 00:00:04,312
see
14
00:00:04,312 --> 00:00:04,450
and
15
00:00:04,450 --> 00:00:05,450
hear.
16
00:00:05,450 --> 00:00:05,900
Easily
17
00:00:05,900 --> 00:00:06,337
convert
18
00:00:06,337 --> 00:00:07,187
ePub,
19
00:00:07,187 --> 00:00:08,250
PDF,
20
00:00:08,250 --> 00:00:08,412
or
21
00:00:08,412 --> 00:00:08,800
text
22
00:00:08,800 --> 00:00:09,162
file
23
00:00:09,162 --> 00:00:09,425
into
24
00:00:09,425 --> 00:00:10,037
audio
25
00:00:10,037 --> 00:00:10,237
with
26
00:00:10,237 --> 00:00:10,875
subtitles
27
00:00:10,875 --> 00:00:11,087
that
28
00:00:11,087 --> 00:00:11,287
light
29
00:00:11,287 --> 00:00:11,487
up
30
00:00:11,487 --> 00:00:11,824
every
31
00:00:11,824 --> 00:00:12,175
word
32
00:00:12,175 --> 00:00:12,300
as
33
00:00:12,300 --> 00:00:12,449
its
34
00:00:12,449 --> 00:00:13,787
spoken.
35
00:00:13,787 --> 00:00:13,925
The
36
00:00:13,925 --> 00:00:14,275
voice
37
00:00:14,275 --> 00:00:14,425
you
38
00:00:14,425 --> 00:00:15,212
hear,
39
00:00:15,212 --> 00:00:15,337
and
40
00:00:15,337 --> 00:00:15,437
the
41
00:00:15,437 --> 00:00:16,024
subtitles
42
00:00:16,024 --> 00:00:16,162
you
43
00:00:16,162 --> 00:00:16,487
see
44
00:00:16,487 --> 00:00:16,649
are
45
00:00:16,649 --> 00:00:16,975
both
46
00:00:16,975 --> 00:00:17,449
created
47
00:00:17,449 --> 00:00:18,362
automatically
48
00:00:18,362 --> 00:00:18,550
by
49
00:00:18,550 --> 00:00:19,074
Abogen
50
00:00:19,074 --> 00:00:19,425
using
51
00:00:19,425 --> 00:00:20,512
Kokoro,
52
00:00:20,512 --> 00:00:20,637
a
53
00:00:20,637 --> 00:00:21,237
powerful
54
00:00:21,237 --> 00:00:22,050
text-to-speech
55
00:00:22,050 --> 00:00:23,225
engine.
56
00:00:23,225 --> 00:00:23,750
Everything
57
00:00:23,750 --> 00:00:24,062
works
58
00:00:24,062 --> 00:00:24,312
right
59
00:00:24,312 --> 00:00:24,412
on
60
00:00:24,412 --> 00:00:24,524
your
61
00:00:24,524 --> 00:00:25,437
computer,
62
00:00:25,437 --> 00:00:25,625
so
63
00:00:25,625 --> 00:00:25,812
your
64
00:00:25,812 --> 00:00:26,225
files
65
00:00:26,225 --> 00:00:26,524
stay
66
00:00:26,524 --> 00:00:27,112
private
67
00:00:27,112 --> 00:00:27,274
and
68
00:00:27,274 --> 00:00:28,337
secure.
69
00:00:28,725 --> 00:00:28,862
The
70
00:00:28,862 --> 00:00:29,225
easy
71
00:00:29,225 --> 00:00:29,824
interface
72
00:00:29,824 --> 00:00:30,112
lets
73
00:00:30,112 --> 00:00:30,637
anyone
74
00:00:30,637 --> 00:00:31,062
create
75
00:00:31,062 --> 00:00:32,299
audiobooks,
76
00:00:32,299 --> 00:00:33,075
voiceovers
77
00:00:33,075 --> 00:00:33,237
for
78
00:00:33,237 --> 00:00:34,325
Instagram,
79
00:00:34,325 --> 00:00:34,962
YouTube
80
00:00:34,962 --> 00:00:35,137
or
81
00:00:35,137 --> 00:00:35,950
TikTok,
82
00:00:35,950 --> 00:00:36,100
or
83
00:00:36,100 --> 00:00:36,325
just
84
00:00:36,325 --> 00:00:37,024
high-quality
85
00:00:37,024 --> 00:00:37,850
text-to-speech
86
00:00:37,850 --> 00:00:38,000
in
87
00:00:38,000 --> 00:00:39,187
seconds.
88
00:00:39,187 --> 00:00:39,475
Whether
89
00:00:39,475 --> 00:00:39,625
you
90
00:00:39,625 --> 00:00:39,825
want
91
00:00:39,825 --> 00:00:39,924
to
92
00:00:39,924 --> 00:00:40,662
listen,
93
00:00:40,662 --> 00:00:41,000
create
94
00:00:41,000 --> 00:00:41,774
content,
95
00:00:41,774 --> 00:00:41,924
or
96
00:00:41,924 --> 00:00:42,137
bring
97
00:00:42,137 --> 00:00:42,250
your
98
00:00:42,250 --> 00:00:42,575
words
99
00:00:42,575 --> 00:00:42,700
to
100
00:00:42,700 --> 00:00:43,512
life,
101
00:00:43,512 --> 00:00:44,075
Abogen
102
00:00:44,075 --> 00:00:44,350
makes
103
00:00:44,350 --> 00:00:44,525
it
104
00:00:44,525 --> 00:00:45,299
fast,
105
00:00:45,299 --> 00:00:45,825
fun,
106
00:00:45,825 --> 00:00:45,950
and
107
00:00:45,950 --> 00:00:47,275
effortless.
108
00:00:47,275 --> 00:00:47,962
Experience
109
00:00:47,962 --> 00:00:48,075
your
110
00:00:48,075 --> 00:00:48,812
stories,
111
00:00:48,812 --> 00:00:49,350
scripts,
112
00:00:49,350 --> 00:00:49,500
and
113
00:00:49,500 --> 00:00:50,075
ideas
114
00:00:50,075 --> 00:00:50,174
in
115
00:00:50,174 --> 00:00:50,299
a
116
00:00:50,299 --> 00:00:50,662
whole
117
00:00:50,662 --> 00:00:50,850
new
118
00:00:50,850 --> 00:00:51,150
way
119
00:00:51,150 --> 00:00:51,299
with
120
00:00:51,299 --> 00:00:52,424
Abogen.
BIN
View File
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
[build-system]
requires = ["setuptools>=77.0"]
build-backend = "setuptools.build_meta"
[project]
name = "abogen"
description = "Generate audiobooks from EPUBs, PDFs and text with synchronized captions."
authors = [
{ name="Deniz Şafak", email="denizsafak98@gmail.com" }
]
readme = "README.md"
license = "MIT"
requires-python = ">=3.10, <3.13"
keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "kokoro", "accessibility", "book-converter", "voice-synthesis", "multilingual", "chapter-management", "subtitles", "content-creation", "media-generation"]
dependencies = [
"PyQt5>=5.15.11",
"kokoro>=0.9.4",
"ebooklib>=0.18",
"beautifulsoup4>=4.13.4",
"PyMuPDF>=1.25.5",
"soundfile>=0.13.1",
"pygame>=2.6.1"
]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent"
]
dynamic = ["version"]
[project.urls]
Homepage = "https://github.com/denizsafak/abogen"
Documentation = "https://github.com/denizsafak/abogen"
Repository = "https://github.com/denizsafak/abogen"
Issues = "https://github.com/denizsafak/abogen/issues"
[project.gui-scripts]
abogen = "abogen.main:main"
[tool.setuptools]
packages = ["abogen", "abogen.assets"]
[tool.setuptools.package-data]
abogen = [
"assets/*.ico",
"assets/*.gif",
"assets/*.png",
"VERSION"
]
[tool.setuptools.dynamic]
version = { file = "abogen/VERSION" }