Initial commit
@@ -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/
|
||||||
@@ -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;">
|
||||||
|
|
||||||
|
[](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'>
|
||||||
|
|
||||||
|
Here’s 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Add chapter metadata for .m4a files using ffmpeg.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
1.0.0
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 585 B |
@@ -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)}")
|
||||||
@@ -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()
|
||||||
@@ -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))
|
||||||
@@ -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.
|
||||||
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -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 it’s 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.
|
||||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -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)
|
||||||
@@ -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
|
||||||
|
it’s
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -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" }
|
||||||