diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c99cf9f
--- /dev/null
+++ b/.gitignore
@@ -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/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6837f1f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,134 @@
+# abogen: Audiobook Generator
+
+[](https://pypi.org/project/abogen)
+
+
+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).
+
+
+
+
+## Demo
+
+
+> 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`
+
+
+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
\ No newline at end of file
diff --git a/WINDOWS_INSTALL.bat b/WINDOWS_INSTALL.bat
new file mode 100644
index 0000000..93118c4
--- /dev/null
+++ b/WINDOWS_INSTALL.bat
@@ -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
+
+
diff --git a/abogen/TODO.md b/abogen/TODO.md
new file mode 100644
index 0000000..2d263c5
--- /dev/null
+++ b/abogen/TODO.md
@@ -0,0 +1 @@
+# Add chapter metadata for .m4a files using ffmpeg.
\ No newline at end of file
diff --git a/abogen/VERSION b/abogen/VERSION
new file mode 100644
index 0000000..afaf360
--- /dev/null
+++ b/abogen/VERSION
@@ -0,0 +1 @@
+1.0.0
\ No newline at end of file
diff --git a/abogen/__init__.py b/abogen/__init__.py
new file mode 100644
index 0000000..ab4795e
--- /dev/null
+++ b/abogen/__init__.py
@@ -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()
diff --git a/abogen/assets/create_shortcuts.bat b/abogen/assets/create_shortcuts.bat
new file mode 100644
index 0000000..21b90c4
--- /dev/null
+++ b/abogen/assets/create_shortcuts.bat
@@ -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
\ No newline at end of file
diff --git a/abogen/assets/icon.ico b/abogen/assets/icon.ico
new file mode 100644
index 0000000..fbab99d
Binary files /dev/null and b/abogen/assets/icon.ico differ
diff --git a/abogen/assets/loading.gif b/abogen/assets/loading.gif
new file mode 100644
index 0000000..fcbb77f
Binary files /dev/null and b/abogen/assets/loading.gif differ
diff --git a/abogen/assets/settings.png b/abogen/assets/settings.png
new file mode 100644
index 0000000..bd6fb88
Binary files /dev/null and b/abogen/assets/settings.png differ
diff --git a/abogen/book_handler.py b/abogen/book_handler.py
new file mode 100644
index 0000000..869e53b
--- /dev/null
+++ b/abogen/book_handler.py
@@ -0,0 +1,1197 @@
+import re
+import html
+import ebooklib
+import base64
+import fitz # PyMuPDF for PDF support
+from ebooklib import epub
+from bs4 import BeautifulSoup
+from PyQt5.QtWidgets import (
+ QDialog,
+ QTreeWidget,
+ QTreeWidgetItem,
+ QDialogButtonBox,
+ QVBoxLayout,
+ QHBoxLayout,
+ QTextEdit,
+ QTreeWidgetItemIterator,
+ QSplitter,
+ QWidget,
+ QPushButton,
+ QCheckBox,
+ QMenu,
+)
+from PyQt5.QtCore import Qt
+from abogen.utils import clean_text, calculate_text_length
+import os
+
+
+class HandlerDialog(QDialog):
+ # Class variables to remember checkbox states between dialog instances
+ _save_chapters_separately = False
+ _merge_chapters_at_end = True
+
+ def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None):
+ super().__init__(parent)
+
+ # Determine file type if not explicitly provided
+ self.file_type = file_type or (
+ "pdf" if book_path.lower().endswith(".pdf") else "epub"
+ )
+ self.book_path = book_path
+
+ # Extract book name from file path
+ book_name = os.path.splitext(os.path.basename(book_path))[0]
+
+ # Set window title based on file type and book name
+ self.setWindowTitle(
+ f'Select {"Chapters" if self.file_type == "epub" else "Pages"} - {book_name}'
+ )
+ self.resize(1200, 900)
+ self._block_signals = False # Flag to prevent recursive signals
+ # Configure window: remove help button and allow resizing
+ self.setWindowFlags(
+ Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint
+ )
+ self.setWindowModality(Qt.NonModal)
+ # Initialize save chapters flags from class variables
+ self.save_chapters_separately = HandlerDialog._save_chapters_separately
+ self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end
+
+ # Load the book based on file type
+ self.book = epub.read_epub(book_path) if self.file_type == "epub" else None
+ self.pdf_doc = fitz.open(book_path) if self.file_type == "pdf" else None
+
+ # Extract book metadata
+ self.book_metadata = self._extract_book_metadata()
+
+ # Initialize UI elements that are used in other methods
+ self.save_chapters_checkbox = None
+ self.merge_chapters_checkbox = None
+
+ # Build treeview
+ self.treeWidget = QTreeWidget(self)
+ self.treeWidget.setHeaderHidden(True)
+ self.treeWidget.setSelectionMode(QTreeWidget.SingleSelection)
+ self.treeWidget.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.treeWidget.customContextMenuRequested.connect(self.on_tree_context_menu)
+
+ # Initialize checked_chapters set
+ self.checked_chapters = set(checked_chapters) if checked_chapters else set()
+
+ # For storing content and lengths
+ self.content_texts = {}
+ self.content_lengths = {}
+
+ # Pre-process content based on file type
+ self._preprocess_content()
+
+ # Add "Information" item at the beginning of the tree
+ info_item = QTreeWidgetItem(self.treeWidget, ["Information"])
+ info_item.setData(0, Qt.UserRole, "info:bookinfo")
+ info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable)
+ font = info_item.font(0)
+ font.setBold(True)
+ info_item.setFont(0, font)
+
+ # Build tree based on file type
+ self._build_tree()
+
+ # Hide expand/collapse decoration if there are no parent items
+ has_parents = False
+ for i in range(self.treeWidget.topLevelItemCount()):
+ if self.treeWidget.topLevelItem(i).childCount() > 0:
+ has_parents = True
+ break
+ self.treeWidget.setRootIsDecorated(has_parents)
+
+ # Setup UI (creates save_chapters_checkbox and other UI elements)
+ self._setup_ui()
+
+ # Run auto-check after UI is setup
+ if not self._are_provided_checks_relevant():
+ self._run_auto_check()
+
+ # Connect signals
+ self.treeWidget.currentItemChanged.connect(self.update_preview)
+ self.treeWidget.itemChanged.connect(self.handle_item_check)
+ self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states())
+ self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click)
+
+ # Select first item and expand all
+ self.treeWidget.expandAll()
+ if self.treeWidget.topLevelItemCount() > 0:
+ self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0))
+
+ # Update checkbox states
+ self._update_checkbox_states()
+
+ def _preprocess_content(self):
+ """Pre-process content from the document"""
+ if self.file_type == "epub":
+ self._preprocess_epub_content()
+ else:
+ self._preprocess_pdf_content()
+
+ def _preprocess_epub_content(self):
+ """Extract EPUB content with preserved formatting."""
+ book, spine = self.book, [
+ item.get_name()
+ for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT)
+ ]
+
+ # Get TOC entries with titles
+ toc = []
+ for entry in book.toc:
+ if isinstance(entry, ebooklib.epub.Link):
+ toc.append((entry.href.split("#")[0], entry.title))
+ elif isinstance(entry, tuple) and hasattr(entry[0], "href"):
+ href = getattr(entry[0], "href", None)
+ if href:
+ toc.append(
+ (
+ href.split("#")[0],
+ entry[0].title if hasattr(entry[0], "title") else None,
+ )
+ )
+
+ # Find chapter ranges and process content
+ toc = [(h, t) for h, t in toc if h in spine and t]
+ chapter_indices = [spine.index(h) for h, _ in toc if h in spine] + [len(spine)]
+
+ def process_html(item):
+ if not item:
+ return ""
+ soup = BeautifulSoup(item.get_content(), "html.parser")
+ for br in soup.find_all("br"):
+ br.replace_with("\n")
+ for p in soup.find_all(["p", "div"]):
+ p.append("\n\n")
+ return re.sub(r"\n{3,}", "\n\n", clean_text(soup.get_text())).strip()
+
+ # Process chapters
+ for i in range(len(toc)):
+ href, title = toc[i]
+ start, end = chapter_indices[i], chapter_indices[i + 1]
+
+ texts = [
+ process_html(book.get_item_with_href(spine[idx]))
+ for idx in range(start, end)
+ ]
+ texts = [t for t in texts if t]
+
+ full_text = "\n\n".join(texts)
+ if title and full_text.lower().startswith(title.lower()):
+ full_text = (
+ full_text[: len(title)].rstrip()
+ + "\n\n"
+ + full_text[len(title) :].lstrip()
+ )
+
+ self.content_texts[href] = full_text
+ self.content_lengths[href] = calculate_text_length(full_text)
+
+ # Process any spine items not in TOC
+ for href in spine:
+ if href not in self.content_texts:
+ text = process_html(book.get_item_with_href(href))
+ self.content_texts[href] = text
+ self.content_lengths[href] = calculate_text_length(text)
+
+ def _preprocess_pdf_content(self):
+ """Pre-process all page contents from PDF document"""
+ for page_num in range(len(self.pdf_doc)):
+ text = clean_text(self.pdf_doc[page_num].get_text())
+ # Remove bracketed numbers (citations, footnotes)
+ text = re.sub(r"\[\s*\d+\s*\]", "", text)
+
+ # Remove standalone page numbers (numbers alone on a line)
+ text = re.sub(r"^\s*\d+\s*$", "", text, flags=re.MULTILINE)
+
+ # Remove page numbers at the end of paragraphs
+ # This pattern looks for digits surrounded by whitespace at the end of paragraphs
+ text = re.sub(r"\s+\d+\s*$", "", text, flags=re.MULTILINE)
+
+ # Also remove page numbers followed by a hyphen or dash at paragraph end
+ # (common in headers/footers like "- 42 -")
+ text = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", text, flags=re.MULTILINE)
+
+ page_id = f"page_{page_num+1}"
+ self.content_texts[page_id] = text
+ self.content_lengths[page_id] = calculate_text_length(text)
+
+ def _build_tree(self):
+ """Build tree based on file type"""
+ if self.file_type == "epub":
+ self._build_epub_tree()
+ else:
+ self._build_pdf_tree()
+
+ def _build_epub_tree(self):
+ """Build the tree for EPUB files from TOC"""
+ checkable_hrefs = set()
+
+ def build_tree(toc_entries, parent_item):
+ for entry in toc_entries:
+ href, title, children = None, "Unknown", []
+
+ if isinstance(entry, ebooklib.epub.Link):
+ href, title = entry.href, entry.title or entry.href
+ elif isinstance(entry, tuple) and len(entry) >= 1:
+ # Handle nested sections
+ section_or_link = entry[0]
+ if isinstance(section_or_link, ebooklib.epub.Section):
+ title = section_or_link.title
+ href = getattr(section_or_link, "href", None)
+ elif isinstance(section_or_link, ebooklib.epub.Link):
+ href, title = (
+ section_or_link.href,
+ section_or_link.title or section_or_link.href,
+ )
+
+ if len(entry) > 1 and isinstance(entry[1], list):
+ children = entry[1]
+ else:
+ continue # Skip unknown entry types
+
+ # Use the href without fragment for content lookup
+ lookup_href = href.split("#")[0] if href else None
+
+ item = QTreeWidgetItem(parent_item, [title])
+ item.setData(0, Qt.UserRole, href) # Store original href
+
+ # Make item checkable if it has content or children
+ has_content = (
+ lookup_href
+ and lookup_href in self.content_texts
+ and self.content_texts[lookup_href]
+ )
+ is_potentially_checkable = has_content or children
+
+ # Only make the first occurrence of a given lookup_href checkable
+ if is_potentially_checkable and (
+ not lookup_href or lookup_href not in checkable_hrefs
+ ):
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
+ if lookup_href:
+ checkable_hrefs.add(lookup_href)
+ # Set initial state based on provided checks
+ is_checked = href and href in self.checked_chapters
+ item.setCheckState(0, Qt.Checked if is_checked else Qt.Unchecked)
+ else:
+ # Not checkable if duplicate content or no content/children
+ item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable)
+
+ if children:
+ build_tree(children, item)
+
+ # Build the tree from the TOC
+ build_tree(self.book.toc, self.treeWidget)
+
+ def _build_pdf_tree(self):
+ """Build the tree for PDF files combining outline/bookmarks with pages"""
+ # Get outline and store if this PDF has bookmarks
+ outline = self.pdf_doc.get_toc()
+ self.has_pdf_bookmarks = bool(outline)
+
+ if not outline:
+ # No bookmarks/outline available, create a simple page list
+ self._build_pdf_pages_tree()
+ return
+
+ # Process the outline to determine page ranges
+ bookmark_pages = []
+ page_to_bookmark = {}
+ next_page_boundaries = {}
+ # Track added pages to prevent duplicates
+ added_pages = set()
+
+ # Extract page numbers from outline recursively
+ def extract_page_numbers(entries):
+ for entry in entries:
+ if (
+ len(entry) >= 3
+ ): # Valid outline entry has at least level, title, page
+ _, title, page = entry[:3]
+ # Convert page reference to actual page number (0-based)
+ page_num = (
+ page - 1
+ if isinstance(page, int)
+ else self.pdf_doc.resolve_link(page)[0]
+ )
+ bookmark_pages.append((page_num, title))
+
+ # Process children recursively
+ if len(entry) > 3 and isinstance(entry[3], list):
+ extract_page_numbers(entry[3])
+
+ extract_page_numbers(outline)
+ bookmark_pages.sort()
+
+ # Determine page ranges for each bookmark
+ for i, (page_num, title) in enumerate(bookmark_pages):
+ if i < len(bookmark_pages) - 1:
+ next_page_boundaries[page_num] = bookmark_pages[i + 1][0]
+ page_to_bookmark[page_num] = title
+
+ # Helper function to build the tree structure recursively
+ def build_outline_tree(entries, parent_item):
+ for entry in entries:
+ if (
+ len(entry) >= 3
+ ): # Valid outline entry has at least level, title, page
+ entry_level, title, page = entry[:3]
+ # Get actual page number (0-based)
+ page_num = (
+ page - 1
+ if isinstance(page, int)
+ else self.pdf_doc.resolve_link(page)[0]
+ )
+ page_id = f"page_{page_num+1}"
+
+ # Create bookmark item
+ bookmark_item = QTreeWidgetItem(parent_item, [title])
+ bookmark_item.setData(0, Qt.UserRole, page_id)
+ bookmark_item.setFlags(
+ bookmark_item.flags() | Qt.ItemIsUserCheckable
+ )
+ bookmark_item.setCheckState(
+ 0,
+ (
+ Qt.Checked
+ if page_id in self.checked_chapters
+ else Qt.Unchecked
+ ),
+ )
+
+ # Mark this page as added
+ added_pages.add(page_num)
+
+ # Add child pages that belong to this bookmark
+ next_page = next_page_boundaries.get(page_num, len(self.pdf_doc))
+ for sub_page_num in range(
+ page_num + 1, next_page
+ ): # Skip the bookmark page itself
+ # Skip if this page is a bookmark itself or already added as a child elsewhere
+ if (
+ sub_page_num in page_to_bookmark
+ or sub_page_num in added_pages
+ ):
+ continue
+
+ page_id = f"page_{sub_page_num+1}"
+ page_title = f"Page {sub_page_num+1}"
+
+ # Try to get a better title from the first line of content
+ page_text = self.content_texts.get(page_id, "").strip()
+ if page_text:
+ first_line = page_text.split("\n", 1)[0].strip()
+ if first_line and len(first_line) < 100:
+ page_title += f" - {first_line}"
+
+ page_item = QTreeWidgetItem(bookmark_item, [page_title])
+ page_item.setData(0, Qt.UserRole, page_id)
+ page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
+ page_item.setCheckState(
+ 0,
+ (
+ Qt.Checked
+ if page_id in self.checked_chapters
+ else Qt.Unchecked
+ ),
+ )
+
+ # Mark this page as added
+ added_pages.add(sub_page_num)
+
+ # Process child bookmarks if any
+ if len(entry) > 3 and isinstance(entry[3], list):
+ build_outline_tree(entry[3], bookmark_item)
+
+ # Start building the tree from the outline
+ build_outline_tree(outline, self.treeWidget)
+
+ # Add pages not covered by bookmarks
+ covered_pages = set(
+ added_pages
+ ) # Use our tracked pages to find uncategorized ones
+
+ # Add remaining pages as top-level items under "Other Pages"
+ uncategorized_pages = [
+ i for i in range(len(self.pdf_doc)) if i not in covered_pages
+ ]
+ if uncategorized_pages:
+ self._add_other_pages(uncategorized_pages)
+
+ def _build_pdf_pages_tree(self):
+ """Build a simple page list for PDFs without bookmarks"""
+ pages_item = QTreeWidgetItem(self.treeWidget, ["Pages"])
+ pages_item.setFlags(pages_item.flags() & ~Qt.ItemIsUserCheckable)
+ font = pages_item.font(0)
+ font.setBold(True)
+ pages_item.setFont(0, font)
+
+ for page_num in range(len(self.pdf_doc)):
+ page_id = f"page_{page_num+1}"
+ page_title = f"Page {page_num+1}"
+
+ # Try to get a better title from the first line of content
+ page_text = self.content_texts.get(page_id, "").strip()
+ if page_text:
+ first_line = page_text.split("\n", 1)[0].strip()
+ if first_line and len(first_line) < 100:
+ page_title += f" - {first_line}"
+
+ page_item = QTreeWidgetItem(pages_item, [page_title])
+ page_item.setData(0, Qt.UserRole, page_id)
+ page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
+ page_item.setCheckState(
+ 0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked
+ )
+
+ def _add_other_pages(self, uncategorized_pages):
+ """Add uncategorized pages to the tree"""
+ other_pages = QTreeWidgetItem(self.treeWidget, ["Other Pages"])
+ other_pages.setFlags(other_pages.flags() & ~Qt.ItemIsUserCheckable)
+ font = other_pages.font(0)
+ font.setBold(True)
+ other_pages.setFont(0, font)
+
+ for page_num in uncategorized_pages:
+ page_id = f"page_{page_num+1}"
+ page_title = f"Page {page_num+1}"
+
+ # Try to get better title from first line
+ page_text = self.content_texts.get(page_id, "").strip()
+ if page_text:
+ first_line = page_text.split("\n", 1)[0].strip()
+ if first_line and len(first_line) < 100:
+ page_title += f" - {first_line}"
+
+ page_item = QTreeWidgetItem(other_pages, [page_title])
+ page_item.setData(0, Qt.UserRole, page_id)
+ page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable)
+ page_item.setCheckState(
+ 0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked
+ )
+
+ def _are_provided_checks_relevant(self):
+ """Check if provided checks are relevant to this book"""
+ if not self.checked_chapters:
+ return False
+
+ # Collect all identifiers present in tree
+ all_identifiers = set()
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.flags() & Qt.ItemIsUserCheckable:
+ identifier = item.data(0, Qt.UserRole)
+ if identifier:
+ all_identifiers.add(identifier)
+ iterator += 1
+
+ # Check for any intersection with provided chapters
+ return bool(self.checked_chapters.intersection(all_identifiers))
+
+ def _setup_ui(self):
+ """Set up the user interface"""
+ # Add preview panel
+ self.previewEdit = QTextEdit(self)
+ self.previewEdit.setReadOnly(True)
+ self.previewEdit.setMinimumWidth(300)
+ self.previewEdit.setStyleSheet("QTextEdit { border: none; }")
+
+ # Dialog buttons
+ buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
+ buttons.accepted.connect(self.accept)
+ buttons.rejected.connect(self.reject)
+
+ # Selection buttons
+ item_type = "chapters" if self.file_type == "epub" else "pages"
+
+ # Auto-select button
+ self.auto_select_btn = QPushButton(f"Auto-select {item_type}", self)
+ self.auto_select_btn.clicked.connect(self.auto_select_chapters)
+ self.auto_select_btn.setToolTip(f"Automatically select main {item_type}")
+
+ # Selection buttons layout
+ buttons_layout = QVBoxLayout()
+ buttons_layout.setContentsMargins(0, 0, 0, 0)
+ buttons_layout.setSpacing(10)
+
+ # Row 1: Auto-select
+ auto_select_layout = QHBoxLayout()
+ auto_select_layout.addWidget(self.auto_select_btn)
+ buttons_layout.addLayout(auto_select_layout)
+
+ # Row 2: Select/Deselect All
+ select_layout = QHBoxLayout()
+ self.select_all_btn = QPushButton("Select all", self)
+ self.select_all_btn.clicked.connect(self.select_all_chapters)
+ self.deselect_all_btn = QPushButton("Clear all", self)
+ self.deselect_all_btn.clicked.connect(self.deselect_all_chapters)
+ select_layout.addWidget(self.select_all_btn)
+ select_layout.addWidget(self.deselect_all_btn)
+ buttons_layout.addLayout(select_layout)
+
+ # Row 3: Parent selection
+ parent_layout = QHBoxLayout()
+ self.select_parents_btn = QPushButton("Select parents", self)
+ self.select_parents_btn.clicked.connect(self.select_parent_chapters)
+ self.deselect_parents_btn = QPushButton("Unselect parents", self)
+ self.deselect_parents_btn.clicked.connect(self.deselect_parent_chapters)
+ parent_layout.addWidget(self.select_parents_btn)
+ parent_layout.addWidget(self.deselect_parents_btn)
+ buttons_layout.addLayout(parent_layout)
+
+ # Row 4: Expand/Collapse
+ expand_layout = QHBoxLayout()
+ self.expand_all_btn = QPushButton("Expand All", self)
+ self.expand_all_btn.clicked.connect(self.treeWidget.expandAll)
+ self.collapse_all_btn = QPushButton("Collapse All", self)
+ self.collapse_all_btn.clicked.connect(self.treeWidget.collapseAll)
+ expand_layout.addWidget(self.expand_all_btn)
+ expand_layout.addWidget(self.collapse_all_btn)
+ buttons_layout.addLayout(expand_layout)
+
+ # Left panel layout
+ leftLayout = QVBoxLayout()
+ leftLayout.setContentsMargins(0, 0, 5, 0)
+ leftLayout.addLayout(buttons_layout)
+ leftLayout.addWidget(self.treeWidget)
+
+ # Save options checkboxes
+ checkbox_text = (
+ "Save each chapter separately"
+ if self.file_type == "epub"
+ else "Save each page separately"
+ )
+ self.save_chapters_checkbox = QCheckBox(checkbox_text, self)
+ self.save_chapters_checkbox.setChecked(self.save_chapters_separately)
+ self.save_chapters_checkbox.stateChanged.connect(self.on_save_chapters_changed)
+ leftLayout.addWidget(self.save_chapters_checkbox)
+
+ self.merge_chapters_checkbox = QCheckBox(
+ "Create a merged version at the end", self
+ )
+ self.merge_chapters_checkbox.setChecked(self.merge_chapters_at_end)
+ self.merge_chapters_checkbox.stateChanged.connect(
+ self.on_merge_chapters_changed
+ )
+ leftLayout.addWidget(self.merge_chapters_checkbox)
+
+ leftLayout.addWidget(buttons)
+
+ # Create left panel widget
+ leftWidget = QWidget()
+ leftWidget.setLayout(leftLayout)
+
+ # Create splitter for left panel and preview
+ self.splitter = QSplitter(Qt.Horizontal)
+ self.splitter.addWidget(leftWidget)
+ self.splitter.addWidget(self.previewEdit)
+ self.splitter.setSizes([280, 420])
+
+ # Set main layout
+ mainLayout = QVBoxLayout(self)
+ mainLayout.addWidget(self.splitter)
+ self.setLayout(mainLayout)
+
+ def _update_checkbox_states(self):
+ """Update checkboxes enabled states based on document type and selection"""
+ # Make sure checkboxes exist before trying to modify them
+ if (
+ not hasattr(self, "save_chapters_checkbox")
+ or not self.save_chapters_checkbox
+ ):
+ return
+
+ # For PDFs without bookmarks, always disable separate chapters option
+ if (
+ self.file_type == "pdf"
+ and hasattr(self, "has_pdf_bookmarks")
+ and not self.has_pdf_bookmarks
+ ):
+ self.save_chapters_checkbox.setEnabled(False)
+ self.merge_chapters_checkbox.setEnabled(False)
+ return
+
+ # Count checked items differently based on file type
+ checked_count = 0
+
+ if self.file_type == "epub":
+ # For EPUB: Count all checked items
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if (
+ item.flags() & Qt.ItemIsUserCheckable
+ and item.checkState(0) == Qt.Checked
+ ):
+ checked_count += 1
+ if checked_count >= 2:
+ break
+ iterator += 1
+
+ else: # PDF
+ # For PDF: Count distinct parent groups
+ # We need content from at least 2 different parents to enable "save separately"
+ parent_groups = set()
+
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if (
+ item.flags() & Qt.ItemIsUserCheckable
+ and item.checkState(0) == Qt.Checked
+ ):
+ # Get the parent (or the item itself if it's a top-level item)
+ parent = item.parent()
+ if parent and parent != self.treeWidget.invisibleRootItem():
+ # Use memory address as a unique identifier since QTreeWidgetItem is not hashable
+ parent_groups.add(id(parent))
+ else:
+ # Top-level items count as their own parent group
+ parent_groups.add(id(item))
+ iterator += 1
+
+ checked_count = len(parent_groups)
+
+ # Enable save separately only if enough distinct groups are checked
+ min_groups_required = 2
+ self.save_chapters_checkbox.setEnabled(checked_count >= min_groups_required)
+
+ # Enable merge only if save separately is enabled and checked
+ self.merge_chapters_checkbox.setEnabled(
+ self.save_chapters_checkbox.isEnabled()
+ and self.save_chapters_checkbox.isChecked()
+ )
+
+ def select_all_chapters(self):
+ """Select all chapters/pages"""
+ self._block_signals = True
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.flags() & Qt.ItemIsUserCheckable:
+ item.setCheckState(0, Qt.Checked)
+ iterator += 1
+ self._block_signals = False
+ self._update_checked_set_from_tree()
+
+ def deselect_all_chapters(self):
+ """Deselect all chapters/pages"""
+ self._block_signals = True
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.flags() & Qt.ItemIsUserCheckable:
+ item.setCheckState(0, Qt.Unchecked)
+ iterator += 1
+ self._block_signals = False
+ self._update_checked_set_from_tree()
+
+ def select_parent_chapters(self):
+ """Select only parent chapters/sections"""
+ self._block_signals = True
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.flags() & Qt.ItemIsUserCheckable and item.childCount() > 0:
+ item.setCheckState(0, Qt.Checked)
+ iterator += 1
+ self._block_signals = False
+ self._update_checked_set_from_tree()
+
+ def deselect_parent_chapters(self):
+ """Deselect only parent chapters/sections"""
+ self._block_signals = True
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.flags() & Qt.ItemIsUserCheckable and item.childCount() > 0:
+ item.setCheckState(0, Qt.Unchecked)
+ iterator += 1
+ self._block_signals = False
+ self._update_checked_set_from_tree()
+
+ def auto_select_chapters(self):
+ """Auto-select chapters/pages"""
+ self._run_auto_check()
+
+ def _run_auto_check(self):
+ """Run automatic content selection based on file type"""
+ self._block_signals = True
+
+ if self.file_type == "epub":
+ self._run_epub_auto_check()
+ else: # PDF
+ self._run_pdf_auto_check()
+
+ self._block_signals = False
+ self._update_checked_set_from_tree()
+
+ def _run_epub_auto_check(self):
+ """Auto-check logic for EPUB files"""
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if not (item.flags() & Qt.ItemIsUserCheckable):
+ iterator += 1
+ continue
+
+ href = item.data(0, Qt.UserRole)
+ lookup_href = href.split("#")[0] if href else None
+
+ # Check based on length (> 1000 chars) and parent status
+ if (
+ lookup_href and self.content_lengths.get(lookup_href, 0) > 1000
+ ) or item.childCount() > 0:
+ item.setCheckState(0, Qt.Checked)
+ # Check children of parents
+ if item.childCount() > 0:
+ for i in range(item.childCount()):
+ child = item.child(i)
+ if child.flags() & Qt.ItemIsUserCheckable:
+ child.setCheckState(0, Qt.Checked)
+ iterator += 1
+
+ def _run_pdf_auto_check(self):
+ """Auto-check logic for PDF files"""
+ # If there are no bookmarks, just check all pages
+ if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks:
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.flags() & Qt.ItemIsUserCheckable:
+ item.setCheckState(0, Qt.Checked)
+ iterator += 1
+ return
+
+ # For PDFs with bookmarks, select all bookmark items and non-empty pages
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if not (item.flags() & Qt.ItemIsUserCheckable):
+ iterator += 1
+ continue
+
+ identifier = item.data(0, Qt.UserRole)
+
+ # Always select bookmark items or non-empty pages
+ if not identifier:
+ iterator += 1
+ continue
+
+ if (
+ not identifier.startswith("page_")
+ or self.content_lengths.get(identifier, 0) > 0
+ ):
+ item.setCheckState(0, Qt.Checked)
+
+ iterator += 1
+
+ def _update_checked_set_from_tree(self):
+ """Update the internal set of checked items"""
+ self.checked_chapters.clear()
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.checkState(0) == Qt.Checked:
+ identifier = item.data(0, Qt.UserRole)
+ if identifier:
+ self.checked_chapters.add(identifier)
+ iterator += 1
+ # Only update checkbox states if they exist
+ if hasattr(self, "save_chapters_checkbox") and self.save_chapters_checkbox:
+ self._update_checkbox_states()
+
+ def handle_item_check(self, item):
+ """Handle item check/uncheck by updating children"""
+ if self._block_signals:
+ return
+
+ self._block_signals = True
+
+ # Update children recursively
+ if item.flags() & Qt.ItemIsUserCheckable:
+ for i in range(item.childCount()):
+ child = item.child(i)
+ if child.flags() & Qt.ItemIsUserCheckable:
+ child.setCheckState(0, item.checkState(0))
+
+ self._block_signals = False
+ self._update_checked_set_from_tree()
+
+ def handle_item_double_click(self, item, column=0):
+ """Toggle check state when a non-parent item is double-clicked on the text, not the checkbox"""
+ # Only toggle items that are checkable and don't have children
+ if item.flags() & Qt.ItemIsUserCheckable and item.childCount() == 0:
+ # Get the rectangle of the checkbox
+ rect = self.treeWidget.visualItemRect(item)
+ checkbox_width = 20 # Approximate width of the checkbox
+
+ # Get current mouse position
+ mouse_pos = self.treeWidget.mapFromGlobal(self.treeWidget.cursor().pos())
+
+ # Only toggle if click position is not on the checkbox
+ if mouse_pos.x() > rect.x() + checkbox_width:
+ # Toggle the check state
+ new_state = (
+ Qt.Unchecked if item.checkState(0) == Qt.Checked else Qt.Checked
+ )
+ item.setCheckState(0, new_state)
+
+ def update_preview(self, current):
+ """Update the preview panel with selected item content"""
+ if not current:
+ self.previewEdit.clear()
+ return
+
+ identifier = current.data(0, Qt.UserRole)
+
+ # Special case for the Information item
+ if identifier == "info:bookinfo":
+ self._display_book_info()
+ return
+
+ # Get content based on file type
+ text = None
+ if self.file_type == "epub":
+ lookup_href = identifier.split("#")[0] if identifier else None
+ text = self.content_texts.get(lookup_href, None)
+ else: # PDF
+ text = self.content_texts.get(identifier, None)
+
+ # Display content or placeholder text
+ if text is None:
+ self.previewEdit.setPlainText(
+ "(Section title - select a sub-item for content)"
+ if current.childCount() > 0
+ else "(No content associated with this entry)"
+ )
+ elif not text.strip():
+ self.previewEdit.setPlainText("(Item is empty)")
+ else:
+ self.previewEdit.setPlainText(text)
+
+ def _display_book_info(self):
+ """Display book metadata and cover image in the preview panel"""
+ self.previewEdit.clear()
+ html_content = "
"
+
+ # Add cover image if available
+ if self.book_metadata["cover_image"]:
+ try:
+ image_data = base64.b64encode(self.book_metadata["cover_image"]).decode(
+ "utf-8"
+ )
+
+ # Determine image type
+ image_type = "jpeg"
+ if self.book_metadata["cover_image"].startswith(b"\x89PNG"):
+ image_type = "png"
+ elif self.book_metadata["cover_image"].startswith(b"GIF"):
+ image_type = "gif"
+
+ html_content += (
+ f""
+ )
+ html_content += (
+ f"

"
+ except Exception as e:
+ html_content += f"Error displaying cover image: {str(e)}
"
+
+ # Add title, authors, publisher
+ if self.book_metadata["title"]:
+ html_content += (
+ f"{self.book_metadata['title']}
"
+ )
+
+ if self.book_metadata["authors"]:
+ authors_text = ", ".join(self.book_metadata["authors"])
+ html_content += f"By {authors_text}
"
+
+ if self.book_metadata["publisher"]:
+ html_content += f"Published by {self.book_metadata['publisher']}
"
+
+ html_content += "
"
+
+ # Add description
+ if self.book_metadata["description"]:
+ desc = re.sub(r"<[^>]+>", "", self.book_metadata["description"])
+ html_content += f"Description:
{desc}
"
+
+ # Add file type and page count for PDFs
+ if self.file_type == "pdf":
+ page_count = len(self.pdf_doc) if self.pdf_doc else 0
+ html_content += f"File type: PDF
Page count: {page_count}
"
+
+ html_content += ""
+ self.previewEdit.setHtml(html_content)
+
+ def _extract_book_metadata(self):
+ """Extract book metadata"""
+ metadata = {
+ "title": None,
+ "authors": [],
+ "description": None,
+ "cover_image": None,
+ "publisher": None,
+ }
+
+ if self.file_type == "epub":
+ # Extract EPUB metadata
+ title_items = self.book.get_metadata("DC", "title")
+ if title_items:
+ metadata["title"] = title_items[0][0]
+
+ author_items = self.book.get_metadata("DC", "creator")
+ if author_items:
+ metadata["authors"] = [author[0] for author in author_items]
+
+ desc_items = self.book.get_metadata("DC", "description")
+ if desc_items:
+ metadata["description"] = desc_items[0][0]
+
+ publisher_items = self.book.get_metadata("DC", "publisher")
+ if publisher_items:
+ metadata["publisher"] = publisher_items[0][0]
+
+ # Try to find cover image
+ for item in self.book.get_items_of_type(ebooklib.ITEM_COVER):
+ metadata["cover_image"] = item.get_content()
+ break
+
+ if not metadata["cover_image"]:
+ for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE):
+ if "cover" in item.get_name().lower():
+ metadata["cover_image"] = item.get_content()
+ break
+ else: # PDF
+ # Extract PDF metadata
+ pdf_info = self.pdf_doc.metadata
+ if pdf_info:
+ metadata["title"] = pdf_info.get("title", None)
+
+ author = pdf_info.get("author", None)
+ if author:
+ metadata["authors"] = [author]
+
+ metadata["description"] = pdf_info.get("subject", None)
+
+ keywords = pdf_info.get("keywords", None)
+ if keywords:
+ if metadata["description"]:
+ metadata["description"] += f"\n\nKeywords: {keywords}"
+ else:
+ metadata["description"] = f"Keywords: {keywords}"
+
+ metadata["publisher"] = pdf_info.get("creator", None)
+
+ # Try to get cover image from first page
+ if len(self.pdf_doc) > 0:
+ try:
+ pix = self.pdf_doc[0].get_pixmap(matrix=fitz.Matrix(2, 2))
+ metadata["cover_image"] = pix.tobytes("png")
+ except Exception:
+ pass
+
+ return metadata
+
+ def get_selected_text(self):
+ """Get selected text and checked identifiers based on file type"""
+ if self.file_type == "epub":
+ return self._get_epub_selected_text()
+ else: # PDF
+ return self._get_pdf_selected_text()
+
+ def _get_epub_selected_text(self):
+ """Get selected text from EPUB content"""
+ all_checked_hrefs = set()
+ included_text_hrefs = set()
+ chapter_titles = []
+
+ # Collect all checked hrefs
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.checkState(0) == Qt.Checked:
+ href = item.data(0, Qt.UserRole)
+ if href:
+ all_checked_hrefs.add(href)
+ iterator += 1
+
+ # Include content for all checked items
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.checkState(0) == Qt.Checked:
+ href = item.data(0, Qt.UserRole)
+ if href:
+ lookup_href = href.split("#")[0]
+ text = self.content_texts.get(lookup_href, "")
+ if text and lookup_href not in included_text_hrefs:
+ title = item.text(0)
+ title = re.sub(r"^\s*-\s*", "", title).strip()
+ marker = f"<>"
+ chapter_titles.append((title, marker + "\n" + text))
+ included_text_hrefs.add(lookup_href)
+ iterator += 1
+
+ return "\n\n".join([t[1] for t in chapter_titles]), all_checked_hrefs
+
+ def _get_pdf_selected_text(self):
+ """Get selected text from PDF content"""
+ all_checked_identifiers = set()
+ included_text_ids = set()
+ section_titles = []
+ all_content = []
+
+ # Check if PDF has no bookmarks
+ pdf_has_no_bookmarks = (
+ hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks
+ )
+
+ # Collect all checked identifiers
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.checkState(0) == Qt.Checked:
+ identifier = item.data(0, Qt.UserRole)
+ if identifier:
+ all_checked_identifiers.add(identifier)
+ iterator += 1
+
+ # For PDFs without bookmarks, collect all content without chapter markers
+ if pdf_has_no_bookmarks:
+ sorted_page_ids = sorted(
+ [id for id in all_checked_identifiers if id.startswith("page_")],
+ key=lambda x: int(x.split("_")[1]) if x.split("_")[1].isdigit() else 0,
+ )
+ for page_id in sorted_page_ids:
+ if page_id not in included_text_ids:
+ text = self.content_texts.get(page_id, "")
+ if text:
+ all_content.append(text)
+ included_text_ids.add(page_id)
+ return "\n\n".join(all_content), all_checked_identifiers
+
+ # For PDFs with bookmarks, process content with parent-child relationships
+ # New logic: if only child pages are selected (not parent), use parent's name as chapter marker at first selected child
+ iterator = QTreeWidgetItemIterator(self.treeWidget)
+ while iterator.value():
+ item = iterator.value()
+ if item.childCount() > 0:
+ parent_checked = item.checkState(0) == Qt.Checked
+ parent_id = item.data(0, Qt.UserRole)
+ parent_title = item.text(0)
+ # Gather checked children
+ checked_children = []
+ for i in range(item.childCount()):
+ child = item.child(i)
+ child_id = child.data(0, Qt.UserRole)
+ if (
+ child.checkState(0) == Qt.Checked
+ and child_id
+ and child_id not in included_text_ids
+ ):
+ checked_children.append((child, child_id))
+ # If parent is checked, use old logic (parent marker, all content)
+ if parent_checked and parent_id and parent_id not in included_text_ids:
+ combined_text = self.content_texts.get(parent_id, "")
+ for child, child_id in checked_children:
+ child_text = self.content_texts.get(child_id, "")
+ if child_text:
+ combined_text += "\n\n" + child_text
+ included_text_ids.add(child_id)
+ if combined_text.strip():
+ title = re.sub(r"^\s*-\s*", "", parent_title).strip()
+ marker = f"<>"
+ section_titles.append((title, marker + "\n" + combined_text))
+ included_text_ids.add(parent_id)
+ # If only children are checked, use parent's name as marker at first child
+ elif not parent_checked and checked_children:
+ title = re.sub(r"^\s*-\s*", "", parent_title).strip()
+ marker = f"<>"
+ for idx, (child, child_id) in enumerate(checked_children):
+ text = self.content_texts.get(child_id, "")
+ if text:
+ if idx == 0:
+ section_titles.append((title, marker + "\n" + text))
+ else:
+ section_titles.append((title, text))
+ included_text_ids.add(child_id)
+ elif item.flags() & Qt.ItemIsUserCheckable:
+ identifier = item.data(0, Qt.UserRole)
+ if (
+ identifier
+ and identifier not in included_text_ids
+ and item.checkState(0) == Qt.Checked
+ ):
+ text = self.content_texts.get(identifier, "")
+ if text:
+ title = item.text(0)
+ title = re.sub(r"^\s*-\s*", "", title).strip()
+ marker = f"<>"
+ section_titles.append((title, marker + "\n" + text))
+ included_text_ids.add(identifier)
+ iterator += 1
+
+ return "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers
+
+ def on_save_chapters_changed(self, state):
+ """Update the save_chapters_separately flag"""
+ self.save_chapters_separately = bool(state)
+ self.merge_chapters_checkbox.setEnabled(self.save_chapters_separately)
+ HandlerDialog._save_chapters_separately = self.save_chapters_separately
+
+ def on_merge_chapters_changed(self, state):
+ """Update the merge_chapters_at_end flag"""
+ self.merge_chapters_at_end = bool(state)
+ HandlerDialog._merge_chapters_at_end = self.merge_chapters_at_end
+
+ def get_save_chapters_separately(self):
+ """Return whether to save chapters separately"""
+ return (
+ self.save_chapters_separately
+ if self.save_chapters_checkbox.isEnabled()
+ else False
+ )
+
+ def get_merge_chapters_at_end(self):
+ """Return whether to merge chapters at the end"""
+ return self.merge_chapters_at_end
+
+ def on_tree_context_menu(self, pos):
+ """Handle context menu on tree items"""
+ item = self.treeWidget.itemAt(pos)
+ if (
+ not item
+ or item.childCount() == 0
+ or not (item.flags() & Qt.ItemIsUserCheckable)
+ ):
+ return
+
+ menu = QMenu(self)
+ checked = item.checkState(0) == Qt.Checked
+ text = "Unselect only this" if checked else "Select only this"
+ action = menu.addAction(text)
+
+ def do_toggle():
+ self.treeWidget.blockSignals(True)
+ new_state = Qt.Unchecked if checked else Qt.Checked
+ item.setCheckState(0, new_state)
+ self.treeWidget.blockSignals(False)
+ self._update_checked_set_from_tree()
+
+ action.triggered.connect(do_toggle)
+ menu.exec_(self.treeWidget.mapToGlobal(pos))
+
+ def closeEvent(self, event):
+ """Clean up resources when the dialog is closed"""
+ if self.pdf_doc is not None:
+ self.pdf_doc.close()
+ event.accept()
diff --git a/abogen/conversion.py b/abogen/conversion.py
new file mode 100644
index 0000000..c454334
--- /dev/null
+++ b/abogen/conversion.py
@@ -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_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)}")
diff --git a/abogen/gui.py b/abogen/gui.py
new file mode 100644
index 0000000..35e5b5b
--- /dev/null
+++ b/abogen/gui.py
@@ -0,0 +1,1865 @@
+import os
+import time
+import tempfile
+import platform
+import base64
+from PyQt5.QtWidgets import (
+ QApplication,
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QPushButton,
+ QFileDialog,
+ QLabel,
+ QProgressBar,
+ QSlider,
+ QComboBox,
+ QSizePolicy,
+ QTextEdit,
+ QFileIconProvider,
+ QMessageBox,
+ QDialog,
+ QCheckBox,
+ QMenu,
+ QAction,
+)
+from PyQt5.QtCore import (
+ Qt,
+ QUrl,
+ QPoint,
+ QFileInfo,
+ QThread,
+ pyqtSignal,
+ QObject,
+ QBuffer,
+ QIODevice,
+ QSize,
+ QTimer,
+)
+from PyQt5.QtGui import (
+ QTextCursor,
+ QDesktopServices,
+ QIcon,
+ QPixmap,
+ QPainter,
+ QPolygon,
+ QColor,
+ QMovie,
+)
+from abogen.utils import (
+ load_config,
+ save_config,
+ get_gpu_acceleration,
+ clean_text,
+ prevent_sleep_start,
+ prevent_sleep_end,
+ calculate_text_length,
+ get_resource_path,
+ LoadPipelineThread,
+ FLAGS,
+ VOICES_INTERNAL,
+ SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
+ LANGUAGE_DESCRIPTIONS,
+)
+from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
+from abogen.book_handler import HandlerDialog
+from abogen import PROGRAM_NAME, VERSION, GITHUB_URL, PROGRAM_DESCRIPTION
+from threading import Thread
+
+# Import ctypes for Windows-specific taskbar icon
+if platform.system() == "Windows":
+ import ctypes
+
+
+class ThreadSafeLogSignal(QObject):
+ log_signal = pyqtSignal(object)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ def emit_log(self, message):
+ self.log_signal.emit(message)
+
+
+class IconProvider(QFileIconProvider):
+ def icon(self, fileInfo):
+ return super().icon(fileInfo)
+
+
+class InputBox(QLabel):
+ LABEL_CSS = """QLabel { border:2px dashed #aaa; border-radius:5px; padding:20px; background:#f5f9fc; min-height:100px; } QLabel:hover { background:#e5f1fa; border-color:#6ab0de; }"""
+ LABEL_CSS_ACTIVE = """QLabel { border:2px dashed #42ad4a; border-radius:5px; padding:20px; background:#e6f7e6;min-height:100px; } QLabel:hover { background:#e5f1fa; border-color:#6ab0de; }"""
+ LABEL_CSS_ERROR = """QLabel { border:2px dashed #e74c3c; border-radius:5px; padding:20px; background:#ffeaea; min-height:100px; color:#c0392b; } QLabel:hover { background:#ffeaea; border-color:#e74c3c; }"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setAlignment(Qt.AlignCenter)
+ self.setAcceptDrops(True)
+ self.setText(
+ "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf)"
+ )
+ self.setStyleSheet(self.LABEL_CSS)
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self.setCursor(Qt.PointingHandCursor)
+ self.clear_btn = QPushButton("✕", self)
+ self.clear_btn.setFixedSize(28, 28)
+ self.clear_btn.hide()
+ self.clear_btn.clicked.connect(self.clear_input)
+ self.chapters_btn = QPushButton("Chapters", self)
+ self.chapters_btn.hide()
+ self.chapters_btn.clicked.connect(self.on_chapters_clicked)
+
+ # Add Textbox button with no padding
+ self.textbox_btn = QPushButton("Textbox", self)
+ self.textbox_btn.setStyleSheet("QPushButton { padding: 6px 10px; }")
+ self.textbox_btn.setToolTip("Input text directly instead of using a file")
+ self.textbox_btn.clicked.connect(self.on_textbox_clicked)
+ # Add Edit button matching the textbox button
+ self.edit_btn = QPushButton("Edit", self)
+ self.edit_btn.setStyleSheet("QPushButton { padding: 6px 10px; }")
+ self.edit_btn.setToolTip("Edit the current text file")
+ self.edit_btn.clicked.connect(self.on_edit_clicked)
+ self.edit_btn.hide()
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ margin = 12
+ self.clear_btn.move(self.width() - self.clear_btn.width() - margin, margin)
+ self.chapters_btn.move(
+ margin, self.height() - self.chapters_btn.height() - margin
+ )
+ # Position textbox button at top left
+ self.textbox_btn.move(margin, margin)
+ self.edit_btn.move(margin, margin)
+
+ def set_file_info(self, file_path):
+ # get icon without resizing using custom provider
+ provider = IconProvider()
+ qicon = provider.icon(QFileInfo(file_path))
+ size = QSize(32, 32)
+ pixmap = qicon.pixmap(size)
+ # convert to base64 PNG
+ buffer = QBuffer()
+ buffer.open(QIODevice.WriteOnly)
+ pixmap.save(buffer, "PNG")
+ img_data = base64.b64encode(buffer.data()).decode()
+
+ size_str = self._human_readable_size(os.path.getsize(file_path))
+ name = os.path.basename(file_path)
+ char_count = "N/A"
+
+ # Format numbers with commas
+ def format_num(n):
+ try:
+ return f"{int(n):,}"
+ except Exception:
+ return str(n)
+
+ if (
+ file_path.lower().endswith(".epub") or file_path.lower().endswith(".pdf")
+ ) and hasattr(self.window(), "selected_chapters"):
+ # EPUB or PDF: sum character counts for selected chapters
+ try:
+
+ book_path = file_path
+ dialog = HandlerDialog(
+ book_path,
+ checked_chapters=self.window().selected_chapters,
+ parent=self.window(),
+ )
+ chapters_text, all_checked_hrefs = dialog.get_selected_text()
+ # Clean text before counting characters
+ cleaned_text = clean_text(chapters_text)
+ char_count = calculate_text_length(cleaned_text)
+ except Exception:
+ char_count = "N/A"
+ else:
+ try:
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
+ text = f.read()
+ # Clean text before counting characters
+ cleaned_text = clean_text(text)
+ char_count = calculate_text_length(cleaned_text)
+ except Exception:
+ char_count = "N/A"
+ # Store numeric char_count on window
+ try:
+ self.window().char_count = int(char_count)
+ except Exception:
+ self.window().char_count = 0
+ # embed icon at native size with word-wrap for the filename
+ self.setText(
+ f'
{name}
Size: {format_num(size_str)}
Characters: {format_num(char_count)}'
+ )
+ # Set fixed width to force wrapping
+ self.setWordWrap(True)
+ self.setStyleSheet(self.LABEL_CSS_ACTIVE)
+ self.clear_btn.show()
+ is_document = self.window().selected_file_type in ["epub", "pdf"]
+ self.chapters_btn.setVisible(is_document)
+ if is_document:
+ chapter_count = len(self.window().selected_chapters)
+ file_type = self.window().selected_file_type
+ # Adjust button text based on file type
+ if file_type == "epub":
+ self.chapters_btn.setText(f"Chapters ({chapter_count})")
+ else: # PDF - always use Pages
+ self.chapters_btn.setText(f"Pages ({chapter_count})")
+
+ # Hide textbox and show edit only for .txt files
+ self.textbox_btn.hide()
+ # Show edit button for txt files directly
+ # Or for epub/pdf files that have generated a temp txt file
+ should_show_edit = file_path.lower().endswith(".txt")
+
+ # For epub/pdf files, show edit if we have a selected_file (temp txt)
+ if (
+ self.window().selected_file_type in ["epub", "pdf"]
+ and self.window().selected_file
+ ):
+ should_show_edit = True
+
+ self.edit_btn.setVisible(should_show_edit)
+
+ def set_error(self, message):
+ self.setText(message)
+ self.setStyleSheet(self.LABEL_CSS_ERROR)
+ # Show textbox button in error state as well
+ self.textbox_btn.show()
+
+ def clear_input(self):
+ self.window().selected_file = None
+ self.window().displayed_file_path = (
+ None # Reset the displayed file path when clearing input
+ )
+ self.setText(
+ "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf)"
+ )
+ self.setStyleSheet(self.LABEL_CSS)
+ self.clear_btn.hide()
+ self.chapters_btn.hide()
+ self.chapters_btn.setText("Chapters") # Reset text
+ # Show textbox and hide edit when input is cleared
+ self.textbox_btn.show()
+ self.edit_btn.hide()
+
+ def _human_readable_size(self, size, decimal_places=2):
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
+ if size < 1024.0:
+ return f"{size:.{decimal_places}f} {unit}"
+ size /= 1024.0
+ return f"{size:.{decimal_places}f} PB"
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.window().open_file_dialog()
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ ext = event.mimeData().urls()[0].toLocalFile().lower()
+ if ext.endswith(".txt") or ext.endswith(".epub") or ext.endswith(".pdf"):
+ event.acceptProposedAction()
+ return
+ event.ignore()
+
+ def dropEvent(self, event):
+ if event.mimeData().hasUrls():
+ file_path = event.mimeData().urls()[0].toLocalFile()
+ win = self.window()
+ if file_path.lower().endswith(".txt"):
+ win.selected_file, win.selected_file_type = file_path, "txt"
+ win.displayed_file_path = (
+ file_path # Set the displayed file path for text files
+ )
+ self.set_file_info(file_path)
+ event.acceptProposedAction()
+ elif file_path.lower().endswith(".epub") or file_path.lower().endswith(
+ ".pdf"
+ ):
+ # Just store the file path but don't set the file info yet
+ win.selected_file_type = (
+ "epub" if file_path.lower().endswith(".epub") else "pdf"
+ )
+ win.selected_book_path = file_path
+ win.open_book_file(
+ file_path
+ ) # This will handle the dialog and setting file info
+ event.acceptProposedAction()
+ else:
+ self.set_error("Please drop a .txt, .epub, or .pdf file.")
+ event.ignore()
+ else:
+ event.ignore()
+
+ def on_chapters_clicked(self):
+ win = self.window()
+ if win.selected_file_type in ["epub", "pdf"] and win.selected_book_path:
+ # Call open_book_file which shows the dialog and updates selected_chapters
+ if win.open_book_file(win.selected_book_path):
+ # Refresh the info label and button text after dialog closes
+ self.set_file_info(win.selected_book_path)
+
+ def on_textbox_clicked(self):
+ self.window().open_textbox_dialog()
+
+ def on_edit_clicked(self):
+ win = self.window()
+ # For PDFs and EPUBs, use the temporary text file
+ if win.selected_file_type in ["epub", "pdf"] and win.selected_file:
+ # Use the temporary .txt file that was generated
+ win.open_textbox_dialog(win.selected_file)
+ else:
+ # For regular txt files
+ win.open_textbox_dialog()
+
+
+class TextboxDialog(QDialog):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("Enter Text")
+ self.setWindowFlags(
+ Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint
+ )
+ self.resize(600, 400)
+
+ layout = QVBoxLayout(self)
+
+ # Instructions
+ instructions = QLabel(
+ "Enter or paste the text you want to convert to audio:", self
+ )
+ layout.addWidget(instructions)
+
+ # Text edit area
+ self.text_edit = QTextEdit(self)
+ self.text_edit.setAcceptRichText(False)
+ self.text_edit.setPlaceholderText("Type or paste your text here...")
+ layout.addWidget(self.text_edit)
+
+ # Character count label
+ self.char_count_label = QLabel("Characters: 0", self)
+ layout.addWidget(self.char_count_label)
+
+ # Connect text changed signal to update character count
+ self.text_edit.textChanged.connect(self.update_char_count)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+
+ self.save_as_button = QPushButton("Save as text", self)
+ self.save_as_button.clicked.connect(self.save_as_text)
+ self.save_as_button.setToolTip("Save the current text to a file")
+
+ self.cancel_button = QPushButton("Cancel", self)
+ self.cancel_button.clicked.connect(self.reject)
+
+ self.ok_button = QPushButton("OK", self)
+ self.ok_button.setDefault(True)
+ self.ok_button.clicked.connect(self.handle_ok)
+
+ button_layout.addWidget(self.save_as_button)
+ button_layout.addWidget(self.cancel_button)
+ button_layout.addWidget(self.ok_button)
+ layout.addLayout(button_layout)
+
+ # Store the original text to detect changes
+ self.original_text = ""
+
+ def update_char_count(self):
+ text = self.text_edit.toPlainText()
+ count = calculate_text_length(text)
+ self.char_count_label.setText(f"Characters: {count:,}")
+
+ def get_text(self):
+ return self.text_edit.toPlainText()
+
+ def handle_ok(self):
+ # If the text hasn't changed, treat as cancel
+ if self.text_edit.toPlainText() == self.original_text:
+ self.reject()
+ else:
+ # Check if we need to warn about overwriting a non-temporary file
+ if hasattr(self, "is_non_temp_file") and self.is_non_temp_file:
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Warning)
+ msg_box.setWindowTitle("File Overwrite Warning")
+ msg_box.setText(
+ f"You are about to overwrite the original file:\n{self.non_temp_file_path}"
+ )
+ msg_box.setInformativeText("Do you want to continue?")
+ msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
+ msg_box.setDefaultButton(QMessageBox.No)
+
+ if msg_box.exec_() != QMessageBox.Yes:
+ # User canceled, don't close the dialog
+ return
+
+ self.accept()
+
+ def save_as_text(self):
+ """Save the text content to a file chosen by the user"""
+ try:
+ text = self.text_edit.toPlainText()
+ if not text.strip():
+ QMessageBox.warning(self, "Save Error", "There is no text to save.")
+ return
+
+ # Get default filename from original file if editing
+ initial_path = ""
+ if hasattr(self, "non_temp_file_path") and self.non_temp_file_path:
+ initial_path = self.non_temp_file_path
+
+ # For EPUB and PDF files, use the displayed_file_path from the main window
+ # This gives a better filename instead of the temporary file path
+ main_window = self.parent()
+ if (
+ hasattr(main_window, "displayed_file_path")
+ and main_window.displayed_file_path
+ ):
+ if main_window.selected_file_type in ["epub", "pdf"]:
+ # Use the base name of the displayed file but change extension to .txt
+ base_name = os.path.splitext(main_window.displayed_file_path)[0]
+ initial_path = base_name + ".txt"
+
+ file_path, _ = QFileDialog.getSaveFileName(
+ self, "Save Text As", initial_path, "Text Files (*.txt);;All Files (*)"
+ )
+
+ if file_path:
+ # Add .txt extension if not specified and no other extension exists
+ if not os.path.splitext(file_path)[1]:
+ file_path += ".txt"
+
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(text)
+
+ except Exception as e:
+ QMessageBox.critical(self, "Save Error", f"Could not save file:\n{e}")
+
+
+class abogen(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.config = load_config()
+ self.check_updates = self.config.get("check_updates", True)
+ self.save_option = self.config.get("save_option", "Save next to input file")
+ self.selected_output_folder = self.config.get("selected_output_folder", None)
+ self.selected_file = self.selected_file_type = self.selected_book_path = None
+ self.displayed_file_path = (
+ None # Add new variable to track the displayed file path
+ )
+ self.selected_chapters = set()
+ self.last_opened_book_path = None # Track the last opened book path
+ self.last_output_path = None
+ self.selected_voice = self.config.get("selected_voice", "af_heart")
+ self.selected_lang = self.selected_voice[0]
+ self.is_converting = False
+ self.subtitle_mode = self.config.get("subtitle_mode", "Sentence")
+ self.max_subtitle_words = self.config.get(
+ "max_subtitle_words", 50
+ ) # Default max words per subtitle
+ self.selected_format = self.config.get("selected_format", "wav")
+ self.use_gpu = self.config.get(
+ "use_gpu", True
+ ) # Load GPU setting with default True
+ self._pending_close_event = None
+ self.gpu_ok = False # Initialize GPU availability status
+
+ # Create thread-safe logging mechanism
+ self.log_signal = ThreadSafeLogSignal()
+ self.log_signal.log_signal.connect(self._update_log_main_thread)
+
+ # Set application icon
+ icon_path = get_resource_path("abogen.assets", "icon.ico")
+ if icon_path:
+ self.setWindowIcon(QIcon(icon_path))
+ # Set taskbar icon for Windows
+ if platform.system() == "Windows":
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("abogen")
+
+ self.initUI()
+ self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
+ self.update_speed_label()
+ idx = self.voice_combo.findData(self.selected_voice)
+ if idx >= 0:
+ self.voice_combo.setCurrentIndex(idx)
+ if self.save_option == "Choose output folder" and self.selected_output_folder:
+ self.save_path_label.setText(self.selected_output_folder)
+ self.save_path_label.show()
+ self.subtitle_combo.setCurrentText(self.subtitle_mode)
+ # Enable/disable subtitle options based on selected language
+ self.subtitle_combo.setEnabled(
+ self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
+ )
+ # loading gif for preview button
+ loading_gif_path = get_resource_path("abogen.assets", "loading.gif")
+ if loading_gif_path:
+ self.loading_movie = QMovie(loading_gif_path)
+ self.loading_movie.frameChanged.connect(
+ lambda: self.btn_preview.setIcon(
+ QIcon(self.loading_movie.currentPixmap())
+ )
+ )
+
+ # Check for updates at startup if enabled
+ if self.check_updates:
+ QTimer.singleShot(1000, self.check_for_updates_startup)
+
+ def initUI(self):
+ self.setWindowTitle(f"{PROGRAM_NAME} v{VERSION}")
+ screen = QApplication.primaryScreen().geometry()
+ width, height = 500, 800
+ x, y = (screen.width() - width) // 2, (screen.height() - height) // 2
+ self.setGeometry(x, y, width, height)
+ outer_layout = QVBoxLayout()
+ outer_layout.setContentsMargins(15, 15, 15, 15)
+ container = QWidget(self)
+ container_layout = QVBoxLayout(container)
+ container_layout.setContentsMargins(0, 0, 0, 0)
+ container_layout.setSpacing(15)
+ self.input_box = InputBox(self)
+ container_layout.addWidget(self.input_box, 1)
+ self.log_text = QTextEdit(self)
+ self.log_text.setReadOnly(True)
+ self.log_text.setFrameStyle(QTextEdit.NoFrame)
+ self.log_text.setStyleSheet("QTextEdit { border: none; }")
+ self.log_text.hide()
+ container_layout.addWidget(self.log_text, 1)
+ controls_layout = QVBoxLayout()
+ controls_layout.setContentsMargins(0, 10, 0, 0)
+ controls_layout.setSpacing(15)
+ # Speed controls
+ speed_layout = QVBoxLayout()
+ speed_layout.setSpacing(2)
+ speed_layout.addWidget(QLabel("Speed:", self))
+ self.speed_slider = QSlider(Qt.Horizontal, self)
+ self.speed_slider.setMinimum(10)
+ self.speed_slider.setMaximum(200)
+ self.speed_slider.setValue(100)
+ self.speed_slider.setTickPosition(QSlider.TicksBelow)
+ self.speed_slider.setTickInterval(5)
+ self.speed_slider.setSingleStep(5)
+ speed_layout.addWidget(self.speed_slider)
+ self.speed_label = QLabel("1.0", self)
+ speed_layout.addWidget(self.speed_label)
+ controls_layout.addLayout(speed_layout)
+ self.speed_slider.valueChanged.connect(self.update_speed_label)
+ # Voice selection
+ voice_layout = QVBoxLayout()
+ voice_layout.setSpacing(7)
+ voice_layout.addWidget(QLabel("Select Voice:", self))
+ voice_row = QHBoxLayout()
+ self.voice_combo = QComboBox(self)
+ for v in VOICES_INTERNAL:
+ flag = FLAGS.get(v[0], "")
+ self.voice_combo.addItem(f"{flag} {v}", v)
+ self.voice_combo.setStyleSheet(
+ "QComboBox { min-height: 20px; padding: 6px 12px; }"
+ )
+ self.voice_combo.currentIndexChanged.connect(self.on_voice_changed)
+ self.voice_combo.setToolTip(
+ "The first character represents the language:\n"
+ '"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
+ )
+ voice_row.addWidget(self.voice_combo)
+
+ # Play/Stop icons
+ def make_icon(color, shape):
+ pix = QPixmap(20, 20)
+ pix.fill(Qt.transparent)
+ p = QPainter(pix)
+ p.setRenderHint(QPainter.Antialiasing)
+ p.setBrush(QColor(*color))
+ p.setPen(Qt.NoPen)
+ if shape == "play":
+ pts = [
+ pix.rect().topLeft() + QPoint(4, 2),
+ pix.rect().bottomLeft() + QPoint(4, -2),
+ pix.rect().center() + QPoint(6, 0),
+ ]
+ p.drawPolygon(QPolygon(pts))
+ else:
+ p.drawRect(5, 5, 10, 10)
+ p.end()
+ return QIcon(pix)
+
+ self.play_icon = make_icon((40, 160, 40), "play")
+ self.stop_icon = make_icon((200, 60, 60), "stop")
+ self.btn_preview = QPushButton(self)
+ self.btn_preview.setIcon(self.play_icon)
+ self.btn_preview.setIconSize(QPixmap(20, 20).size())
+ self.btn_preview.setToolTip("Preview selected voice")
+ self.btn_preview.setFixedSize(40, 36)
+ self.btn_preview.setStyleSheet("QPushButton { padding: 6px 12px; }")
+ self.btn_preview.clicked.connect(self.preview_voice)
+ voice_row.addWidget(self.btn_preview)
+ self.preview_playing = False
+ voice_layout.addLayout(voice_row)
+ controls_layout.addLayout(voice_layout)
+ # Subtitle mode
+ subtitle_layout = QVBoxLayout()
+ subtitle_layout.addWidget(QLabel("Generate subtitles:", self))
+ self.subtitle_combo = QComboBox(self)
+ self.subtitle_combo.setToolTip(
+ "Choose how subtitles will be generated:\n"
+ "Disabled: No subtitles will be generated.\n"
+ "Sentence: Subtitles will be generated for each sentence.\n"
+ "Sentence + Comma: Subtitles will be generated for each sentence and comma.\n"
+ "1+ word: Subtitles will be generated for each word(s).\n\n"
+ "Supported languages for subtitle generation:\n"
+ + "\n".join(
+ f'"{lang}" => {LANGUAGE_DESCRIPTIONS.get(lang, lang)}'
+ for lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
+ )
+ )
+ subtitle_options = ["Disabled", "Sentence", "Sentence + Comma"] + [
+ f"{i} word" if i == 1 else f"{i} words" for i in range(1, 11)
+ ]
+ self.subtitle_combo.addItems(subtitle_options)
+ self.subtitle_combo.setStyleSheet(
+ "QComboBox { min-height: 20px; padding: 6px 12px; }"
+ )
+ self.subtitle_combo.setCurrentText(self.subtitle_mode)
+ self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
+ # Enable/disable subtitle options based on selected language
+ self.subtitle_combo.setEnabled(
+ self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
+ )
+ subtitle_layout.addWidget(self.subtitle_combo)
+ controls_layout.addLayout(subtitle_layout)
+ # Output format
+ format_layout = QVBoxLayout()
+ format_layout.addWidget(QLabel("Output Format:", self))
+ self.format_combo = QComboBox(self)
+ self.format_combo.setStyleSheet(
+ "QComboBox { min-height: 20px; padding: 6px 12px; }"
+ )
+ format_options = ["wav", "flac", "mp3"]
+ self.format_combo.addItems(format_options)
+ self.format_combo.setCurrentText(self.selected_format)
+ self.format_combo.currentTextChanged.connect(self.on_format_changed)
+ format_layout.addWidget(self.format_combo)
+ controls_layout.addLayout(format_layout)
+ # Save location
+ save_layout = QVBoxLayout()
+ save_layout.addWidget(QLabel("Save Location:", self))
+ self.save_combo = QComboBox(self)
+ save_options = [
+ "Save next to input file",
+ "Save to Desktop",
+ "Choose output folder",
+ ]
+ self.save_combo.addItems(save_options)
+ self.save_combo.setStyleSheet(
+ "QComboBox { min-height: 20px; padding: 6px 12px; }"
+ )
+ self.save_combo.setCurrentText(self.save_option)
+ self.save_combo.currentTextChanged.connect(self.on_save_option_changed)
+ save_layout.addWidget(self.save_combo)
+ self.save_path_label = QLabel("", self)
+ self.save_path_label.hide()
+ save_layout.addWidget(self.save_path_label)
+ controls_layout.addLayout(save_layout)
+ # GPU Acceleration Checkbox with Settings button
+ gpu_layout = QHBoxLayout()
+ gpu_checkbox_layout = QVBoxLayout()
+ self.gpu_checkbox = QCheckBox("Use GPU Acceleration (if available)", self)
+ self.gpu_checkbox.setChecked(self.use_gpu)
+ self.gpu_checkbox.setToolTip(
+ "Uncheck to force using CPU even if a compatible GPU is detected."
+ )
+ self.gpu_checkbox.stateChanged.connect(self.on_gpu_setting_changed)
+ gpu_checkbox_layout.addWidget(self.gpu_checkbox)
+ gpu_layout.addLayout(gpu_checkbox_layout)
+
+ # Settings button with icon
+ settings_icon_path = get_resource_path("abogen.assets", "settings.png")
+ self.settings_btn = QPushButton(self)
+ if settings_icon_path and os.path.exists(settings_icon_path):
+ self.settings_btn.setIcon(QIcon(settings_icon_path))
+ else:
+ # Fallback text if icon not found
+ self.settings_btn.setText("⚙")
+ self.settings_btn.setToolTip("Settings")
+ self.settings_btn.setFixedSize(36, 36)
+ self.settings_btn.clicked.connect(self.show_settings_menu)
+ gpu_layout.addWidget(self.settings_btn)
+
+ controls_layout.addLayout(gpu_layout)
+
+ # Start button
+ self.btn_start = QPushButton("Start", self)
+ self.btn_start.setFixedHeight(60)
+ self.btn_start.clicked.connect(self.start_conversion)
+ controls_layout.addWidget(self.btn_start)
+ self.controls_widget = QWidget()
+ self.controls_widget.setLayout(controls_layout)
+ self.controls_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
+ container_layout.addWidget(self.controls_widget)
+ # Progress bar
+ self.progress_bar = QProgressBar(self)
+ self.progress_bar.setValue(0)
+ self.progress_bar.hide()
+ container_layout.addWidget(self.progress_bar)
+ # ETR Label
+ self.etr_label = QLabel("Estimated time remaining: Calculating...", self)
+ self.etr_label.setAlignment(Qt.AlignCenter)
+ self.etr_label.hide()
+ container_layout.addWidget(self.etr_label)
+ # Cancel button
+ self.btn_cancel = QPushButton("Cancel", self)
+ self.btn_cancel.setFixedHeight(60)
+ self.btn_cancel.clicked.connect(self.cancel_conversion)
+ self.btn_cancel.hide()
+ container_layout.addWidget(self.btn_cancel)
+ # Finish buttons
+ self.finish_widget = QWidget()
+ finish_layout = QVBoxLayout()
+ finish_layout.setContentsMargins(0, 0, 0, 0)
+ finish_layout.setSpacing(10)
+ self.open_file_btn = None # Store reference to open file button
+
+ # Create buttons with their functions
+ finish_buttons = [
+ ("Open file", self.open_file, "Open the output file."),
+ (
+ "Go to folder",
+ self.go_to_file,
+ "Open the folder containing the output file.",
+ ),
+ ("New Conversion", self.reset_ui, "Start a new conversion."),
+ ("Go back", self.go_back_ui, "Return to the previous screen."),
+ ]
+
+ for text, func, tip in finish_buttons:
+ btn = QPushButton(text, self)
+ btn.setFixedHeight(35)
+ btn.setToolTip(tip)
+ btn.clicked.connect(func)
+ finish_layout.addWidget(btn)
+ # Identify the Open file button by its function reference
+ if func == self.open_file:
+ self.open_file_btn = btn # Save reference to the open file button
+
+ self.finish_widget.setLayout(finish_layout)
+ self.finish_widget.hide()
+ container_layout.addWidget(self.finish_widget)
+ outer_layout.addWidget(container)
+ self.setLayout(outer_layout)
+
+ def open_file_dialog(self):
+ if self.is_converting:
+ return
+ try:
+ file_path, _ = QFileDialog.getOpenFileName(
+ self, "Select File", "", "Supported Files (*.txt *.epub *.pdf)"
+ )
+ if not file_path:
+ return
+ if file_path.lower().endswith(".epub") or file_path.lower().endswith(
+ ".pdf"
+ ):
+ self.selected_file_type = (
+ "epub" if file_path.lower().endswith(".epub") else "pdf"
+ )
+ self.selected_book_path = file_path
+ # Don't set file info immediately, open_book_file will handle it after dialog is accepted
+ if not self.open_book_file(file_path):
+ return
+ else:
+ self.selected_file, self.selected_file_type = file_path, "txt"
+ self.displayed_file_path = (
+ file_path # Set the displayed file path for text files
+ )
+ self.input_box.set_file_info(file_path)
+ except Exception as e:
+ self._show_error_message_box(
+ "File Dialog Error", f"Could not open file dialog:\n{e}"
+ )
+
+ def open_book_file(self, book_path):
+ # Clear selected chapters if this is a different book than the last one
+ if (
+ not hasattr(self, "last_opened_book_path")
+ or self.last_opened_book_path != book_path
+ ):
+ self.selected_chapters = set()
+ self.last_opened_book_path = book_path
+
+ dialog = HandlerDialog(
+ book_path, checked_chapters=self.selected_chapters, parent=self
+ )
+ dialog.setWindowModality(Qt.NonModal)
+ dialog.setModal(False)
+ dialog.show()
+
+ # We'll handle the dialog result asynchronously
+ def on_dialog_finished(result):
+ if result != QDialog.Accepted:
+ return False
+ chapters_text, all_checked_hrefs = dialog.get_selected_text()
+ if not all_checked_hrefs:
+ file_type = "pdf" if book_path.lower().endswith(".pdf") else "epub"
+ error_msg = (
+ f"No {'pages' if file_type == 'pdf' else 'chapters'} selected."
+ )
+ self._show_error_message_box(f"{file_type.upper()} Error", error_msg)
+ return False
+ self.selected_chapters = all_checked_hrefs
+ self.save_chapters_separately = dialog.get_save_chapters_separately()
+ self.merge_chapters_at_end = dialog.get_merge_chapters_at_end()
+
+ # Store if the PDF has bookmarks for button text display
+ if book_path.lower().endswith(".pdf"):
+ self.pdf_has_bookmarks = getattr(dialog, "has_pdf_bookmarks", False)
+
+ # Use "abogen" prefix for temporary files
+ # Extract base name without extension
+ base_name = os.path.splitext(os.path.basename(book_path))[0]
+ temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
+ os.makedirs(temp_dir, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(
+ prefix=f"abogen_{base_name}_", suffix=".txt", dir=temp_dir
+ )
+ os.close(fd)
+ with open(tmp, "w", encoding="utf-8") as f:
+ f.write(chapters_text)
+ self.selected_file = tmp
+ self.selected_book_path = book_path
+ self.displayed_file_path = book_path
+ # Only set file info if dialog was accepted
+ self.input_box.set_file_info(book_path)
+ return True
+
+ dialog.finished.connect(on_dialog_finished)
+ return True
+
+ def open_textbox_dialog(self, file_path=None):
+ """Shows dialog for direct text input or editing and processes the entered text"""
+ if self.is_converting:
+ return
+
+ editing = False
+ is_temp_file = False
+ # If path is explicitly provided, use it
+ if file_path and os.path.exists(file_path):
+ editing = True
+ edit_file = file_path
+ # Check if this is a temporary file
+ is_temp_file = tempfile.gettempdir() in file_path
+ # Otherwise use selected_file if it's a txt file
+ elif (
+ self.selected_file_type == "txt"
+ and self.selected_file
+ and os.path.exists(self.selected_file)
+ ):
+ editing = True
+ edit_file = self.selected_file
+ # Check if this is a temporary file
+ is_temp_file = tempfile.gettempdir() in self.selected_file
+
+ dialog = TextboxDialog(self)
+ if editing:
+ try:
+ with open(edit_file, "r", encoding="utf-8", errors="ignore") as f:
+ dialog.text_edit.setText(f.read())
+ dialog.update_char_count()
+ dialog.original_text = (
+ dialog.text_edit.toPlainText()
+ ) # Store original text
+
+ # If editing a non-temporary file, alert the user
+ if not is_temp_file:
+ dialog.is_non_temp_file = True
+ dialog.non_temp_file_path = edit_file
+ except Exception:
+ pass
+ if dialog.exec_() == QDialog.Accepted:
+ text = dialog.get_text()
+ if not text.strip():
+ self._show_error_message_box("Textbox Error", "Text cannot be empty.")
+ return
+ try:
+ if editing:
+ with open(edit_file, "w", encoding="utf-8") as f:
+ f.write(text)
+ # Update the display path to the edited file
+ self.displayed_file_path = edit_file
+ self.input_box.set_file_info(edit_file)
+ # Hide chapters button since we're using custom text now
+ self.input_box.chapters_btn.hide()
+ else:
+ temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
+ os.makedirs(temp_dir, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(
+ prefix="abogen_", suffix=".txt", dir=temp_dir
+ )
+ os.close(fd)
+ with open(tmp, "w", encoding="utf-8") as f:
+ f.write(text)
+ self.selected_file = tmp
+ self.selected_file_type = "txt"
+ self.displayed_file_path = None
+ self.input_box.set_file_info(tmp)
+ # Hide chapters button since we're using custom text now
+ self.input_box.chapters_btn.hide()
+ if hasattr(self, "conversion_thread"):
+ self.conversion_thread.is_direct_text = True
+ except Exception as e:
+ self._show_error_message_box(
+ "Textbox Error", f"Could not process text input:\n{e}"
+ )
+
+ def update_speed_label(self):
+ s = self.speed_slider.value() / 100.0
+ self.speed_label.setText(f"{s}")
+ self.config["speed"] = s
+ save_config(self.config)
+
+ def on_voice_changed(self, index):
+ voice = self.voice_combo.itemData(index)
+ self.selected_voice, self.selected_lang = voice, voice[0]
+ self.config["selected_voice"] = voice
+ save_config(self.config)
+ # Enable/disable subtitle options based on language
+ if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
+ self.subtitle_combo.setEnabled(True)
+ self.subtitle_mode = self.subtitle_combo.currentText()
+ else:
+ self.subtitle_combo.setEnabled(False)
+
+ def convert_input_box_to_log(self):
+ self.input_box.hide()
+ self.log_text.show()
+ self.log_text.clear()
+ QApplication.processEvents()
+
+ def restore_input_box(self):
+ self.log_text.hide()
+ self.input_box.show()
+
+ def color_text(self, message, color):
+ return f'{message.replace(chr(10), "
")}
'
+
+ def update_log(self, message):
+ # Use signal-based approach for thread-safe logging
+ if QThread.currentThread() != QApplication.instance().thread():
+ # We're in a background thread, emit signal for the main thread
+ self.log_signal.emit_log(message)
+ return
+
+ # Direct update if already on main thread
+ self._update_log_main_thread(message)
+
+ def _update_log_main_thread(self, message):
+ sb = self.log_text.verticalScrollBar()
+ prev_val = sb.value()
+ at_bottom = prev_val == sb.maximum()
+ # save current text cursor to preserve selection
+ old_cursor = self.log_text.textCursor()
+ # prepare html
+ if isinstance(message, tuple):
+ text, spec = message
+ color = "green" if spec is True else ("red" if spec is False else spec)
+ html = self.color_text(text, color)
+ else:
+ html = str(message).replace("\n", "
") + "
"
+ # move cursor to end for insertion
+ insert_cursor = self.log_text.textCursor()
+ insert_cursor.movePosition(QTextCursor.End)
+ self.log_text.setTextCursor(insert_cursor)
+ self.log_text.insertHtml(html)
+ # restore original cursor/selection
+ self.log_text.setTextCursor(old_cursor)
+ # restore scroll position
+ sb.setValue(sb.maximum() if at_bottom else prev_val)
+ QApplication.processEvents()
+
+ def update_progress(self, value, etr_str): # Add etr_str parameter
+ self.progress_bar.setValue(value)
+ self.progress_bar.setFormat("%p%") # Keep format as percentage only
+ self.etr_label.setText(
+ f"Estimated time remaining: {etr_str}"
+ ) # Update ETR label
+ self.etr_label.show() # Show only when estimate is ready
+ self.progress_bar.repaint()
+ QApplication.processEvents()
+
+ def start_conversion(self):
+ if not self.selected_file:
+ self.input_box.set_error("Please add a file.")
+ return
+ prevent_sleep_start()
+ self.is_converting = True
+ self.convert_input_box_to_log()
+ self.progress_bar.setValue(0)
+ self.progress_bar.setFormat("%p%") # Reset format initially
+ self.etr_label.hide() # Hide ETR label initially
+ self.controls_widget.hide()
+ self.progress_bar.show()
+ self.btn_cancel.show()
+ QApplication.processEvents()
+ self.btn_cancel.setEnabled(False)
+ self.start_time = time.time()
+ self.finish_widget.hide()
+ speed = self.speed_slider.value() / 100.0
+
+ # Get the display file path for logs
+ display_path = (
+ self.displayed_file_path if self.displayed_file_path else self.selected_file
+ )
+
+ # Get file size string
+ try:
+ file_size_str = self.input_box._human_readable_size(
+ os.path.getsize(self.selected_file)
+ )
+ except Exception:
+ file_size_str = "Unknown"
+
+ # pipeline_loaded_callback remains unchanged
+ def pipeline_loaded_callback(np_module, kpipeline_class, error):
+ if error:
+ self.update_log((f"Error loading numpy or KPipeline: {error}", False))
+ prevent_sleep_end()
+ return
+
+ self.btn_cancel.setEnabled(True)
+
+ # Override subtitle_mode to "Disabled" if subtitle_combo is disabled
+ actual_subtitle_mode = (
+ "Disabled"
+ if not self.subtitle_combo.isEnabled()
+ else self.subtitle_mode
+ )
+
+ self.conversion_thread = ConversionThread(
+ self.selected_file,
+ self.selected_lang,
+ speed,
+ self.selected_voice,
+ self.save_option,
+ self.selected_output_folder,
+ subtitle_mode=actual_subtitle_mode,
+ output_format=self.selected_format,
+ np_module=np_module,
+ kpipeline_class=kpipeline_class,
+ start_time=self.start_time,
+ total_char_count=self.char_count,
+ use_gpu=self.gpu_ok,
+ ) # Use gpu_ok status
+ # Pass the displayed file path to the log_updated signal handler in ConversionThread
+ self.conversion_thread.display_path = display_path
+ # Pass the file size string
+ self.conversion_thread.file_size_str = file_size_str
+ # Pass max_subtitle_words from config
+ self.conversion_thread.max_subtitle_words = self.max_subtitle_words
+ # Pass chapter count for EPUB or PDF files
+ if self.selected_file_type in ["epub", "pdf"] and hasattr(
+ self, "selected_chapters"
+ ):
+ self.conversion_thread.chapter_count = len(self.selected_chapters)
+ # Pass save_chapters_separately flag if available
+ self.conversion_thread.save_chapters_separately = getattr(
+ self, "save_chapters_separately", False
+ )
+ # Pass merge_chapters_at_end flag if available
+ self.conversion_thread.merge_chapters_at_end = getattr(
+ self, "merge_chapters_at_end", True
+ )
+ self.conversion_thread.progress_updated.connect(self.update_progress)
+ self.conversion_thread.log_updated.connect(self.update_log)
+ self.conversion_thread.conversion_finished.connect(
+ self.on_conversion_finished
+ )
+
+ # Connect chapters_detected signal
+ self.conversion_thread.chapters_detected.connect(
+ self.show_chapter_options_dialog
+ )
+
+ self.conversion_thread.start()
+ QApplication.processEvents()
+
+ # Run GPU acceleration and module loading in a background thread
+ def gpu_and_load():
+ self.update_log("Checking GPU acceleration...")
+ # Pass the use_gpu setting from the checkbox
+ gpu_msg, gpu_ok = get_gpu_acceleration(self.gpu_checkbox.isChecked())
+ # Store gpu_ok status to use when creating the conversion thread
+ self.gpu_ok = gpu_ok
+ self.update_log((gpu_msg, gpu_ok))
+ self.update_log("Loading modules...")
+ load_thread = LoadPipelineThread(pipeline_loaded_callback)
+ load_thread.start()
+
+ Thread(target=gpu_and_load, daemon=True).start()
+
+ def on_conversion_finished(self, message, output_path):
+ prevent_sleep_end()
+ if message == "Cancelled":
+ self.etr_label.hide() # Hide ETR label
+ self.progress_bar.hide()
+ self.btn_cancel.hide()
+ self.is_converting = False
+ self.controls_widget.show()
+ self.finish_widget.hide()
+ self.restore_input_box()
+ display_path = (
+ self.displayed_file_path
+ if self.displayed_file_path
+ else self.selected_file
+ )
+ # Check if the file exists before trying to set file info
+ if display_path and os.path.exists(display_path):
+ self.input_box.set_file_info(display_path)
+ else:
+ # Clear the input if the file no longer exists
+ self.input_box.clear_input()
+ return
+
+ self.update_log(message)
+ if output_path:
+ self.last_output_path = output_path
+ self.etr_label.hide() # Hide ETR label
+ self.progress_bar.setValue(100)
+ self.progress_bar.hide()
+ self.btn_cancel.hide()
+ self.is_converting = False
+ elapsed = int(time.time() - self.start_time)
+ h, m, s = elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60
+ self.update_log(f"\nTime elapsed: {h:02d}:{m:02d}:{s:02d}")
+
+ # Default to showing the button
+ show_open_file_button = True
+ # Check conditions to hide the button (only if flags exist for the completed conversion)
+ save_sep = getattr(self, "save_chapters_separately", False)
+ merge_end = getattr(
+ self, "merge_chapters_at_end", True
+ ) # Default to True if flag doesn't exist
+ if save_sep and not merge_end:
+ show_open_file_button = False
+
+ if self.open_file_btn:
+ self.open_file_btn.setVisible(show_open_file_button)
+
+ self.controls_widget.hide()
+ self.finish_widget.show()
+ self.log_text.moveCursor(QTextCursor.End)
+ self.log_text.ensureCursorVisible()
+ save_config(self.config)
+
+ def reset_ui(self):
+ try:
+ self.restore_input_box()
+ self.input_box.clear_input() # Reset text and style
+ self.etr_label.hide() # Hide ETR label
+ self.progress_bar.setValue(0)
+ self.progress_bar.hide()
+ self.selected_file = self.selected_file_type = self.selected_book_path = (
+ None
+ )
+ self.selected_chapters = set() # Reset selected chapters
+
+ # Ensure open file button is visible when resetting
+ if self.open_file_btn:
+ self.open_file_btn.show()
+ self.controls_widget.show()
+ self.finish_widget.hide()
+ self.btn_start.setText("Start")
+ # Disconnect only if connected, then reconnect
+ try:
+ self.btn_start.clicked.disconnect()
+ except TypeError:
+ pass # Ignore error if not connected
+ self.btn_start.clicked.connect(self.start_conversion)
+ except Exception as e:
+ self._show_error_message_box("Reset Error", f"Could not reset UI:\n{e}")
+
+ def go_back_ui(self):
+ self.finish_widget.hide()
+ self.controls_widget.show()
+ self.progress_bar.hide()
+ self.restore_input_box()
+
+ # Use displayed_file_path instead of selected_file for EPUBs or PDFs
+ display_path = (
+ self.displayed_file_path if self.displayed_file_path else self.selected_file
+ )
+
+ # Check if the file exists before trying to set file info
+ if display_path and os.path.exists(display_path):
+ self.input_box.set_file_info(display_path)
+ else:
+ # Clear the input if the file no longer exists
+ self.input_box.clear_input()
+
+ # Ensure open file button is visible when going back
+ if self.open_file_btn:
+ self.open_file_btn.show()
+
+ def on_save_option_changed(self, option):
+ self.save_option = option
+ self.config["save_option"] = option
+ if option == "Choose output folder":
+ try:
+ folder = QFileDialog.getExistingDirectory(
+ self, "Select Output Folder", ""
+ )
+ if folder:
+ self.selected_output_folder = folder
+ self.save_path_label.setText(folder)
+ self.save_path_label.show()
+ self.config["selected_output_folder"] = folder
+ else:
+ self.save_option = "Save next to input file"
+ self.save_combo.setCurrentText(self.save_option)
+ self.config["save_option"] = self.save_option
+ except Exception as e:
+ self._show_error_message_box(
+ "Folder Dialog Error", f"Could not open folder dialog:\n{e}"
+ )
+ self.save_option = "Save next to input file"
+ self.save_combo.setCurrentText(self.save_option)
+ self.config["save_option"] = self.save_option
+ else:
+ self.save_path_label.hide()
+ self.selected_output_folder = None
+ self.config["selected_output_folder"] = None
+ save_config(self.config)
+
+ def go_to_file(self):
+ path = self.last_output_path
+ if not path:
+ return
+ try:
+ # Check if path is a directory (for multiple chapter files)
+ if os.path.isdir(path):
+ folder = path
+ else:
+ folder = os.path.dirname(path)
+ QDesktopServices.openUrl(QUrl.fromLocalFile(folder))
+ except Exception as e:
+ self._show_error_message_box(
+ "Open Folder Error", f"Could not open folder:\n{e}"
+ )
+
+ def open_file(self):
+ path = self.last_output_path
+ if not path:
+ return
+ try:
+ # Check if path exists and is a file before opening
+ if os.path.exists(path):
+ if os.path.isdir(path):
+ self._show_error_message_box(
+ "Open File Error",
+ "Cannot open a directory as a file. Please use 'Go to folder' instead.",
+ )
+ return
+ QDesktopServices.openUrl(QUrl.fromLocalFile(path))
+ else:
+ self._show_error_message_box(
+ "Open File Error", f"File not found: {path}"
+ )
+ except Exception as e:
+ self._show_error_message_box(
+ "Open File Error", f"Could not open file:\n{e}"
+ )
+
+ def preview_voice(self):
+ if self.preview_playing:
+ try:
+ import pygame
+ pygame.mixer.music.stop()
+ except Exception:
+ pass
+ self._preview_cleanup()
+ return
+
+ if hasattr(self, "preview_thread") and self.preview_thread.isRunning():
+ return
+
+ self.btn_preview.setEnabled(False)
+ self.btn_preview.setToolTip("Loading...")
+ self.voice_combo.setEnabled(False)
+ self.btn_start.setEnabled(False) # Disable start button during preview
+ # start loading animation
+ self.loading_movie.start()
+
+ def pipeline_loaded_callback(np_module, kpipeline_class, error):
+ self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
+
+ load_thread = LoadPipelineThread(pipeline_loaded_callback)
+ load_thread.start()
+
+ def _on_pipeline_loaded_for_preview(self, np_module, kpipeline_class, error):
+ # stop loading animation and restore icon on error
+ if error:
+ self.loading_movie.stop()
+ self.btn_preview.setIcon(self.play_icon)
+ self._show_error_message_box(
+ "Loading Error", f"Error loading numpy or KPipeline: {error}"
+ )
+ self.btn_preview.setEnabled(True)
+ self.btn_preview.setToolTip("Preview selected voice")
+ self.voice_combo.setEnabled(True)
+ self.btn_start.setEnabled(True) # Re-enable start button on error
+ return
+
+ lang, voice, speed = (
+ self.selected_voice[0],
+ self.selected_voice,
+ self.speed_slider.value() / 100.0,
+ )
+ self.preview_thread = VoicePreviewThread(
+ np_module, kpipeline_class, lang, voice, speed
+ )
+ self.preview_thread.finished.connect(self._play_preview_audio)
+ self.preview_thread.error.connect(self._preview_error)
+ self.preview_thread.start()
+
+ def _play_preview_audio(self):
+ temp_wav = self.preview_thread.temp_wav
+ if not temp_wav:
+ self._show_error_message_box(
+ "Preview Error", "Preview error: No audio generated."
+ )
+ self.btn_preview.setEnabled(True)
+ self.btn_preview.setToolTip("Preview selected voice")
+ self.voice_combo.setEnabled(True)
+ self.btn_start.setEnabled(True)
+ return
+ # stop loading animation, switch to stop icon
+ self.loading_movie.stop()
+ self.preview_playing = True
+ self.btn_preview.setIcon(self.stop_icon)
+ self.btn_preview.setToolTip("Stop preview")
+ self.btn_preview.setEnabled(True)
+
+ def cleanup():
+ try:
+ os.remove(temp_wav)
+ except Exception:
+ pass
+ self._preview_cleanup()
+
+ try:
+ self.audio_thread = PlayAudioThread(temp_wav)
+ self.audio_thread.finished.connect(cleanup)
+ self.audio_thread.error.connect(
+ lambda msg: (self._show_preview_error_box(msg), cleanup())
+ )
+ self.audio_thread.start()
+ except Exception as e:
+ self._show_error_message_box(
+ "Preview Error", f"Could not play preview audio:\n{e}"
+ )
+ cleanup()
+
+ def _show_error_message_box(self, title, message):
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Critical)
+ box.setWindowTitle(title)
+ box.setText(message)
+ copy_btn = QPushButton("Copy")
+ box.addButton(copy_btn, QMessageBox.ActionRole)
+ box.addButton(QMessageBox.Ok)
+ copy_btn.clicked.connect(lambda: QApplication.clipboard().setText(message))
+ box.exec_()
+
+ def _show_preview_error_box(self, msg):
+ self._show_error_message_box("Preview Error", f"Preview error: {msg}")
+
+ def _preview_cleanup(self):
+ self.preview_playing = False
+ self.btn_preview.setIcon(self.play_icon)
+ self.btn_preview.setToolTip("Preview selected voice")
+ self.btn_preview.setEnabled(True)
+ self.voice_combo.setEnabled(True)
+ self.btn_start.setEnabled(True)
+
+ def _preview_error(self, msg):
+ self._show_error_message_box("Preview Error", f"Preview error: {msg}")
+ self._preview_cleanup()
+
+ def cancel_conversion(self):
+ if self.is_converting:
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Warning)
+ box.setWindowTitle("Cancel Conversion")
+ box.setText(
+ "A conversion is currently running. Are you sure you want to cancel?"
+ )
+ box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
+ box.setDefaultButton(QMessageBox.No)
+ if box.exec_() != QMessageBox.Yes:
+ return
+ try:
+ if (
+ hasattr(self, "conversion_thread")
+ and self.conversion_thread.isRunning()
+ ):
+ self.conversion_thread.cancel()
+ self.is_converting = False
+ self.etr_label.hide() # Hide ETR label
+ self.progress_bar.hide()
+ self.btn_cancel.hide()
+ self.controls_widget.show()
+ self.finish_widget.hide()
+ self.restore_input_box()
+ display_path = (
+ self.displayed_file_path
+ if self.displayed_file_path
+ else self.selected_file
+ )
+ # Check if the file exists before trying to set file info
+ if display_path and os.path.exists(display_path):
+ self.input_box.set_file_info(display_path)
+ else:
+ # Clear the input if the file no longer exists
+ self.input_box.clear_input()
+ if (
+ hasattr(self, "conversion_thread")
+ and self.conversion_thread.isRunning()
+ ):
+ self.conversion_thread.wait()
+ prevent_sleep_end()
+ except Exception as e:
+ self._show_error_message_box(
+ "Cancel Error", f"Could not cancel conversion:\n{e}"
+ )
+
+ def on_subtitle_mode_changed(self, mode):
+ self.subtitle_mode = mode
+ self.config["subtitle_mode"] = mode
+ save_config(self.config)
+
+ def on_format_changed(self, fmt):
+ self.selected_format = fmt
+ self.config["selected_format"] = fmt
+ save_config(self.config)
+
+ def on_gpu_setting_changed(self, state):
+ self.use_gpu = state == Qt.Checked
+ self.config["use_gpu"] = self.use_gpu
+ save_config(self.config)
+
+ def closeEvent(self, event):
+ if self.is_converting:
+ box = QMessageBox(self)
+ box.setIcon(QMessageBox.Warning)
+ box.setWindowTitle("Conversion in Progress")
+ box.setText(
+ "A conversion is currently running. Are you sure you want to exit?"
+ )
+ box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
+ box.setDefaultButton(QMessageBox.No)
+ if box.exec_() == QMessageBox.Yes:
+ if (
+ hasattr(self, "conversion_thread")
+ and self.conversion_thread.isRunning()
+ ):
+ self.conversion_thread.cancel()
+ self.conversion_thread.wait()
+ event.accept()
+ else:
+ event.ignore()
+ else:
+ event.accept()
+
+ def show_chapter_options_dialog(self, chapter_count):
+ """Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
+ from abogen.conversion import ChapterOptionsDialog
+
+ dialog = ChapterOptionsDialog(chapter_count, parent=self)
+ dialog.setWindowModality(Qt.ApplicationModal)
+
+ # If dialog is accepted, pass the options to the conversion thread
+ if dialog.exec_() == QDialog.Accepted:
+ options = dialog.get_options()
+ if (
+ hasattr(self, "conversion_thread")
+ and self.conversion_thread.isRunning()
+ ):
+ self.conversion_thread.set_chapter_options(options)
+ else:
+ # If dialog is rejected, cancel the conversion
+ self.cancel_conversion()
+
+ def show_settings_menu(self):
+ """Show a dropdown menu for settings options."""
+ menu = QMenu(self)
+
+ # Add max words per subtitle option
+ max_words_action = QAction("Configure max words per subtitle", self)
+ max_words_action.triggered.connect(self.set_max_subtitle_words)
+ menu.addAction(max_words_action)
+
+ # Add seperator
+ menu.addSeparator()
+
+ # Add shortcut to desktop (Windows only)
+ if platform.system() == "Windows":
+ add_shortcut_action = QAction("Create desktop shortcut", self)
+ add_shortcut_action.triggered.connect(self.add_shortcut_to_desktop)
+ menu.addAction(add_shortcut_action)
+
+ # Add reveal config option
+ reveal_config_action = QAction("Open config.json directory", self)
+ reveal_config_action.triggered.connect(self.reveal_config_in_explorer)
+ menu.addAction(reveal_config_action)
+
+ # Add open temp directory option
+ open_temp_action = QAction("Open temp directory", self)
+ open_temp_action.triggered.connect(self.open_temp_directory)
+ menu.addAction(open_temp_action)
+
+ # Add clear temporary files option
+ clear_temp_action = QAction("Clear all temporary files", self)
+ clear_temp_action.triggered.connect(self.clear_temp_files)
+ menu.addAction(clear_temp_action)
+
+ # Add seperator"
+ menu.addSeparator()
+
+ # Add check for updates option
+ check_updates_action = QAction("Check for updates at startup", self)
+ check_updates_action.setCheckable(True)
+ check_updates_action.setChecked(self.config.get("check_updates", True))
+ check_updates_action.triggered.connect(self.toggle_check_updates)
+ menu.addAction(check_updates_action)
+
+ # Add about action
+ about_action = QAction("About", self)
+ about_action.triggered.connect(self.show_about_dialog)
+ menu.addAction(about_action)
+
+ menu.exec_(self.settings_btn.mapToGlobal(QPoint(0, self.settings_btn.height())))
+
+ def reveal_config_in_explorer(self):
+ """Open the configuration file location in file explorer."""
+ from abogen.utils import get_user_config_path
+
+ try:
+ config_path = get_user_config_path()
+ # Open the directory containing the config file
+ QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(config_path)))
+ except Exception as e:
+ QMessageBox.critical(
+ self, "Config Error", f"Could not open config location:\n{e}"
+ )
+
+ def open_temp_directory(self):
+ """Open the temporary directory used by the program."""
+ try:
+ # Get the temp directory path
+ temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
+
+ # Create the directory if it doesn't exist
+ if not os.path.exists(temp_dir):
+ os.makedirs(temp_dir)
+
+ # Open the directory in file explorer
+ QDesktopServices.openUrl(QUrl.fromLocalFile(temp_dir))
+ except Exception as e:
+ QMessageBox.critical(
+ self, "Temp Directory Error", f"Could not open temp directory:\n{e}"
+ )
+
+ def add_shortcut_to_desktop(self):
+ """Create a desktop shortcut to this program using PowerShell."""
+ import sys, subprocess
+ from PyQt5.QtWidgets import QMessageBox
+
+ try:
+ # where to put the .lnk
+ desktop = os.path.join(os.environ.get("USERPROFILE", ""), "Desktop")
+ shortcut_path = os.path.join(desktop, "abogen.lnk")
+
+ # target exe
+ python_dir = os.path.dirname(sys.executable)
+ target = os.path.join(python_dir, "Scripts", "abogen.exe")
+ if not os.path.exists(target):
+ QMessageBox.critical(
+ self, "Shortcut Error", f"Could not find abogen.exe at:\n{target}"
+ )
+ return
+
+ # icon (fallback to exe if missing)
+ icon = get_resource_path("abogen.assets", "icon.ico")
+ if not icon or not os.path.exists(icon):
+ icon = target
+
+ # PowerShell command to make the shortcut
+ ps_cmd = r"""
+ $s = New-Object -ComObject WScript.Shell
+ $lnk = $s.CreateShortcut('{lnk}')
+ $lnk.TargetPath = '{tgt}'
+ $lnk.WorkingDirectory = '{cwd}'
+ $lnk.IconLocation = '{ico}'
+ $lnk.Save()
+ """.format(
+ lnk=shortcut_path.replace("'", "''"),
+ tgt=target.replace("'", "''"),
+ cwd=os.path.dirname(target).replace("'", "''"),
+ ico=icon.replace("'", "''"),
+ ).strip()
+
+ subprocess.run(
+ ["powershell", "-NoProfile", "-Command", ps_cmd],
+ check=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ QMessageBox.information(
+ self,
+ "Shortcut Created",
+ f"Shortcut created on desktop:\n{shortcut_path}",
+ )
+
+ except subprocess.CalledProcessError as e:
+ QMessageBox.critical(
+ self,
+ "Shortcut Error",
+ f"PowerShell failed:\n{e.stderr.decode(errors='ignore')}",
+ )
+ except Exception as e:
+ QMessageBox.critical(
+ self, "Shortcut Error", f"Could not create shortcut:\n{e}"
+ )
+
+ def toggle_check_updates(self, checked):
+ self.config["check_updates"] = checked
+ save_config(self.config)
+
+ def show_about_dialog(self):
+ """Show an About dialog with program information including GitHub link."""
+ # Get application icon for dialog
+ icon = self.windowIcon()
+
+ # Create custom dialog
+ dialog = QDialog(self)
+ dialog.setWindowTitle(f"About {PROGRAM_NAME}")
+ dialog.setWindowFlags(dialog.windowFlags() & ~Qt.WindowContextHelpButtonHint)
+ dialog.setFixedSize(400, 320) # Increased height for new button
+
+ layout = QVBoxLayout(dialog)
+ layout.setSpacing(10)
+
+ # Header with icon and title
+ header_layout = QHBoxLayout()
+ icon_label = QLabel()
+ if not icon.isNull():
+ icon_label.setPixmap(icon.pixmap(64, 64))
+ else:
+ # Fallback text if icon not available
+ icon_label.setText("📚")
+ icon_label.setStyleSheet("font-size: 48px;")
+
+ header_layout.addWidget(icon_label)
+
+ # Fix: Added style to reduce space between h1 and h3
+ title_label = QLabel(
+ f"{PROGRAM_NAME} v{VERSION}
Audiobook Generator
"
+ )
+ title_label.setTextFormat(Qt.RichText)
+ header_layout.addWidget(title_label, 1)
+ layout.addLayout(header_layout)
+
+ # Description
+ desc_label = QLabel(
+ f"{PROGRAM_DESCRIPTION}
"
+ "Visit the GitHub repository for updates, documentation, and to report issues.
"
+ )
+ desc_label.setTextFormat(Qt.RichText)
+ desc_label.setWordWrap(True)
+ layout.addWidget(desc_label)
+
+ # GitHub link
+ github_btn = QPushButton("Visit GitHub Repository")
+ github_btn.setIcon(QIcon(get_resource_path("abogen.assets", "github.png")))
+ github_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(GITHUB_URL)))
+ github_btn.setFixedHeight(32)
+ layout.addWidget(github_btn)
+
+ # Check for updates button
+ update_btn = QPushButton("Check for updates")
+ update_btn.clicked.connect(self.manual_check_for_updates)
+ update_btn.setFixedHeight(32)
+ layout.addWidget(update_btn)
+
+ # Close button
+ close_btn = QPushButton("Close")
+ close_btn.clicked.connect(dialog.accept)
+ close_btn.setFixedHeight(32)
+ layout.addWidget(close_btn)
+
+ dialog.exec_()
+
+ def manual_check_for_updates(self):
+ """Manually check for updates and always show result"""
+ # Set a flag to always show the result message
+ self._show_update_check_result = True
+ self.check_for_updates_startup()
+
+ def check_for_updates_startup(self):
+ import urllib.request
+
+ def show_update_message(remote_version, local_version):
+ msg_box = QMessageBox(self)
+ msg_box.setIcon(QMessageBox.Information)
+ msg_box.setWindowTitle("Update Available")
+ msg_box.setText(
+ f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})"
+ )
+ msg_box.setInformativeText(
+ f"If you installed via pip, update by running:\n"
+ f"pip install --upgrade {PROGRAM_NAME}\n\n"
+ f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n"
+ "Alternatively, visit the GitHub repository for more information. "
+ "Would you like to view the changelog?"
+ )
+ msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
+ msg_box.setDefaultButton(QMessageBox.Yes)
+ if msg_box.exec_() == QMessageBox.Yes:
+ try:
+ QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest"))
+ except Exception:
+ pass
+
+ # Reset flag to track if we should show "no updates" message
+ show_result = (
+ hasattr(self, "_show_update_check_result")
+ and self._show_update_check_result
+ )
+ self._show_update_check_result = False
+
+ try:
+ update_url = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION"
+ with urllib.request.urlopen(update_url) as response:
+ remote_raw = response.read().decode().strip()
+ local_raw = VERSION
+
+ # Parse version numbers
+ remote_version = remote_raw
+ local_version = local_raw
+
+ try:
+ remote_num = int("".join(remote_version.split(".")))
+ local_num = int("".join(local_version.split(".")))
+ except ValueError as ve:
+ return
+
+ if remote_num > local_num:
+ # Use QTimer to ensure UI is ready, then show update message.
+ QTimer.singleShot(
+ 1000, lambda: show_update_message(remote_version, local_version)
+ )
+ elif show_result:
+ # Show "no updates" message if manually checking
+ QMessageBox.information(
+ self,
+ "Up to Date",
+ f"You are running the latest version of {PROGRAM_NAME} ({local_version}).",
+ )
+ except Exception as e:
+ if show_result:
+ QMessageBox.warning(
+ self,
+ "Update Check Failed",
+ f"Could not check for updates:\n{str(e)}",
+ )
+ pass
+
+ def clear_temp_files(self):
+ """Clear all temporary files created by the program."""
+ import glob
+
+ try:
+ # Get the abogen temp directory
+ temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
+
+ # Find all .txt files in the abogen temp directory
+ pattern = os.path.join(temp_dir, "*.txt")
+ temp_files = glob.glob(pattern)
+
+ # Count the files
+ file_count = len(temp_files)
+
+ if file_count == 0:
+ QMessageBox.information(
+ self, "No Temporary Files", "No temporary files were found."
+ )
+ return
+
+ # Confirm with the user
+ confirm = QMessageBox.question(
+ self,
+ "Clear Temporary Files",
+ f"Found {file_count} temporary file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} temp folder.\n"
+ "Do you want to delete them?",
+ QMessageBox.Yes | QMessageBox.No,
+ )
+
+ if confirm != QMessageBox.Yes:
+ return
+
+ # Delete the files
+ deleted_count = 0
+ for file_path in temp_files:
+ try:
+ os.remove(file_path)
+ deleted_count += 1
+ except Exception as e:
+ print(f"Error deleting {file_path}: {e}")
+
+ # Show results
+ QMessageBox.information(
+ self,
+ "Temporary Files Cleared",
+ f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}.",
+ )
+
+ # If currently selected file is in the temp directory, clear the UI
+ if (
+ self.selected_file
+ and os.path.dirname(self.selected_file) == temp_dir
+ and self.selected_file.endswith(".txt")
+ ):
+ self.input_box.clear_input()
+
+ except Exception as e:
+ QMessageBox.critical(
+ self, "Error", f"An error occurred while clearing temporary files:\n{e}"
+ )
+
+ def set_max_subtitle_words(self):
+ """Open a dialog to set the maximum words per subtitle"""
+ from PyQt5.QtWidgets import QInputDialog
+
+ current_value = self.config.get("max_subtitle_words", 50)
+
+ value, ok = QInputDialog.getInt(
+ self,
+ "Max Words Per Subtitle",
+ "Enter the maximum number of words per\nsubtitle (before splitting the subtitle):",
+ current_value,
+ 1, # min value
+ 200, # max value
+ 1, # step
+ )
+
+ if ok:
+ # Save the new value
+ self.max_subtitle_words = value
+ self.config["max_subtitle_words"] = value
+ save_config(self.config)
+
+ # Show confirmation
+ QMessageBox.information(
+ self,
+ "Setting Saved",
+ f"Maximum words per subtitle set to {value}.",
+ )
diff --git a/abogen/main.py b/abogen/main.py
new file mode 100644
index 0000000..0b3b195
--- /dev/null
+++ b/abogen/main.py
@@ -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()
diff --git a/abogen/utils.py b/abogen/utils.py
new file mode 100644
index 0000000..9420dce
--- /dev/null
+++ b/abogen/utils.py
@@ -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"(? 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]} ")
+ sys.exit(1)
+
+ input_file = sys.argv[1]
+ process_subtitle_file(input_file)
\ No newline at end of file
diff --git a/demo/demo.srt b/demo/demo.srt
new file mode 100644
index 0000000..40829cd
--- /dev/null
+++ b/demo/demo.srt
@@ -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.
+
diff --git a/demo/demo.webm b/demo/demo.webm
new file mode 100644
index 0000000..48b453d
Binary files /dev/null and b/demo/demo.webm differ
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..05177f6
--- /dev/null
+++ b/pyproject.toml
@@ -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" }
\ No newline at end of file