mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
feat: Implement conversion service with job management and logging
- Added `ConversionService` class to handle job queuing, processing, and cancellation. - Introduced `Job`, `JobLog`, and `JobResult` data classes to manage job details and results. - Implemented job status tracking with enums for better state management. - Created a web interface with HTML templates for job submission and monitoring. - Developed CSS styles for a modern UI layout and responsive design. - Added functionality for voice profile management in the voice mixer. - Implemented a Docker Compose configuration for GPU support. - Wrote unit tests for the conversion service to ensure job processing works as expected.
This commit is contained in:
@@ -1,425 +1,163 @@
|
||||
# abogen <img width="40px" title="abogen icon" src="https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/assets/icon.ico" align="right" style="padding-left: 10px; padding-top:5px;">
|
||||
|
||||
[](https://github.com/denizsafak/abogen/actions)
|
||||
[](https://github.com/denizsafak/abogen/releases/latest)
|
||||
[](https://pypi.org/project/abogen/)
|
||||
[](https://github.com/denizsafak/abogen/releases/latest)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
Abogen is a web-first text-to-speech workstation. Drop in an EPUB, PDF, Markdown, or plain text file and Abogen will turn it into high-quality audio with perfectly synced subtitles. The new interface runs entirely inside your browser using Flask + htmx, so it behaves like a modern web app whether you launch it locally or from a container.
|
||||
|
||||
<a href="https://trendshift.io/repositories/14433" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14433" alt="denizsafak%2Fabogen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
## Highlights
|
||||
- Natural-sounding speech powered by Kokoro-82M with per-job voice, speed, GPU toggle, and subtitle style controls
|
||||
- Clean dashboard that tracks the status, progress, and logs of every job in real time (thanks to htmx partial updates)
|
||||
- Automatic chapter detection and subtitle generation with SRT/ASS exports
|
||||
- Runs well in Docker, ships a REST-style JSON API, and works across macOS, Linux, and Windows
|
||||
|
||||
Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, text or markdown 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).
|
||||
## Quick start
|
||||
Abogen supports Python 3.10–3.12.
|
||||
|
||||
<img title="Abogen Main" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.png' width="380"> <img title="Abogen Processing" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen2.png' width="380">
|
||||
|
||||
## Demo
|
||||
|
||||
https://github.com/user-attachments/assets/094ba3df-7d66-494a-bc31-0e4b41d0b865
|
||||
|
||||
> 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/tree/main/demo).
|
||||
|
||||
## `How to install?` <a href="https://pypi.org/project/abogen/" target="_blank"><img src="https://img.shields.io/pypi/pyversions/abogen" alt="Abogen Compatible PyPi Python Versions" align="right" style="margin-top:6px;"></a>
|
||||
|
||||
### Windows
|
||||
Go to [espeak-ng latest release](https://github.com/espeak-ng/espeak-ng/releases/latest) download and run the *.msi file.
|
||||
|
||||
#### OPTION 1: Install using script
|
||||
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).)
|
||||
|
||||
> [!NOTE]
|
||||
> You don't need to install Python separately. The script will install Python automatically.
|
||||
|
||||
#### OPTION 2: Install using pip
|
||||
### Install with pip
|
||||
```bash
|
||||
# Create a virtual environment (optional)
|
||||
mkdir abogen && cd abogen
|
||||
python -m venv venv
|
||||
venv\Scripts\activate
|
||||
|
||||
# For NVIDIA GPUs:
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
||||
|
||||
# For AMD GPUs:
|
||||
# Not supported yet, because ROCm is not available on Windows. Use Linux if you have AMD GPU.
|
||||
|
||||
# Install abogen
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # On Windows use: .venv\Scripts\activate
|
||||
pip install abogen
|
||||
```
|
||||
|
||||
### Mac
|
||||
```bash
|
||||
# Install espeak-ng
|
||||
brew install espeak-ng
|
||||
|
||||
# Create a virtual environment (recommended)
|
||||
mkdir abogen && cd abogen
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install abogen
|
||||
pip3 install abogen
|
||||
|
||||
# For Silicon Mac (M1, M2 etc.)
|
||||
# After installing abogen, we need to install Kokoro's development version which includes MPS support.
|
||||
pip3 install git+https://github.com/hexgrad/kokoro.git
|
||||
```
|
||||
### Linux
|
||||
```bash
|
||||
# Install espeak-ng
|
||||
sudo apt install espeak-ng # Ubuntu/Debian
|
||||
sudo pacman -S espeak-ng # Arch Linux
|
||||
sudo dnf install espeak-ng # Fedora
|
||||
|
||||
# Create a virtual environment (recommended)
|
||||
mkdir abogen && cd abogen
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# Install abogen
|
||||
pip3 install abogen
|
||||
|
||||
# For NVIDIA GPUs:
|
||||
# Already supported, no need to install CUDA separately.
|
||||
|
||||
# For AMD GPUs:
|
||||
# After installing abogen, we need to uninstall the existing torch package
|
||||
pip3 uninstall torch
|
||||
pip3 install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4
|
||||
```
|
||||
|
||||
> See [How to fix "CUDA GPU is not available. Using CPU" warning?](#cuda-warning)
|
||||
|
||||
> See [How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux?](#path-warning)
|
||||
|
||||
> See [How to fix "No matching distribution found" error?](#no-matching-distribution-found)
|
||||
|
||||
> See [How to use "uv" instead of "pip"?](#use-uv-instead-of-pip)
|
||||
|
||||
> Special thanks to [@hg000125](https://github.com/hg000125) for his contribution in [#23](https://github.com/denizsafak/abogen/issues/23). AMD GPU support is possible thanks to his work.
|
||||
|
||||
## `How to run?`
|
||||
If you installed using pip, you can simply run the following command to start Abogen:
|
||||
|
||||
### Launch the web app
|
||||
```bash
|
||||
abogen
|
||||
```
|
||||
> [!TIP]
|
||||
> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly.
|
||||
|
||||
## `How to use?`
|
||||
1) Drag and drop any ePub, PDF, text or markdown file (or use the built-in text editor)
|
||||
2) Configure the settings:
|
||||
- Set speech speed
|
||||
- Select a voice (or create a custom voice using voice mixer)
|
||||
- Select subtitle generation style (by sentence, word, etc.)
|
||||
- Select output format
|
||||
- Select where to save the output
|
||||
3) Hit Start
|
||||
Then open http://localhost:8000 and drag in your documents. Jobs run in the background worker and the browser updates automatically.
|
||||
|
||||
## `In action`
|
||||
<img title="Abogen in action" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.gif'>
|
||||
> **Tip:** Keep the terminal open while the server is running. Use `Ctrl+C` to stop it.
|
||||
|
||||
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 **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware.
|
||||
|
||||
## `Configuration`
|
||||
|
||||
| Options | Description |
|
||||
|---------|-------------|
|
||||
| **Input Box** | Drag and drop `ePub`, `PDF`, `.TXT` or `.MD` files (or use built-in text editor) |
|
||||
| **Queue options** | Add multiple files to a queue and process them in batch, with individual settings for each file. See [Queue mode](#queue-mode) for more details. |
|
||||
| **Speed** | Adjust speech rate from `0.1x` to `2.0x` |
|
||||
| **Select Voice** | 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. |
|
||||
| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. |
|
||||
| **Voice preview** | Listen to the selected voice before processing. |
|
||||
| **Generate subtitles** | `Disabled`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
|
||||
| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` |
|
||||
| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. |
|
||||
| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. |
|
||||
| **Save location** | `Save next to input file`, `Save to desktop`, or `Choose output folder` |
|
||||
|
||||
> Special thanks to [@brianxiadong](https://github.com/brianxiadong) for adding markdown support in PR [#75](https://github.com/denizsafak/abogen/pull/75)
|
||||
|
||||
> Special thanks to [@jborza](https://github.com/jborza) for chapter support in PR [#10](https://github.com/denizsafak/abogen/pull/10)
|
||||
|
||||
| Book handler options | Description |
|
||||
|---------|-------------|
|
||||
| **Chapter Control** | Select specific `chapters` from ePUBs or markdown files or `chapters + pages` from PDFs. |
|
||||
| **Save each chapter separately** | Save each chapter in e-books as a separate audio file. |
|
||||
| **Create a merged version** | Create a single audio file that combines all chapters. (If `Save each chapter separately` is disabled, this option will be the default behavior.) |
|
||||
| **Save in a project folder with metadata** | Save the converted items in a project folder with available metadata files. |
|
||||
|
||||
| Menu options | Description |
|
||||
|---------|-------------|
|
||||
| **Theme** | Change the application's theme using `System`, `Light`, or `Dark` options. |
|
||||
| **Configure max words per subtitle** | Configures the maximum number of words per subtitle entry. |
|
||||
| **Configure silence between chapters** | Configures the duration of silence between chapters (in seconds). |
|
||||
| **Configure max lines in log window** | Configures the maximum number of lines to display in the log window. |
|
||||
| **Separate chapters audio format** | Configures the audio format for separate chapters as `wav`, `flac`, `mp3`, or `opus`. |
|
||||
| **Create desktop shortcut** | Creates a shortcut on your desktop for easy access. |
|
||||
| **Open config directory** | Opens the directory where the configuration file is stored. |
|
||||
| **Open cache directory** | Opens the cache directory where converted text files are stored. |
|
||||
| **Clear cache files** | Deletes cache files created during the conversion or preview. |
|
||||
| **Check for updates at startup** | Automatically checks for updates when the program starts. |
|
||||
| **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. |
|
||||
| **Reset to default settings** | Resets all settings to their default values. |
|
||||
|
||||
> Special thanks to [@robmckinnon](https://github.com/robmckinnon) for adding Sentence + Highlighting feature in PR [#65](https://github.com/denizsafak/abogen/pull/65)
|
||||
|
||||
## `Voice Mixer`
|
||||
<img title="Abogen Voice Mixer" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/voice_mixer.png'>
|
||||
|
||||
With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices.
|
||||
|
||||
> Special thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5)
|
||||
|
||||
## `Queue Mode`
|
||||
<img title="Abogen queue mode" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/queue.png'>
|
||||
|
||||
Abogen supports **queue mode**, allowing you to add multiple files to a processing queue. This is useful if you want to convert several files in one batch.
|
||||
|
||||
- You can add text files (`.txt`) directly using the **Add files** button in the Queue Manager. To add PDF, EPUB, or markdown files, use the input box in the main window and click the **Add to Queue** button.
|
||||
- Each file in the queue keeps the configuration settings that were active when it was added. Changing the main window configuration afterward does **not** affect files already in the queue.
|
||||
- You can view each file's configuration by hovering over them.
|
||||
|
||||
Abogen will process each item in the queue automatically, saving outputs as configured.
|
||||
|
||||
> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35)
|
||||
|
||||
## `About Chapter Markers`
|
||||
When you process ePUB, PDF or markdown files, Abogen converts them into text files stored in your cache directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
|
||||
|
||||
```
|
||||
<<CHAPTER_MARKER:Chapter Title>>
|
||||
```
|
||||
These are chapter markers. They are automatically added when you process ePUB, PDF or markdown files, based on the chapters you select. They serve an important purpose:
|
||||
- Allow you to split the text into separate audio files for each chapter
|
||||
- Save time by letting you reprocess only specific chapters if errors occur, rather than the entire file
|
||||
|
||||
You can manually add these markers to plain text files for the same benefits. Simply include them in your text like this:
|
||||
|
||||
```
|
||||
<<CHAPTER_MARKER:Introduction>>
|
||||
This is the beginning of my text...
|
||||
|
||||
<<CHAPTER_MARKER:Main Content>>
|
||||
Here's another part...
|
||||
```
|
||||
When you process the text file, Abogen will detect these markers automatically and ask if you want to save each chapter separately and create a merged version.
|
||||
|
||||

|
||||
|
||||
## `About Metadata Tags`
|
||||
Similar to chapter markers, it is possible to add metadata tags for `M4B` files. This is useful for audiobook players that support metadata, allowing you to add information like title, author, year, etc. Abogen automatically adds these tags when you process ePUB, PDF or markdown files, but you can also add them manually to your text files. Add metadata tags **at the beginning of your text file** like this:
|
||||
```
|
||||
<<METADATA_TITLE:Title>>
|
||||
<<METADATA_ARTIST:Author>>
|
||||
<<METADATA_ALBUM:Album Title>>
|
||||
<<METADATA_YEAR:Year>>
|
||||
<<METADATA_ALBUM_ARTIST:Album Artist>>
|
||||
<<METADATA_COMPOSER:Narrator>>
|
||||
<<METADATA_GENRE:Audiobook>>
|
||||
```
|
||||
|
||||
## `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).
|
||||
|
||||
> See [How to fix Japanese audio not working?](#japanese-audio-not-working)
|
||||
|
||||
## `MPV Config`
|
||||
I highly recommend using [MPV](https://mpv.io/installation/) to play your audio files, as it supports displaying subtitles even without a video track. Here's my `mpv.conf`:
|
||||
```
|
||||
# --- MPV Settings ---
|
||||
save-position-on-quit
|
||||
keep-open=yes
|
||||
# --- Subtitle ---
|
||||
sub-ass-override=no
|
||||
sub-margin-y=50
|
||||
sub-margin-x=50
|
||||
# --- Audio Quality ---
|
||||
audio-spdif=ac3,dts,eac3,truehd,dts-hd
|
||||
audio-channels=auto
|
||||
audio-samplerate=48000
|
||||
volume-max=200
|
||||
```
|
||||
|
||||
## `Docker Guide`
|
||||
If you want to run Abogen in a Docker container:
|
||||
1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git.
|
||||
2) Go to `abogen` folder. You should see `Dockerfile` there.
|
||||
3) Open your termminal in that directory and run the following commands:
|
||||
## Container image
|
||||
A lightweight Dockerfile lives in `abogen/Dockerfile`.
|
||||
|
||||
```bash
|
||||
# Build the Docker image:
|
||||
docker build --progress plain -t abogen .
|
||||
|
||||
# Note that building the image may take a while.
|
||||
# After building is complete, run the Docker container:
|
||||
|
||||
# Windows
|
||||
docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
|
||||
|
||||
# Linux
|
||||
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
|
||||
|
||||
# MacOS
|
||||
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen
|
||||
|
||||
# We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client.
|
||||
docker build -t abogen .
|
||||
mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs
|
||||
docker run --rm \
|
||||
-p 8000:8000 \
|
||||
-v ~/abogen-data:/data \
|
||||
--name abogen \
|
||||
abogen
|
||||
```
|
||||
|
||||
Abogen launches automatically inside the container.
|
||||
- You can access it via a web browser at [http://localhost:5800](http://localhost:5800) or connect to it using a VNC client at `localhost:5900`.
|
||||
- You can use `/shared` directory to share files between your host and the container.
|
||||
- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
|
||||
- Pass in `-e WEB_AUDIO="1"` for `docker run` to enable audio.
|
||||
Browse to http://localhost:8000. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`.
|
||||
|
||||
Known issues:
|
||||
- Audio preview is not working inside container (ALSA error) if using a VNC client.
|
||||
- `Open cache directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with Abogen).
|
||||
### Container environment variables
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server |
|
||||
| `ABOGEN_PORT` | `8000` | HTTP port |
|
||||
| `ABOGEN_DEBUG` | `false` | Enable Flask debug mode |
|
||||
| `ABOGEN_UPLOAD_ROOT` | `/data/uploads` | Directory where uploaded files are stored |
|
||||
| `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles |
|
||||
|
||||
> Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).
|
||||
Set any of these with `-e VAR=value` when starting the container.
|
||||
|
||||
## `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.
|
||||
- [epub_to_audiobook](https://github.com/p0n1/epub_to_audiobook): EPUB to audiobook converter, optimized for Audiobookshelf
|
||||
- [ebook2audiobook](https://github.com/DrewThomasson/ebook2audiobook): Convert ebooks to audiobooks with chapters and metadata using dynamic AI models and voice cloning
|
||||
### GPU-enabled build
|
||||
If you want CUDA acceleration inside the container, a GPU-aware Docker runtime (for example the NVIDIA Container Toolkit) is required. The repository ships an updated `abogen/Dockerfile` based on the CUDA runtime plus a helper Compose file.
|
||||
|
||||
## `Roadmap`
|
||||
- [ ] Add OCR scan feature for PDF files using docling/teserract.
|
||||
- [x] Add chapter metadata for .m4a files. (Issue [#9](https://github.com/denizsafak/abogen/issues/9), PR [#10](https://github.com/denizsafak/abogen/pull/10))
|
||||
- [ ] Add support for different languages in GUI.
|
||||
- [x] Add voice formula feature that enables mixing different voice models. (Issue [#1](https://github.com/denizsafak/abogen/issues/1), PR [#5](https://github.com/denizsafak/abogen/pull/5))
|
||||
- [ ] Add support for kokoro-onnx (If it's necessary).
|
||||
- [x] Add dark mode.
|
||||
|
||||
## `Troubleshooting`
|
||||
If you encounter any issues while running Abogen, try launching it from the command line with:
|
||||
```
|
||||
abogen-cli
|
||||
```
|
||||
|
||||
If you installed using the Windows installer `(WINDOWS_INSTALL.bat)`, go to `python_embedded/Scripts` and run:
|
||||
```
|
||||
abogen-cli.exe
|
||||
```
|
||||
|
||||
This will start Abogen in command-line mode and display detailed error messages. Please open a new issue on the [Issues](https://github.com/denizsafak/abogen/issues) page with the error message and a description of your problem.
|
||||
|
||||
## `Common Issues & Solutions`
|
||||
|
||||
<details><summary><b>
|
||||
<a name="about-abogen">About the name "abogen"</a>
|
||||
</b></summary>
|
||||
|
||||
> The name **"abogen"** comes from a shortened form of **"audiobook generator"**, which is the purpose of this project.
|
||||
>
|
||||
> After releasing the project, I learned from [community feedback](https://news.ycombinator.com/item?id=44853064#44857237) that the prefix *"abo"* can unfortunately be understood as an ethnic slur in certain regions (particularly Australia and New Zealand). This was something I was not aware of when naming the project, as English is not my first language.
|
||||
>
|
||||
> I want to make it clear that the name was chosen only for its technical meaning, with **no offensive intent**. I’m grateful to those who kindly pointed this out, as it helps ensure the project remains respectful and welcoming to everyone.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="cuda-warning">How to fix "CUDA GPU is not available. Using CPU" warning?</a>
|
||||
</b></summary>
|
||||
|
||||
> This message means PyTorch couldn't use your GPU. On Windows, Abogen supports NVIDIA GPUs with CUDA. AMD GPUs are supported only on Linux. Abogen will still run on the CPU, but it will be slower.
|
||||
>
|
||||
> If you have a compatible NVIDIA GPU on Windows and still see this warning:
|
||||
> Open your terminal in the Abogen folder (the folder that contains `python_embedded`) and type:
|
||||
> ```bash
|
||||
> python_embedded\python.exe -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
||||
> ```
|
||||
> If you have an AMD GPU, use Linux and follow the Linux/ROCm [instructions](#how-to-install-). If you want to keep running on CPU, no action is required, but performance will just be reduced. See [#32](https://github.com/denizsafak/abogen/issues/32) for more details.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="path-warning">How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux?</a>
|
||||
</b></summary>
|
||||
|
||||
> Run the following command to add Abogen to your PATH:
|
||||
> ```bash
|
||||
> echo "export PATH=\"/home/$USER/.local/bin:\$PATH\"" >> ~/.bashrc && source ~/.bashrc
|
||||
> ```
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="no-matching-distribution-found">How to fix "No matching distribution found" error?<a>
|
||||
</b></summary>
|
||||
|
||||
> Try installing Abogen on supported Python (3.10 to 3.12) versions. You can use [pyenv](https://github.com/pyenv/pyenv) to manage multiple Python versions easily in Linux. Watch this [video](https://www.youtube.com/watch?v=MVyb-nI4KyI) by NetworkChuck for a quick guide.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="japanese-audio-not-working">How to fix Japanese audio not working?</a>
|
||||
</b></summary>
|
||||
|
||||
> Japanese audio may require additional configuration.
|
||||
> I'm not sure about the exact solution, but it seems to be related to installing additional dependencies for Japanese support in Kokoro. Please check [#56](https://github.com/denizsafak/abogen/issues/56) for more information.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><b>
|
||||
<a name="use-uv-instead-of-pip">How to use "uv" instead of "pip"?</a>
|
||||
</b></summary>
|
||||
|
||||
> Abogen needs "pip", because Kokoro uses pip to download voice models from HuggingFace Hub. If you want to use "uv" instead of "pip", you can use the following command to run Abogen:
|
||||
>
|
||||
> ```bash
|
||||
> uvx --with pip abogen
|
||||
> ```
|
||||
|
||||
</details>
|
||||
|
||||
## `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
|
||||
pip install build # Install the build package
|
||||
python -m build # Builds the package in dist folder (optional)
|
||||
abogen # Opens the GUI
|
||||
# Build the GPU image (installs the matching CUDA PyTorch wheel)
|
||||
docker compose -f docker-compose.gpu.yml build
|
||||
|
||||
# Start the service with GPU access (--profile gpu in Compose v2 is optional)
|
||||
docker compose -f docker-compose.gpu.yml up -d
|
||||
```
|
||||
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 team for making this possible.
|
||||
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
|
||||
- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files.
|
||||
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
|
||||
- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male), [Adjust](https://icons8.com/icon/21698/adjust) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/).
|
||||
Useful overrides:
|
||||
|
||||
## `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.
|
||||
- `TORCH_VERSION` – pin a specific PyTorch release that matches your host driver (leave empty for latest).
|
||||
- `TORCH_INDEX_URL` – change the download index if you need a different CUDA build.
|
||||
- `ABOGEN_DATA` – host path that stores uploads/outputs (defaults to `./data`).
|
||||
|
||||
## `Star History`
|
||||
[](https://www.star-history.com/#denizsafak/abogen&Date)
|
||||
The Compose file reserves a GPU via `device_requests`. Standard `docker run` works as well:
|
||||
|
||||
> [!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.
|
||||
```bash
|
||||
docker build -f abogen/Dockerfile -t abogen-gpu .
|
||||
docker run --rm \
|
||||
--gpus all \
|
||||
-p 8000:8000 \
|
||||
-v ~/abogen-data:/data \
|
||||
abogen-gpu
|
||||
```
|
||||
|
||||
> 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, markdown to audio, content-creation, media-generation
|
||||
## GPU acceleration
|
||||
Abogen detects CUDA automatically. To use an NVIDIA GPU, install the matching PyTorch build before installing Abogen:
|
||||
```bash
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128
|
||||
pip install abogen
|
||||
```
|
||||
|
||||
On Linux with AMD GPUs, install PyTorch/ROCm nightly wheels:
|
||||
```bash
|
||||
pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.4
|
||||
```
|
||||
Abogen falls back to CPU rendering if no GPU is available.
|
||||
|
||||
## Using the web UI
|
||||
1. Upload a document (drag & drop or use the upload button).
|
||||
2. Choose voice, language, speed, subtitle style, and output format.
|
||||
3. Click **Create job**. The job immediately appears in the queue.
|
||||
4. Watch progress and logs update live. Download audio/subtitle assets when complete.
|
||||
5. Cancel or delete jobs any time. Download logs for troubleshooting.
|
||||
|
||||
Multiple jobs can run sequentially; the worker processes them in order.
|
||||
|
||||
## JSON endpoints
|
||||
Need machine-readable status updates? The dashboard calls a small set of helper endpoints you can reuse:
|
||||
- `GET /api/jobs/<id>` returns job metadata, progress, and log lines in JSON.
|
||||
- `GET /partials/jobs` renders the live job list as HTML (htmx uses this for polling).
|
||||
- `GET /partials/jobs/<id>/logs` renders just the log window.
|
||||
|
||||
More automation hooks are planned; contributions are very welcome if you need additional routes.
|
||||
|
||||
## Configuration reference
|
||||
Most behaviour is controlled through the UI, but a few environment variables are helpful for automation:
|
||||
- `ABOGEN_SECRET_KEY` – provide your own random secret when deploying across multiple replicas.
|
||||
- `ABOGEN_DEBUG` – set to `true` for verbose Flask error output.
|
||||
|
||||
If unset, Abogen picks sensible defaults suitable for local usage.
|
||||
|
||||
## Development workflow
|
||||
```bash
|
||||
git clone https://github.com/denizsafak/abogen.git
|
||||
cd abogen
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .
|
||||
pip install pytest
|
||||
```
|
||||
|
||||
Run the server in development mode:
|
||||
```bash
|
||||
export ABOGEN_DEBUG=true
|
||||
abogen
|
||||
```
|
||||
|
||||
Static files live in `abogen/web/static`, templates in `abogen/web/templates`, and the conversion pipeline in `abogen/web/conversion_runner.py`.
|
||||
|
||||
## Tests
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Unit tests cover the queue service, web routes, and conversion pipeline helpers. Contributions that add features should include new tests whenever practical.
|
||||
|
||||
## Upgrading from the desktop GUI
|
||||
The legacy PyQt5 interface is no longer packaged. Existing scripts that call `abogen.main` should switch to the new web entry point (`abogen.web.app:main`). The new experience works headlessly, plays nicely in Docker, and exposes JSON APIs for automation.
|
||||
|
||||
## Troubleshooting
|
||||
- Conversion jobs stay pending → ensure the background worker has write access to the upload/output directories.
|
||||
- GPU not detected → verify the correct PyTorch wheel is installed (`pip show torch`) and drivers match the container/host.
|
||||
- Subtitle files missing → check the job configuration; subtitles are optional and can be disabled per job.
|
||||
- Logs are empty → run with `ABOGEN_DEBUG=true` to get verbose Flask error output in the server console.
|
||||
|
||||
If you hit a bug, open an issue describing the input file and the exact log output.
|
||||
|
||||
## Contributing
|
||||
Pull requests are welcome! Please:
|
||||
- Keep changes focused and well-tested
|
||||
- Run `python -m pytest`
|
||||
- Update documentation when behaviour changes
|
||||
|
||||
Thanks for helping make Abogen a great open-source audiobook generator.
|
||||
|
||||
+46
-37
@@ -1,42 +1,51 @@
|
||||
# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
|
||||
# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
|
||||
FROM nvidia/cuda:12.4.1-cudnn-runtime-ubuntu22.04
|
||||
|
||||
# Use a docker base image that runs a window manager that can be viewed
|
||||
# outside the image with a web browser or VNC client.
|
||||
# https://github.com/jlesage/docker-baseimage-gui
|
||||
FROM jlesage/baseimage-gui:debian-12-v4
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
VIRTUAL_ENV=/opt/venv \
|
||||
PATH=/opt/venv/bin:$PATH
|
||||
|
||||
ARG TORCH_INDEX_URL=https://download.pytorch.org/whl/cu124
|
||||
ARG TORCH_VERSION=
|
||||
|
||||
# Load stuff needed by abogen
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
python3-pyqt5 \
|
||||
espeak-ng \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
ffmpeg \
|
||||
libsndfile1 \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# The base image will run /startapp.sh on launch.
|
||||
#
|
||||
# The base image runs that script as user 'app' uid=1000. That user
|
||||
# does not exist in the base image but is created at run time.
|
||||
#
|
||||
# We need to install abogen in python venv (requirement of newer python3).
|
||||
#
|
||||
# The python venv has to be writable by the 'app' user as abogen dynamically
|
||||
# installs python packages, so create the venv as that user
|
||||
#
|
||||
# We intend to share the /shared directory with the host using a bind volume
|
||||
# in order to access any source files and the created files.
|
||||
RUN python3 -m venv "$VIRTUAL_ENV"
|
||||
|
||||
RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \
|
||||
&& chmod 555 /startapp.sh \
|
||||
&& mkdir /app /shared \
|
||||
&& chown 1000:1000 /app /shared \
|
||||
&& chmod 755 /app /shared
|
||||
USER 1000:1000
|
||||
RUN python3 -m venv /app/venv
|
||||
RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen"
|
||||
# Change back to user ROOT as the startup scripts inside base image needs it
|
||||
USER root
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY abogen ./abogen
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& if [ -n "$TORCH_VERSION" ]; then \
|
||||
pip install torch=="$TORCH_VERSION" torchvision=="$TORCH_VERSION" torchaudio=="$TORCH_VERSION" --index-url "$TORCH_INDEX_URL"; \
|
||||
else \
|
||||
pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \
|
||||
fi \
|
||||
&& pip install --no-cache-dir .
|
||||
|
||||
ENV ABOGEN_HOST=0.0.0.0 \
|
||||
ABOGEN_PORT=8000
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV ABOGEN_UPLOAD_ROOT=/data/uploads \
|
||||
ABOGEN_OUTPUT_ROOT=/data/outputs
|
||||
|
||||
RUN mkdir -p /data/uploads /data/outputs
|
||||
|
||||
CMD ["abogen"]
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import List, Sequence
|
||||
|
||||
import ebooklib
|
||||
import fitz
|
||||
import markdown
|
||||
from bs4 import BeautifulSoup
|
||||
from ebooklib import epub
|
||||
|
||||
from .utils import clean_text, detect_encoding
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractedChapter:
|
||||
title: str
|
||||
text: str
|
||||
|
||||
@property
|
||||
def characters(self) -> int:
|
||||
return len(self.text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtractionResult:
|
||||
chapters: List[ExtractedChapter]
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def combined_text(self) -> str:
|
||||
return "\n\n".join(chapter.text for chapter in self.chapters)
|
||||
|
||||
@property
|
||||
def total_characters(self) -> int:
|
||||
return sum(chapter.characters for chapter in self.chapters)
|
||||
|
||||
|
||||
def extract_from_path(path: Path) -> ExtractionResult:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".txt":
|
||||
return _extract_plaintext(path)
|
||||
if suffix == ".pdf":
|
||||
return _extract_pdf(path)
|
||||
if suffix in {".md", ".markdown"}:
|
||||
return _extract_markdown(path)
|
||||
if suffix == ".epub":
|
||||
return _extract_epub(path)
|
||||
raise ValueError(f"Unsupported input type: {suffix}")
|
||||
|
||||
|
||||
def _extract_plaintext(path: Path) -> ExtractionResult:
|
||||
encoding = detect_encoding(str(path))
|
||||
raw = path.read_text(encoding=encoding, errors="replace")
|
||||
return _extract_from_string(raw, default_title=path.stem)
|
||||
|
||||
|
||||
METADATA_PATTERN = re.compile(r"<<METADATA_([A-Z_]+):(.*?)>>", re.DOTALL)
|
||||
CHAPTER_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_from_string(raw: str, default_title: str) -> ExtractionResult:
|
||||
metadata, body = _strip_metadata(raw)
|
||||
chapters = _split_chapters(body, default_title)
|
||||
if not chapters:
|
||||
chapters = [ExtractedChapter(title=default_title, text="")]
|
||||
return ExtractionResult(chapters=chapters, metadata=metadata)
|
||||
|
||||
|
||||
def _strip_metadata(content: str) -> tuple[dict[str, str], str]:
|
||||
metadata: dict[str, str] = {}
|
||||
|
||||
def _replacer(match: re.Match) -> str:
|
||||
key = match.group(1).strip().upper()
|
||||
value = match.group(2).strip()
|
||||
if value:
|
||||
metadata[key] = value
|
||||
return ""
|
||||
|
||||
stripped = METADATA_PATTERN.sub(_replacer, content)
|
||||
return metadata, stripped
|
||||
|
||||
|
||||
def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]:
|
||||
matches = list(CHAPTER_PATTERN.finditer(content))
|
||||
if not matches:
|
||||
cleaned = clean_text(content)
|
||||
return [ExtractedChapter(title=default_title, text=cleaned)]
|
||||
|
||||
chapters: List[ExtractedChapter] = []
|
||||
last_index = 0
|
||||
current_title = default_title
|
||||
|
||||
for match in matches:
|
||||
segment = content[last_index:match.start()]
|
||||
if segment.strip():
|
||||
chapters.append(ExtractedChapter(title=current_title, text=clean_text(segment)))
|
||||
current_title = match.group(1).strip() or default_title
|
||||
last_index = match.end()
|
||||
|
||||
tail = content[last_index:]
|
||||
if tail.strip():
|
||||
chapters.append(ExtractedChapter(title=current_title, text=clean_text(tail)))
|
||||
|
||||
return chapters
|
||||
|
||||
|
||||
def _extract_pdf(path: Path) -> ExtractionResult:
|
||||
document = fitz.open(str(path))
|
||||
chapters: List[ExtractedChapter] = []
|
||||
for index, page in enumerate(document):
|
||||
text = clean_text(page.get_text())
|
||||
if not text:
|
||||
continue
|
||||
title = f"Page {index + 1}"
|
||||
chapters.append(ExtractedChapter(title=title, text=text))
|
||||
if not chapters:
|
||||
chapters.append(ExtractedChapter(title=path.stem, text=""))
|
||||
return ExtractionResult(chapters)
|
||||
|
||||
|
||||
def _extract_markdown(path: Path) -> ExtractionResult:
|
||||
encoding = detect_encoding(str(path))
|
||||
raw = path.read_text(encoding=encoding, errors="replace")
|
||||
html = markdown.markdown(raw, extensions=["toc", "fenced_code"])
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
headings = soup.find_all([f"h{i}" for i in range(1, 7)])
|
||||
chapters: List[ExtractedChapter] = []
|
||||
if headings:
|
||||
for heading in headings:
|
||||
sibling_text = _collect_heading_text(heading)
|
||||
text = clean_text(sibling_text)
|
||||
if text:
|
||||
chapters.append(ExtractedChapter(title=heading.get_text(strip=True), text=text))
|
||||
if not chapters:
|
||||
chapters.append(ExtractedChapter(title=path.stem, text=clean_text(raw)))
|
||||
return ExtractionResult(chapters)
|
||||
|
||||
|
||||
def _collect_heading_text(node) -> str:
|
||||
texts: List[str] = []
|
||||
for sibling in node.next_siblings:
|
||||
if getattr(sibling, "name", None) and sibling.name.startswith("h"):
|
||||
break
|
||||
text = getattr(sibling, "get_text", lambda **_: "")()
|
||||
if text:
|
||||
texts.append(text)
|
||||
return "\n".join(texts)
|
||||
|
||||
|
||||
def _extract_epub(path: Path) -> ExtractionResult:
|
||||
book = epub.read_epub(str(path))
|
||||
chapters: List[ExtractedChapter] = []
|
||||
spine_docs: Sequence[str] = [item[0] for item in book.spine]
|
||||
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
|
||||
name = item.get_name()
|
||||
if name not in spine_docs:
|
||||
continue
|
||||
html_bytes = item.get_content()
|
||||
soup = BeautifulSoup(html_bytes, "html.parser")
|
||||
for ol in soup.find_all("ol"):
|
||||
start = int(ol.get("start", 1))
|
||||
for idx, li in enumerate(ol.find_all("li", recursive=False)):
|
||||
number = f"{start + idx}. "
|
||||
if li.string:
|
||||
li.string.replace_with(number + li.string)
|
||||
else:
|
||||
li.insert(0, number)
|
||||
text = clean_text(soup.get_text())
|
||||
if not text:
|
||||
continue
|
||||
title = _resolve_epub_title(soup, name)
|
||||
chapters.append(ExtractedChapter(title=title, text=text))
|
||||
if not chapters:
|
||||
chapters.append(ExtractedChapter(title=path.stem, text=""))
|
||||
return ExtractionResult(chapters)
|
||||
|
||||
|
||||
def _resolve_epub_title(soup: BeautifulSoup, fallback: str) -> str:
|
||||
if soup.title and soup.title.string:
|
||||
return soup.title.string.strip()
|
||||
for heading_tag in ("h1", "h2", "h3"):
|
||||
heading = soup.find(heading_tag)
|
||||
if heading and heading.get_text(strip=True):
|
||||
return heading.get_text(strip=True)
|
||||
return fallback
|
||||
@@ -0,0 +1,9 @@
|
||||
__all__ = ["create_app"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "create_app":
|
||||
from .app import create_app
|
||||
|
||||
return create_app
|
||||
raise AttributeError(name)
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from abogen.utils import get_user_cache_path
|
||||
|
||||
from .conversion_runner import run_conversion_job
|
||||
from .service import build_service
|
||||
|
||||
|
||||
def _default_dirs() -> tuple[Path, Path]:
|
||||
uploads_override = os.environ.get("ABOGEN_UPLOAD_ROOT")
|
||||
outputs_override = os.environ.get("ABOGEN_OUTPUT_ROOT")
|
||||
|
||||
if uploads_override and outputs_override:
|
||||
uploads = Path(uploads_override)
|
||||
outputs = Path(outputs_override)
|
||||
else:
|
||||
base = Path(get_user_cache_path("web"))
|
||||
uploads = base / "uploads"
|
||||
outputs = base / "outputs"
|
||||
|
||||
uploads.mkdir(parents=True, exist_ok=True)
|
||||
outputs.mkdir(parents=True, exist_ok=True)
|
||||
return uploads, outputs
|
||||
|
||||
|
||||
def create_app(config: Optional[dict[str, Any]] = None) -> Flask:
|
||||
uploads_dir, outputs_dir = _default_dirs()
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder="static",
|
||||
template_folder="templates",
|
||||
)
|
||||
base_config = {
|
||||
"SECRET_KEY": os.environ.get("ABOGEN_SECRET_KEY", os.urandom(16)),
|
||||
"UPLOAD_FOLDER": str(uploads_dir),
|
||||
"OUTPUT_FOLDER": str(outputs_dir),
|
||||
"MAX_CONTENT_LENGTH": 1024 * 1024 * 400, # 400 MB uploads
|
||||
}
|
||||
if config:
|
||||
base_config.update(config)
|
||||
app.config.update(base_config)
|
||||
|
||||
service = build_service(
|
||||
runner=run_conversion_job,
|
||||
output_root=Path(app.config["OUTPUT_FOLDER"]),
|
||||
uploads_root=Path(app.config["UPLOAD_FOLDER"]),
|
||||
)
|
||||
app.extensions["conversion_service"] = service
|
||||
|
||||
from .routes import web_bp, api_bp
|
||||
|
||||
app.register_blueprint(web_bp)
|
||||
app.register_blueprint(api_bp, url_prefix="/api")
|
||||
|
||||
atexit.register(service.shutdown)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = create_app()
|
||||
host = os.environ.get("ABOGEN_HOST", "0.0.0.0")
|
||||
port = int(os.environ.get("ABOGEN_PORT", "8000"))
|
||||
debug = os.environ.get("ABOGEN_DEBUG", "false").lower() == "true"
|
||||
app.run(host=host, port=port, debug=debug)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,430 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import static_ffmpeg
|
||||
|
||||
from abogen.text_extractor import ExtractedChapter, extract_from_path
|
||||
from abogen.utils import (
|
||||
calculate_text_length,
|
||||
create_process,
|
||||
get_user_cache_path,
|
||||
load_config,
|
||||
load_numpy_kpipeline,
|
||||
)
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
from .service import Job, JobStatus
|
||||
|
||||
|
||||
SPLIT_PATTERN = r"\n+"
|
||||
SAMPLE_RATE = 24000
|
||||
|
||||
|
||||
class _JobCancelled(Exception):
|
||||
"""Raised internally to abort a conversion when the client cancels."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioSink:
|
||||
write: Callable[[np.ndarray], None]
|
||||
|
||||
|
||||
def run_conversion_job(job: Job) -> None:
|
||||
job.add_log("Preparing conversion pipeline")
|
||||
canceller = _make_canceller(job)
|
||||
|
||||
sink_stack = ExitStack()
|
||||
subtitle_writer: Optional[SubtitleWriter] = None
|
||||
chapter_paths: list[Path] = []
|
||||
try:
|
||||
pipeline = _load_pipeline(job)
|
||||
extraction = extract_from_path(job.stored_path)
|
||||
job.metadata_tags = extraction.metadata or {}
|
||||
|
||||
total_characters = extraction.total_characters or calculate_text_length(extraction.combined_text)
|
||||
if job.total_characters == 0:
|
||||
job.total_characters = total_characters
|
||||
job.add_log(f"Total characters: {job.total_characters:,}")
|
||||
|
||||
_apply_newline_policy(extraction.chapters, job.replace_single_newlines)
|
||||
|
||||
base_output_dir = _prepare_output_dir(job)
|
||||
project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, base_output_dir)
|
||||
|
||||
merged_required = job.merge_chapters_at_end or not job.save_chapters_separately
|
||||
audio_path: Optional[Path] = None
|
||||
audio_sink: Optional[AudioSink] = None
|
||||
if merged_required:
|
||||
audio_path = _build_output_path(audio_dir, job.original_filename, job.output_format)
|
||||
meta_for_sink = job.metadata_tags if job.metadata_tags else None
|
||||
audio_sink = _open_audio_sink(audio_path, job, sink_stack, metadata=meta_for_sink)
|
||||
subtitle_writer = _create_subtitle_writer(job, audio_path)
|
||||
job.result.audio_path = audio_path
|
||||
if subtitle_writer:
|
||||
job.result.subtitle_paths.append(subtitle_writer.path)
|
||||
|
||||
chapter_dir: Optional[Path] = None
|
||||
if job.save_chapters_separately:
|
||||
chapter_dir = audio_dir / "chapters"
|
||||
chapter_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
voice = _resolve_voice(pipeline, job)
|
||||
processed_chars = 0
|
||||
subtitle_index = 1
|
||||
current_time = 0.0
|
||||
total_chapters = len(extraction.chapters)
|
||||
|
||||
for idx, chapter in enumerate(extraction.chapters, start=1):
|
||||
canceller()
|
||||
job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}")
|
||||
|
||||
chapter_sink_stack = ExitStack()
|
||||
chapter_sink: Optional[AudioSink] = None
|
||||
chapter_audio_path: Optional[Path] = None
|
||||
|
||||
if chapter_dir is not None:
|
||||
chapter_audio_path = _build_output_path(
|
||||
chapter_dir,
|
||||
f"{Path(job.original_filename).stem}_{_slugify(chapter.title, idx)}",
|
||||
job.separate_chapters_format,
|
||||
)
|
||||
chapter_sink = _open_audio_sink(
|
||||
chapter_audio_path,
|
||||
job,
|
||||
chapter_sink_stack,
|
||||
fmt=job.separate_chapters_format,
|
||||
)
|
||||
|
||||
for segment in pipeline(
|
||||
chapter.text,
|
||||
voice=voice,
|
||||
speed=job.speed,
|
||||
split_pattern=SPLIT_PATTERN,
|
||||
):
|
||||
canceller()
|
||||
graphemes = segment.graphemes.strip()
|
||||
if not graphemes:
|
||||
continue
|
||||
|
||||
audio = _to_float32(segment.audio)
|
||||
if chapter_sink:
|
||||
chapter_sink.write(audio)
|
||||
if audio_sink:
|
||||
audio_sink.write(audio)
|
||||
|
||||
duration = len(audio) / SAMPLE_RATE
|
||||
processed_chars += len(graphemes)
|
||||
job.processed_characters = processed_chars
|
||||
if job.total_characters:
|
||||
job.progress = min(processed_chars / job.total_characters, 0.999)
|
||||
job.add_log(f"{processed_chars:,}/{job.total_characters or '—'}: {graphemes[:80]}")
|
||||
|
||||
if subtitle_writer and audio_sink:
|
||||
subtitle_writer.write_segment(
|
||||
index=subtitle_index,
|
||||
text=graphemes,
|
||||
start=current_time,
|
||||
end=current_time + duration,
|
||||
)
|
||||
subtitle_index += 1
|
||||
|
||||
if audio_sink:
|
||||
current_time += duration
|
||||
|
||||
if chapter_sink:
|
||||
chapter_sink_stack.close()
|
||||
job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path
|
||||
chapter_paths.append(chapter_audio_path)
|
||||
|
||||
if (
|
||||
audio_sink
|
||||
and job.merge_chapters_at_end
|
||||
and idx < total_chapters
|
||||
and job.silence_between_chapters > 0
|
||||
):
|
||||
silence_samples = int(job.silence_between_chapters * SAMPLE_RATE)
|
||||
if silence_samples > 0:
|
||||
silence = np.zeros(silence_samples, dtype="float32")
|
||||
audio_sink.write(silence)
|
||||
current_time += job.silence_between_chapters
|
||||
|
||||
if not audio_path and chapter_paths:
|
||||
job.result.audio_path = chapter_paths[0]
|
||||
|
||||
if metadata_dir:
|
||||
metadata_dir.mkdir(parents=True, exist_ok=True)
|
||||
metadata_file = metadata_dir / "metadata.json"
|
||||
metadata_file.write_text(json.dumps({"metadata": job.metadata_tags}, indent=2), encoding="utf-8")
|
||||
job.result.artifacts["metadata"] = metadata_file
|
||||
|
||||
if job.save_as_project:
|
||||
job.result.artifacts["project_root"] = project_root
|
||||
|
||||
if job.status != JobStatus.CANCELLED:
|
||||
job.progress = 1.0
|
||||
|
||||
except _JobCancelled:
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.add_log("Job cancelled", level="warning")
|
||||
except Exception as exc: # pragma: no cover - defensive guard
|
||||
job.error = str(exc)
|
||||
job.status = JobStatus.FAILED
|
||||
job.add_log(f"Job failed: {exc}", level="error")
|
||||
finally:
|
||||
sink_stack.close()
|
||||
if subtitle_writer:
|
||||
subtitle_writer.close()
|
||||
|
||||
|
||||
def _load_pipeline(job: Job):
|
||||
cfg = load_config()
|
||||
disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True)
|
||||
device = "cpu"
|
||||
if not disable_gpu:
|
||||
device = _select_device()
|
||||
_np, KPipeline = load_numpy_kpipeline()
|
||||
return KPipeline(lang_code=job.language, repo_id="hexgrad/Kokoro-82M", device=device)
|
||||
|
||||
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
return "mps"
|
||||
return "cuda"
|
||||
|
||||
|
||||
def _prepare_output_dir(job: Job) -> Path:
|
||||
from platformdirs import user_desktop_dir
|
||||
|
||||
default_output = Path(get_user_cache_path("outputs"))
|
||||
if job.save_mode == "Save to Desktop":
|
||||
directory = Path(user_desktop_dir())
|
||||
elif job.save_mode == "Save next to input file":
|
||||
directory = job.stored_path.parent
|
||||
elif job.save_mode == "Choose output folder" and job.output_folder:
|
||||
directory = Path(job.output_folder)
|
||||
else:
|
||||
directory = default_output
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _build_output_path(directory: Path, original_name: str, extension: str) -> Path:
|
||||
base_name = Path(original_name).stem
|
||||
sanitized = re.sub(r"[^\w\-_.]+", "_", base_name).strip("_") or "output"
|
||||
candidate = directory / f"{sanitized}.{extension}"
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = directory / f"{sanitized}_{counter}.{extension}"
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _prepare_project_layout(job: Job, base_dir: Path) -> tuple[Path, Path, Path, Optional[Path]]:
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = Path(job.original_filename).stem
|
||||
if job.save_as_project:
|
||||
project_root = _ensure_unique_directory(base_dir, f"{stem}_project")
|
||||
audio_dir = project_root / "audio"
|
||||
subtitle_dir = project_root / "subtitles"
|
||||
metadata_dir = project_root / "metadata"
|
||||
for directory in (audio_dir, subtitle_dir, metadata_dir):
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return project_root, audio_dir, subtitle_dir, metadata_dir
|
||||
|
||||
return base_dir, base_dir, base_dir, None
|
||||
|
||||
|
||||
def _ensure_unique_directory(parent: Path, name: str) -> Path:
|
||||
candidate = parent / name
|
||||
counter = 1
|
||||
while candidate.exists():
|
||||
candidate = parent / f"{name}_{counter}"
|
||||
counter += 1
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
return candidate
|
||||
|
||||
|
||||
def _apply_newline_policy(chapters: List[ExtractedChapter], replace_single_newlines: bool) -> None:
|
||||
if not replace_single_newlines:
|
||||
return
|
||||
newline_regex = re.compile(r"(?<!\n)\n(?!\n)")
|
||||
for chapter in chapters:
|
||||
chapter.text = newline_regex.sub(" ", chapter.text)
|
||||
|
||||
|
||||
def _slugify(title: str, index: int) -> str:
|
||||
sanitized = re.sub(r"[^\w\-]+", "_", title.lower()).strip("_")
|
||||
if not sanitized:
|
||||
sanitized = f"chapter_{index:02d}"
|
||||
return sanitized[:80]
|
||||
|
||||
|
||||
def _open_audio_sink(
|
||||
path: Path,
|
||||
job: Job,
|
||||
stack: ExitStack,
|
||||
*,
|
||||
fmt: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
) -> AudioSink:
|
||||
static_ffmpeg.add_paths()
|
||||
fmt_value = (fmt or job.output_format).lower()
|
||||
|
||||
if fmt_value in {"wav", "flac"}:
|
||||
soundfile = stack.enter_context(
|
||||
sf.SoundFile(path, mode="w", samplerate=SAMPLE_RATE, channels=1, format=fmt_value.upper())
|
||||
)
|
||||
return AudioSink(write=lambda data: soundfile.write(data))
|
||||
|
||||
cmd = _build_ffmpeg_command(path, fmt_value, metadata=metadata)
|
||||
process = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||
|
||||
def _finalize() -> None:
|
||||
if process.stdin and not process.stdin.closed:
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
|
||||
stack.callback(_finalize)
|
||||
|
||||
def _write(data: np.ndarray) -> None:
|
||||
if job.cancel_requested or process.stdin is None:
|
||||
return
|
||||
process.stdin.write(data.tobytes())
|
||||
|
||||
return AudioSink(write=_write)
|
||||
|
||||
|
||||
def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str]] = None) -> list[str]:
|
||||
base = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-ac",
|
||||
"1",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]
|
||||
if fmt == "mp3":
|
||||
base += ["-c:a", "libmp3lame", "-qscale:a", "2"]
|
||||
elif fmt == "opus":
|
||||
base += ["-c:a", "libopus", "-b:a", "24000"]
|
||||
elif fmt == "m4b":
|
||||
base += ["-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart+use_metadata_tags"]
|
||||
else:
|
||||
base += ["-c:a", "copy"]
|
||||
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value:
|
||||
base += ["-metadata", f"{key}={value}"]
|
||||
base.append(str(path))
|
||||
return base
|
||||
|
||||
|
||||
def _resolve_voice(pipeline, job: Job):
|
||||
if "*" in job.voice:
|
||||
return get_new_voice(pipeline, job.voice, job.use_gpu)
|
||||
return job.voice
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
if hasattr(audio_segment, "numpy"):
|
||||
return audio_segment.numpy().astype("float32")
|
||||
return np.asarray(audio_segment, dtype="float32")
|
||||
|
||||
|
||||
class SubtitleWriter:
|
||||
def __init__(self, path: Path, format_key: str) -> None:
|
||||
self.path = path
|
||||
self.format_key = format_key
|
||||
self._file = path.open("w", encoding="utf-8", errors="replace")
|
||||
if format_key == "ass":
|
||||
self._write_ass_header()
|
||||
|
||||
def write_segment(self, *, index: int, text: str, start: float, end: float) -> None:
|
||||
if self.format_key == "ass":
|
||||
self._write_ass_event(text, start, end)
|
||||
else:
|
||||
self._write_srt_line(index, text, start, end)
|
||||
|
||||
def close(self) -> None:
|
||||
self._file.close()
|
||||
|
||||
def _write_srt_line(self, index: int, text: str, start: float, end: float) -> None:
|
||||
self._file.write(f"{index}\n")
|
||||
self._file.write(f"{_format_timestamp(start)} --> {_format_timestamp(end)}\n")
|
||||
self._file.write(text.strip() + "\n\n")
|
||||
|
||||
def _write_ass_header(self) -> None:
|
||||
self._file.write("[Script Info]\n")
|
||||
self._file.write("Title: Generated by Abogen\n")
|
||||
self._file.write("ScriptType: v4.00+\n\n")
|
||||
self._file.write("[V4+ Styles]\n")
|
||||
self._file.write(
|
||||
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
|
||||
)
|
||||
self._file.write(
|
||||
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
|
||||
)
|
||||
self._file.write("[Events]\n")
|
||||
self._file.write(
|
||||
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
|
||||
)
|
||||
|
||||
def _write_ass_event(self, text: str, start: float, end: float) -> None:
|
||||
self._file.write(
|
||||
f"Dialogue: 0,{_format_timestamp(start, ass=True)},{_format_timestamp(end, ass=True)},Default,,0000,0000,0000,,{text.strip()}\n"
|
||||
)
|
||||
|
||||
|
||||
def _create_subtitle_writer(job: Job, audio_path: Path) -> Optional[SubtitleWriter]:
|
||||
if job.subtitle_mode == "Disabled":
|
||||
return None
|
||||
|
||||
fmt = (job.subtitle_format or "srt").lower()
|
||||
if job.subtitle_mode == "Sentence + Highlighting" and fmt == "srt":
|
||||
job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning")
|
||||
fmt = "ass"
|
||||
|
||||
if fmt == "srt":
|
||||
return SubtitleWriter(audio_path.with_suffix(".srt"), "srt")
|
||||
if "ass" in fmt:
|
||||
return SubtitleWriter(audio_path.with_suffix(".ass"), "ass")
|
||||
|
||||
job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning")
|
||||
return None
|
||||
|
||||
|
||||
def _format_timestamp(value: float, ass: bool = False) -> str:
|
||||
hours = int(value // 3600)
|
||||
minutes = int((value % 3600) // 60)
|
||||
seconds = int(value % 60)
|
||||
milliseconds = int((value - math.floor(value)) * 1000)
|
||||
if ass:
|
||||
centiseconds = int(milliseconds / 10)
|
||||
return f"{hours:d}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
|
||||
|
||||
|
||||
def _make_canceller(job: Job) -> Callable[[], None]:
|
||||
def _cancel() -> None:
|
||||
if job.cancel_requested:
|
||||
raise _JobCancelled
|
||||
|
||||
return _cancel
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
current_app,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from abogen.constants import (
|
||||
LANGUAGE_DESCRIPTIONS,
|
||||
SUBTITLE_FORMATS,
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
SUPPORTED_SOUND_FORMATS,
|
||||
VOICES_INTERNAL,
|
||||
)
|
||||
from abogen.utils import calculate_text_length, clean_text
|
||||
from abogen.voice_profiles import delete_profile, load_profiles, save_profiles
|
||||
|
||||
from .service import ConversionService, Job, JobStatus
|
||||
|
||||
web_bp = Blueprint("web", __name__)
|
||||
api_bp = Blueprint("api", __name__)
|
||||
|
||||
|
||||
def _service() -> ConversionService:
|
||||
return current_app.extensions["conversion_service"]
|
||||
|
||||
|
||||
def _template_options() -> Dict[str, Any]:
|
||||
profiles = load_profiles()
|
||||
ordered_profiles = sorted(profiles.items())
|
||||
return {
|
||||
"languages": LANGUAGE_DESCRIPTIONS,
|
||||
"voices": VOICES_INTERNAL,
|
||||
"subtitle_formats": SUBTITLE_FORMATS,
|
||||
"supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||
"output_formats": SUPPORTED_SOUND_FORMATS,
|
||||
"voice_profiles": ordered_profiles,
|
||||
"separate_formats": ["wav", "flac", "mp3", "opus"],
|
||||
}
|
||||
|
||||
|
||||
def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]:
|
||||
voices = entry.get("voices") or []
|
||||
if not voices:
|
||||
return None
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
return None
|
||||
|
||||
def _format_weight(value: float) -> str:
|
||||
normalized = value / total if total else 0.0
|
||||
return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0"
|
||||
|
||||
parts = [f"{name}*{_format_weight(weight)}" for name, weight in voices if weight > 0]
|
||||
return "+".join(parts) if parts else None
|
||||
|
||||
|
||||
def _resolve_voice_choice(
|
||||
language: str,
|
||||
base_voice: str,
|
||||
profile_name: str,
|
||||
custom_formula: str,
|
||||
profiles: Dict[str, Any],
|
||||
) -> tuple[str, str, Optional[str]]:
|
||||
resolved_voice = base_voice
|
||||
resolved_language = language
|
||||
selected_profile = None
|
||||
|
||||
if profile_name:
|
||||
entry = profiles.get(profile_name)
|
||||
formula = _formula_from_profile(entry or {}) if entry else None
|
||||
if formula:
|
||||
resolved_voice = formula
|
||||
selected_profile = profile_name
|
||||
profile_language = (entry or {}).get("language")
|
||||
if profile_language:
|
||||
resolved_language = profile_language
|
||||
|
||||
if custom_formula:
|
||||
resolved_voice = custom_formula
|
||||
selected_profile = None
|
||||
|
||||
return resolved_voice, resolved_language, selected_profile
|
||||
|
||||
|
||||
def _parse_voice_formula(formula: str) -> List[tuple[str, float]]:
|
||||
parts = [segment.strip() for segment in formula.split("+") if segment.strip()]
|
||||
voices: List[tuple[str, float]] = []
|
||||
for part in parts:
|
||||
if "*" not in part:
|
||||
raise ValueError("Each component must be in the form voice*weight")
|
||||
name, weight_str = part.split("*", 1)
|
||||
name = name.strip()
|
||||
if name not in VOICES_INTERNAL:
|
||||
raise ValueError(f"Unknown voice '{name}'")
|
||||
try:
|
||||
weight = float(weight_str.strip())
|
||||
except ValueError as exc: # pragma: no cover - validated via form
|
||||
raise ValueError(f"Invalid weight for {name}") from exc
|
||||
if weight <= 0:
|
||||
raise ValueError(f"Weight for {name} must be positive")
|
||||
voices.append((name, weight))
|
||||
total = sum(weight for _, weight in voices)
|
||||
if total <= 0:
|
||||
raise ValueError("Voice weights must sum to a positive value")
|
||||
return voices
|
||||
|
||||
|
||||
@web_bp.app_template_filter("datetimeformat")
|
||||
def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
if not value:
|
||||
return "—"
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.fromtimestamp(value).strftime(fmt)
|
||||
|
||||
|
||||
@web_bp.get("/")
|
||||
def index() -> str:
|
||||
service = _service()
|
||||
jobs = service.list_jobs()
|
||||
return render_template(
|
||||
"index.html",
|
||||
jobs=jobs,
|
||||
options=_template_options(),
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/voices")
|
||||
def voice_profiles_page() -> str:
|
||||
profiles = load_profiles()
|
||||
rendered = []
|
||||
for name, data in sorted(profiles.items()):
|
||||
rendered.append(
|
||||
{
|
||||
"name": name,
|
||||
"language": data.get("language", "a"),
|
||||
"formula": _formula_from_profile(data) or "",
|
||||
}
|
||||
)
|
||||
return render_template(
|
||||
"voices.html",
|
||||
profiles=rendered,
|
||||
languages=LANGUAGE_DESCRIPTIONS,
|
||||
voices=VOICES_INTERNAL,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.post("/voices")
|
||||
def save_voice_profile_route() -> Response:
|
||||
name = request.form.get("name", "").strip()
|
||||
language = request.form.get("language", "a").strip() or "a"
|
||||
formula = request.form.get("formula", "").strip()
|
||||
if not name or not formula:
|
||||
abort(400, "Name and formula are required")
|
||||
voices = _parse_voice_formula(formula)
|
||||
profiles = load_profiles()
|
||||
profiles[name] = {"voices": voices, "language": language}
|
||||
save_profiles(profiles)
|
||||
return redirect(url_for("web.voice_profiles_page"))
|
||||
|
||||
|
||||
@web_bp.post("/voices/<name>/delete")
|
||||
def delete_voice_profile_route(name: str) -> Response:
|
||||
delete_profile(name)
|
||||
return redirect(url_for("web.voice_profiles_page"))
|
||||
|
||||
|
||||
@web_bp.post("/jobs")
|
||||
def enqueue_job() -> Response:
|
||||
service = _service()
|
||||
uploads_dir = Path(current_app.config["UPLOAD_FOLDER"])
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file = request.files.get("source_file")
|
||||
text_input = request.form.get("source_text", "").strip()
|
||||
|
||||
if not file and not text_input:
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
stored_path: Path
|
||||
original_name: str
|
||||
|
||||
if file and file.filename:
|
||||
filename = secure_filename(file.filename)
|
||||
if not filename:
|
||||
return redirect(url_for("web.index"))
|
||||
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{filename}"
|
||||
file.save(stored_path)
|
||||
original_name = filename
|
||||
total_chars = 0
|
||||
else:
|
||||
original_name = "direct_text.txt"
|
||||
stored_path = uploads_dir / f"{uuid.uuid4().hex}_{original_name}"
|
||||
stored_path.write_text(text_input, encoding="utf-8")
|
||||
total_chars = calculate_text_length(clean_text(text_input))
|
||||
|
||||
profiles = load_profiles()
|
||||
|
||||
language = request.form.get("language", "a")
|
||||
base_voice = request.form.get("voice", "af_alloy")
|
||||
profile_name = request.form.get("voice_profile", "").strip()
|
||||
custom_formula = request.form.get("voice_formula", "").strip()
|
||||
voice, language, selected_profile = _resolve_voice_choice(
|
||||
language,
|
||||
base_voice,
|
||||
profile_name,
|
||||
custom_formula,
|
||||
profiles,
|
||||
)
|
||||
speed = float(request.form.get("speed", "1.0"))
|
||||
subtitle_mode = request.form.get("subtitle_mode", "Disabled")
|
||||
output_format = request.form.get("output_format", "wav")
|
||||
subtitle_format = request.form.get("subtitle_format", "srt")
|
||||
save_mode = request.form.get("save_mode", "Save next to input file")
|
||||
replace_single_newlines = request.form.get("replace_single_newlines") in {"true", "on", "1"}
|
||||
use_gpu = request.form.get("use_gpu") in {"true", "on", "1"}
|
||||
save_chapters_separately = request.form.get("save_chapters_separately") in {"true", "on", "1"}
|
||||
merge_chapters_at_end = request.form.get("merge_chapters_at_end") in {"true", "on", "1"}
|
||||
if not save_chapters_separately:
|
||||
merge_chapters_at_end = True
|
||||
save_as_project = request.form.get("save_as_project") in {"true", "on", "1"}
|
||||
separate_chapters_format = request.form.get("separate_chapters_format", "wav").lower()
|
||||
try:
|
||||
silence_between_chapters = float(request.form.get("silence_between_chapters", "2.0") or 0.0)
|
||||
except ValueError:
|
||||
silence_between_chapters = 2.0
|
||||
silence_between_chapters = max(0.0, silence_between_chapters)
|
||||
try:
|
||||
max_subtitle_words = int(request.form.get("max_subtitle_words", "50") or 50)
|
||||
except ValueError:
|
||||
max_subtitle_words = 50
|
||||
max_subtitle_words = max(1, min(max_subtitle_words, 200))
|
||||
|
||||
job = service.enqueue(
|
||||
original_filename=original_name,
|
||||
stored_path=stored_path,
|
||||
language=language,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
subtitle_mode=subtitle_mode,
|
||||
output_format=output_format,
|
||||
save_mode=save_mode,
|
||||
output_folder=None,
|
||||
replace_single_newlines=replace_single_newlines,
|
||||
subtitle_format=subtitle_format,
|
||||
total_characters=total_chars,
|
||||
save_chapters_separately=save_chapters_separately,
|
||||
merge_chapters_at_end=merge_chapters_at_end,
|
||||
separate_chapters_format=separate_chapters_format,
|
||||
silence_between_chapters=silence_between_chapters,
|
||||
save_as_project=save_as_project,
|
||||
voice_profile=selected_profile,
|
||||
max_subtitle_words=max_subtitle_words,
|
||||
)
|
||||
return redirect(url_for("web.job_detail", job_id=job.id))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>")
|
||||
def job_detail(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return render_template(
|
||||
"job_detail.html",
|
||||
job=job,
|
||||
options=_template_options(),
|
||||
)
|
||||
|
||||
|
||||
@web_bp.post("/jobs/<job_id>/cancel")
|
||||
def cancel_job(job_id: str) -> Response:
|
||||
_service().cancel(job_id)
|
||||
return redirect(url_for("web.job_detail", job_id=job_id))
|
||||
|
||||
|
||||
@web_bp.post("/jobs/<job_id>/delete")
|
||||
def delete_job(job_id: str) -> Response:
|
||||
_service().delete(job_id)
|
||||
return redirect(url_for("web.index"))
|
||||
|
||||
|
||||
@web_bp.get("/jobs/<job_id>/download")
|
||||
def download_job(job_id: str) -> Response:
|
||||
job = _service().get_job(job_id)
|
||||
if not job or job.status != JobStatus.COMPLETED:
|
||||
abort(404)
|
||||
if not job.result.audio_path:
|
||||
abort(404)
|
||||
path = job.result.audio_path
|
||||
if not path.exists():
|
||||
abort(404)
|
||||
mime_type, _ = mimetypes.guess_type(str(path))
|
||||
return send_file(
|
||||
path,
|
||||
mimetype=mime_type or "application/octet-stream",
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
)
|
||||
|
||||
|
||||
@web_bp.get("/partials/jobs")
|
||||
def jobs_partial() -> str:
|
||||
return render_template("partials/jobs.html", jobs=_service().list_jobs())
|
||||
|
||||
|
||||
@web_bp.get("/partials/jobs/<job_id>/logs")
|
||||
def job_logs_partial(job_id: str) -> str:
|
||||
job = _service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return render_template("partials/logs.html", job=job)
|
||||
|
||||
|
||||
@api_bp.get("/jobs/<job_id>")
|
||||
def job_json(job_id: str) -> Response:
|
||||
job = _service().get_job(job_id)
|
||||
if not job:
|
||||
abort(404)
|
||||
return jsonify(job.as_dict())
|
||||
@@ -0,0 +1,325 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobLog:
|
||||
timestamp: float
|
||||
message: str
|
||||
level: str = "info"
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobResult:
|
||||
audio_path: Optional[Path] = None
|
||||
subtitle_paths: List[Path] = field(default_factory=list)
|
||||
artifacts: Dict[str, Path] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
id: str
|
||||
original_filename: str
|
||||
stored_path: Path
|
||||
language: str
|
||||
voice: str
|
||||
speed: float
|
||||
use_gpu: bool
|
||||
subtitle_mode: str
|
||||
output_format: str
|
||||
save_mode: str
|
||||
output_folder: Optional[Path]
|
||||
replace_single_newlines: bool
|
||||
subtitle_format: str
|
||||
created_at: float
|
||||
save_chapters_separately: bool = False
|
||||
merge_chapters_at_end: bool = True
|
||||
separate_chapters_format: str = "wav"
|
||||
silence_between_chapters: float = 2.0
|
||||
save_as_project: bool = False
|
||||
voice_profile: Optional[str] = None
|
||||
metadata_tags: Dict[str, str] = field(default_factory=dict)
|
||||
max_subtitle_words: int = 50
|
||||
status: JobStatus = JobStatus.PENDING
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
progress: float = 0.0
|
||||
total_characters: int = 0
|
||||
processed_characters: int = 0
|
||||
logs: List[JobLog] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
result: JobResult = field(default_factory=JobResult)
|
||||
chapters: List[str] = field(default_factory=list)
|
||||
queue_position: Optional[int] = None
|
||||
cancel_requested: bool = False
|
||||
|
||||
def add_log(self, message: str, level: str = "info") -> None:
|
||||
self.logs.append(JobLog(timestamp=time.time(), message=message, level=level))
|
||||
|
||||
def as_dict(self) -> Dict[str, object]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"original_filename": self.original_filename,
|
||||
"status": self.status.value,
|
||||
"use_gpu": self.use_gpu,
|
||||
"created_at": self.created_at,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"progress": self.progress,
|
||||
"total_characters": self.total_characters,
|
||||
"processed_characters": self.processed_characters,
|
||||
"error": self.error,
|
||||
"logs": [log.__dict__ for log in self.logs],
|
||||
"result": {
|
||||
"audio": str(self.result.audio_path) if self.result.audio_path else None,
|
||||
"subtitles": [str(path) for path in self.result.subtitle_paths],
|
||||
"artifacts": {key: str(path) for key, path in self.result.artifacts.items()},
|
||||
},
|
||||
"queue_position": self.queue_position,
|
||||
"options": {
|
||||
"save_chapters_separately": self.save_chapters_separately,
|
||||
"merge_chapters_at_end": self.merge_chapters_at_end,
|
||||
"separate_chapters_format": self.separate_chapters_format,
|
||||
"silence_between_chapters": self.silence_between_chapters,
|
||||
"save_as_project": self.save_as_project,
|
||||
"voice_profile": self.voice_profile,
|
||||
"max_subtitle_words": self.max_subtitle_words,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConversionService:
|
||||
def __init__(
|
||||
self,
|
||||
output_root: Path,
|
||||
runner: Callable[[Job], None],
|
||||
*,
|
||||
uploads_root: Optional[Path] = None,
|
||||
poll_interval: float = 0.5,
|
||||
) -> None:
|
||||
self._jobs: Dict[str, Job] = {}
|
||||
self._queue: List[str] = []
|
||||
self._lock = threading.RLock()
|
||||
self._worker_thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._wake_event = threading.Event()
|
||||
self._output_root = output_root
|
||||
self._uploads_root = uploads_root or output_root / "uploads"
|
||||
self._runner = runner
|
||||
self._poll_interval = poll_interval
|
||||
self._ensure_directories()
|
||||
|
||||
# Public API ---------------------------------------------------------
|
||||
def list_jobs(self) -> List[Job]:
|
||||
with self._lock:
|
||||
return sorted(self._jobs.values(), key=lambda job: job.created_at, reverse=True)
|
||||
|
||||
def get_job(self, job_id: str) -> Optional[Job]:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
original_filename: str,
|
||||
stored_path: Path,
|
||||
language: str,
|
||||
voice: str,
|
||||
speed: float,
|
||||
use_gpu: bool,
|
||||
subtitle_mode: str,
|
||||
output_format: str,
|
||||
save_mode: str,
|
||||
output_folder: Optional[Path],
|
||||
replace_single_newlines: bool,
|
||||
subtitle_format: str,
|
||||
total_characters: int,
|
||||
chapters: Optional[Iterable[str]] = None,
|
||||
save_chapters_separately: bool = False,
|
||||
merge_chapters_at_end: bool = True,
|
||||
separate_chapters_format: str = "wav",
|
||||
silence_between_chapters: float = 2.0,
|
||||
save_as_project: bool = False,
|
||||
voice_profile: Optional[str] = None,
|
||||
max_subtitle_words: int = 50,
|
||||
) -> Job:
|
||||
job_id = uuid.uuid4().hex
|
||||
job = Job(
|
||||
id=job_id,
|
||||
original_filename=original_filename,
|
||||
stored_path=stored_path,
|
||||
language=language,
|
||||
voice=voice,
|
||||
speed=speed,
|
||||
use_gpu=use_gpu,
|
||||
subtitle_mode=subtitle_mode,
|
||||
output_format=output_format,
|
||||
save_mode=save_mode,
|
||||
output_folder=output_folder,
|
||||
replace_single_newlines=replace_single_newlines,
|
||||
subtitle_format=subtitle_format,
|
||||
save_chapters_separately=save_chapters_separately,
|
||||
merge_chapters_at_end=merge_chapters_at_end,
|
||||
separate_chapters_format=separate_chapters_format,
|
||||
silence_between_chapters=silence_between_chapters,
|
||||
save_as_project=save_as_project,
|
||||
voice_profile=voice_profile,
|
||||
max_subtitle_words=max_subtitle_words,
|
||||
created_at=time.time(),
|
||||
total_characters=total_characters,
|
||||
chapters=list(chapters or []),
|
||||
)
|
||||
with self._lock:
|
||||
self._jobs[job_id] = job
|
||||
self._queue.append(job_id)
|
||||
self._update_queue_positions_locked()
|
||||
self._wake_event.set()
|
||||
self._ensure_worker()
|
||||
job.add_log("Job queued")
|
||||
return job
|
||||
|
||||
def cancel(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
return False
|
||||
job.cancel_requested = True
|
||||
job.add_log("Cancellation requested", level="warning")
|
||||
if job.status == JobStatus.PENDING:
|
||||
job.status = JobStatus.CANCELLED
|
||||
self._queue.remove(job_id)
|
||||
job.finished_at = time.time()
|
||||
self._update_queue_positions_locked()
|
||||
return True
|
||||
|
||||
def delete(self, job_id: str) -> bool:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if job is None:
|
||||
return False
|
||||
if job.status in {JobStatus.RUNNING}:
|
||||
return False
|
||||
self._jobs.pop(job_id)
|
||||
if job_id in self._queue:
|
||||
self._queue.remove(job_id)
|
||||
self._update_queue_positions_locked()
|
||||
return True
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self._stop_event.set()
|
||||
self._wake_event.set()
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
self._worker_thread.join(timeout=5)
|
||||
self._worker_thread = None
|
||||
|
||||
# Internal -----------------------------------------------------------
|
||||
def _ensure_directories(self) -> None:
|
||||
self._output_root.mkdir(parents=True, exist_ok=True)
|
||||
self._uploads_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _ensure_worker(self) -> None:
|
||||
with self._lock:
|
||||
if self._worker_thread and self._worker_thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name="abogen-conversion-worker",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
while not self._stop_event.is_set():
|
||||
job = None
|
||||
with self._lock:
|
||||
self._wake_event.clear()
|
||||
while self._queue and self._jobs[self._queue[0]].status in {
|
||||
JobStatus.CANCELLED,
|
||||
JobStatus.COMPLETED,
|
||||
JobStatus.FAILED,
|
||||
}:
|
||||
self._queue.pop(0)
|
||||
if self._queue:
|
||||
job = self._jobs[self._queue.pop(0)]
|
||||
else:
|
||||
self._update_queue_positions_locked()
|
||||
if job is None:
|
||||
self._wake_event.wait(timeout=self._poll_interval)
|
||||
continue
|
||||
if job.cancel_requested:
|
||||
job.add_log("Job cancelled before start", level="warning")
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.finished_at = time.time()
|
||||
continue
|
||||
self._run_job(job)
|
||||
|
||||
def _run_job(self, job: Job) -> None:
|
||||
job.status = JobStatus.RUNNING
|
||||
job.started_at = time.time()
|
||||
job.add_log("Job started", level="info")
|
||||
try:
|
||||
self._runner(job)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
job.error = str(exc)
|
||||
job.status = JobStatus.FAILED
|
||||
job.finished_at = time.time()
|
||||
job.add_log(f"Job failed: {exc}", level="error")
|
||||
else:
|
||||
if job.cancel_requested:
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.add_log("Job cancelled", level="warning")
|
||||
elif job.status != JobStatus.FAILED:
|
||||
job.status = JobStatus.COMPLETED
|
||||
job.add_log("Job completed", level="success")
|
||||
job.finished_at = time.time()
|
||||
finally:
|
||||
with self._lock:
|
||||
self._update_queue_positions_locked()
|
||||
|
||||
def _update_queue_positions_locked(self) -> None:
|
||||
for index, job_id in enumerate(self._queue, start=1):
|
||||
job = self._jobs.get(job_id)
|
||||
if job:
|
||||
job.queue_position = index
|
||||
|
||||
|
||||
def default_storage_root() -> Path:
|
||||
base = Path.cwd()
|
||||
uploads = base / "var" / "uploads"
|
||||
outputs = base / "var" / "outputs"
|
||||
outputs.mkdir(parents=True, exist_ok=True)
|
||||
uploads.mkdir(parents=True, exist_ok=True)
|
||||
return outputs
|
||||
|
||||
|
||||
def build_service(
|
||||
runner: Callable[[Job], None],
|
||||
*,
|
||||
output_root: Optional[Path] = None,
|
||||
uploads_root: Optional[Path] = None,
|
||||
) -> ConversionService:
|
||||
output_root = output_root or default_storage_root()
|
||||
service = ConversionService(
|
||||
output_root=output_root,
|
||||
uploads_root=uploads_root,
|
||||
runner=runner,
|
||||
)
|
||||
return service
|
||||
@@ -0,0 +1,382 @@
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--bg: #0f172a;
|
||||
--bg-alt: #111c30;
|
||||
--panel: rgba(255, 255, 255, 0.04);
|
||||
--panel-border: rgba(148, 163, 184, 0.12);
|
||||
--text: #e2e8f0;
|
||||
--muted: #94a3b8;
|
||||
--accent: #38bdf8;
|
||||
--accent-strong: #0ea5e9;
|
||||
--success: #34d399;
|
||||
--danger: #f87171;
|
||||
--warning: #fbbf24;
|
||||
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.12), transparent 40%),
|
||||
radial-gradient(circle at 100% 0%, rgba(244, 114, 182, 0.08), transparent 45%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
backdrop-filter: blur(20px);
|
||||
background: linear-gradient(90deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.7));
|
||||
padding: 1.25rem 3rem;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.brand__name {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.brand__tagline {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.top-actions .btn {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.top-actions .btn:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 2.5rem 3rem 3.5rem;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 1.5rem 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--muted);
|
||||
border-top: 1px solid var(--panel-border);
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 28px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 20px 50px rgba(8, 15, 32, 0.35);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.15), transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card__title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid--two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 0.85rem 1.2rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
box-shadow: 0 12px 30px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 16px 30px rgba(14, 165, 233, 0.28);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--panel-border);
|
||||
color: var(--muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.button--ghost:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: border 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.15);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.badge--pending {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge--running {
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge--completed {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.badge--failed,
|
||||
.badge--cancelled {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
padding: 0.75rem 0.95rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.log-item--success {
|
||||
border-color: rgba(52, 211, 153, 0.4);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.log-item--error {
|
||||
border-color: rgba(248, 113, 113, 0.45);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.log-item--warning {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.jobs-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
overflow: hidden;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.jobs-table thead {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.jobs-table th,
|
||||
.jobs-table td {
|
||||
padding: 0.9rem 1.15rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.jobs-table tbody tr {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.jobs-table tbody tr:hover {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: inherit;
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.list__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
background: linear-gradient(135deg, var(--danger), #ef4444);
|
||||
box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.pill-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
progress.progress {
|
||||
width: 100%;
|
||||
height: 0.6rem;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
}
|
||||
|
||||
progress.progress::-webkit-progress-bar {
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
progress.progress::-webkit-progress-value {
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
progress.progress::-moz-progress-bar {
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-strong));
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}abogen{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('web.static', filename='styles.css') }}">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.3"></script>
|
||||
<script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-bar">
|
||||
<div class="brand">
|
||||
<span class="brand__mark">🔊</span>
|
||||
<span class="brand__name">abogen</span>
|
||||
<span class="brand__tagline">Audiobooks for humans, not desktops</span>
|
||||
</div>
|
||||
<nav class="top-actions">
|
||||
<a href="{{ url_for('web.index') }}" class="btn">Dashboard</a>
|
||||
<a href="{{ url_for('web.voice_profiles_page') }}" class="btn">Voice mixer</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<span>Need help?</span>
|
||||
<a href="https://github.com/denizsafak/abogen" target="_blank" rel="noopener">Read the docs</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1 class="card__title">Create a new audiobook</h1>
|
||||
<form action="{{ url_for('web.enqueue_job') }}" method="post" enctype="multipart/form-data" class="grid grid--two">
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="source_file">Source file</label>
|
||||
<input type="file" id="source_file" name="source_file" accept=".txt,.pdf,.epub,.md,.markdown">
|
||||
<p class="tag">You can also paste text below.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
{% for key, label in options.languages.items() %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice">Voice</label>
|
||||
<select id="voice" name="voice">
|
||||
{% for voice in options.voices %}
|
||||
<option value="{{ voice }}">{{ voice }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice_profile">Voice profile</label>
|
||||
<select id="voice_profile" name="voice_profile">
|
||||
<option value="">Use selected voice</option>
|
||||
{% for name, data in options.voice_profiles %}
|
||||
<option value="{{ name }}">{{ name }} ({{ data.language|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="tag">Manage mixes in the <a href="{{ url_for('web.voice_profiles') }}">voice mixer</a>.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voice_formula">Custom voice formula</label>
|
||||
<input type="text" id="voice_formula" name="voice_formula" placeholder="af_nova*0.4+am_liam*0.6">
|
||||
<p class="tag">Overrides the dropdown/profile when provided.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="speed">Speed <span class="tag" id="speed_value">1.00×</span></label>
|
||||
<input type="range" id="speed" name="speed" min="0.6" max="1.4" step="0.05" value="1.0" oninput="document.getElementById('speed_value').textContent = Number.parseFloat(this.value).toFixed(2) + '×';">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_mode">Subtitle mode</label>
|
||||
<select id="subtitle_mode" name="subtitle_mode">
|
||||
<option value="Disabled">Disabled</option>
|
||||
<option value="Sentence">Sentence</option>
|
||||
<option value="Sentence + Comma">Sentence + Comma</option>
|
||||
<option value="Sentence + Highlighting">Sentence + Highlighting</option>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }} {% if i == 1 %}word{% else %}words{% endif %}">{{ i }} {% if i == 1 %}word{% else %}words{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="output_format">Audio format</label>
|
||||
<select id="output_format" name="output_format">
|
||||
{% for fmt in options.output_formats %}
|
||||
<option value="{{ fmt }}">{{ fmt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="subtitle_format">Subtitle file format</label>
|
||||
<select id="subtitle_format" name="subtitle_format">
|
||||
{% for value, text in options.subtitle_formats %}
|
||||
<option value="{{ value }}">{{ text }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="save_mode">Save location</label>
|
||||
<select id="save_mode" name="save_mode">
|
||||
<option>Save next to input file</option>
|
||||
<option>Save to Desktop</option>
|
||||
<option>Choose output folder</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="split">
|
||||
<label class="tag"><input type="checkbox" name="replace_single_newlines" value="true"> Replace single newlines</label>
|
||||
<label class="tag"><input type="checkbox" name="use_gpu" value="true" checked> Use GPU (when available)</label>
|
||||
</div>
|
||||
<details class="field">
|
||||
<summary>Advanced chapter & project options</summary>
|
||||
<div class="field inline">
|
||||
<label class="tag"><input type="checkbox" name="save_chapters_separately" value="true"> Save each chapter separately</label>
|
||||
<label class="tag"><input type="checkbox" name="merge_chapters_at_end" value="true" checked> Also create merged audiobook</label>
|
||||
<label class="tag"><input type="checkbox" name="save_as_project" value="true"> Save in project folder with metadata</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="separate_chapters_format">Separate chapter format</label>
|
||||
<select id="separate_chapters_format" name="separate_chapters_format">
|
||||
{% for fmt in options.separate_formats %}
|
||||
<option value="{{ fmt }}">{{ fmt|upper }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="silence_between_chapters">Silence between chapters (seconds)</label>
|
||||
<input type="number" step="0.5" min="0" value="2.0" id="silence_between_chapters" name="silence_between_chapters">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="max_subtitle_words">Max words per subtitle entry</label>
|
||||
<input type="number" min="1" max="100" value="50" id="max_subtitle_words" name="max_subtitle_words">
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="field">
|
||||
<label for="source_text">Or paste text directly</label>
|
||||
<textarea id="source_text" name="source_text" rows="12" placeholder="Drop some text here if you don't have a file handy..."></textarea>
|
||||
</div>
|
||||
<div class="field" style="justify-content: flex-end;">
|
||||
<button type="submit" class="button">Queue conversion</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card" id="jobs-panel" hx-get="{{ url_for('web.jobs_partial') }}" hx-trigger="load, every 3s" hx-target="#jobs-panel" hx-swap="innerHTML">
|
||||
{% include "partials/jobs.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Job · {{ job.original_filename }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<div class="card__title">{{ job.original_filename }}</div>
|
||||
<div class="grid grid--two">
|
||||
<article>
|
||||
<h2>Settings</h2>
|
||||
<ul>
|
||||
<li><strong>Voice:</strong> {{ job.voice }}</li>
|
||||
<li><strong>Language:</strong> {{ options.languages.get(job.language, job.language) }}</li>
|
||||
{% if job.voice_profile %}
|
||||
<li><strong>Voice profile:</strong> {{ job.voice_profile }}</li>
|
||||
{% endif %}
|
||||
<li><strong>Speed:</strong> {{ '%.2f' | format(job.speed) }}×</li>
|
||||
<li><strong>Subtitle mode:</strong> {{ job.subtitle_mode }}</li>
|
||||
<li><strong>Audio format:</strong> {{ job.output_format }}</li>
|
||||
<li><strong>Subtitle format:</strong> {{ job.subtitle_format }}</li>
|
||||
<li><strong>GPU:</strong> {{ 'Yes' if job.use_gpu else 'No' }}</li>
|
||||
<li><strong>Save chapters separately:</strong> {{ 'Yes' if job.save_chapters_separately else 'No' }}</li>
|
||||
<li><strong>Merge at end:</strong> {{ 'Yes' if job.merge_chapters_at_end else 'No' }}</li>
|
||||
<li><strong>Separate chapter format:</strong> {{ job.separate_chapters_format|upper }}</li>
|
||||
<li><strong>Silence between chapters:</strong> {{ '%.1f'|format(job.silence_between_chapters) }}s</li>
|
||||
<li><strong>Max words per subtitle:</strong> {{ job.max_subtitle_words }}</li>
|
||||
<li><strong>Project folder:</strong> {{ 'Yes' if job.save_as_project else 'No' }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Status</h2>
|
||||
<p><span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span></p>
|
||||
<p>Created: {{ job.created_at|datetimeformat }}</p>
|
||||
<p>Started: {{ job.started_at|datetimeformat }}</p>
|
||||
<p>Finished: {{ job.finished_at|datetimeformat }}</p>
|
||||
<p>Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}</p>
|
||||
{% if job.result.audio_path %}
|
||||
<p><a class="button" href="{{ url_for('web.download_job', job_id=job.id) }}">Download audio</a></p>
|
||||
{% endif %}
|
||||
<form action="{{ url_for('web.cancel_job', job_id=job.id) }}" method="post">
|
||||
<button type="submit" class="button button--ghost">Cancel job</button>
|
||||
</form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="logs" hx-get="{{ url_for('web.job_logs_partial', job_id=job.id) }}" hx-trigger="load, every 2s" hx-target="#logs" hx-swap="innerHTML">
|
||||
{% include "partials/logs.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class="card__title">Queue</div>
|
||||
{% if jobs %}
|
||||
<table class="jobs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job</th>
|
||||
<th>Status</th>
|
||||
<th>Progress</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ job.original_filename }}</strong>
|
||||
<div class="tag">
|
||||
{% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge--{{ job.status.value }}">{{ job.status.value|title }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<progress value="{{ job.processed_characters or 0 }}" max="{{ job.total_characters or 1 }}" class="progress"></progress>
|
||||
<small>{{ job.processed_characters }} / {{ job.total_characters or '—' }}</small>
|
||||
</td>
|
||||
<td><small>{{ job.created_at|datetimeformat }}</small></td>
|
||||
<td><a class="btn" href="{{ url_for('web.job_detail', job_id=job.id) }}">Inspect</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p>No jobs yet. Drop a file or paste text to get started.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,13 @@
|
||||
<div class="card__title">Live log</div>
|
||||
{% if job.logs %}
|
||||
<ul class="log-list">
|
||||
{% for entry in job.logs|reverse %}
|
||||
<li class="log-item log-item--{{ entry.level }}">
|
||||
<small>{{ entry.timestamp|datetimeformat('%H:%M:%S') }}</small>
|
||||
<div>{{ entry.message }}</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No log entries yet. Check back soon!</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}abogen · Voice mixer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1 class="card__title">Voice mixer</h1>
|
||||
<p class="tag">Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.</p>
|
||||
<div class="grid grid--two">
|
||||
<div class="grid">
|
||||
<h2>Saved profiles</h2>
|
||||
{% if profiles %}
|
||||
<ul class="list">
|
||||
{% for profile in profiles %}
|
||||
<li class="list__item">
|
||||
<div>
|
||||
<strong>{{ profile.name }}</strong>
|
||||
<p class="tag">Language: {{ languages.get(profile.language, profile.language|upper) }}</p>
|
||||
<p class="tag">Formula: {{ profile.formula }}</p>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('web.delete_voice_profile_route', name=profile.name) }}">
|
||||
<button type="submit" class="button button--danger">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>No saved profiles yet. Use the form to create one.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="grid">
|
||||
<h2>Create or update</h2>
|
||||
<form method="post" action="{{ url_for('web.save_voice_profile_route') }}" class="grid">
|
||||
<div class="field">
|
||||
<label for="name">Profile name</label>
|
||||
<input type="text" id="name" name="name" required placeholder="Narrator Mix">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
{% for key, label in languages.items() %}
|
||||
<option value="{{ key }}">{{ label }} ({{ key|upper }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="formula">Voice formula</label>
|
||||
<input type="text" id="formula" name="formula" required placeholder="af_nova*0.4+am_liam*0.6">
|
||||
<p class="tag">Use <code>voice_name*weight</code> segments joined with <code>+</code>. Weights will be normalised automatically.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="voices">Available voices</label>
|
||||
<div class="pill-grid">
|
||||
{% for voice in voices %}
|
||||
<span class="pill">{{ voice }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button type="submit" class="button">Save profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
abogen:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: abogen/Dockerfile
|
||||
args:
|
||||
TORCH_INDEX_URL: ${TORCH_INDEX_URL:-https://download.pytorch.org/whl/cu124}
|
||||
TORCH_VERSION: ${TORCH_VERSION:-}
|
||||
image: abogen-gpu:latest
|
||||
ports:
|
||||
- "${ABOGEN_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- ${ABOGEN_DATA:-./data}:/data
|
||||
environment:
|
||||
ABOGEN_HOST: 0.0.0.0
|
||||
ABOGEN_PORT: 8000
|
||||
ABOGEN_UPLOAD_ROOT: /data/uploads
|
||||
ABOGEN_OUTPUT_ROOT: /data/outputs
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
device_requests:
|
||||
- driver: nvidia
|
||||
count: -1
|
||||
capabilities: [gpu]
|
||||
restart: unless-stopped
|
||||
+14
-4
@@ -13,7 +13,6 @@ 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",
|
||||
"misaki[zh]>=0.9.4",
|
||||
"ebooklib>=0.19",
|
||||
@@ -25,7 +24,10 @@ dependencies = [
|
||||
"charset_normalizer>=3.4.1",
|
||||
"chardet>=5.2.0",
|
||||
"static_ffmpeg>=2.13",
|
||||
"Markdown>=3.9"
|
||||
"Markdown>=3.9",
|
||||
"Flask>=3.0.3",
|
||||
"numpy>=1.24.0",
|
||||
"gpustat>=1.1.1"
|
||||
]
|
||||
|
||||
classifiers = [
|
||||
@@ -48,11 +50,13 @@ 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"
|
||||
abogen = "abogen.web.app:main"
|
||||
|
||||
[project.scripts]
|
||||
abogen-cli = "abogen.main:main"
|
||||
abogen-cli = "abogen.web.app:main"
|
||||
abogen-web = "abogen.web.app:main"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
exclude = [
|
||||
@@ -66,6 +70,12 @@ exclude = [
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["abogen"]
|
||||
|
||||
[tool.hatch.build]
|
||||
include = [
|
||||
"abogen/web/templates/**",
|
||||
"abogen/web/static/**",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "abogen/VERSION"
|
||||
pattern = "^(?P<version>.+)$"
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from abogen.web.service import JobStatus, build_service
|
||||
|
||||
|
||||
def test_service_processes_job(tmp_path):
|
||||
uploads = tmp_path / "uploads"
|
||||
outputs = tmp_path / "outputs"
|
||||
uploads.mkdir()
|
||||
outputs.mkdir()
|
||||
|
||||
source = uploads / "sample.txt"
|
||||
payload = "hello world"
|
||||
source.write_text(payload, encoding="utf-8")
|
||||
|
||||
runner_invocations: list[str] = []
|
||||
|
||||
def runner(job):
|
||||
runner_invocations.append(job.id)
|
||||
job.add_log("processing")
|
||||
job.progress = 1.0
|
||||
job.processed_characters = job.total_characters or len(payload)
|
||||
job.result.audio_path = outputs / f"{job.id}.wav"
|
||||
|
||||
service = build_service(
|
||||
runner=runner,
|
||||
output_root=outputs,
|
||||
uploads_root=uploads,
|
||||
)
|
||||
|
||||
job = service.enqueue(
|
||||
original_filename="sample.txt",
|
||||
stored_path=source,
|
||||
language="a",
|
||||
voice="af_alloy",
|
||||
speed=1.0,
|
||||
use_gpu=False,
|
||||
subtitle_mode="Sentence",
|
||||
output_format="wav",
|
||||
save_mode="Save next to input file",
|
||||
output_folder=outputs,
|
||||
replace_single_newlines=False,
|
||||
subtitle_format="srt",
|
||||
total_characters=len(payload),
|
||||
)
|
||||
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline and job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
|
||||
time.sleep(0.05)
|
||||
|
||||
service.shutdown()
|
||||
|
||||
assert runner_invocations, "conversion runner was never called"
|
||||
assert job.status is JobStatus.COMPLETED
|
||||
assert job.progress == 1.0
|
||||
assert job.result.audio_path == outputs / f"{job.id}.wav"
|
||||
Reference in New Issue
Block a user