From 338ff104e80d217b7e17efb09cab409c71dcce95 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 5 Oct 2025 15:53:33 -0700 Subject: [PATCH 001/245] 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. --- README.md | 528 ++++++------------------ abogen/Dockerfile | 83 ++-- abogen/text_extractor.py | 188 +++++++++ abogen/web/__init__.py | 9 + abogen/web/app.py | 77 ++++ abogen/web/conversion_runner.py | 430 +++++++++++++++++++ abogen/web/routes.py | 335 +++++++++++++++ abogen/web/service.py | 325 +++++++++++++++ abogen/web/static/styles.css | 382 +++++++++++++++++ abogen/web/templates/base.html | 31 ++ abogen/web/templates/index.html | 130 ++++++ abogen/web/templates/job_detail.html | 50 +++ abogen/web/templates/partials/jobs.html | 37 ++ abogen/web/templates/partials/logs.html | 13 + abogen/web/templates/voices.html | 66 +++ docker-compose.gpu.yml | 30 ++ pyproject.toml | 18 +- tests/test_service.py | 57 +++ 18 files changed, 2353 insertions(+), 436 deletions(-) create mode 100644 abogen/text_extractor.py create mode 100644 abogen/web/__init__.py create mode 100644 abogen/web/app.py create mode 100644 abogen/web/conversion_runner.py create mode 100644 abogen/web/routes.py create mode 100644 abogen/web/service.py create mode 100644 abogen/web/static/styles.css create mode 100644 abogen/web/templates/base.html create mode 100644 abogen/web/templates/index.html create mode 100644 abogen/web/templates/job_detail.html create mode 100644 abogen/web/templates/partials/jobs.html create mode 100644 abogen/web/templates/partials/logs.html create mode 100644 abogen/web/templates/voices.html create mode 100644 docker-compose.gpu.yml create mode 100644 tests/test_service.py diff --git a/README.md b/README.md index 4d1f265..d6ae13c 100644 --- a/README.md +++ b/README.md @@ -1,425 +1,163 @@ # abogen -[![Build Status](https://github.com/denizsafak/abogen/actions/workflows/test_pip.yml/badge.svg)](https://github.com/denizsafak/abogen/actions) -[![GitHub Release](https://img.shields.io/github/v/release/denizsafak/abogen)](https://github.com/denizsafak/abogen/releases/latest) -[![Abogen PyPi Python Versions](https://img.shields.io/pypi/pyversions/abogen)](https://pypi.org/project/abogen/) -[![Operating Systems](https://img.shields.io/badge/os-windows%20%7C%20linux%20%7C%20macos%20-blue)](https://github.com/denizsafak/abogen/releases/latest) -[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![License: MIT](https://img.shields.io/badge/License-MIT-maroon.svg)](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. -denizsafak%2Fabogen | Trendshift +## 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. - - -## 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?` Abogen Compatible PyPi Python Versions - -### 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` - +> **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` - - -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` - - -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: - -``` -<> -``` -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: - -``` -<> -This is the beginning of my text... - -<> -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. - -![Abogen Chapter Marker](https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/chapter_marker.png) - -## `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: -``` -<> -<> -<> -<> -<> -<> -<> -``` - -## `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` - -
-About the name "abogen" - - -> 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. - -
- -
-How to fix "CUDA GPU is not available. Using CPU" warning? - - -> 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. - -
- -
-How to fix "WARNING: The script abogen-cli is installed in '/home/username/.local/bin' which is not on PATH" error in Linux? - - -> Run the following command to add Abogen to your PATH: -> ```bash -> echo "export PATH=\"/home/$USER/.local/bin:\$PATH\"" >> ~/.bashrc && source ~/.bashrc -> ``` - -
- -
-How to fix "No matching distribution found" error? - - -> 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. - -
- -
-How to fix Japanese audio not working? - - -> 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. - -
- -
-How to use "uv" instead of "pip"? - - -> 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 -> ``` - -
- -## `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` -[![Star History Chart](https://api.star-history.com/svg?repos=denizsafak/abogen&type=Date)](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/` 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//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. diff --git a/abogen/Dockerfile b/abogen/Dockerfile index af76df7..3d0f385 100644 --- a/abogen/Dockerfile +++ b/abogen/Dockerfile @@ -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"] diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py new file mode 100644 index 0000000..37ad876 --- /dev/null +++ b/abogen/text_extractor.py @@ -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"<>", re.DOTALL) +CHAPTER_PATTERN = re.compile(r"<>", 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 diff --git a/abogen/web/__init__.py b/abogen/web/__init__.py new file mode 100644 index 0000000..89f8e6a --- /dev/null +++ b/abogen/web/__init__.py @@ -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) diff --git a/abogen/web/app.py b/abogen/web/app.py new file mode 100644 index 0000000..8081c41 --- /dev/null +++ b/abogen/web/app.py @@ -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() diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py new file mode 100644 index 0000000..1aac1cd --- /dev/null +++ b/abogen/web/conversion_runner.py @@ -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"(? 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 diff --git a/abogen/web/routes.py b/abogen/web/routes.py new file mode 100644 index 0000000..d4da093 --- /dev/null +++ b/abogen/web/routes.py @@ -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//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/") +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//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//delete") +def delete_job(job_id: str) -> Response: + _service().delete(job_id) + return redirect(url_for("web.index")) + + +@web_bp.get("/jobs//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//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/") +def job_json(job_id: str) -> Response: + job = _service().get_job(job_id) + if not job: + abort(404) + return jsonify(job.as_dict()) diff --git a/abogen/web/service.py b/abogen/web/service.py new file mode 100644 index 0000000..280a12f --- /dev/null +++ b/abogen/web/service.py @@ -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 diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css new file mode 100644 index 0000000..c3a6687 --- /dev/null +++ b/abogen/web/static/styles.css @@ -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; +} diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html new file mode 100644 index 0000000..c0af229 --- /dev/null +++ b/abogen/web/templates/base.html @@ -0,0 +1,31 @@ + + + + + + {% block title %}abogen{% endblock %} + + + + + +
+
+ 🔊 + abogen + Audiobooks for humans, not desktops +
+ +
+
+ {% block content %}{% endblock %} +
+ + + diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html new file mode 100644 index 0000000..f6ae1c0 --- /dev/null +++ b/abogen/web/templates/index.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} + +{% block title %}abogen · Dashboard{% endblock %} + +{% block content %} +
+

Create a new audiobook

+
+
+
+ + +

You can also paste text below.

+
+
+ + +
+
+ + +
+
+ + +

Manage mixes in the voice mixer.

+
+
+ + +

Overrides the dropdown/profile when provided.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Advanced chapter & project options +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ +
+
+
+
+ +
+ {% include "partials/jobs.html" %} +
+{% endblock %} diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html new file mode 100644 index 0000000..b23083f --- /dev/null +++ b/abogen/web/templates/job_detail.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}Job · {{ job.original_filename }}{% endblock %} + +{% block content %} +
+
{{ job.original_filename }}
+
+
+

Settings

+
    +
  • Voice: {{ job.voice }}
  • +
  • Language: {{ options.languages.get(job.language, job.language) }}
  • + {% if job.voice_profile %} +
  • Voice profile: {{ job.voice_profile }}
  • + {% endif %} +
  • Speed: {{ '%.2f' | format(job.speed) }}×
  • +
  • Subtitle mode: {{ job.subtitle_mode }}
  • +
  • Audio format: {{ job.output_format }}
  • +
  • Subtitle format: {{ job.subtitle_format }}
  • +
  • GPU: {{ 'Yes' if job.use_gpu else 'No' }}
  • +
  • Save chapters separately: {{ 'Yes' if job.save_chapters_separately else 'No' }}
  • +
  • Merge at end: {{ 'Yes' if job.merge_chapters_at_end else 'No' }}
  • +
  • Separate chapter format: {{ job.separate_chapters_format|upper }}
  • +
  • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
  • +
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • +
  • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
  • +
+
+
+

Status

+

{{ job.status.value|title }}

+

Created: {{ job.created_at|datetimeformat }}

+

Started: {{ job.started_at|datetimeformat }}

+

Finished: {{ job.finished_at|datetimeformat }}

+

Characters: {{ job.processed_characters }} / {{ job.total_characters or '—' }}

+ {% if job.result.audio_path %} +

Download audio

+ {% endif %} +
+ +
+
+
+
+ +
+ {% include "partials/logs.html" %} +
+{% endblock %} diff --git a/abogen/web/templates/partials/jobs.html b/abogen/web/templates/partials/jobs.html new file mode 100644 index 0000000..6a9b903 --- /dev/null +++ b/abogen/web/templates/partials/jobs.html @@ -0,0 +1,37 @@ +
Queue
+{% if jobs %} + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + {% endfor %} + +
JobStatusProgressCreated
+ {{ job.original_filename }} +
+ {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }} +
+
+ {{ job.status.value|title }} + + + {{ job.processed_characters }} / {{ job.total_characters or '—' }} + {{ job.created_at|datetimeformat }}Inspect
+{% else %} +

No jobs yet. Drop a file or paste text to get started.

+{% endif %} diff --git a/abogen/web/templates/partials/logs.html b/abogen/web/templates/partials/logs.html new file mode 100644 index 0000000..6e63984 --- /dev/null +++ b/abogen/web/templates/partials/logs.html @@ -0,0 +1,13 @@ +
Live log
+{% if job.logs %} +
    + {% for entry in job.logs|reverse %} +
  • + {{ entry.timestamp|datetimeformat('%H:%M:%S') }} +
    {{ entry.message }}
    +
  • + {% endfor %} +
+{% else %} +

No log entries yet. Check back soon!

+{% endif %} diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html new file mode 100644 index 0000000..90deb9c --- /dev/null +++ b/abogen/web/templates/voices.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} + +{% block title %}abogen · Voice mixer{% endblock %} + +{% block content %} +
+

Voice mixer

+

Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.

+
+
+

Saved profiles

+ {% if profiles %} +
    + {% for profile in profiles %} +
  • +
    + {{ profile.name }} +

    Language: {{ languages.get(profile.language, profile.language|upper) }}

    +

    Formula: {{ profile.formula }}

    +
    +
    + +
    +
  • + {% endfor %} +
+ {% else %} +

No saved profiles yet. Use the form to create one.

+ {% endif %} +
+
+

Create or update

+
+
+ + +
+
+ + +
+
+ + +

Use voice_name*weight segments joined with +. Weights will be normalised automatically.

+
+
+ +
+ {% for voice in voices %} + {{ voice }} + {% endfor %} +
+
+
+ +
+
+
+
+
+{% endblock %} diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml new file mode 100644 index 0000000..f498225 --- /dev/null +++ b/docker-compose.gpu.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index b668986..dad6101 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.+)$" \ No newline at end of file diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..a744617 --- /dev/null +++ b/tests/test_service.py @@ -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" \ No newline at end of file From 66a0679e18fb1ab203dd2719aaa52084e303b56f Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 5 Oct 2025 16:05:16 -0700 Subject: [PATCH 002/245] feat: Update Docker configuration for GPU support and remove deprecated compose file --- README.md | 18 +++++++--------- docker-compose.gpu.yml => docker-compose.yaml | 21 ++++++++++++------- 2 files changed, 20 insertions(+), 19 deletions(-) rename docker-compose.gpu.yml => docker-compose.yaml (50%) diff --git a/README.md b/README.md index d6ae13c..865a3c9 100644 --- a/README.md +++ b/README.md @@ -53,24 +53,20 @@ Browse to http://localhost:8000. Uploaded source files are stored in `/data/uplo Set any of these with `-e VAR=value` when starting the container. -### 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. +### Docker Compose (GPU by default) +The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: ```bash -# 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 +docker compose up -d --build ``` -Useful overrides: +Key build/runtime knobs: -- `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. +- `TORCH_VERSION` – pin a specific PyTorch release that matches your driver (leave blank for the latest on the configured index). +- `TORCH_INDEX_URL` – swap out the PyTorch download index when targeting a different CUDA build. - `ABOGEN_DATA` – host path that stores uploads/outputs (defaults to `./data`). -The Compose file reserves a GPU via `device_requests`. Standard `docker run` works as well: +CPU-only deployment: comment out the `deploy.resources.reservations.devices` block (and the optional `runtime: nvidia` line) inside the compose file. Compose will then run without requesting a GPU. If you prefer the classic CLI: ```bash docker build -f abogen/Dockerfile -t abogen-gpu . diff --git a/docker-compose.gpu.yml b/docker-compose.yaml similarity index 50% rename from docker-compose.gpu.yml rename to docker-compose.yaml index f498225..8395d21 100644 --- a/docker-compose.gpu.yml +++ b/docker-compose.yaml @@ -1,3 +1,5 @@ +version: "3.9" + services: abogen: build: @@ -6,7 +8,7 @@ services: args: TORCH_INDEX_URL: ${TORCH_INDEX_URL:-https://download.pytorch.org/whl/cu124} TORCH_VERSION: ${TORCH_VERSION:-} - image: abogen-gpu:latest + image: abogen:latest ports: - "${ABOGEN_PORT:-8000}:8000" volumes: @@ -16,15 +18,18 @@ services: ABOGEN_PORT: 8000 ABOGEN_UPLOAD_ROOT: /data/uploads ABOGEN_OUTPUT_ROOT: /data/outputs + # --- GPU support ----------------------------------------------------- + # These settings assume the NVIDIA Container Toolkit is installed. + # Leave them in place for GPU acceleration; comment out the entire block + # below if you are deploying to a CPU-only host. deploy: resources: reservations: devices: - - driver: nvidia - count: all - capabilities: [gpu] - device_requests: - - driver: nvidia - count: -1 - capabilities: [gpu] + - capabilities: [gpu] + # driver: nvidia + # count: all + # Runtime flag is only honored by legacy docker-compose (v1) CLI. + # Uncomment if you're still using it: + # runtime: nvidia restart: unless-stopped From 1629d3e80c29962b5a631371af6141630aee15d3 Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 5 Oct 2025 16:18:05 -0700 Subject: [PATCH 003/245] feat: Update application to use port 8808 instead of 8000 in README, Dockerfile, app.py, and docker-compose.yaml --- README.md | 10 +++++----- abogen/Dockerfile | 4 ++-- abogen/web/app.py | 2 +- abogen/web/templates/base.html | 2 +- docker-compose.yaml | 6 ++---- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 865a3c9..bc3c41a 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ pip install abogen abogen ``` -Then open http://localhost:8000 and drag in your documents. Jobs run in the background worker and the browser updates automatically. +Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically. > **Tip:** Keep the terminal open while the server is running. Use `Ctrl+C` to stop it. @@ -34,19 +34,19 @@ A lightweight Dockerfile lives in `abogen/Dockerfile`. docker build -t abogen . mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs docker run --rm \ - -p 8000:8000 \ + -p 8808:8808 \ -v ~/abogen-data:/data \ --name abogen \ abogen ``` -Browse to http://localhost:8000. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`. +Browse to http://localhost:8808. Uploaded source files are stored in `/data/uploads` and rendered audio/subtitles appear in `/data/outputs`. ### Container environment variables | Variable | Default | Purpose | |----------|---------|---------| | `ABOGEN_HOST` | `0.0.0.0` | Bind address for the Flask server | -| `ABOGEN_PORT` | `8000` | HTTP port | +| `ABOGEN_PORT` | `8808` | 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 | @@ -72,7 +72,7 @@ CPU-only deployment: comment out the `deploy.resources.reservations.devices` blo docker build -f abogen/Dockerfile -t abogen-gpu . docker run --rm \ --gpus all \ - -p 8000:8000 \ + -p 8808:8808 \ -v ~/abogen-data:/data \ abogen-gpu ``` diff --git a/abogen/Dockerfile b/abogen/Dockerfile index 3d0f385..e389de3 100644 --- a/abogen/Dockerfile +++ b/abogen/Dockerfile @@ -37,9 +37,9 @@ RUN pip install --upgrade pip \ && pip install --no-cache-dir . ENV ABOGEN_HOST=0.0.0.0 \ - ABOGEN_PORT=8000 + ABOGEN_PORT=8808 -EXPOSE 8000 +EXPOSE 8808 VOLUME ["/data"] diff --git a/abogen/web/app.py b/abogen/web/app.py index 8081c41..e385fb4 100644 --- a/abogen/web/app.py +++ b/abogen/web/app.py @@ -68,7 +68,7 @@ def create_app(config: Optional[dict[str, Any]] = None) -> Flask: def main() -> None: app = create_app() host = os.environ.get("ABOGEN_HOST", "0.0.0.0") - port = int(os.environ.get("ABOGEN_PORT", "8000")) + port = int(os.environ.get("ABOGEN_PORT", "8808")) debug = os.environ.get("ABOGEN_DEBUG", "false").lower() == "true" app.run(host=host, port=port, debug=debug) diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index c0af229..c03b9dd 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -4,7 +4,7 @@ {% block title %}abogen{% endblock %} - + diff --git a/docker-compose.yaml b/docker-compose.yaml index 8395d21..e856208 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.9" - services: abogen: build: @@ -10,12 +8,12 @@ services: TORCH_VERSION: ${TORCH_VERSION:-} image: abogen:latest ports: - - "${ABOGEN_PORT:-8000}:8000" + - "${ABOGEN_PORT:-8808}:8808" volumes: - ${ABOGEN_DATA:-./data}:/data environment: ABOGEN_HOST: 0.0.0.0 - ABOGEN_PORT: 8000 + ABOGEN_PORT: 8808 ABOGEN_UPLOAD_ROOT: /data/uploads ABOGEN_OUTPUT_ROOT: /data/outputs # --- GPU support ----------------------------------------------------- From 9ba23625282d3bc83cf179792a1abb57341ae4bc Mon Sep 17 00:00:00 2001 From: JB Date: Sun, 5 Oct 2025 16:24:00 -0700 Subject: [PATCH 004/245] fix: Correct link to voice mixer in index.html --- abogen/web/templates/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index f6ae1c0..2168edb 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -36,7 +36,7 @@ {% endfor %} -

Manage mixes in the voice mixer.

+

Manage mixes in the voice mixer.

From b718dae1b34767d39b80c9ce9e73839e16f461b7 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 05:10:32 -0700 Subject: [PATCH 005/245] feat: Implement voice mixer UI and functionality - Added new styles for the voice mixer components in styles.css. - Updated base.html to include a block for scripts. - Refactored voices.html to create a structured voice mixer interface with profile management features. - Introduced voices.js to handle voice mixer logic, including profile creation, editing, and previewing. - Implemented actions for importing and exporting voice profiles. - Enhanced user experience with loading states and status messages. --- abogen/voice_profiles.py | 109 ++++- abogen/web/routes.py | 362 +++++++++++++++-- abogen/web/static/styles.css | 272 +++++++++++++ abogen/web/static/voices.js | 672 +++++++++++++++++++++++++++++++ abogen/web/templates/base.html | 1 + abogen/web/templates/voices.html | 127 +++--- 6 files changed, 1457 insertions(+), 86 deletions(-) create mode 100644 abogen/web/static/voices.js diff --git a/abogen/voice_profiles.py b/abogen/voice_profiles.py index 77a07d0..4f87820 100644 --- a/abogen/voice_profiles.py +++ b/abogen/voice_profiles.py @@ -1,5 +1,8 @@ -import os import json +import os +from typing import Dict, Iterable, List, Tuple + +from abogen.constants import VOICES_INTERNAL from abogen.utils import get_user_config_path @@ -57,3 +60,107 @@ def export_profiles(export_path): profiles = load_profiles() with open(export_path, "w", encoding="utf-8") as f: json.dump({"abogen_voice_profiles": profiles}, f, indent=2) + + +def serialize_profiles() -> Dict[str, Dict[str, Iterable[Tuple[str, float]]]]: + """Return profiles in canonical dictionary form.""" + return load_profiles() + + +def _normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + normalized: List[Tuple[str, float]] = [] + for item in entries or []: + if isinstance(item, dict): + voice = item.get("id") or item.get("voice") + weight = item.get("weight") + elif isinstance(item, (list, tuple)) and len(item) >= 2: + voice, weight = item[0], item[1] + else: + continue + if voice not in VOICES_INTERNAL: + continue + if weight is None: + continue + try: + weight_val = float(weight) + except (TypeError, ValueError): + continue + if weight_val <= 0: + continue + normalized.append((voice, weight_val)) + return normalized + + +def normalize_voice_entries(entries: Iterable) -> List[Tuple[str, float]]: + """Public helper to normalize voice-weight pairs from arbitrary payloads.""" + + return _normalize_voice_entries(entries) + + +def save_profile(name: str, *, language: str, voices: Iterable) -> None: + """Persist a single profile after validating its data.""" + + name = (name or "").strip() + if not name: + raise ValueError("Profile name is required") + + normalized = _normalize_voice_entries(voices) + if not normalized: + raise ValueError("At least one voice with a weight above zero is required") + + if not language: + language = "a" + + profiles = load_profiles() + profiles[name] = {"language": language, "voices": normalized} + save_profiles(profiles) + + +def remove_profile(name: str) -> None: + delete_profile(name) + + +def import_profiles_data(data: Dict, *, replace_existing: bool = False) -> List[str]: + """Merge profiles from a dictionary structure and persist them. + + Returns the list of profile names that were added or updated. + """ + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + if "abogen_voice_profiles" in data: + data = data["abogen_voice_profiles"] + + if not isinstance(data, dict): + raise ValueError("Invalid profile payload") + + current = load_profiles() + updated: List[str] = [] + for name, entry in data.items(): + if not isinstance(entry, dict): + continue + voices = _normalize_voice_entries(entry.get("voices", [])) + if not voices: + continue + language = entry.get("language", "a") + if name in current and not replace_existing: + # skip duplicates unless explicit replacement is requested + continue + current[name] = {"language": language, "voices": voices} + updated.append(name) + + if updated: + save_profiles(current) + return updated + + +def export_profiles_payload(names: Iterable[str] | None = None) -> Dict[str, Dict]: + """Return profiles limited to the provided names for download/export.""" + + profiles = load_profiles() + if names is None: + subset = profiles + else: + subset = {name: profiles[name] for name in names if name in profiles} + return {"abogen_voice_profiles": subset} diff --git a/abogen/web/routes.py b/abogen/web/routes.py index d4da093..c48c412 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -1,9 +1,12 @@ from __future__ import annotations +import io +import json import mimetypes +import threading import uuid from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast from flask import ( Blueprint, @@ -19,28 +22,67 @@ from flask import ( ) from werkzeug.utils import secure_filename +import numpy as np +import soundfile as sf from abogen.constants import ( LANGUAGE_DESCRIPTIONS, + SAMPLE_VOICE_TEXTS, 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 abogen.utils import calculate_text_length, clean_text, load_config, load_numpy_kpipeline +from abogen.voice_profiles import ( + delete_profile, + duplicate_profile, + export_profiles_payload, + import_profiles_data, + load_profiles, + normalize_voice_entries, + remove_profile, + save_profile, + save_profiles, + serialize_profiles, +) +from abogen.voice_formulas import get_new_voice +from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32 from .service import ConversionService, Job, JobStatus web_bp = Blueprint("web", __name__) api_bp = Blueprint("api", __name__) +_preview_pipeline_lock = threading.RLock() +_preview_pipelines: Dict[Tuple[str, str], Any] = {} + + def _service() -> ConversionService: return current_app.extensions["conversion_service"] +def _build_voice_catalog() -> List[Dict[str, str]]: + catalog: List[Dict[str, str]] = [] + gender_map = {"f": "Female", "m": "Male"} + for voice_id in VOICES_INTERNAL: + prefix, _, rest = voice_id.partition("_") + language_code = prefix[0] if prefix else "a" + gender_code = prefix[1] if len(prefix) > 1 else "" + catalog.append( + { + "id": voice_id, + "language": language_code, + "language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()), + "gender": gender_map.get(gender_code, "Unknown"), + "display_name": rest.replace("_", " ").title() if rest else voice_id, + } + ) + return catalog + + def _template_options() -> Dict[str, Any]: - profiles = load_profiles() + profiles = serialize_profiles() ordered_profiles = sorted(profiles.items()) return { "languages": LANGUAGE_DESCRIPTIONS, @@ -50,6 +92,9 @@ def _template_options() -> Dict[str, Any]: "output_formats": SUPPORTED_SOUND_FORMATS, "voice_profiles": ordered_profiles, "separate_formats": ["wav", "flac", "mp3", "opus"], + "voice_catalog": _build_voice_catalog(), + "sample_voice_texts": SAMPLE_VOICE_TEXTS, + "voice_profiles_data": profiles, } @@ -120,6 +165,54 @@ def _parse_voice_formula(formula: str) -> List[tuple[str, float]]: return voices +def _sanitize_voice_entries(entries: Iterable[Any]) -> List[Dict[str, Any]]: + sanitized: List[Dict[str, Any]] = [] + for entry in entries or []: + if isinstance(entry, dict): + voice_id = entry.get("id") or entry.get("voice") + if not voice_id: + continue + enabled = entry.get("enabled", True) + if not enabled: + continue + sanitized.append({"voice": voice_id, "weight": entry.get("weight")}) + elif isinstance(entry, (list, tuple)) and len(entry) >= 2: + sanitized.append({"voice": entry[0], "weight": entry[1]}) + return sanitized + + +def _pairs_to_formula(pairs: Iterable[Tuple[str, float]]) -> Optional[str]: + voices = [(voice, float(weight)) for voice, weight in pairs if float(weight) > 0] + if not voices: + return None + total = sum(weight for _, weight in voices) + if total <= 0: + return None + + def _format_value(value: float) -> str: + normalized = value / total if total else 0.0 + return (f"{normalized:.4f}").rstrip("0").rstrip(".") or "0" + + parts = [f"{voice}*{_format_value(weight)}" for voice, weight in voices] + return "+".join(parts) + + +def _profiles_payload() -> Dict[str, Any]: + return {"profiles": serialize_profiles()} + + +def _get_preview_pipeline(language: str, device: str): + key = (language, device) + with _preview_pipeline_lock: + pipeline = _preview_pipelines.get(key) + if pipeline is not None: + return pipeline + _, KPipeline = load_numpy_kpipeline() + pipeline = KPipeline(lang_code=language, repo_id="hexgrad/Kokoro-82M", device=device) + _preview_pipelines[key] = pipeline + return pipeline + + @web_bp.app_template_filter("datetimeformat") def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: if not value: @@ -142,22 +235,8 @@ def index() -> str: @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, - ) + options = _template_options() + return render_template("voices.html", options=options) @web_bp.post("/voices") @@ -180,6 +259,223 @@ def delete_voice_profile_route(name: str) -> Response: return redirect(url_for("web.voice_profiles_page")) +@api_bp.get("/voice-profiles") +def api_list_voice_profiles() -> Response: + return jsonify(_profiles_payload()) + + +@api_bp.post("/voice-profiles") +def api_save_voice_profile() -> Response: + payload = request.get_json(force=True, silent=False) + name = (payload.get("name") or "").strip() + if not name: + abort(400, "Profile name is required") + + original = (payload.get("originalName") or "").strip() + language = (payload.get("language") or "a").strip() or "a" + formula = (payload.get("formula") or "").strip() + + try: + if formula: + voices = _parse_voice_formula(formula) + else: + voices_raw = _sanitize_voice_entries(payload.get("voices", [])) + voices = normalize_voice_entries(voices_raw) + if not voices: + raise ValueError("At least one voice must be enabled with a weight above zero") + save_profile(name, language=language, voices=voices) + if original and original != name: + remove_profile(original) + except ValueError as exc: + abort(400, str(exc)) + + return jsonify({"ok": True, "profile": name, **_profiles_payload()}) + + +@api_bp.delete("/voice-profiles/") +def api_delete_voice_profile(name: str) -> Response: + remove_profile(name) + return jsonify({"ok": True, **_profiles_payload()}) + + +@api_bp.post("/voice-profiles//duplicate") +def api_duplicate_voice_profile(name: str) -> Response: + payload = request.get_json(silent=True) or {} + new_name = (payload.get("name") or payload.get("new_name") or "").strip() + if not new_name: + abort(400, "Duplicate name is required") + duplicate_profile(name, new_name) + return jsonify({"ok": True, "profile": new_name, **_profiles_payload()}) + + +@api_bp.post("/voice-profiles/import") +def api_import_voice_profiles() -> Response: + replace = False + data: Optional[Dict[str, Any]] = None + if "file" in request.files: + file_storage = request.files["file"] + try: + data = json.load(file_storage) + except Exception as exc: # pragma: no cover - defensive + abort(400, f"Invalid JSON file: {exc}") + replace = request.form.get("replace_existing") in {"true", "1", "on"} + else: + payload = request.get_json(force=True, silent=False) + replace = bool(payload.get("replace_existing", False)) + data = payload.get("profiles") or payload.get("data") or payload + if not isinstance(data, dict): + data = None + if data is None: + abort(400, "Import payload must be a dictionary") + data_dict = cast(Dict[str, Any], data) + imported: List[str] = [] + try: + imported = import_profiles_data(data_dict, replace_existing=replace) + except ValueError as exc: + abort(400, str(exc)) + return jsonify({"ok": True, "imported": imported, **_profiles_payload()}) + + +@api_bp.get("/voice-profiles/export") +def api_export_voice_profiles() -> Response: + names_param = request.args.get("names") + names = None + if names_param: + names = [name.strip() for name in names_param.split(",") if name.strip()] + payload = export_profiles_payload(names) + buffer = io.BytesIO() + buffer.write(json.dumps(payload, indent=2).encode("utf-8")) + buffer.seek(0) + filename = request.args.get("filename") or "voice_profiles.json" + return send_file( + buffer, + mimetype="application/json", + as_attachment=True, + download_name=filename, + ) + + +@api_bp.post("/voice-profiles/preview") +def api_preview_voice_mix() -> Response: + payload = request.get_json(force=True, silent=False) + language = (payload.get("language") or "a").strip() or "a" + text = (payload.get("text") or "").strip() + speed = float(payload.get("speed", 1.0) or 1.0) + max_seconds = float(payload.get("max_seconds", 12.0) or 12.0) + profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip() + formula = (payload.get("formula") or "").strip() + + voices: List[Tuple[str, float]] = [] + if profile_name: + profiles = load_profiles() + entry = profiles.get(profile_name) + if entry is None: + abort(404, "Profile not found") + if not isinstance(entry, dict): + abort(400, "Profile data is invalid") + entry_dict = cast(Dict[str, Any], entry) + language = entry_dict.get("language", language) + profile_voices = entry_dict.get("voices", []) + for item in profile_voices: + if isinstance(item, (list, tuple)) and len(item) >= 2: + try: + voices.append((str(item[0]), float(item[1]))) + except (TypeError, ValueError): + continue + else: + try: + if formula: + voices = _parse_voice_formula(formula) + else: + voices_raw = _sanitize_voice_entries(payload.get("voices", [])) + voices = normalize_voice_entries(voices_raw) + except ValueError as exc: + abort(400, str(exc)) + + if not voices: + abort(400, "At least one voice must be provided for preview") + + if not text: + text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice.")) + + cfg = load_config() + use_gpu_cfg = bool(cfg.get("use_gpu", True)) + use_gpu = use_gpu_cfg if payload.get("use_gpu") is None else bool(payload.get("use_gpu")) + device = "cpu" + if use_gpu: + try: + device = _select_device() + except Exception: # pragma: no cover - fallback + device = "cpu" + use_gpu = False + + pipeline: Any = None + try: + pipeline = _get_preview_pipeline(language, device) + except Exception as exc: # pragma: no cover - defensive guard + abort(500, f"Failed to initialise preview pipeline: {exc}") + if pipeline is None: # pragma: no cover - defensive double-check + abort(500, "Preview pipeline initialisation failed") + + voice_choice: Any = None + if len(voices) == 1: + voice_choice = voices[0][0] + else: + formula_value = _pairs_to_formula(voices) + if not formula_value: + abort(400, "Invalid voice weights provided") + try: + voice_choice = get_new_voice(pipeline, formula_value, use_gpu) + except ValueError as exc: + abort(400, str(exc)) + if voice_choice is None: + abort(400, "Unable to resolve voice selection") + + segments = pipeline( + text, + voice=voice_choice, + speed=speed, + split_pattern=SPLIT_PATTERN, + ) + + audio_chunks: List[np.ndarray] = [] + accumulated = 0 + max_samples = int(max_seconds * SAMPLE_RATE) + + for segment in segments: + graphemes = segment.graphemes.strip() + if not graphemes: + continue + audio = _to_float32(segment.audio) + if audio.size == 0: + continue + remaining = max_samples - accumulated + if remaining <= 0: + break + if audio.shape[0] > remaining: + audio = audio[:remaining] + audio_chunks.append(audio) + accumulated += audio.shape[0] + if accumulated >= max_samples: + break + + if not audio_chunks: + abort(500, "Preview could not be generated") + + audio_data = np.concatenate(audio_chunks) + buffer = io.BytesIO() + sf.write(buffer, audio_data, SAMPLE_RATE, format="WAV") + buffer.seek(0) + response = send_file( + buffer, + mimetype="audio/wav", + as_attachment=False, + download_name="voice_preview.wav", + ) + response.headers["Cache-Control"] = "no-store" + return response + + @web_bp.post("/jobs") def enqueue_job() -> Response: service = _service() @@ -298,19 +594,23 @@ def delete_job(job_id: str) -> Response: @web_bp.get("/jobs//download") def download_job(job_id: str) -> Response: job = _service().get_job(job_id) - if not job or job.status != JobStatus.COMPLETED: + if job is None or job.status != JobStatus.COMPLETED: abort(404) - if not job.result.audio_path: + result = getattr(job, "result", None) + audio_path = getattr(result, "audio_path", None) + if audio_path is None: abort(404) - path = job.result.audio_path - if not path.exists(): + if not isinstance(audio_path, Path): # pragma: no cover - sanity guard abort(404) - mime_type, _ = mimetypes.guess_type(str(path)) + audio_path_path = cast(Path, audio_path) + if not audio_path_path.exists(): + abort(404) + mime_type, _ = mimetypes.guess_type(str(audio_path_path)) return send_file( - path, + audio_path_path, mimetype=mime_type or "application/octet-stream", as_attachment=True, - download_name=path.name, + download_name=audio_path_path.name, ) @@ -330,6 +630,10 @@ def job_logs_partial(job_id: str) -> str: @api_bp.get("/jobs/") def job_json(job_id: str) -> Response: job = _service().get_job(job_id) - if not job: + if job is None: abort(404) - return jsonify(job.as_dict()) + if not isinstance(job, Job): # pragma: no cover - defensive guard + abort(404) + job_obj = cast(Job, job) + payload = job_obj.as_dict() + return jsonify(payload) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index c3a6687..8f8b244 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -380,3 +380,275 @@ progress.progress::-moz-progress-bar { background: linear-gradient(90deg, var(--accent), var(--accent-strong)); border-radius: 999px; } + +.voice-mixer__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.voice-mixer__header-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.voice-mixer__layout { + display: grid; + grid-template-columns: minmax(240px, 280px) 1fr; + gap: 2rem; + align-items: start; +} + +.voice-mixer__profiles { + display: flex; + flex-direction: column; + gap: 1rem; + border-right: 1px solid var(--panel-border); + padding-right: 1.5rem; +} + +.voice-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.voice-list__header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.voice-list__item { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 16px; + padding: 0.75rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + background: rgba(15, 23, 42, 0.35); + transition: border 0.2s ease, background 0.2s ease; +} + +.voice-list__item.is-selected { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.1); +} + +.voice-list__select { + all: unset; + display: flex; + flex-direction: column; + gap: 0.25rem; + cursor: pointer; +} + +.voice-list__select:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.voice-list__name { + font-weight: 600; +} + +.voice-list__meta { + font-size: 0.78rem; + color: var(--muted); +} + +.voice-list__actions { + display: flex; + gap: 0.5rem; +} + +.voice-list__action { + border: none; + border-radius: 999px; + padding: 0.35rem 0.65rem; + font-size: 0.75rem; + cursor: pointer; + background: rgba(148, 163, 184, 0.15); + color: var(--muted); + transition: background 0.2s ease, color 0.2s ease; +} + +.voice-list__action:hover { + background: rgba(56, 189, 248, 0.18); + color: var(--accent); +} + +.voice-list__action--danger:hover { + background: rgba(248, 113, 113, 0.2); + color: var(--danger); +} + +.voice-editor { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.voice-editor__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} + +.voice-editor__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.voice-editor__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; + max-height: 60vh; + overflow-y: auto; + padding-right: 0.5rem; +} + +.voice-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.33); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + transition: border 0.2s ease, transform 0.2s ease; +} + +.voice-card:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.voice-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.voice-card__toggle { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.voice-card__checkbox { + width: 1rem; + height: 1rem; +} + +.voice-card__name { + font-weight: 600; +} + +.voice-card__meta { + font-size: 0.8rem; + color: var(--muted); +} + +.voice-card__value { + margin-left: auto; + font-size: 0.8rem; + color: var(--accent); +} + +.voice-card__body { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.voice-card__slider { + flex: 1; + accent-color: var(--accent); +} + +.voice-card__number { + width: 4.5rem; + text-align: center; +} + +.voice-editor__actions { + display: grid; + gap: 1.25rem; +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.voice-preview { + display: grid; + gap: 0.75rem; +} + +.voice-preview__controls { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.voice-preview audio { + width: 100%; + border-radius: 12px; + background: rgba(15, 23, 42, 0.45); +} + +.voice-status { + min-height: 1.2rem; + font-size: 0.9rem; +} + +.voice-status--info { + color: var(--accent); +} + +.voice-status--success { + color: var(--success); +} + +.voice-status--warning { + color: var(--warning); +} + +.voice-status--danger { + color: var(--danger); +} + +.voice-mixer[data-state="loading"] .voice-editor { + opacity: 0.6; + pointer-events: none; +} + +@media (max-width: 980px) { + .voice-mixer__layout { + grid-template-columns: 1fr; + } + + .voice-mixer__profiles { + border-right: none; + border-bottom: 1px solid var(--panel-border); + padding-right: 0; + padding-bottom: 1.5rem; + margin-bottom: 1.5rem; + } +} diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js new file mode 100644 index 0000000..b8b18e0 --- /dev/null +++ b/abogen/web/static/voices.js @@ -0,0 +1,672 @@ +const setupVoiceMixer = () => { + const data = window.ABOGEN_VOICE_MIXER_DATA || {}; + const languages = data.languages || {}; + const voiceCatalog = data.voice_catalog || []; + const samples = data.sample_voice_texts || {}; + let profiles = data.voice_profiles_data || {}; + + const app = document.getElementById("voice-mixer-app"); + const profileListEl = app.querySelector('[data-role="profile-list"]'); + const voiceGridEl = app.querySelector('[data-role="voice-grid"]'); + const statusEl = app.querySelector('[data-role="status"]'); + const saveBtn = app.querySelector('[data-role="save-profile"]'); + const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]'); + const deleteBtn = app.querySelector('[data-role="delete-profile"]'); + const previewBtn = app.querySelector('[data-role="preview-button"]'); + const loadSampleBtn = app.querySelector('[data-role="load-sample"]'); + const previewTextEl = app.querySelector('[data-role="preview-text"]'); + const previewAudio = app.querySelector('[data-role="preview-audio"]'); + const profileSummaryEl = app.querySelector('[data-role="profile-summary"]'); + const mixTotalEl = app.querySelector('[data-role="mix-total"]'); + const nameInput = document.getElementById("profile-name"); + const languageSelect = document.getElementById("profile-language"); + const speedInput = document.getElementById("preview-speed"); + const importInput = document.getElementById("voice-import-input"); + const headerActions = document.querySelector('.voice-mixer__header-actions'); + + if (!app) { + return; + } + + if (!voiceCatalog.length) { + if (profileListEl) { + profileListEl.innerHTML = "

No voices available.

"; + } + return; + } + + const state = { + selectedProfile: null, + originalName: null, + dirty: false, + previewUrl: null, + draft: { + name: "", + language: "a", + voices: new Map(), + }, + }; + + const voiceControls = new Map(); + let statusTimeout = null; + + const voiceGenderIcon = (gender) => (gender === "Female" ? "♀" : gender === "Male" ? "♂" : "•"); + const voiceLanguageLabel = (code) => languages[code] || code.toUpperCase(); + + const clearStatus = () => { + if (statusTimeout) { + clearTimeout(statusTimeout); + statusTimeout = null; + } + if (statusEl) { + statusEl.textContent = ""; + statusEl.className = "voice-status"; + } + }; + + const setStatus = (message, tone = "info", timeout = 4000) => { + if (!statusEl) return; + clearStatus(); + statusEl.textContent = message; + statusEl.className = `voice-status voice-status--${tone}`; + if (timeout > 0) { + statusTimeout = window.setTimeout(() => { + clearStatus(); + }, timeout); + } + }; + + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + + const formatWeight = (value) => value.toFixed(2); + + const mixTotal = () => { + let total = 0; + state.draft.voices.forEach((weight) => { + total += weight; + }); + return total; + }; + + const updateActionButtons = () => { + const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); + if (duplicateBtn) { + duplicateBtn.disabled = !hasSelection; + } + if (deleteBtn) { + deleteBtn.disabled = !hasSelection; + } + }; + + const updateMixSummary = () => { + if (mixTotalEl) { + mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`; + } + if (profileSummaryEl) { + const voiceCount = state.draft.voices.size; + if (!state.draft.name && !voiceCount) { + profileSummaryEl.textContent = "Select or create a profile to begin."; + } else { + const profileLabel = state.draft.name ? `Editing: ${state.draft.name}` : "Unsaved profile"; + profileSummaryEl.textContent = `${profileLabel} · ${voiceCount} voice${voiceCount === 1 ? "" : "s"}`; + } + } + }; + + const markDirty = () => { + state.dirty = true; + if (saveBtn) { + saveBtn.disabled = false; + } + }; + + const resetDirty = () => { + state.dirty = false; + if (saveBtn) { + saveBtn.disabled = true; + } + }; + + const applyDraftToControls = () => { + if (nameInput) { + nameInput.value = state.draft.name || ""; + } + if (languageSelect) { + languageSelect.value = state.draft.language || "a"; + } + + voiceControls.forEach((control, voiceId) => { + const weight = state.draft.voices.get(voiceId) || 0; + const enabled = weight > 0; + control.checkbox.checked = enabled; + control.slider.disabled = !enabled; + control.number.disabled = !enabled; + control.slider.value = String(Math.round(weight * 100)); + control.number.value = formatWeight(enabled ? weight : 0); + control.weightLabel.textContent = `${formatWeight(weight)}`; + }); + + updateMixSummary(); + resetDirty(); + updateActionButtons(); + }; + + const setVoiceWeight = (voiceId, weight, enabled) => { + const normalized = enabled ? clamp(weight, 0, 1) : 0; + if (normalized > 0) { + state.draft.voices.set(voiceId, normalized); + } else { + state.draft.voices.delete(voiceId); + } + updateMixSummary(); + markDirty(); + }; + + const buildVoiceCard = (voice) => { + const card = document.createElement("div"); + card.className = "voice-card"; + card.dataset.voiceId = voice.id; + + const header = document.createElement("div"); + header.className = "voice-card__header"; + + const toggleLabel = document.createElement("label"); + toggleLabel.className = "voice-card__toggle"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "voice-card__checkbox"; + toggleLabel.appendChild(checkbox); + + const nameSpan = document.createElement("span"); + nameSpan.className = "voice-card__name"; + nameSpan.textContent = voice.display_name || voice.id; + toggleLabel.appendChild(nameSpan); + + header.appendChild(toggleLabel); + + const meta = document.createElement("span"); + meta.className = "voice-card__meta"; + meta.textContent = `${voiceLanguageLabel(voice.language)} · ${voiceGenderIcon(voice.gender)}`; + header.appendChild(meta); + + const weightLabel = document.createElement("span"); + weightLabel.className = "voice-card__value"; + weightLabel.textContent = "0.00"; + header.appendChild(weightLabel); + + const body = document.createElement("div"); + body.className = "voice-card__body"; + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "100"; + slider.step = "1"; + slider.value = "0"; + slider.disabled = true; + slider.className = "voice-card__slider"; + + const number = document.createElement("input"); + number.type = "number"; + number.min = "0"; + number.max = "1"; + number.step = "0.01"; + number.value = "0.00"; + number.disabled = true; + number.className = "voice-card__number"; + + body.appendChild(slider); + body.appendChild(number); + + card.appendChild(header); + card.appendChild(body); + + checkbox.addEventListener("change", () => { + const enabled = checkbox.checked; + slider.disabled = !enabled; + number.disabled = !enabled; + if (!enabled) { + slider.value = "0"; + number.value = "0.00"; + } + const weight = enabled ? parseFloat(number.value || "0") : 0; + weightLabel.textContent = formatWeight(enabled ? weight : 0); + setVoiceWeight(voice.id, weight, enabled); + }); + + slider.addEventListener("input", () => { + const weight = clamp(parseInt(slider.value, 10) / 100, 0, 1); + number.value = formatWeight(weight); + weightLabel.textContent = formatWeight(weight); + if (!checkbox.checked && weight > 0) { + checkbox.checked = true; + slider.disabled = false; + number.disabled = false; + } + setVoiceWeight(voice.id, weight, true); + }); + + number.addEventListener("change", () => { + const weight = clamp(parseFloat(number.value || "0"), 0, 1); + number.value = formatWeight(weight); + slider.value = String(Math.round(weight * 100)); + weightLabel.textContent = formatWeight(weight); + if (!checkbox.checked && weight > 0) { + checkbox.checked = true; + slider.disabled = false; + number.disabled = false; + } + setVoiceWeight(voice.id, weight, checkbox.checked); + }); + + voiceControls.set(voice.id, { checkbox, slider, number, weightLabel }); + return card; + }; + + const buildVoiceGrid = () => { + if (!voiceGridEl) return; + voiceGridEl.innerHTML = ""; + voiceCatalog.forEach((voice) => { + voiceGridEl.appendChild(buildVoiceCard(voice)); + }); + }; + + const loadSampleText = () => { + if (!previewTextEl || !languageSelect) return; + const lang = languageSelect.value || "a"; + previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; + }; + + const selectProfile = (name) => { + state.selectedProfile = name; + state.originalName = name; + const profile = profiles[name]; + state.draft = { + name, + language: profile?.language || "a", + voices: new Map(), + }; + if (Array.isArray(profile?.voices)) { + profile.voices.forEach((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + const [voiceId, weight] = entry; + const value = parseFloat(weight); + if (!Number.isNaN(value) && value > 0) { + state.draft.voices.set(String(voiceId), clamp(value, 0, 1)); + } + } + }); + } + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + setStatus(`Loaded profile “${name}”.`, "info", 2500); + }; + + const createNewProfile = () => { + state.selectedProfile = null; + state.originalName = null; + state.draft = { + name: "", + language: languageSelect ? languageSelect.value || "a" : "a", + voices: new Map(), + }; + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + }; + + const buildProfilePayload = () => { + const payload = []; + voiceControls.forEach((control, voiceId) => { + const enabled = control.checkbox.checked; + const weight = enabled ? clamp(parseFloat(control.number.value || "0"), 0, 1) : 0; + payload.push({ id: voiceId, weight, enabled }); + }); + return payload; + }; + + const renderProfileList = () => { + if (!profileListEl) return; + profileListEl.innerHTML = ""; + + const header = document.createElement("div"); + header.className = "voice-list__header"; + const title = document.createElement("h2"); + title.textContent = "Saved profiles"; + header.appendChild(title); + profileListEl.appendChild(header); + + const list = document.createElement("ul"); + list.className = "voice-list"; + + const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b)); + if (!names.length) { + const empty = document.createElement("p"); + empty.className = "tag"; + empty.textContent = "No profiles yet. Create one on the right."; + profileListEl.appendChild(empty); + return; + } + + names.forEach((name) => { + const li = document.createElement("li"); + li.className = "voice-list__item"; + if (state.selectedProfile === name) { + li.classList.add("is-selected"); + } + + const selectBtn = document.createElement("button"); + selectBtn.type = "button"; + selectBtn.className = "voice-list__select"; + selectBtn.dataset.name = name; + const profile = profiles[name] || {}; + selectBtn.innerHTML = ` + ${name} + ${voiceLanguageLabel(profile.language || "a")} + `; + selectBtn.addEventListener("click", () => selectProfile(name)); + + const actions = document.createElement("div"); + actions.className = "voice-list__actions"; + + const duplicateAction = document.createElement("button"); + duplicateAction.type = "button"; + duplicateAction.className = "voice-list__action"; + duplicateAction.textContent = "Duplicate"; + duplicateAction.addEventListener("click", (event) => { + event.stopPropagation(); + runDuplicate(name); + }); + + const deleteAction = document.createElement("button"); + deleteAction.type = "button"; + deleteAction.className = "voice-list__action voice-list__action--danger"; + deleteAction.textContent = "Delete"; + deleteAction.addEventListener("click", (event) => { + event.stopPropagation(); + runDelete(name); + }); + + actions.appendChild(duplicateAction); + actions.appendChild(deleteAction); + + li.appendChild(selectBtn); + li.appendChild(actions); + list.appendChild(li); + }); + + profileListEl.appendChild(list); + }; + + const refreshProfiles = (nextProfiles, selectedName = null) => { + profiles = nextProfiles || {}; + if (selectedName && profiles[selectedName]) { + selectProfile(selectedName); + } else if (state.selectedProfile && profiles[state.selectedProfile]) { + selectProfile(state.selectedProfile); + } else { + const names = Object.keys(profiles); + if (names.length) { + selectProfile(names[0]); + } else { + createNewProfile(); + } + } + updateActionButtons(); + }; + + const withJson = async (response) => { + if (response.ok) { + return response.json(); + } + let message = "Unexpected error"; + try { + const data = await response.json(); + message = data.error || data.message || message; + } catch (err) { + message = await response.text(); + } + throw new Error(message); + }; + + const runSave = async () => { + if (!nameInput) return; + const name = nameInput.value.trim(); + if (!name) { + setStatus("Give your profile a name first.", "warning"); + return; + } + const payload = { + name, + originalName: state.originalName, + language: languageSelect ? languageSelect.value : "a", + voices: buildProfilePayload(), + }; + try { + const response = await fetch("/api/voice-profiles", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const result = await withJson(response); + refreshProfiles(result.profiles, result.profile); + resetDirty(); + setStatus(`Saved profile “${result.profile}”.`, "success"); + } catch (error) { + setStatus(error.message || "Failed to save profile", "danger", 7000); + } + }; + + const runDelete = async (targetName = null) => { + const name = targetName || state.selectedProfile; + if (!name) { + setStatus("Select a profile to delete.", "warning"); + return; + } + const confirmed = window.confirm(`Delete profile “${name}”?`); + if (!confirmed) return; + try { + const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}`, { + method: "DELETE", + }); + const result = await withJson(response); + refreshProfiles(result.profiles); + setStatus(`Deleted profile “${name}”.`, "info"); + } catch (error) { + setStatus(error.message || "Failed to delete profile", "danger", 7000); + } + }; + + const runDuplicate = async (targetName = null) => { + const name = targetName || state.selectedProfile; + if (!name) { + setStatus("Select a profile to duplicate.", "warning"); + return; + } + const newName = window.prompt("Duplicate profile as…", `${name} copy`); + if (!newName) return; + try { + const response = await fetch(`/api/voice-profiles/${encodeURIComponent(name)}/duplicate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName }), + }); + const result = await withJson(response); + refreshProfiles(result.profiles, result.profile); + setStatus(`Duplicated to “${result.profile}”.`, "success"); + } catch (error) { + setStatus(error.message || "Failed to duplicate profile", "danger", 7000); + } + }; + + const runImport = async (file) => { + try { + const text = await file.text(); + const parsed = JSON.parse(text); + const replace = window.confirm("Replace existing profiles if duplicates are found?"); + const response = await fetch("/api/voice-profiles/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: parsed, replace_existing: replace }), + }); + const result = await withJson(response); + refreshProfiles(result.profiles); + setStatus(`Imported ${result.imported.length} profile${result.imported.length === 1 ? "" : "s"}.`, "success"); + } catch (error) { + setStatus(error.message || "Import failed", "danger", 7000); + } finally { + importInput.value = ""; + } + }; + + const runExport = async () => { + const name = state.selectedProfile; + const query = name ? `?names=${encodeURIComponent(name)}` : ""; + try { + const response = await fetch(`/api/voice-profiles/export${query}`); + if (!response.ok) { + throw new Error("Export failed"); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = name ? `${name}.json` : "voice_profiles.json"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + setStatus("Export complete.", "success"); + } catch (error) { + setStatus(error.message || "Export failed", "danger", 7000); + } + }; + + const runPreview = async () => { + if (!previewBtn) return; + const payload = { + language: languageSelect ? languageSelect.value : "a", + voices: buildProfilePayload(), + text: previewTextEl ? previewTextEl.value : "", + speed: speedInput ? parseFloat(speedInput.value || "1") : 1, + }; + const enabledVoices = payload.voices.filter((entry) => entry.enabled && entry.weight > 0); + if (!enabledVoices.length) { + setStatus("Enable at least one voice to preview.", "warning"); + return; + } + previewBtn.disabled = true; + previewBtn.dataset.loading = "true"; + setStatus("Generating preview…", "info", 0); + try { + const response = await fetch("/api/voice-profiles/preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(await response.text()); + } + const blob = await response.blob(); + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + } + state.previewUrl = URL.createObjectURL(blob); + if (previewAudio) { + previewAudio.src = state.previewUrl; + previewAudio.play().catch(() => {}); + } + setStatus("Preview ready.", "success"); + } catch (error) { + setStatus(error.message || "Preview failed", "danger", 7000); + } finally { + previewBtn.disabled = false; + previewBtn.dataset.loading = "false"; + } + }; + + if (saveBtn) { + const form = saveBtn.closest("form"); + if (form) { + form.addEventListener("submit", (event) => { + event.preventDefault(); + runSave(); + }); + } + } + + if (duplicateBtn) { + duplicateBtn.addEventListener("click", () => runDuplicate()); + } + + if (deleteBtn) { + deleteBtn.addEventListener("click", () => runDelete()); + } + + if (previewBtn) { + previewBtn.addEventListener("click", () => runPreview()); + } + + if (loadSampleBtn) { + loadSampleBtn.addEventListener("click", loadSampleText); + } + + if (languageSelect) { + languageSelect.addEventListener("change", () => { + state.draft.language = languageSelect.value; + markDirty(); + loadSampleText(); + }); + } + + if (nameInput) { + nameInput.addEventListener("input", () => { + state.draft.name = nameInput.value; + markDirty(); + }); + } + + if (importInput) { + importInput.addEventListener("change", () => { + const [file] = importInput.files || []; + if (file) { + runImport(file); + } + }); + } + + if (headerActions) { + headerActions.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const action = target.dataset.action; + if (!action) return; + if (action === "new-profile") { + createNewProfile(); + setStatus("New profile ready.", "info"); + } else if (action === "import-profiles") { + importInput?.click(); + } else if (action === "export-profiles") { + runExport(); + } + }); + } + + buildVoiceGrid(); + renderProfileList(); + createNewProfile(); + if (Object.keys(profiles).length) { + const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0]; + selectProfile(first); + } + loadSampleText(); + app.dataset.state = "ready"; +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupVoiceMixer, { once: true }); +} else { + setupVoiceMixer(); +} diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index c03b9dd..c4fbfe7 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -27,5 +27,6 @@ Need help? Read the docs + {% block scripts %}{% endblock %} diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index 90deb9c..f3aa57b 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -3,64 +3,79 @@ {% block title %}abogen · Voice mixer{% endblock %} {% block content %} -
-

Voice mixer

-

Blend multiple voices, store them as reusable profiles, and reuse them in the dashboard form.

-
-
-

Saved profiles

- {% if profiles %} -
    - {% for profile in profiles %} -
  • -
    - {{ profile.name }} -

    Language: {{ languages.get(profile.language, profile.language|upper) }}

    -

    Formula: {{ profile.formula }}

    -
    -
    - -
    -
  • - {% endfor %} -
- {% else %} -

No saved profiles yet. Use the form to create one.

- {% endif %} +
+
+
+

Voice mixer

+

Blend multiple Kokoro voices, audition the mix instantly, and keep reusable presets.

-
-

Create or update

-
-
- - -
-
- - -
-
- - -

Use voice_name*weight segments joined with +. Weights will be normalised automatically.

-
-
- -
- {% for voice in voices %} - {{ voice }} - {% endfor %} -
-
-
- -
-
+
+ + +
+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Select or create a profile to begin. + Total weight: 0.00 +
+
+
+
+ + + +
+
+
+ + +
+
+ + +
+ +
+
+
+
+
+
{% endblock %} + +{% block scripts %} + + +{% endblock %} From 0c47067cb8d79562718093deac79e8eb49efb8fe Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 05:32:12 -0700 Subject: [PATCH 006/245] feat: Revamp voice mixer UI with new layout and enhanced voice management features --- abogen/web/static/styles.css | 230 ++++++++++--- abogen/web/static/voices.js | 570 +++++++++++++++++++------------ abogen/web/templates/voices.html | 20 +- 3 files changed, 557 insertions(+), 263 deletions(-) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 8f8b244..612a769 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -509,80 +509,223 @@ progress.progress::-moz-progress-bar { flex-wrap: wrap; } -.voice-editor__grid { +.voice-editor__canvas { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; - max-height: 60vh; - overflow-y: auto; - padding-right: 0.5rem; + grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr); + gap: 1.5rem; + align-items: start; } -.voice-card { - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 18px; - background: rgba(15, 23, 42, 0.33); - padding: 1rem; +.voice-available, +.voice-mix { display: flex; flex-direction: column; - gap: 0.75rem; - transition: border 0.2s ease, transform 0.2s ease; + gap: 1rem; } -.voice-card:hover { - border-color: var(--accent); - transform: translateY(-1px); -} - -.voice-card__header { +.voice-available__header, +.voice-mix__header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; - flex-wrap: wrap; } -.voice-card__toggle { +.voice-available__list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; + max-height: 55vh; + overflow-y: auto; + padding-right: 0.5rem; +} + +.voice-available__card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 18px; + background: rgba(15, 23, 42, 0.33); + padding: 0.9rem 1rem; display: flex; align-items: center; - gap: 0.5rem; - cursor: pointer; + justify-content: space-between; + gap: 0.75rem; + cursor: grab; + transition: border 0.2s ease, transform 0.2s ease, background 0.2s ease; } -.voice-card__checkbox { - width: 1rem; - height: 1rem; +.voice-available__card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; } -.voice-card__name { +.voice-available__card:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + +.voice-available__card.is-active { + border-color: rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + cursor: default; +} + +.voice-available__info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.voice-available__name { font-weight: 600; } -.voice-card__meta { - font-size: 0.8rem; +.voice-available__meta { + font-size: 0.78rem; color: var(--muted); } -.voice-card__value { - margin-left: auto; - font-size: 0.8rem; +.voice-available__add { + border: none; + border-radius: 12px; + padding: 0.4rem 0.85rem; + cursor: pointer; + background: rgba(56, 189, 248, 0.15); color: var(--accent); + font-weight: 600; + transition: background 0.2s ease, color 0.2s ease; } -.voice-card__body { +.voice-available__add:hover:not(:disabled) { + background: rgba(56, 189, 248, 0.25); + color: #fff; +} + +.voice-available__add:disabled { + cursor: default; + background: rgba(148, 163, 184, 0.2); + color: var(--muted); +} + +.voice-mix__dropzone { + border: 2px dashed rgba(148, 163, 184, 0.25); + border-radius: 20px; + padding: 1.25rem; + min-height: 260px; + background: rgba(15, 23, 42, 0.25); + transition: border 0.2s ease, background 0.2s ease; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.voice-mix__dropzone.is-hovered { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.08); +} + +.voice-mix__empty { + margin: 0; + text-align: center; + color: var(--muted); + font-size: 0.9rem; +} + +.voice-mix__list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.mix-voice { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.4); + padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.mix-voice__header { display: flex; align-items: center; gap: 0.75rem; } -.voice-card__slider { - flex: 1; - accent-color: var(--accent); +.mix-voice__info { + display: flex; + flex-direction: column; + gap: 0.25rem; } -.voice-card__number { - width: 4.5rem; - text-align: center; +.mix-voice__title { + font-weight: 600; +} + +.mix-voice__meta { + font-size: 0.78rem; + color: var(--muted); +} + +.mix-voice__weight { + margin-left: auto; + font-size: 0.85rem; + font-weight: 600; + color: var(--accent); +} + +.mix-voice__remove { + border: none; + border-radius: 12px; + width: 28px; + height: 28px; + background: rgba(248, 113, 113, 0.2); + color: #fca5a5; + font-size: 1.1rem; + cursor: pointer; + line-height: 1; + display: grid; + place-items: center; + transition: background 0.2s ease, color 0.2s ease; +} + +.mix-voice__remove:hover { + background: rgba(248, 113, 113, 0.35); + color: #fff; +} + +.mix-slider { + width: 100%; + appearance: none; + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.25); + outline: none; + cursor: pointer; + transition: box-shadow 0.2s ease; +} + +.mix-slider:focus-visible { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2); +} + +.mix-slider::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); + border: none; +} + +.mix-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); } .voice-editor__actions { @@ -651,4 +794,13 @@ progress.progress::-moz-progress-bar { padding-bottom: 1.5rem; margin-bottom: 1.5rem; } + + .voice-editor__canvas { + grid-template-columns: 1fr; + } + + .voice-available__list { + max-height: none; + padding-right: 0; + } } diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index b8b18e0..3a9bdba 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -1,13 +1,16 @@ const setupVoiceMixer = () => { const data = window.ABOGEN_VOICE_MIXER_DATA || {}; const languages = data.languages || {}; - const voiceCatalog = data.voice_catalog || []; + const voiceCatalog = Array.isArray(data.voice_catalog) ? data.voice_catalog : []; const samples = data.sample_voice_texts || {}; let profiles = data.voice_profiles_data || {}; const app = document.getElementById("voice-mixer-app"); + if (!app) { + return; + } + const profileListEl = app.querySelector('[data-role="profile-list"]'); - const voiceGridEl = app.querySelector('[data-role="voice-grid"]'); const statusEl = app.querySelector('[data-role="status"]'); const saveBtn = app.querySelector('[data-role="save-profile"]'); const duplicateBtn = app.querySelector('[data-role="duplicate-profile"]'); @@ -22,18 +25,25 @@ const setupVoiceMixer = () => { const languageSelect = document.getElementById("profile-language"); const speedInput = document.getElementById("preview-speed"); const importInput = document.getElementById("voice-import-input"); - const headerActions = document.querySelector('.voice-mixer__header-actions'); + const headerActions = document.querySelector(".voice-mixer__header-actions"); + const availableListEl = app.querySelector('[data-role="available-voices"]'); + const selectedListEl = app.querySelector('[data-role="selected-voices"]'); + const dropzoneEl = app.querySelector('[data-role="dropzone"]'); + const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); - if (!app) { + if (!profileListEl || !availableListEl || !selectedListEl) { return; } - if (!voiceCatalog.length) { - if (profileListEl) { - profileListEl.innerHTML = "

No voices available.

"; + const voiceLookup = new Map(); + voiceCatalog.forEach((voice) => { + if (voice && voice.id) { + voiceLookup.set(voice.id, voice); } - return; - } + }); + + const availableCards = new Map(); + const selectedControls = new Map(); const state = { selectedProfile: null, @@ -47,11 +57,25 @@ const setupVoiceMixer = () => { }, }; - const voiceControls = new Map(); let statusTimeout = null; - const voiceGenderIcon = (gender) => (gender === "Female" ? "♀" : gender === "Male" ? "♂" : "•"); - const voiceLanguageLabel = (code) => languages[code] || code.toUpperCase(); + const clamp = (value, min, max) => Math.min(Math.max(value, min), max); + const formatWeight = (value) => value.toFixed(2); + + const setSliderFill = (slider, weight) => { + const percent = Math.round(clamp(weight, 0, 1) * 100); + slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; + }; + + const voiceGenderIcon = (gender) => { + if (!gender) return "•"; + const initial = gender[0].toLowerCase(); + if (initial === "f") return "♀"; + if (initial === "m") return "♂"; + return "•"; + }; + + const voiceLanguageLabel = (code) => languages[code] || code?.toUpperCase() || ""; const clearStatus = () => { if (statusTimeout) { @@ -76,10 +100,6 @@ const setupVoiceMixer = () => { } }; - const clamp = (value, min, max) => Math.min(Math.max(value, min), max); - - const formatWeight = (value) => value.toFixed(2); - const mixTotal = () => { let total = 0; state.draft.voices.forEach((weight) => { @@ -88,16 +108,6 @@ const setupVoiceMixer = () => { return total; }; - const updateActionButtons = () => { - const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); - if (duplicateBtn) { - duplicateBtn.disabled = !hasSelection; - } - if (deleteBtn) { - deleteBtn.disabled = !hasSelection; - } - }; - const updateMixSummary = () => { if (mixTotalEl) { mixTotalEl.textContent = `Total weight: ${formatWeight(mixTotal())}`; @@ -127,6 +137,213 @@ const setupVoiceMixer = () => { } }; + const ensureEmptyState = () => { + if (!emptyStateEl) return; + emptyStateEl.hidden = state.draft.voices.size > 0; + }; + + const updateAvailableState = () => { + availableCards.forEach(({ card, addButton }, voiceId) => { + const isActive = state.draft.voices.has(voiceId); + card.classList.toggle("is-active", isActive); + if (addButton) { + addButton.disabled = isActive; + addButton.textContent = isActive ? "Added" : "Add"; + } + }); + }; + + const setSliderFocus = (voiceId) => { + const control = selectedControls.get(voiceId); + if (control?.slider) { + control.slider.focus({ preventScroll: false }); + } + }; + + const renderSelectedVoices = () => { + selectedControls.clear(); + selectedListEl.innerHTML = ""; + + state.draft.voices.forEach((weight, voiceId) => { + const meta = voiceLookup.get(voiceId) || {}; + const card = document.createElement("div"); + card.className = "mix-voice"; + card.dataset.voiceId = voiceId; + + const header = document.createElement("div"); + header.className = "mix-voice__header"; + + const titleWrap = document.createElement("div"); + titleWrap.className = "mix-voice__info"; + + const title = document.createElement("div"); + title.className = "mix-voice__title"; + title.textContent = meta.display_name || meta.name || voiceId; + + const metaLabel = document.createElement("div"); + metaLabel.className = "mix-voice__meta"; + const languageCode = meta.language || voiceId.charAt(0) || "a"; + metaLabel.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(meta.gender)}`; + + titleWrap.appendChild(title); + titleWrap.appendChild(metaLabel); + + const weightLabel = document.createElement("span"); + weightLabel.className = "mix-voice__weight"; + weightLabel.textContent = formatWeight(weight); + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "mix-voice__remove"; + removeBtn.setAttribute("aria-label", `Remove ${title.textContent} from mix`); + removeBtn.innerHTML = "×"; + removeBtn.addEventListener("click", () => { + state.draft.voices.delete(voiceId); + renderSelectedVoices(); + updateAvailableState(); + updateMixSummary(); + markDirty(); + }); + + header.appendChild(titleWrap); + header.appendChild(weightLabel); + header.appendChild(removeBtn); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.min = "0"; + slider.max = "100"; + slider.step = "1"; + slider.className = "mix-slider"; + slider.value = String(Math.round(clamp(weight, 0, 1) * 100)); + setSliderFill(slider, weight); + slider.addEventListener("input", () => { + const value = clamp(Number(slider.value) / 100, 0, 1); + state.draft.voices.set(voiceId, value); + weightLabel.textContent = formatWeight(value); + setSliderFill(slider, value); + updateMixSummary(); + markDirty(); + }); + + card.appendChild(header); + card.appendChild(slider); + selectedListEl.appendChild(card); + + selectedControls.set(voiceId, { slider, weightLabel }); + }); + + ensureEmptyState(); + }; + + const renderAvailableVoices = () => { + availableCards.clear(); + availableListEl.innerHTML = ""; + + const sortedVoices = voiceCatalog + .slice() + .sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id)); + + sortedVoices.forEach((voice) => { + if (!voice?.id) { + return; + } + const card = document.createElement("article"); + card.className = "voice-available__card"; + card.draggable = true; + card.dataset.voiceId = voice.id; + card.tabIndex = 0; + + card.addEventListener("dragstart", (event) => { + card.classList.add("is-dragging"); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData("text/plain", voice.id); + } + }); + + card.addEventListener("dragend", () => { + card.classList.remove("is-dragging"); + }); + + card.addEventListener("dblclick", () => { + addVoiceToDraft(voice.id); + }); + + card.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + addVoiceToDraft(voice.id); + } + }); + + const info = document.createElement("div"); + info.className = "voice-available__info"; + + const name = document.createElement("div"); + name.className = "voice-available__name"; + name.textContent = voice.display_name || voice.id; + + const meta = document.createElement("div"); + meta.className = "voice-available__meta"; + const languageCode = voice.language || voice.id.charAt(0) || "a"; + meta.textContent = `${voiceLanguageLabel(languageCode)} · ${voiceGenderIcon(voice.gender)}`; + + info.appendChild(name); + info.appendChild(meta); + + const addButton = document.createElement("button"); + addButton.type = "button"; + addButton.className = "voice-available__add"; + addButton.textContent = "Add"; + addButton.addEventListener("click", (event) => { + event.stopPropagation(); + addVoiceToDraft(voice.id); + }); + + card.appendChild(info); + card.appendChild(addButton); + availableListEl.appendChild(card); + + availableCards.set(voice.id, { card, addButton }); + }); + + updateAvailableState(); + }; + + const addVoiceToDraft = (voiceId, weight = 0.6) => { + if (!voiceLookup.has(voiceId)) { + return; + } + if (state.draft.voices.has(voiceId)) { + setSliderFocus(voiceId); + return; + } + state.draft.voices.set(voiceId, clamp(weight, 0.05, 1)); + renderSelectedVoices(); + updateAvailableState(); + updateMixSummary(); + markDirty(); + setSliderFocus(voiceId); + }; + + const buildProfilePayload = () => + Array.from(state.draft.voices.entries()).map(([voiceId, weight]) => ({ + id: voiceId, + weight, + enabled: weight > 0, + })); + + const updateActionButtons = () => { + const hasSelection = Boolean(state.selectedProfile && profiles[state.selectedProfile]); + if (duplicateBtn) { + duplicateBtn.disabled = !hasSelection; + } + if (deleteBtn) { + deleteBtn.disabled = !hasSelection; + } + }; + const applyDraftToControls = () => { if (nameInput) { nameInput.value = state.draft.name || ""; @@ -134,213 +351,23 @@ const setupVoiceMixer = () => { if (languageSelect) { languageSelect.value = state.draft.language || "a"; } - - voiceControls.forEach((control, voiceId) => { - const weight = state.draft.voices.get(voiceId) || 0; - const enabled = weight > 0; - control.checkbox.checked = enabled; - control.slider.disabled = !enabled; - control.number.disabled = !enabled; - control.slider.value = String(Math.round(weight * 100)); - control.number.value = formatWeight(enabled ? weight : 0); - control.weightLabel.textContent = `${formatWeight(weight)}`; - }); - + renderSelectedVoices(); updateMixSummary(); - resetDirty(); + updateAvailableState(); updateActionButtons(); - }; - - const setVoiceWeight = (voiceId, weight, enabled) => { - const normalized = enabled ? clamp(weight, 0, 1) : 0; - if (normalized > 0) { - state.draft.voices.set(voiceId, normalized); - } else { - state.draft.voices.delete(voiceId); - } - updateMixSummary(); - markDirty(); - }; - - const buildVoiceCard = (voice) => { - const card = document.createElement("div"); - card.className = "voice-card"; - card.dataset.voiceId = voice.id; - - const header = document.createElement("div"); - header.className = "voice-card__header"; - - const toggleLabel = document.createElement("label"); - toggleLabel.className = "voice-card__toggle"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.className = "voice-card__checkbox"; - toggleLabel.appendChild(checkbox); - - const nameSpan = document.createElement("span"); - nameSpan.className = "voice-card__name"; - nameSpan.textContent = voice.display_name || voice.id; - toggleLabel.appendChild(nameSpan); - - header.appendChild(toggleLabel); - - const meta = document.createElement("span"); - meta.className = "voice-card__meta"; - meta.textContent = `${voiceLanguageLabel(voice.language)} · ${voiceGenderIcon(voice.gender)}`; - header.appendChild(meta); - - const weightLabel = document.createElement("span"); - weightLabel.className = "voice-card__value"; - weightLabel.textContent = "0.00"; - header.appendChild(weightLabel); - - const body = document.createElement("div"); - body.className = "voice-card__body"; - - const slider = document.createElement("input"); - slider.type = "range"; - slider.min = "0"; - slider.max = "100"; - slider.step = "1"; - slider.value = "0"; - slider.disabled = true; - slider.className = "voice-card__slider"; - - const number = document.createElement("input"); - number.type = "number"; - number.min = "0"; - number.max = "1"; - number.step = "0.01"; - number.value = "0.00"; - number.disabled = true; - number.className = "voice-card__number"; - - body.appendChild(slider); - body.appendChild(number); - - card.appendChild(header); - card.appendChild(body); - - checkbox.addEventListener("change", () => { - const enabled = checkbox.checked; - slider.disabled = !enabled; - number.disabled = !enabled; - if (!enabled) { - slider.value = "0"; - number.value = "0.00"; - } - const weight = enabled ? parseFloat(number.value || "0") : 0; - weightLabel.textContent = formatWeight(enabled ? weight : 0); - setVoiceWeight(voice.id, weight, enabled); - }); - - slider.addEventListener("input", () => { - const weight = clamp(parseInt(slider.value, 10) / 100, 0, 1); - number.value = formatWeight(weight); - weightLabel.textContent = formatWeight(weight); - if (!checkbox.checked && weight > 0) { - checkbox.checked = true; - slider.disabled = false; - number.disabled = false; - } - setVoiceWeight(voice.id, weight, true); - }); - - number.addEventListener("change", () => { - const weight = clamp(parseFloat(number.value || "0"), 0, 1); - number.value = formatWeight(weight); - slider.value = String(Math.round(weight * 100)); - weightLabel.textContent = formatWeight(weight); - if (!checkbox.checked && weight > 0) { - checkbox.checked = true; - slider.disabled = false; - number.disabled = false; - } - setVoiceWeight(voice.id, weight, checkbox.checked); - }); - - voiceControls.set(voice.id, { checkbox, slider, number, weightLabel }); - return card; - }; - - const buildVoiceGrid = () => { - if (!voiceGridEl) return; - voiceGridEl.innerHTML = ""; - voiceCatalog.forEach((voice) => { - voiceGridEl.appendChild(buildVoiceCard(voice)); - }); - }; - - const loadSampleText = () => { - if (!previewTextEl || !languageSelect) return; - const lang = languageSelect.value || "a"; - previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; - }; - - const selectProfile = (name) => { - state.selectedProfile = name; - state.originalName = name; - const profile = profiles[name]; - state.draft = { - name, - language: profile?.language || "a", - voices: new Map(), - }; - if (Array.isArray(profile?.voices)) { - profile.voices.forEach((entry) => { - if (Array.isArray(entry) && entry.length >= 2) { - const [voiceId, weight] = entry; - const value = parseFloat(weight); - if (!Number.isNaN(value) && value > 0) { - state.draft.voices.set(String(voiceId), clamp(value, 0, 1)); - } - } - }); - } - applyDraftToControls(); - renderProfileList(); - loadSampleText(); - setStatus(`Loaded profile “${name}”.`, "info", 2500); - }; - - const createNewProfile = () => { - state.selectedProfile = null; - state.originalName = null; - state.draft = { - name: "", - language: languageSelect ? languageSelect.value || "a" : "a", - voices: new Map(), - }; - applyDraftToControls(); - renderProfileList(); - loadSampleText(); - }; - - const buildProfilePayload = () => { - const payload = []; - voiceControls.forEach((control, voiceId) => { - const enabled = control.checkbox.checked; - const weight = enabled ? clamp(parseFloat(control.number.value || "0"), 0, 1) : 0; - payload.push({ id: voiceId, weight, enabled }); - }); - return payload; + resetDirty(); }; const renderProfileList = () => { - if (!profileListEl) return; profileListEl.innerHTML = ""; const header = document.createElement("div"); header.className = "voice-list__header"; - const title = document.createElement("h2"); - title.textContent = "Saved profiles"; - header.appendChild(title); + const heading = document.createElement("h2"); + heading.textContent = "Saved profiles"; + header.appendChild(heading); profileListEl.appendChild(header); - const list = document.createElement("ul"); - list.className = "voice-list"; - const names = Object.keys(profiles).sort((a, b) => a.localeCompare(b)); if (!names.length) { const empty = document.createElement("p"); @@ -350,6 +377,9 @@ const setupVoiceMixer = () => { return; } + const list = document.createElement("ul"); + list.className = "voice-list"; + names.forEach((name) => { const li = document.createElement("li"); li.className = "voice-list__item"; @@ -400,8 +430,48 @@ const setupVoiceMixer = () => { profileListEl.appendChild(list); }; + const selectProfile = (name) => { + state.selectedProfile = name; + state.originalName = name; + const profile = profiles[name]; + state.draft = { + name, + language: profile?.language || "a", + voices: new Map(), + }; + if (Array.isArray(profile?.voices)) { + profile.voices.forEach((entry) => { + if (Array.isArray(entry) && entry.length >= 2) { + const [voiceId, weight] = entry; + const value = clamp(parseFloat(weight), 0, 1); + if (!Number.isNaN(value) && value > 0) { + state.draft.voices.set(String(voiceId), value); + } + } + }); + } + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + setStatus(`Loaded profile “${name}”.`, "info", 2500); + }; + + const createNewProfile = () => { + state.selectedProfile = null; + state.originalName = null; + state.draft = { + name: "", + language: languageSelect ? languageSelect.value || "a" : "a", + voices: new Map(), + }; + applyDraftToControls(); + renderProfileList(); + loadSampleText(); + }; + const refreshProfiles = (nextProfiles, selectedName = null) => { profiles = nextProfiles || {}; + renderProfileList(); if (selectedName && profiles[selectedName]) { selectProfile(selectedName); } else if (state.selectedProfile && profiles[state.selectedProfile]) { @@ -417,6 +487,12 @@ const setupVoiceMixer = () => { updateActionButtons(); }; + const loadSampleText = () => { + if (!previewTextEl || !languageSelect) return; + const lang = languageSelect.value || "a"; + previewTextEl.value = samples[lang] || samples.a || "This is a sample of the selected voice."; + }; + const withJson = async (response) => { if (response.ok) { return response.json(); @@ -625,6 +701,7 @@ const setupVoiceMixer = () => { nameInput.addEventListener("input", () => { state.draft.name = nameInput.value; markDirty(); + updateMixSummary(); }); } @@ -654,15 +731,62 @@ const setupVoiceMixer = () => { }); } - buildVoiceGrid(); + if (dropzoneEl) { + const setHover = (hovered) => { + dropzoneEl.classList.toggle("is-hovered", hovered); + }; + [dropzoneEl, selectedListEl].forEach((target) => { + target.addEventListener("dragover", (event) => { + event.preventDefault(); + setHover(true); + }); + target.addEventListener("dragenter", (event) => { + event.preventDefault(); + setHover(true); + }); + target.addEventListener("dragleave", (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setHover(false); + } + }); + target.addEventListener("drop", (event) => { + event.preventDefault(); + const voiceId = event.dataTransfer?.getData("text/plain"); + if (voiceId) { + addVoiceToDraft(voiceId); + } + setHover(false); + }); + }); + + dropzoneEl.addEventListener("click", () => { + const firstInactive = Array.from(availableCards.entries()).find( + ([voiceId]) => !state.draft.voices.has(voiceId), + ); + if (firstInactive) { + addVoiceToDraft(firstInactive[0]); + } + }); + } + + renderAvailableVoices(); renderProfileList(); createNewProfile(); + if (Object.keys(profiles).length) { const first = Object.keys(profiles).sort((a, b) => a.localeCompare(b))[0]; selectProfile(first); } + loadSampleText(); + updateActionButtons(); app.dataset.state = "ready"; + + window.addEventListener("beforeunload", () => { + if (state.previewUrl) { + URL.revokeObjectURL(state.previewUrl); + } + }); }; if (document.readyState === "loading") { diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index f3aa57b..e9fb99e 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -47,7 +47,25 @@ Select or create a profile to begin. Total weight: 0.00
-
+
+
+
+

Available voices

+

Drag into the mixer or click Add.

+
+
+
+
+
+

Your mix

+

Drop voices below to set levels.

+
+
+

Drag voices here or tap “Add”.

+
+
+
+
From 2e402f6b5b562ed7a539ca95d9ea872fabad58f6 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 06:00:17 -0700 Subject: [PATCH 007/245] feat: Enhance voice mixer functionality with language filtering and improved directory management --- .env.example | 6 +++ .gitignore | 3 ++ README.md | 8 +++ abogen/utils.py | 91 ++++++++++++++++++++++---------- abogen/web/app.py | 16 +++--- abogen/web/static/styles.css | 83 +++++++++++++++++++++++++---- abogen/web/static/voices.js | 42 ++++++++++++--- abogen/web/templates/voices.html | 18 +++++-- pyproject.toml | 1 + 9 files changed, 214 insertions(+), 54 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e21e65c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy this file to `.env` and customize the paths to match your environment. +# Relative paths are resolved from the repository root when running locally. + +ABOGEN_SETTINGS_DIR=./config +ABOGEN_TEMP_DIR=./storage/tmp +ABOGEN_OUTPUT_DIR=./storage/output diff --git a/.gitignore b/.gitignore index c99cf9f..f60d968 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ __pycache__/ env/ venv/ .env/ +.env .venv/ test/ @@ -30,6 +31,8 @@ python_embedded/ # abogen *config.json +config/ +storage/ build/ dist/ .old/ diff --git a/README.md b/README.md index bc3c41a..d288fab 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `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 | +| `ABOGEN_TEMP_DIR` | Platform cache dir (e.g. `~/.cache/abogen`) | Override the cache/temp directory | +| `ABOGEN_OUTPUT_DIR` | Same as `ABOGEN_OUTPUT_ROOT` | Override the rendered output directory | +| `ABOGEN_SETTINGS_DIR` | Platform config dir (e.g. `~/.config/abogen`) | Override where JSON settings (profiles, config) are stored | Set any of these with `-e VAR=value` when starting the container. @@ -111,9 +114,14 @@ More automation hooks are planned; contributions are very welcome if you need ad 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. +- `ABOGEN_SETTINGS_DIR` – change where Abogen stores its JSON settings/configuration files. +- `ABOGEN_TEMP_DIR` – change where temporary uploads and cache files are stored. +- `ABOGEN_OUTPUT_DIR` – change where rendered audio/subtitles are written. If unset, Abogen picks sensible defaults suitable for local usage. +You can also create a `.env` file in the project root (see `.env.example`) to configure these paths when running locally. The application loads `.env` automatically on startup. + ## Development workflow ```bash git clone https://github.com/denizsafak/abogen.git diff --git a/abogen/utils.py b/abogen/utils.py index 0435d77..94144af 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -1,13 +1,19 @@ -import os -import sys import json -import warnings +import os import platform +import re import shutil import subprocess -import re +import sys +import warnings from threading import Thread +from functools import lru_cache + +from dotenv import load_dotenv + +load_dotenv() + warnings.filterwarnings("ignore") @@ -82,40 +88,69 @@ def get_version(): # Define config path -def get_user_config_path(): +def ensure_directory(path): + resolved = os.path.abspath(os.path.expanduser(str(path))) + os.makedirs(resolved, exist_ok=True) + return resolved + + +@lru_cache(maxsize=1) +def get_user_settings_dir(): + override = os.environ.get("ABOGEN_SETTINGS_DIR") + if override: + return ensure_directory(override) + from platformdirs import user_config_dir - # TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used. - # On non‑Windows, prefer ~/.config/abogen if it already exists if platform.system() != "Windows": - custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") - if os.path.exists(custom_dir): - config_dir = custom_dir - else: - config_dir = user_config_dir( - "abogen", appauthor=False, roaming=True, ensure_exists=True - ) - else: - # Windows and fallback case - config_dir = user_config_dir( - "abogen", appauthor=False, roaming=True, ensure_exists=True - ) + legacy_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") + if os.path.exists(legacy_dir): + return ensure_directory(legacy_dir) - return os.path.join(config_dir, "config.json") + config_dir = user_config_dir("abogen", appauthor=False, roaming=True, ensure_exists=True) + return ensure_directory(config_dir) + + +def get_user_config_path(): + return os.path.join(get_user_settings_dir(), "config.json") # Define cache path -def get_user_cache_path(folder=None): +@lru_cache(maxsize=1) +def get_user_cache_root(): + override = os.environ.get("ABOGEN_TEMP_DIR") + if override: + return ensure_directory(override) + from platformdirs import user_cache_dir - cache_dir = user_cache_dir( - "abogen", appauthor=False, opinion=True, ensure_exists=True - ) + cache_dir = user_cache_dir("abogen", appauthor=False, opinion=True, ensure_exists=True) + return ensure_directory(cache_dir) + + +def get_user_cache_path(folder=None): + base = get_user_cache_root() if folder: - cache_dir = os.path.join(cache_dir, folder) - # Ensure the directory exists - os.makedirs(cache_dir, exist_ok=True) - return cache_dir + return ensure_directory(os.path.join(base, folder)) + return base + + +@lru_cache(maxsize=1) +def get_user_output_root(): + override = ( + os.environ.get("ABOGEN_OUTPUT_DIR") + or os.environ.get("ABOGEN_OUTPUT_ROOT") + ) + if override: + return ensure_directory(override) + return ensure_directory(os.path.join(get_user_cache_root(), "outputs")) + + +def get_user_output_path(folder=None): + base = get_user_output_root() + if folder: + return ensure_directory(os.path.join(base, folder)) + return base _sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes diff --git a/abogen/web/app.py b/abogen/web/app.py index e385fb4..0a4923e 100644 --- a/abogen/web/app.py +++ b/abogen/web/app.py @@ -7,7 +7,7 @@ from typing import Any, Optional from flask import Flask -from abogen.utils import get_user_cache_path +from abogen.utils import get_user_cache_path, get_user_output_path from .conversion_runner import run_conversion_job from .service import build_service @@ -17,13 +17,15 @@ 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) + if uploads_override: + uploads = Path(os.path.expanduser(uploads_override)).resolve() else: - base = Path(get_user_cache_path("web")) - uploads = base / "uploads" - outputs = base / "outputs" + uploads = Path(get_user_cache_path("web/uploads")) + + if outputs_override: + outputs = Path(os.path.expanduser(outputs_override)).resolve() + else: + outputs = Path(get_user_output_path("web")) uploads.mkdir(parents=True, exist_ok=True) outputs.mkdir(parents=True, exist_ok=True) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 612a769..62b3123 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -33,6 +33,9 @@ body { display: flex; align-items: center; justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; } .brand { @@ -397,8 +400,8 @@ progress.progress::-moz-progress-bar { .voice-mixer__layout { display: grid; - grid-template-columns: minmax(240px, 280px) 1fr; - gap: 2rem; + grid-template-columns: minmax(260px, 300px) 1fr; + gap: 2.25rem; align-items: start; } @@ -511,8 +514,8 @@ progress.progress::-moz-progress-bar { .voice-editor__canvas { display: grid; - grid-template-columns: minmax(260px, 1fr) minmax(320px, 1fr); - gap: 1.5rem; + grid-template-columns: minmax(280px, 340px) minmax(320px, 1fr); + gap: 2rem; align-items: start; } @@ -526,20 +529,67 @@ progress.progress::-moz-progress-bar { .voice-available__header, .voice-mix__header { display: flex; - align-items: center; + align-items: flex-end; justify-content: space-between; - gap: 0.75rem; + gap: 1rem; + flex-wrap: wrap; +} + +.voice-available__title { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.voice-filter { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 180px; +} + +.voice-filter label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.voice-filter select { + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.55); + padding: 0.55rem 0.75rem; + color: var(--text); + font-size: 0.95rem; +} + +.voice-filter select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); } .voice-available__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 0.75rem; + gap: 0.85rem; max-height: 55vh; overflow-y: auto; padding-right: 0.5rem; } +.voice-available__empty { + margin: 0; + padding: 1.25rem 1rem; + text-align: center; + border: 1px dashed rgba(148, 163, 184, 0.25); + border-radius: 16px; + color: var(--muted); + background: rgba(15, 23, 42, 0.3); +} + .voice-available__card { border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 18px; @@ -609,8 +659,8 @@ progress.progress::-moz-progress-bar { .voice-mix__dropzone { border: 2px dashed rgba(148, 163, 184, 0.25); border-radius: 20px; - padding: 1.25rem; - min-height: 260px; + padding: 1.5rem; + min-height: 320px; background: rgba(15, 23, 42, 0.25); transition: border 0.2s ease, background 0.2s ease; display: flex; @@ -640,7 +690,7 @@ progress.progress::-moz-progress-bar { border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 18px; background: rgba(15, 23, 42, 0.4); - padding: 1rem 1.1rem; + padding: 1.1rem 1.2rem; display: flex; flex-direction: column; gap: 0.9rem; @@ -803,4 +853,17 @@ progress.progress::-moz-progress-bar { max-height: none; padding-right: 0; } + + .voice-available__header, + .voice-mix__header { + align-items: stretch; + } + + .voice-filter { + width: 100%; + } + + .voice-mix__dropzone { + min-height: 220px; + } } diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 3a9bdba..352a77b 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -30,6 +30,7 @@ const setupVoiceMixer = () => { const selectedListEl = app.querySelector('[data-role="selected-voices"]'); const dropzoneEl = app.querySelector('[data-role="dropzone"]'); const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); + const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); if (!profileListEl || !availableListEl || !selectedListEl) { return; @@ -55,6 +56,7 @@ const setupVoiceMixer = () => { language: "a", voices: new Map(), }, + languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "", }; let statusTimeout = null; @@ -211,14 +213,16 @@ const setupVoiceMixer = () => { const slider = document.createElement("input"); slider.type = "range"; - slider.min = "0"; + slider.min = "5"; slider.max = "100"; slider.step = "1"; slider.className = "mix-slider"; - slider.value = String(Math.round(clamp(weight, 0, 1) * 100)); - setSliderFill(slider, weight); + const normalizedWeight = clamp(weight, 0.05, 1); + slider.value = String(Math.round(normalizedWeight * 100)); + setSliderFill(slider, normalizedWeight); slider.addEventListener("input", () => { - const value = clamp(Number(slider.value) / 100, 0, 1); + const value = clamp(Number(slider.value) / 100, 0.05, 1); + slider.value = String(Math.round(value * 100)); state.draft.voices.set(voiceId, value); weightLabel.textContent = formatWeight(value); setSliderFill(slider, value); @@ -244,7 +248,24 @@ const setupVoiceMixer = () => { .slice() .sort((a, b) => (a.display_name || a.id).localeCompare(b.display_name || b.id)); - sortedVoices.forEach((voice) => { + const filteredVoices = sortedVoices.filter((voice) => { + const languageCode = voice.language || voice.id?.charAt(0) || "a"; + return !state.languageFilter || state.languageFilter === languageCode; + }); + + if (!filteredVoices.length) { + const empty = document.createElement("p"); + empty.className = "voice-available__empty"; + const label = state.languageFilter + ? voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase() + : "All voices"; + empty.innerHTML = `No voices for ${label} yet.`; + availableListEl.appendChild(empty); + updateAvailableState(); + return; + } + + filteredVoices.forEach((voice) => { if (!voice?.id) { return; } @@ -309,6 +330,7 @@ const setupVoiceMixer = () => { }); updateAvailableState(); + availableListEl.scrollTop = 0; }; const addVoiceToDraft = (voiceId, weight = 0.6) => { @@ -445,7 +467,8 @@ const setupVoiceMixer = () => { const [voiceId, weight] = entry; const value = clamp(parseFloat(weight), 0, 1); if (!Number.isNaN(value) && value > 0) { - state.draft.voices.set(String(voiceId), value); + const normalized = clamp(value, 0.05, 1); + state.draft.voices.set(String(voiceId), normalized); } } }); @@ -697,6 +720,13 @@ const setupVoiceMixer = () => { }); } + if (voiceFilterSelect) { + voiceFilterSelect.addEventListener("change", () => { + state.languageFilter = voiceFilterSelect.value; + renderAvailableVoices(); + }); + } + if (nameInput) { nameInput.addEventListener("input", () => { state.draft.name = nameInput.value; diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index e9fb99e..8fb86bc 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -50,8 +50,19 @@
-

Available voices

-

Drag into the mixer or click Add.

+
+

Available voices

+

Drag into the mixer or click Add.

+
+
+ + +
@@ -92,8 +103,9 @@ {% endblock %} {% block scripts %} + {% endblock %} diff --git a/pyproject.toml b/pyproject.toml index dad6101..a16632d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "pygame>=2.6.1", "charset_normalizer>=3.4.1", "chardet>=5.2.0", + "python-dotenv>=1.0.1", "static_ffmpeg>=2.13", "Markdown>=3.9", "Flask>=3.0.3", From 01209f68786e9ed53d3ac3cd5dbafb277af2f3db Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 06:59:16 -0700 Subject: [PATCH 008/245] feat: Add gender filter to voice mixer and enhance UI for better user experience feat: Implement dynamic settings page with form handling and default values feat: Create a queue page to display ongoing jobs with auto-refresh feat: Revamp dashboard with live text preview and character/word count fix: Update navigation links in base template for active state indication --- abogen/web/conversion_runner.py | 3 + abogen/web/routes.py | 244 ++++++++++++++++++++++++----- abogen/web/static/dashboard.js | 87 ++++++++++ abogen/web/static/styles.css | 138 +++++++++++++++- abogen/web/static/voices.js | 58 ++++++- abogen/web/templates/base.html | 7 +- abogen/web/templates/index.html | 99 ++++-------- abogen/web/templates/queue.html | 9 ++ abogen/web/templates/settings.html | 78 +++++++++ abogen/web/templates/voices.html | 20 ++- 10 files changed, 624 insertions(+), 119 deletions(-) create mode 100644 abogen/web/static/dashboard.js create mode 100644 abogen/web/templates/queue.html create mode 100644 abogen/web/templates/settings.html diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 1aac1cd..113690b 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -18,6 +18,7 @@ from abogen.utils import ( calculate_text_length, create_process, get_user_cache_path, + get_user_output_path, load_config, load_numpy_kpipeline, ) @@ -215,6 +216,8 @@ def _prepare_output_dir(job: Job) -> Path: directory = job.stored_path.parent elif job.save_mode == "Choose output folder" and job.output_folder: directory = Path(job.output_folder) + elif job.save_mode == "Use default save location": + directory = Path(get_user_output_path()) else: directory = default_output directory.mkdir(parents=True, exist_ok=True) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index c48c412..513e318 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -3,6 +3,7 @@ from __future__ import annotations import io import json import mimetypes +import os import threading import uuid from pathlib import Path @@ -32,7 +33,14 @@ from abogen.constants import ( SUPPORTED_SOUND_FORMATS, VOICES_INTERNAL, ) -from abogen.utils import calculate_text_length, clean_text, load_config, load_numpy_kpipeline +from abogen.utils import ( + calculate_text_length, + clean_text, + get_user_output_path, + load_config, + load_numpy_kpipeline, + save_config, +) from abogen.voice_profiles import ( delete_profile, duplicate_profile, @@ -98,6 +106,113 @@ def _template_options() -> Dict[str, Any]: } +SAVE_MODE_LABELS = { + "save_next_to_input": "Save next to input file", + "save_to_desktop": "Save to Desktop", + "choose_output_folder": "Choose output folder", + "default_output": "Use default save location", +} + +LEGACY_SAVE_MODE_MAP = {label: key for key, label in SAVE_MODE_LABELS.items()} + +BOOLEAN_SETTINGS = { + "replace_single_newlines", + "use_gpu", + "save_chapters_separately", + "merge_chapters_at_end", + "save_as_project", +} + +FLOAT_SETTINGS = {"silence_between_chapters"} +INT_SETTINGS = {"max_subtitle_words"} + + +def _has_output_override() -> bool: + return bool(os.environ.get("ABOGEN_OUTPUT_DIR") or os.environ.get("ABOGEN_OUTPUT_ROOT")) + + +def _settings_defaults() -> Dict[str, Any]: + return { + "output_format": "wav", + "subtitle_format": "srt", + "save_mode": "default_output" if _has_output_override() else "save_next_to_input", + "replace_single_newlines": False, + "use_gpu": True, + "save_chapters_separately": False, + "merge_chapters_at_end": True, + "save_as_project": False, + "separate_chapters_format": "wav", + "silence_between_chapters": 2.0, + "max_subtitle_words": 50, + } + + +def _coerce_bool(value: Any, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"true", "1", "yes", "on"} + if value is None: + return default + return bool(value) + + +def _coerce_float(value: Any, default: float) -> float: + try: + return max(0.0, float(value)) + except (TypeError, ValueError): + return default + + +def _coerce_int(value: Any, default: int, *, minimum: int = 1, maximum: int = 200) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(minimum, min(parsed, maximum)) + + +def _normalize_save_mode(value: Any, default: str) -> str: + if isinstance(value, str): + if value in SAVE_MODE_LABELS: + return value + if value in LEGACY_SAVE_MODE_MAP: + return LEGACY_SAVE_MODE_MAP[value] + return default + + +def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> Any: + if key in BOOLEAN_SETTINGS: + return _coerce_bool(value, defaults[key]) + if key in FLOAT_SETTINGS: + return _coerce_float(value, defaults[key]) + if key in INT_SETTINGS: + return _coerce_int(value, defaults[key]) + if key == "save_mode": + return _normalize_save_mode(value, defaults[key]) + if key == "output_format": + return value if value in SUPPORTED_SOUND_FORMATS else defaults[key] + if key == "subtitle_format": + valid = {item[0] for item in SUBTITLE_FORMATS} + return value if value in valid else defaults[key] + if key == "separate_chapters_format": + if isinstance(value, str): + normalized = value.lower() + if normalized in {"wav", "flac", "mp3", "opus"}: + return normalized + return defaults[key] + return value if value is not None else defaults.get(key) + + +def _load_settings() -> Dict[str, Any]: + defaults = _settings_defaults() + cfg = load_config() or {} + settings: Dict[str, Any] = {} + for key, default in defaults.items(): + raw_value = cfg.get(key, default) + settings[key] = _normalize_setting_value(key, raw_value, defaults) + return settings + def _formula_from_profile(entry: Dict[str, Any]) -> Optional[str]: voices = entry.get("voices") or [] if not voices: @@ -224,13 +339,62 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: @web_bp.get("/") def index() -> str: - service = _service() - jobs = service.list_jobs() - return render_template( - "index.html", - jobs=jobs, - options=_template_options(), - ) + return render_template("index.html", options=_template_options()) + + +@web_bp.get("/queue") +def queue_page() -> str: + jobs = _service().list_jobs() + return render_template("queue.html", jobs=jobs) + + +@web_bp.route("/settings", methods=["GET", "POST"]) +def settings_page() -> Response | str: + options = _template_options() + current_settings = _load_settings() + + if request.method == "POST": + form = request.form + defaults = _settings_defaults() + updated: Dict[str, Any] = {} + + updated["output_format"] = _normalize_setting_value( + "output_format", form.get("output_format"), defaults + ) + updated["subtitle_format"] = _normalize_setting_value( + "subtitle_format", form.get("subtitle_format"), defaults + ) + updated["save_mode"] = _normalize_setting_value( + "save_mode", form.get("save_mode"), defaults + ) + for key in sorted(BOOLEAN_SETTINGS): + updated[key] = _coerce_bool(form.get(key), False) + updated["separate_chapters_format"] = _normalize_setting_value( + "separate_chapters_format", form.get("separate_chapters_format"), defaults + ) + updated["silence_between_chapters"] = _coerce_float( + form.get("silence_between_chapters"), defaults["silence_between_chapters"] + ) + updated["max_subtitle_words"] = _coerce_int( + form.get("max_subtitle_words"), defaults["max_subtitle_words"] + ) + + cfg = load_config() or {} + cfg.update(updated) + save_config(cfg) + return redirect(url_for("web.settings_page", saved="1")) + + save_locations = [ + {"value": key, "label": label} for key, label in SAVE_MODE_LABELS.items() + ] + context = { + "options": options, + "settings": current_settings, + "save_locations": save_locations, + "default_output_dir": get_user_output_path(), + "saved": request.args.get("saved") == "1", + } + return render_template("settings.html", **context) @web_bp.get("/voices") @@ -361,7 +525,11 @@ def api_preview_voice_mix() -> Response: language = (payload.get("language") or "a").strip() or "a" text = (payload.get("text") or "").strip() speed = float(payload.get("speed", 1.0) or 1.0) - max_seconds = float(payload.get("max_seconds", 12.0) or 12.0) + try: + requested_preview = float(payload.get("max_seconds", 60.0) or 60.0) + except (TypeError, ValueError): + requested_preview = 60.0 + max_seconds = max(1.0, min(60.0, requested_preview)) profile_name = (payload.get("profile") or payload.get("profile_name") or "").strip() formula = (payload.get("formula") or "").strip() @@ -398,9 +566,12 @@ def api_preview_voice_mix() -> Response: if not text: text = SAMPLE_VOICE_TEXTS.get(language, SAMPLE_VOICE_TEXTS.get("a", "This is a sample of the selected voice.")) - cfg = load_config() - use_gpu_cfg = bool(cfg.get("use_gpu", True)) - use_gpu = use_gpu_cfg if payload.get("use_gpu") is None else bool(payload.get("use_gpu")) + settings = _load_settings() + use_gpu_default = settings.get("use_gpu", True) + if "use_gpu" in payload: + use_gpu = _coerce_bool(payload.get("use_gpu"), use_gpu_default) + else: + use_gpu = use_gpu_default device = "cpu" if use_gpu: try: @@ -506,11 +677,23 @@ def enqueue_job() -> Response: total_chars = calculate_text_length(clean_text(text_input)) profiles = load_profiles() + settings = _load_settings() 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() + profile_selection = (request.form.get("voice_profile") or "__standard").strip() + custom_formula_raw = request.form.get("voice_formula", "").strip() + + if profile_selection in {"__standard", ""}: + profile_name = "" + custom_formula = "" + elif profile_selection == "__formula": + profile_name = "" + custom_formula = custom_formula_raw + else: + profile_name = profile_selection + custom_formula = "" + voice, language, selected_profile = _resolve_voice_choice( language, base_voice, @@ -520,27 +703,18 @@ def enqueue_job() -> Response: ) 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)) + output_format = settings["output_format"] + subtitle_format = settings["subtitle_format"] + save_mode_key = settings["save_mode"] + save_mode = SAVE_MODE_LABELS.get(save_mode_key, SAVE_MODE_LABELS["save_next_to_input"]) + replace_single_newlines = settings["replace_single_newlines"] + use_gpu = settings["use_gpu"] + save_chapters_separately = settings["save_chapters_separately"] + merge_chapters_at_end = settings["merge_chapters_at_end"] or not save_chapters_separately + save_as_project = settings["save_as_project"] + separate_chapters_format = settings["separate_chapters_format"] + silence_between_chapters = settings["silence_between_chapters"] + max_subtitle_words = settings["max_subtitle_words"] job = service.enqueue( original_filename=original_name, diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js new file mode 100644 index 0000000..a9fce26 --- /dev/null +++ b/abogen/web/static/dashboard.js @@ -0,0 +1,87 @@ +const initDashboard = () => { + const profileSelect = document.querySelector('[data-role="voice-profile"]'); + const voiceField = document.querySelector('[data-role="voice-field"]'); + const voiceSelect = document.querySelector('[data-role="voice-select"]'); + const formulaField = document.querySelector('[data-role="formula-field"]'); + const formulaInput = document.querySelector('[data-role="voice-formula"]'); + + const sourceText = document.querySelector('[data-role="source-text"]'); + const previewEl = document.querySelector('[data-role="text-preview"]'); + const previewBody = document.querySelector('[data-role="preview-body"]'); + const charCountEl = document.querySelector('[data-role="char-count"]'); + const wordCountEl = document.querySelector('[data-role="word-count"]'); + + const updateVoiceControls = () => { + if (!profileSelect) { + return; + } + const value = profileSelect.value; + const showVoice = !value || value === "__standard"; + const showFormula = value === "__formula"; + + if (voiceField) { + voiceField.hidden = !showVoice; + voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true"); + } + if (voiceSelect) { + voiceSelect.disabled = !showVoice; + } + + if (formulaField) { + formulaField.hidden = !showFormula; + formulaField.setAttribute("aria-hidden", showFormula ? "false" : "true"); + } + if (formulaInput) { + formulaInput.disabled = !showFormula; + if (!showFormula) { + formulaInput.value = formulaInput.value.trim(); + } + } + }; + + const updatePreview = () => { + if (!sourceText || !previewBody || !charCountEl || !wordCountEl) { + return; + } + const raw = sourceText.value || ""; + const trimmed = raw.trim(); + const charCount = raw.length; + const wordCount = trimmed ? trimmed.split(/\s+/).length : 0; + + const charLabel = `${charCount.toLocaleString()} ${charCount === 1 ? "character" : "characters"}`; + const wordLabel = `${wordCount.toLocaleString()} ${wordCount === 1 ? "word" : "words"}`; + + charCountEl.textContent = charLabel; + wordCountEl.textContent = wordLabel; + + if (!trimmed) { + previewBody.textContent = "Paste text to see a live preview and character count."; + if (previewEl) { + previewEl.setAttribute("data-state", "empty"); + } + return; + } + + const snippet = trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}…` : trimmed; + previewBody.textContent = snippet; + if (previewEl) { + previewEl.setAttribute("data-state", "ready"); + } + }; + + if (profileSelect) { + profileSelect.addEventListener("change", updateVoiceControls); + updateVoiceControls(); + } + + if (sourceText) { + sourceText.addEventListener("input", updatePreview); + updatePreview(); + } +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initDashboard, { once: true }); +} else { + initDashboard(); +} diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 62b3123..ed60614 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -73,6 +73,13 @@ body { background: rgba(56, 189, 248, 0.1); } +.top-actions .btn.is-active { + border-color: var(--accent); + background: rgba(56, 189, 248, 0.18); + color: #fff; + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + .main { flex: 1; padding: 2.5rem 3rem 3.5rem; @@ -341,11 +348,54 @@ body { gap: 1rem; } +.alert { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-radius: 14px; + font-size: 0.95rem; +} + +.alert--success { + background: rgba(52, 211, 153, 0.12); + border: 1px solid rgba(52, 211, 153, 0.35); + color: var(--success); +} + .button--danger { background: linear-gradient(135deg, var(--danger), #ef4444); box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2); } +.settings__form { + display: grid; + gap: 1.75rem; +} + +.settings__section { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + padding: 1.25rem 1.4rem; + display: grid; + gap: 1rem; +} + +.settings__section legend { + font-weight: 600; + padding: 0 0.5rem; + color: var(--muted); +} + +.field--inline { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.settings__actions { + display: flex; + justify-content: flex-end; +} + .pill-grid { display: flex; flex-wrap: wrap; @@ -361,6 +411,51 @@ body { background: rgba(15, 23, 42, 0.4); } +.text-preview { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.35); + padding: 1.1rem 1.25rem; + display: grid; + gap: 0.85rem; + min-height: 220px; +} + +.text-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.text-preview__header h2 { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.text-preview__meta { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + color: var(--muted); +} + +.text-preview__body { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + font-size: 0.95rem; + line-height: 1.5; + color: var(--text); +} + +.text-preview[data-state="empty"] .text-preview__body { + color: var(--muted); +} + progress.progress { width: 100%; height: 0.6rem; @@ -548,7 +643,7 @@ progress.progress::-moz-progress-bar { min-width: 180px; } -.voice-filter label { +.voice-filter__label { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em; @@ -556,6 +651,13 @@ progress.progress::-moz-progress-bar { color: var(--muted); } +.voice-filter__controls { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + .voice-filter select { border-radius: 12px; border: 1px solid rgba(148, 163, 184, 0.2); @@ -563,6 +665,8 @@ progress.progress::-moz-progress-bar { padding: 0.55rem 0.75rem; color: var(--text); font-size: 0.95rem; + min-width: 0; + flex: 1 1 200px; } .voice-filter select:focus { @@ -571,6 +675,38 @@ progress.progress::-moz-progress-bar { box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); } +.voice-gender-filter { + display: inline-flex; + align-items: center; + gap: 0.35rem; + flex: 0 0 auto; +} + +.voice-gender-filter__button { + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(15, 23, 42, 0.45); + color: var(--muted); + font-size: 1.05rem; + line-height: 1; + width: 2.25rem; + height: 2.25rem; + border-radius: 999px; + cursor: pointer; + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease; +} + +.voice-gender-filter__button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.voice-gender-filter__button[aria-pressed="true"] { + background: rgba(56, 189, 248, 0.16); + border-color: var(--accent); + color: #fff; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15); +} + .voice-available__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 352a77b..4880d63 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -31,6 +31,7 @@ const setupVoiceMixer = () => { const dropzoneEl = app.querySelector('[data-role="dropzone"]'); const emptyStateEl = app.querySelector('[data-role="mix-empty"]'); const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); + const genderFilterEl = app.querySelector('[data-role="gender-filter"]'); if (!profileListEl || !availableListEl || !selectedListEl) { return; @@ -57,6 +58,7 @@ const setupVoiceMixer = () => { voices: new Map(), }, languageFilter: voiceFilterSelect ? voiceFilterSelect.value : "", + genderFilter: "", }; let statusTimeout = null; @@ -155,6 +157,16 @@ const setupVoiceMixer = () => { }); }; + const updateGenderFilterButtons = () => { + if (!genderFilterEl) return; + const buttons = genderFilterEl.querySelectorAll("[data-value]"); + buttons.forEach((button) => { + const value = button.getAttribute("data-value") || ""; + const pressed = value === state.genderFilter; + button.setAttribute("aria-pressed", pressed ? "true" : "false"); + }); + }; + const setSliderFocus = (voiceId) => { const control = selectedControls.get(voiceId); if (control?.slider) { @@ -250,18 +262,30 @@ const setupVoiceMixer = () => { const filteredVoices = sortedVoices.filter((voice) => { const languageCode = voice.language || voice.id?.charAt(0) || "a"; - return !state.languageFilter || state.languageFilter === languageCode; + const languageMatch = !state.languageFilter || state.languageFilter === languageCode; + const genderCode = (voice.gender || "").charAt(0).toLowerCase(); + const genderMatch = !state.genderFilter || state.genderFilter === genderCode; + return languageMatch && genderMatch; }); if (!filteredVoices.length) { const empty = document.createElement("p"); empty.className = "voice-available__empty"; - const label = state.languageFilter - ? voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase() - : "All voices"; - empty.innerHTML = `No voices for ${label} yet.`; + const filters = []; + if (state.languageFilter) { + filters.push(voiceLanguageLabel(state.languageFilter) || state.languageFilter.toUpperCase()); + } + if (state.genderFilter) { + filters.push(state.genderFilter === "f" ? "♀ Female" : "♂ Male"); + } + if (filters.length) { + empty.innerHTML = `No voices match ${filters.join(" · ")}.`; + } else { + empty.textContent = "No voices available."; + } availableListEl.appendChild(empty); updateAvailableState(); + updateGenderFilterButtons(); return; } @@ -330,6 +354,7 @@ const setupVoiceMixer = () => { }); updateAvailableState(); + updateGenderFilterButtons(); availableListEl.scrollTop = 0; }; @@ -727,6 +752,18 @@ const setupVoiceMixer = () => { }); } + if (genderFilterEl) { + genderFilterEl.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLButtonElement)) return; + const value = (target.getAttribute("data-value") || "").toLowerCase(); + if (!value) return; + state.genderFilter = state.genderFilter === value ? "" : value; + renderAvailableVoices(); + }); + updateGenderFilterButtons(); + } + if (nameInput) { nameInput.addEventListener("input", () => { state.draft.name = nameInput.value; @@ -789,7 +826,16 @@ const setupVoiceMixer = () => { }); }); - dropzoneEl.addEventListener("click", () => { + dropzoneEl.addEventListener("click", (event) => { + if (!(event.target instanceof HTMLElement)) { + return; + } + if (event.target.closest(".mix-voice")) { + return; + } + if (event.target.closest(".mix-slider")) { + return; + } const firstInactive = Array.from(availableCards.entries()).find( ([voiceId]) => !state.draft.voices.has(voiceId), ); diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index c4fbfe7..bc8a09f 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -16,8 +16,11 @@ Audiobooks for humans, not desktops
diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 2168edb..5ff4372 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -10,7 +10,6 @@
-

You can also paste text below.

@@ -20,9 +19,9 @@ {% endfor %}
-
+
- {% for voice in options.voices %} {% endfor %} @@ -30,18 +29,22 @@
- + + + {% if options.voice_profiles %} + + {% for name, data in options.voice_profiles %} + + {% endfor %} + + {% endif %} -

Manage mixes in the voice mixer.

+

Manage mixes in the voice mixer.

-
+
@@ -59,63 +62,22 @@ {% endfor %}
-
- - -
-
- - -
-
- - -
-
- - -
-
- Advanced chapter & project options -
- - - -
-
- - -
-
- - -
-
- - -
-
- + +
+
+
+

Preview

+
+ 0 characters + · + 0 words +
+
+
Paste text to see a live preview and character count.
@@ -123,8 +85,9 @@
+{% endblock %} -
- {% include "partials/jobs.html" %} -
+{% block scripts %} + {{ super() }} + {% endblock %} diff --git a/abogen/web/templates/queue.html b/abogen/web/templates/queue.html new file mode 100644 index 0000000..cebde96 --- /dev/null +++ b/abogen/web/templates/queue.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}abogen · Queue{% endblock %} + +{% block content %} +
+ {% include "partials/jobs.html" %} +
+{% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html new file mode 100644 index 0000000..703099f --- /dev/null +++ b/abogen/web/templates/settings.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}abogen · Settings{% endblock %} + +{% block content %} +
+

Application settings

+

Settings apply to new jobs you queue from the dashboard.

+ + {% if saved %} +
Settings saved successfully.
+ {% endif %} + +
+
+ Output defaults +
+ + +
+
+ + +
+
+ + +

Default output: {{ default_output_dir }}

+
+
+ +
+ Processing +
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+{% endblock %} diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index 8fb86bc..392f2ab 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -55,13 +55,19 @@

Drag into the mixer or click Add.

- - + +
+ +
+ + +
+
From 97d81d78e203493ef67f939442d6da5664ef872b Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 07:27:04 -0700 Subject: [PATCH 009/245] feat: Refactor environment loading to use explicit path and find_dotenv for better configuration management --- abogen/utils.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/abogen/utils.py b/abogen/utils.py index 94144af..f27add2 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -10,9 +10,19 @@ from threading import Thread from functools import lru_cache -from dotenv import load_dotenv +from dotenv import load_dotenv, find_dotenv -load_dotenv() +def _load_environment() -> None: + explicit_path = os.environ.get("ABOGEN_ENV_FILE") + if explicit_path: + load_dotenv(explicit_path, override=False) + return + dotenv_path = find_dotenv(usecwd=True) + if dotenv_path: + load_dotenv(dotenv_path, override=False) + + +_load_environment() warnings.filterwarnings("ignore") From e44ba1e903b5a29eb9d2d1374572ad5c3378e48e Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 07:56:42 -0700 Subject: [PATCH 010/245] feat: Enhance voice mixer UI with new preview speed control and improved layout for profile actions --- abogen/utils.py | 14 +++ abogen/web/static/styles.css | 191 +++++++++++++++++++++++++++++-- abogen/web/static/voices.js | 30 +++++ abogen/web/templates/voices.html | 40 ++++--- 4 files changed, 254 insertions(+), 21 deletions(-) diff --git a/abogen/utils.py b/abogen/utils.py index f27add2..257c535 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -110,6 +110,20 @@ def get_user_settings_dir(): if override: return ensure_directory(override) + data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR") + if data_root: + try: + return ensure_directory(os.path.join(data_root, "settings")) + except OSError: + pass + + data_mount = "/data" + if os.path.isdir(data_mount): + try: + return ensure_directory(os.path.join(data_mount, "settings")) + except OSError: + pass + from platformdirs import user_config_dir if platform.system() != "Windows": diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index ed60614..6448c2a 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -594,11 +594,34 @@ progress.progress::-moz-progress-bar { } .voice-editor__meta { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + display: flex; + flex-direction: column; gap: 1rem; } +.voice-editor__identity { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + align-items: flex-end; +} + +.voice-editor__name-field { + flex: 1 1 220px; + min-width: 200px; +} + +.voice-editor__toolbar { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.voice-editor__language { + max-width: 260px; + width: min(100%, 260px); +} + .voice-editor__summary { display: flex; align-items: center; @@ -919,17 +942,56 @@ progress.progress::-moz-progress-bar { gap: 1.25rem; } -.button-row { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; -} - .voice-preview { display: grid; gap: 0.75rem; } +.field--slider { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.field--slider label { + display: flex; + align-items: center; + gap: 0.5rem; +} + +#preview-speed { + width: 100%; + appearance: none; + height: 8px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.25); + outline: none; + transition: box-shadow 0.2s ease; +} + +#preview-speed:focus-visible { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.2); +} + +#preview-speed::-webkit-slider-thumb { + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); +} + +#preview-speed::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + box-shadow: 0 6px 15px rgba(14, 165, 233, 0.4); +} + .voice-preview__controls { display: flex; flex-wrap: wrap; @@ -968,6 +1030,105 @@ progress.progress::-moz-progress-bar { pointer-events: none; } +.icon-button { + appearance: none; + border-radius: 16px; + width: 2.6rem; + height: 2.6rem; + display: grid; + place-items: center; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.4); + color: var(--muted); + cursor: pointer; + transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; +} + +.icon-button svg { + width: 1.15rem; + height: 1.15rem; +} + +.icon-button:hover { + border-color: var(--accent); + color: var(--accent); +} + +.icon-button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.icon-button:disabled { + opacity: 0.45; + cursor: not-allowed; + box-shadow: none; +} + +.icon-button--primary { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border: none; + color: #fff; + box-shadow: 0 10px 25px rgba(56, 189, 248, 0.32); +} + +.icon-button--primary:hover { + box-shadow: 0 14px 28px rgba(14, 165, 233, 0.4); +} + +.icon-button--danger { + background: linear-gradient(135deg, var(--danger), #ef4444); + border: none; + color: #fff; + box-shadow: 0 10px 25px rgba(239, 68, 68, 0.28); +} + +.icon-button--danger:hover { + box-shadow: 0 14px 30px rgba(248, 113, 113, 0.35); +} + +.icon-button--danger:disabled { + background: rgba(248, 113, 113, 0.2); + color: rgba(248, 113, 113, 0.65); + border: 1px solid rgba(248, 113, 113, 0.25); +} + +.icon-button--primary:disabled { + background: rgba(56, 189, 248, 0.25); + border: 1px solid rgba(56, 189, 248, 0.2); + color: rgba(255, 255, 255, 0.8); + box-shadow: none; +} + +.button[data-role="preview-button"] { + position: relative; +} + +.button[data-role="preview-button"][data-loading="true"] { + padding-left: 2.8rem; + color: rgba(255, 255, 255, 0.8); +} + +.button[data-role="preview-button"][data-loading="true"]::after { + content: ""; + position: absolute; + left: 1.1rem; + top: 50%; + width: 1rem; + height: 1rem; + margin-top: -0.5rem; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.35); + border-right-color: rgba(255, 255, 255, 0.85); + animation: spin 0.8s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .button[data-role="preview-button"][data-loading="true"]::after { + animation-duration: 1.6s; + } +} + @media (max-width: 980px) { .voice-mixer__layout { grid-template-columns: 1fr; @@ -1002,4 +1163,18 @@ progress.progress::-moz-progress-bar { .voice-mix__dropzone { min-height: 220px; } + + .field--slider label { + flex-direction: column; + align-items: flex-start; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } diff --git a/abogen/web/static/voices.js b/abogen/web/static/voices.js index 4880d63..1176a0a 100644 --- a/abogen/web/static/voices.js +++ b/abogen/web/static/voices.js @@ -19,6 +19,7 @@ const setupVoiceMixer = () => { const loadSampleBtn = app.querySelector('[data-role="load-sample"]'); const previewTextEl = app.querySelector('[data-role="preview-text"]'); const previewAudio = app.querySelector('[data-role="preview-audio"]'); + const previewSpeedLabel = app.querySelector('[data-role="preview-speed-display"]'); const profileSummaryEl = app.querySelector('[data-role="profile-summary"]'); const mixTotalEl = app.querySelector('[data-role="mix-total"]'); const nameInput = document.getElementById("profile-name"); @@ -33,6 +34,10 @@ const setupVoiceMixer = () => { const voiceFilterSelect = app.querySelector('[data-role="voice-filter"]'); const genderFilterEl = app.querySelector('[data-role="gender-filter"]'); + if (previewBtn && !previewBtn.dataset.label) { + previewBtn.dataset.label = previewBtn.textContent.trim(); + } + if (!profileListEl || !availableListEl || !selectedListEl) { return; } @@ -71,6 +76,15 @@ const setupVoiceMixer = () => { slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; }; + const setRangeFill = (slider) => { + if (!slider) return; + const min = parseFloat(slider.min || "0"); + const max = parseFloat(slider.max || "1"); + const value = parseFloat(slider.value || String(min)); + const percent = max === min ? 0 : Math.round(((value - min) / (max - min)) * 100); + slider.style.background = `linear-gradient(90deg, var(--accent) 0%, var(--accent) ${percent}%, rgba(148, 163, 184, 0.25) ${percent}%, rgba(148, 163, 184, 0.25) 100%)`; + }; + const voiceGenderIcon = (gender) => { if (!gender) return "•"; const initial = gender[0].toLowerCase(); @@ -683,6 +697,8 @@ const setupVoiceMixer = () => { } previewBtn.disabled = true; previewBtn.dataset.loading = "true"; + previewBtn.setAttribute("aria-busy", "true"); + previewBtn.textContent = "Previewing…"; setStatus("Generating preview…", "info", 0); try { const response = await fetch("/api/voice-profiles/preview", { @@ -708,6 +724,8 @@ const setupVoiceMixer = () => { } finally { previewBtn.disabled = false; previewBtn.dataset.loading = "false"; + previewBtn.textContent = previewBtn.dataset.label || "Preview mix"; + previewBtn.removeAttribute("aria-busy"); } }; @@ -752,6 +770,18 @@ const setupVoiceMixer = () => { }); } + if (speedInput) { + const updatePreviewSpeedLabel = () => { + const speed = parseFloat(speedInput.value || "1"); + if (previewSpeedLabel) { + previewSpeedLabel.textContent = `${speed.toFixed(2)}×`; + } + setRangeFill(speedInput); + }; + speedInput.addEventListener("input", updatePreviewSpeedLabel); + updatePreviewSpeedLabel(); + } + if (genderFilterEl) { genderFilterEl.addEventListener("click", (event) => { const target = event.target; diff --git a/abogen/web/templates/voices.html b/abogen/web/templates/voices.html index 392f2ab..8943762 100644 --- a/abogen/web/templates/voices.html +++ b/abogen/web/templates/voices.html @@ -26,11 +26,30 @@
-
- - +
+
+ + +
+
+ + + +
-
+
-
- - -
Select or create a profile to begin. @@ -84,16 +99,15 @@
-
- - - -
+
+ + +
From 1b907be3223887bdf9c3937ffa193fd9f6b844dd Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 08:13:05 -0700 Subject: [PATCH 011/245] feat: Adjust voice editor layout for improved alignment and spacing --- abogen/web/static/styles.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 6448c2a..c7c9ce9 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -603,7 +603,7 @@ progress.progress::-moz-progress-bar { display: flex; flex-wrap: wrap; gap: 0.75rem 1rem; - align-items: flex-end; + align-items: flex-start; } .voice-editor__name-field { @@ -615,6 +615,9 @@ progress.progress::-moz-progress-bar { display: inline-flex; align-items: center; gap: 0.5rem; + flex: 0 0 auto; + margin-left: auto; + min-height: 2.6rem; } .voice-editor__language { @@ -1044,6 +1047,11 @@ progress.progress::-moz-progress-bar { transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; } +.voice-editor__identity > .icon-button, +.voice-editor__identity > .voice-editor__toolbar { + align-self: flex-end; +} + .icon-button svg { width: 1.15rem; height: 1.15rem; From 5497697741d99c1601d60cbc9842b2108abc2ef9 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 09:20:33 -0700 Subject: [PATCH 012/245] feat: Enhance voice selection and settings UI with default voice option and improved layout --- abogen/web/routes.py | 24 ++++- abogen/web/static/dashboard.js | 46 ++++++++-- abogen/web/static/styles.css | 139 ++++++++++++++++++++++++++--- abogen/web/templates/index.html | 16 ++-- abogen/web/templates/settings.html | 38 ++++++-- 5 files changed, 226 insertions(+), 37 deletions(-) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 513e318..70ebe32 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -92,6 +92,15 @@ def _build_voice_catalog() -> List[Dict[str, str]]: def _template_options() -> Dict[str, Any]: profiles = serialize_profiles() ordered_profiles = sorted(profiles.items()) + profile_options = [] + for name, entry in ordered_profiles: + profile_options.append( + { + "name": name, + "language": (entry or {}).get("language", ""), + "formula": _formula_from_profile(entry or {}) or "", + } + ) return { "languages": LANGUAGE_DESCRIPTIONS, "voices": VOICES_INTERNAL, @@ -99,6 +108,7 @@ def _template_options() -> Dict[str, Any]: "supported_langs_for_subs": SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, "output_formats": SUPPORTED_SOUND_FORMATS, "voice_profiles": ordered_profiles, + "voice_profile_options": profile_options, "separate_formats": ["wav", "flac", "mp3", "opus"], "voice_catalog": _build_voice_catalog(), "sample_voice_texts": SAMPLE_VOICE_TEXTS, @@ -136,6 +146,7 @@ def _settings_defaults() -> Dict[str, Any]: "output_format": "wav", "subtitle_format": "srt", "save_mode": "default_output" if _has_output_override() else "save_next_to_input", + "default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", "replace_single_newlines": False, "use_gpu": True, "save_chapters_separately": False, @@ -201,6 +212,10 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> if normalized in {"wav", "flac", "mp3", "opus"}: return normalized return defaults[key] + if key == "default_voice": + if isinstance(value, str) and value in VOICES_INTERNAL: + return value + return defaults[key] return value if value is not None else defaults.get(key) @@ -339,7 +354,11 @@ def datetimeformat(value: float, fmt: str = "%Y-%m-%d %H:%M:%S") -> str: @web_bp.get("/") def index() -> str: - return render_template("index.html", options=_template_options()) + return render_template( + "index.html", + options=_template_options(), + settings=_load_settings(), + ) @web_bp.get("/queue") @@ -367,6 +386,9 @@ def settings_page() -> Response | str: updated["save_mode"] = _normalize_setting_value( "save_mode", form.get("save_mode"), defaults ) + updated["default_voice"] = _normalize_setting_value( + "default_voice", form.get("default_voice"), defaults + ) for key in sorted(BOOLEAN_SETTINGS): updated[key] = _coerce_bool(form.get(key), False) updated["separate_chapters_format"] = _normalize_setting_value( diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index a9fce26..f08b92a 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -4,6 +4,7 @@ const initDashboard = () => { const voiceSelect = document.querySelector('[data-role="voice-select"]'); const formulaField = document.querySelector('[data-role="formula-field"]'); const formulaInput = document.querySelector('[data-role="voice-formula"]'); + const languageSelect = document.getElementById("language"); const sourceText = document.querySelector('[data-role="source-text"]'); const previewEl = document.querySelector('[data-role="text-preview"]'); @@ -11,20 +12,45 @@ const initDashboard = () => { const charCountEl = document.querySelector('[data-role="char-count"]'); const wordCountEl = document.querySelector('[data-role="word-count"]'); + const hydrateDefaultVoice = () => { + if (!voiceSelect) return; + const defaultVoice = voiceSelect.dataset.default; + if (!defaultVoice) return; + const option = voiceSelect.querySelector(`option[value="${defaultVoice}"]`); + if (option) { + voiceSelect.value = defaultVoice; + } + }; + const updateVoiceControls = () => { if (!profileSelect) { return; } const value = profileSelect.value; - const showVoice = !value || value === "__standard"; - const showFormula = value === "__formula"; + const isStandard = !value || value === "__standard"; + const isFormula = value === "__formula"; + const isSavedProfile = Boolean(value && !isStandard && !isFormula); if (voiceField) { - voiceField.hidden = !showVoice; - voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true"); + voiceField.hidden = false; + voiceField.setAttribute("aria-hidden", "false"); } if (voiceSelect) { - voiceSelect.disabled = !showVoice; + voiceSelect.disabled = !isStandard; + voiceSelect.dataset.state = isStandard ? "editable" : "locked"; + } + + let showFormula = isFormula || isSavedProfile; + let presetFormula = ""; + if (isSavedProfile) { + const option = profileSelect.selectedOptions[0]; + if (option) { + presetFormula = option.dataset.formula || ""; + const profileLang = option.dataset.language || ""; + if (profileLang && languageSelect) { + languageSelect.value = profileLang; + } + } } if (formulaField) { @@ -33,9 +59,15 @@ const initDashboard = () => { } if (formulaInput) { formulaInput.disabled = !showFormula; - if (!showFormula) { + if (showFormula) { + if (presetFormula) { + formulaInput.value = presetFormula; + } + } else { formulaInput.value = formulaInput.value.trim(); } + formulaInput.dataset.state = isSavedProfile ? "locked" : "editable"; + formulaInput.readOnly = isSavedProfile; } }; @@ -74,6 +106,8 @@ const initDashboard = () => { updateVoiceControls(); } + hydrateDefaultVoice(); + if (sourceText) { sourceText.addEventListener("input", updatePreview); updatePreview(); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index c7c9ce9..8e67269 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -138,6 +138,14 @@ body { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); } +.form-grid { + align-items: start; +} + +.form-grid > .grid { + align-items: start; +} + .button { appearance: none; border: none; @@ -175,6 +183,20 @@ body { gap: 0.4rem; } +.field--full { + max-width: none; +} + +.field--full textarea { + min-height: 260px; +} + +.field--actions { + display: flex; + justify-content: flex-end; + align-items: center; +} + .field label { font-weight: 500; color: var(--muted); @@ -194,6 +216,18 @@ body { transition: border 0.2s ease, box-shadow 0.2s ease; } +.field input[type="text"], +.field input[type="file"], +.field input[type="number"], +.field select { + max-width: 420px; + width: min(100%, 420px); +} + +.field input[type="number"] { + max-width: 200px; +} + .field input:focus, .field select:focus, .field textarea:focus { @@ -376,19 +410,70 @@ body { border-radius: 18px; padding: 1.25rem 1.4rem; display: grid; - gap: 1rem; + gap: 1.1rem 1.4rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } .settings__section legend { font-weight: 600; padding: 0 0.5rem; color: var(--muted); + grid-column: 1 / -1; } -.field--inline { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; +.field--choices { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.85rem; +} + +.toggle-pill { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.65rem; + cursor: pointer; +} + +.toggle-pill input { + position: absolute; + opacity: 0; + inset: 0; + cursor: pointer; +} + +.toggle-pill span { + display: inline-flex; + align-items: center; + justify-content: flex-start; + width: 100%; + padding: 0.65rem 0.95rem; + border-radius: 16px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(15, 23, 42, 0.45); + color: var(--muted); + transition: border 0.2s ease, background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.toggle-pill:hover span { + border-color: var(--accent); + color: var(--accent); +} + +.toggle-pill input:focus-visible + span { + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.toggle-pill input:checked + span { + background: rgba(56, 189, 248, 0.15); + border-color: rgba(56, 189, 248, 0.4); + color: var(--accent); +} + +.hint { + margin: 0; + font-size: 0.8rem; + color: var(--muted); } .settings__actions { @@ -600,23 +685,21 @@ progress.progress::-moz-progress-bar { } .voice-editor__identity { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 0.75rem 1rem; - align-items: flex-start; + align-items: end; } .voice-editor__name-field { - flex: 1 1 220px; min-width: 200px; } .voice-editor__toolbar { display: inline-flex; align-items: center; - gap: 0.5rem; - flex: 0 0 auto; - margin-left: auto; + justify-content: flex-end; + gap: 0.65rem; min-height: 2.6rem; } @@ -1033,6 +1116,17 @@ progress.progress::-moz-progress-bar { pointer-events: none; } +[data-state="locked"] { + cursor: not-allowed; +} + +select[data-state="locked"], +input[data-state="locked"] { + background: rgba(15, 23, 42, 0.35); + color: rgba(148, 163, 184, 0.8); + border-color: rgba(148, 163, 184, 0.2); +} + .icon-button { appearance: none; border-radius: 16px; @@ -1047,9 +1141,8 @@ progress.progress::-moz-progress-bar { transition: color 0.2s ease, border 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; } -.voice-editor__identity > .icon-button, .voice-editor__identity > .voice-editor__toolbar { - align-self: flex-end; + align-self: end; } .icon-button svg { @@ -1178,6 +1271,24 @@ progress.progress::-moz-progress-bar { } } +@media (max-width: 720px) { + .voice-editor__identity { + grid-template-columns: 1fr; + } + + .voice-editor__toolbar { + justify-content: flex-start; + } + + .settings__section { + grid-template-columns: 1fr; + } + + .field--choices { + grid-template-columns: 1fr; + } +} + @keyframes spin { 0% { transform: rotate(0deg); diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 5ff4372..54c45cc 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -5,7 +5,7 @@ {% block content %}

Create a new audiobook

- +
@@ -21,9 +21,9 @@
- {% for voice in options.voices %} - + {% endfor %}
@@ -32,10 +32,10 @@
@@ -79,7 +79,7 @@
Paste text to see a live preview and character count.
-
+
diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 703099f..c9c584d 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -30,6 +30,15 @@ {% endfor %}
+
+ + +

Used when “Standard voice” is selected on the dashboard.

+
Replace single newlines - -
-
- - - +
+ + + + +
From 54bc632b2eced24c94f4d008e87e4086a1323f6f Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 10:37:34 -0700 Subject: [PATCH 013/245] feat: Improve UI consistency and accessibility across dashboard and settings pages --- abogen/web/static/dashboard.js | 39 ++++++++++++++++++++----- abogen/web/static/styles.css | 9 ++++-- abogen/web/templates/index.html | 47 +++++++++++++++--------------- abogen/web/templates/settings.html | 32 ++++++++++---------- 4 files changed, 77 insertions(+), 50 deletions(-) diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index f08b92a..7881705 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -22,6 +22,19 @@ const initDashboard = () => { } }; + const selectFirstProfileIfAvailable = () => { + if (!profileSelect) return false; + const saved = Array.from(profileSelect.options).filter( + (option) => option.value && option.value !== "__standard" && option.value !== "__formula", + ); + if (!saved.length) { + profileSelect.value = "__standard"; + return false; + } + saved[0].selected = true; + return true; + }; + const updateVoiceControls = () => { if (!profileSelect) { return; @@ -32,15 +45,18 @@ const initDashboard = () => { const isSavedProfile = Boolean(value && !isStandard && !isFormula); if (voiceField) { - voiceField.hidden = false; - voiceField.setAttribute("aria-hidden", "false"); + const showVoice = isStandard; + voiceField.hidden = !showVoice; + voiceField.setAttribute("aria-hidden", showVoice ? "false" : "true"); } if (voiceSelect) { voiceSelect.disabled = !isStandard; voiceSelect.dataset.state = isStandard ? "editable" : "locked"; + if (isStandard) { + hydrateDefaultVoice(); + } } - let showFormula = isFormula || isSavedProfile; let presetFormula = ""; if (isSavedProfile) { const option = profileSelect.selectedOptions[0]; @@ -53,6 +69,7 @@ const initDashboard = () => { } } + const showFormula = isFormula; if (formulaField) { formulaField.hidden = !showFormula; formulaField.setAttribute("aria-hidden", showFormula ? "false" : "true"); @@ -60,14 +77,16 @@ const initDashboard = () => { if (formulaInput) { formulaInput.disabled = !showFormula; if (showFormula) { - if (presetFormula) { + if (presetFormula && !formulaInput.value) { formulaInput.value = presetFormula; } + formulaInput.readOnly = false; + formulaInput.dataset.state = "editable"; } else { formulaInput.value = formulaInput.value.trim(); + formulaInput.readOnly = true; + formulaInput.dataset.state = isSavedProfile ? "locked" : "editable"; } - formulaInput.dataset.state = isSavedProfile ? "locked" : "editable"; - formulaInput.readOnly = isSavedProfile; } }; @@ -102,12 +121,16 @@ const initDashboard = () => { }; if (profileSelect) { + const hasSaved = selectFirstProfileIfAvailable(); profileSelect.addEventListener("change", updateVoiceControls); updateVoiceControls(); + if (!hasSaved) { + hydrateDefaultVoice(); + } + } else { + hydrateDefaultVoice(); } - hydrateDefaultVoice(); - if (sourceText) { sourceText.addEventListener("input", updatePreview); updatePreview(); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 8e67269..1edcf9d 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -1030,13 +1030,17 @@ progress.progress::-moz-progress-bar { .voice-preview { display: grid; - gap: 0.75rem; + gap: 0.9rem; + padding: 1rem 1.25rem; + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.35); } .field--slider { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.65rem; } .field--slider label { @@ -1088,6 +1092,7 @@ progress.progress::-moz-progress-bar { width: 100%; border-radius: 12px; background: rgba(15, 23, 42, 0.45); + border: 1px solid rgba(148, 163, 184, 0.18); } .voice-status { diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 54c45cc..7a32398 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -4,46 +4,45 @@ {% block content %}
-

Create a new audiobook

+

Create a New Audiobook

- +
- +
-
- +
+ + +
+ -
- - -

Manage mixes in the voice mixer.

-
-
+ +
+
+

Recent results

+ {% if total_finished > 0 %} + + {% endif %} +
+ {% if finished_jobs %} +
    + {% for job in finished_jobs %} +
  • +
    +
    + {{ job.original_filename }} +
    Finished {{ job.finished_at|datetimeformat }}
    +
    + {{ job.status.value|title }} +
    + +
  • + {% endfor %} + {% if total_finished > finished_jobs|length %} +
  • +
    {{ total_finished - finished_jobs|length }} more finished job{{ 's' if (total_finished - finished_jobs|length) != 1 else '' }} hidden. Clear finished to remove them.
    +
  • + {% endif %} +
+ {% else %} +

Completed jobs will appear here.

+ {% endif %} +
From b75e1c1b2ef55d09fd3f0ab936361ce91dc966e0 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 12:55:44 -0700 Subject: [PATCH 017/245] feat: Refactor queue page to use jobs panel rendering for improved performance --- abogen/web/routes.py | 3 +-- abogen/web/templates/queue.html | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 2f63fe8..618877b 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -363,8 +363,7 @@ def index() -> str: @web_bp.get("/queue") def queue_page() -> str: - jobs = _service().list_jobs() - return render_template("queue.html", jobs=jobs) + return render_template("queue.html", jobs_panel=_render_jobs_panel()) @web_bp.route("/settings", methods=["GET", "POST"]) diff --git a/abogen/web/templates/queue.html b/abogen/web/templates/queue.html index cebde96..36bdc07 100644 --- a/abogen/web/templates/queue.html +++ b/abogen/web/templates/queue.html @@ -4,6 +4,6 @@ {% block content %}
- {% include "partials/jobs.html" %} + {{ jobs_panel|safe }}
{% endblock %} From 7d132e6fcc1a802611449d149931b9d6d44a84d5 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 14:04:17 -0700 Subject: [PATCH 018/245] feat: Enhance voice resolution and logging in conversion job for improved feedback and performance --- abogen/web/conversion_runner.py | 66 ++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 113690b..de9aa03 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -79,11 +79,15 @@ def run_conversion_job(job: Job) -> None: chapter_dir = audio_dir / "chapters" chapter_dir.mkdir(parents=True, exist_ok=True) - voice = _resolve_voice(pipeline, job) + voice_spec = job.voice or "" + cached_voice = None + if "*" not in voice_spec: + cached_voice = _resolve_voice(pipeline, voice_spec, job.use_gpu) processed_chars = 0 subtitle_index = 1 current_time = 0.0 total_chapters = len(extraction.chapters) + job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") for idx, chapter in enumerate(extraction.chapters, start=1): canceller() @@ -106,18 +110,27 @@ def run_conversion_job(job: Job) -> None: fmt=job.separate_chapters_format, ) + voice_choice = cached_voice if cached_voice is not None else _resolve_voice( + pipeline, voice_spec, job.use_gpu + ) + + segments_emitted = 0 + for segment in pipeline( chapter.text, - voice=voice, + voice=voice_choice, speed=job.speed, split_pattern=SPLIT_PATTERN, ): canceller() - graphemes = segment.graphemes.strip() - if not graphemes: + graphemes_raw = getattr(segment, "graphemes", "") or "" + graphemes = graphemes_raw.strip() + + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size == 0: continue - audio = _to_float32(segment.audio) + segments_emitted += 1 if chapter_sink: chapter_sink.write(audio) if audio_sink: @@ -128,9 +141,13 @@ def run_conversion_job(job: Job) -> None: 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]}") + else: + job.progress = 0.0 if processed_chars == 0 else 0.999 - if subtitle_writer and audio_sink: + preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") + job.add_log(f"{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}") + + if subtitle_writer and audio_sink and graphemes: subtitle_writer.write_segment( index=subtitle_index, text=graphemes, @@ -144,9 +161,19 @@ def run_conversion_job(job: Job) -> None: if chapter_sink: chapter_sink_stack.close() + + if chapter_audio_path is not None: job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path chapter_paths.append(chapter_audio_path) + if segments_emitted == 0: + job.add_log( + f"No audio segments were generated for chapter {idx}.", + level="warning", + ) + else: + job.add_log(f"Finished chapter {idx} with {segments_emitted} segments.") + if ( audio_sink and job.merge_chapters_at_end @@ -340,16 +367,27 @@ def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str 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 _resolve_voice(pipeline, voice_spec: str, use_gpu: bool): + if "*" in voice_spec: + return get_new_voice(pipeline, voice_spec, use_gpu) + return voice_spec 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") + if audio_segment is None: + return np.zeros(0, dtype="float32") + + tensor = audio_segment + if hasattr(tensor, "detach"): + tensor = tensor.detach() + if hasattr(tensor, "cpu"): + try: + tensor = tensor.cpu() + except Exception: + pass + if hasattr(tensor, "numpy"): + return np.asarray(tensor.numpy(), dtype="float32").reshape(-1) + return np.asarray(tensor, dtype="float32").reshape(-1) class SubtitleWriter: From 26e0e764db9d3683c7b8aa599c0f37f9c1081418 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 14:30:55 -0700 Subject: [PATCH 019/245] feat: Add UID/GID configuration to .env.example and update README for container user settings --- .env.example | 7 +++++++ README.md | 11 +++++++++++ docker-compose.yaml | 1 + 3 files changed, 19 insertions(+) diff --git a/.env.example b/.env.example index e21e65c..1ec0651 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,10 @@ ABOGEN_SETTINGS_DIR=./config ABOGEN_TEMP_DIR=./storage/tmp ABOGEN_OUTPUT_DIR=./storage/output + +# UID/GID used when running the Docker container. 1000:1000 matches most Linux hosts. +# Find your current values with: +# id -u # UID +# id -g # GID +ABOGEN_UID=1000 +ABOGEN_GID=1000 diff --git a/README.md b/README.md index d288fab..aeec516 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,23 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `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 | +| `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | +| `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | | `ABOGEN_TEMP_DIR` | Platform cache dir (e.g. `~/.cache/abogen`) | Override the cache/temp directory | | `ABOGEN_OUTPUT_DIR` | Same as `ABOGEN_OUTPUT_ROOT` | Override the rendered output directory | | `ABOGEN_SETTINGS_DIR` | Platform config dir (e.g. `~/.config/abogen`) | Override where JSON settings (profiles, config) are stored | Set any of these with `-e VAR=value` when starting the container. +To discover your local UID/GID for matching file permissions inside the container, run: + +```bash +id -u +id -g +``` + +Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file. + ### Docker Compose (GPU by default) The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: diff --git a/docker-compose.yaml b/docker-compose.yaml index e856208..3564a48 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,7 @@ services: TORCH_INDEX_URL: ${TORCH_INDEX_URL:-https://download.pytorch.org/whl/cu124} TORCH_VERSION: ${TORCH_VERSION:-} image: abogen:latest + user: "${ABOGEN_UID:-1000}:${ABOGEN_GID:-1000}" ports: - "${ABOGEN_PORT:-8808}:8808" volumes: From 153d5ba92c4ec1b3632da3136b9edfe414d8ae2a Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 14:42:54 -0700 Subject: [PATCH 020/245] feat: Update environment configuration for Docker to include temporary directory settings --- .env.example | 7 ++++++- README.md | 6 +++++- abogen/Dockerfile | 5 +++-- docker-compose.yaml | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 1ec0651..0cffb77 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,14 @@ # Relative paths are resolved from the repository root when running locally. ABOGEN_SETTINGS_DIR=./config -ABOGEN_TEMP_DIR=./storage/tmp ABOGEN_OUTPUT_DIR=./storage/output +# Temporary working directory. When running in Docker, keep this inside the +# mounted /data volume so non-root users can write to it. For local (non-Docker) +# usage, change this to a path that makes sense on your machine or comment it out +# to fall back to the OS cache directory. +ABOGEN_TEMP_DIR=/data/cache + # UID/GID used when running the Docker container. 1000:1000 matches most Linux hosts. # Find your current values with: # id -u # UID diff --git a/README.md b/README.md index aeec516..ace3d4b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles | | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | -| `ABOGEN_TEMP_DIR` | Platform cache dir (e.g. `~/.cache/abogen`) | Override the cache/temp directory | +| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Override the cache/temp directory | | `ABOGEN_OUTPUT_DIR` | Same as `ABOGEN_OUTPUT_ROOT` | Override the rendered output directory | | `ABOGEN_SETTINGS_DIR` | Platform config dir (e.g. `~/.config/abogen`) | Override where JSON settings (profiles, config) are stored | @@ -67,6 +67,10 @@ id -g Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file. +When running via Docker Compose, the container defaults to `/data/cache` for +temporary files. Make sure the corresponding host directory is writable (the +compose volume at `${ABOGEN_DATA:-./data}` will automatically satisfy this). + ### Docker Compose (GPU by default) The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: diff --git a/abogen/Dockerfile b/abogen/Dockerfile index e389de3..81a2954 100644 --- a/abogen/Dockerfile +++ b/abogen/Dockerfile @@ -44,8 +44,9 @@ EXPOSE 8808 VOLUME ["/data"] ENV ABOGEN_UPLOAD_ROOT=/data/uploads \ - ABOGEN_OUTPUT_ROOT=/data/outputs + ABOGEN_OUTPUT_ROOT=/data/outputs \ + ABOGEN_TEMP_DIR=/data/cache -RUN mkdir -p /data/uploads /data/outputs +RUN mkdir -p /data/uploads /data/outputs /data/cache CMD ["abogen"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 3564a48..4d9c882 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -17,6 +17,7 @@ services: ABOGEN_PORT: 8808 ABOGEN_UPLOAD_ROOT: /data/uploads ABOGEN_OUTPUT_ROOT: /data/outputs + ABOGEN_TEMP_DIR: "${ABOGEN_TEMP_DIR:-/data/cache}" # --- GPU support ----------------------------------------------------- # These settings assume the NVIDIA Container Toolkit is installed. # Leave them in place for GPU acceleration; comment out the entire block From dc7a115e2e37524fd1377210c87f2547a9752c69 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 15:26:17 -0700 Subject: [PATCH 021/245] feat: Update environment variables and Docker configuration for improved directory management --- .env.example | 15 +++++++++++---- README.md | 17 ++++++++++------- abogen/utils.py | 40 +++++++++++++++++++++++++++++++++++++--- docker-compose.yaml | 9 +++++++-- 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 0cffb77..816abb4 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,21 @@ # Copy this file to `.env` and customize the paths to match your environment. # Relative paths are resolved from the repository root when running locally. +# Each `*_DIR` value below points to a directory on the host. Docker Compose +# mounts it into the container at the standard path noted in the comment. +# Host directory that stores JSON settings. Mounted to /config in Docker. ABOGEN_SETTINGS_DIR=./config + +# Host directory for rendered audio/subtitle files. Mounted to /data/outputs +# in Docker. ABOGEN_OUTPUT_DIR=./storage/output # Temporary working directory. When running in Docker, keep this inside the -# mounted /data volume so non-root users can write to it. For local (non-Docker) -# usage, change this to a path that makes sense on your machine or comment it out -# to fall back to the OS cache directory. -ABOGEN_TEMP_DIR=/data/cache +# mounted data volume (or another writable host path) so non-root users can +# write to it. For local (non-Docker) usage, change this to a path that makes +# sense on your machine or comment it out to fall back to the OS cache +# directory. Mounted to /data/cache in Docker. +ABOGEN_TEMP_DIR=./storage/tmp # UID/GID used when running the Docker container. 1000:1000 matches most Linux hosts. # Find your current values with: diff --git a/README.md b/README.md index ace3d4b..3fdd8f2 100644 --- a/README.md +++ b/README.md @@ -49,12 +49,12 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `ABOGEN_PORT` | `8808` | 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 | +| `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles (legacy alias of `ABOGEN_OUTPUT_DIR`) | +| `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles | +| `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration | +| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary files | | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | -| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Override the cache/temp directory | -| `ABOGEN_OUTPUT_DIR` | Same as `ABOGEN_OUTPUT_ROOT` | Override the rendered output directory | -| `ABOGEN_SETTINGS_DIR` | Platform config dir (e.g. `~/.config/abogen`) | Override where JSON settings (profiles, config) are stored | Set any of these with `-e VAR=value` when starting the container. @@ -67,9 +67,12 @@ id -g Use those values to populate `ABOGEN_UID` / `ABOGEN_GID` in your `.env` file. -When running via Docker Compose, the container defaults to `/data/cache` for -temporary files. Make sure the corresponding host directory is writable (the -compose volume at `${ABOGEN_DATA:-./data}` will automatically satisfy this). +When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`, +`ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host +directories you want mounted into the container. Compose maps them to +`/config`, `/data/outputs`, and `/data/cache` respectively while exporting +those in-container paths to the application. Ensure each host directory exists +and is writable by the UID/GID you configure before starting the stack. ### Docker Compose (GPU by default) The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: diff --git a/abogen/utils.py b/abogen/utils.py index 257c535..1d004dd 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -1,4 +1,5 @@ import json +import logging import os import platform import re @@ -142,14 +143,47 @@ def get_user_config_path(): # Define cache path @lru_cache(maxsize=1) def get_user_cache_root(): + logger = logging.getLogger(__name__) + + def _try_paths(*paths): + last_error = None + for candidate in paths: + if not candidate: + continue + try: + return ensure_directory(candidate) + except OSError as exc: + last_error = exc + logger.debug("Unable to use cache directory %s: %s", candidate, exc) + if last_error is not None: + raise last_error + override = os.environ.get("ABOGEN_TEMP_DIR") if override: - return ensure_directory(override) + try: + return ensure_directory(override) + except OSError as exc: + logger.warning("ABOGEN_TEMP_DIR=%s is not writable: %s", override, exc) from platformdirs import user_cache_dir - cache_dir = user_cache_dir("abogen", appauthor=False, opinion=True, ensure_exists=True) - return ensure_directory(cache_dir) + default_cache = user_cache_dir("abogen", appauthor=False, opinion=True) + + data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR") + fallback_paths = [ + default_cache, + os.path.join(data_root, "cache") if data_root else None, + "/data/cache", + "/tmp/abogen-cache", + ] + + try: + return _try_paths(*fallback_paths) + except OSError: + # Final safety net – attempt a tmp directory unique to this process. + tmp_candidate = os.path.join("/tmp", f"abogen-cache-{os.getpid()}") + logger.warning("Falling back to temp cache directory %s", tmp_candidate) + return ensure_directory(tmp_candidate) def get_user_cache_path(folder=None): diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d9c882..828029e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,12 +12,17 @@ services: - "${ABOGEN_PORT:-8808}:8808" volumes: - ${ABOGEN_DATA:-./data}:/data + - ${ABOGEN_SETTINGS_DIR:-./config}:/config + - ${ABOGEN_OUTPUT_DIR:-./storage/output}:/data/outputs + - ${ABOGEN_TEMP_DIR:-./storage/tmp}:/data/cache environment: ABOGEN_HOST: 0.0.0.0 ABOGEN_PORT: 8808 + ABOGEN_SETTINGS_DIR: "/config" ABOGEN_UPLOAD_ROOT: /data/uploads - ABOGEN_OUTPUT_ROOT: /data/outputs - ABOGEN_TEMP_DIR: "${ABOGEN_TEMP_DIR:-/data/cache}" + ABOGEN_OUTPUT_DIR: "/data/outputs" + ABOGEN_OUTPUT_ROOT: "/data/outputs" + ABOGEN_TEMP_DIR: "/data/cache" # --- GPU support ----------------------------------------------------- # These settings assume the NVIDIA Container Toolkit is installed. # Leave them in place for GPU acceleration; comment out the entire block From c19050261cb3443ecc511128ec67887dc5ac3613 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 15:50:32 -0700 Subject: [PATCH 022/245] feat: Configure cache environment variables for Hugging Face and update Docker settings --- .env.example | 7 +++--- README.md | 9 +++++++- abogen/utils.py | 53 ++++++++++++++++++++++++++++++--------------- docker-compose.yaml | 5 +++++ 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 816abb4..1b92287 100644 --- a/.env.example +++ b/.env.example @@ -12,9 +12,10 @@ ABOGEN_OUTPUT_DIR=./storage/output # Temporary working directory. When running in Docker, keep this inside the # mounted data volume (or another writable host path) so non-root users can -# write to it. For local (non-Docker) usage, change this to a path that makes -# sense on your machine or comment it out to fall back to the OS cache -# directory. Mounted to /data/cache in Docker. +# write to it. Hugging Face caches and other temp files are redirected here via +# XDG_CACHE_HOME/HF_HOME when running under Docker. For local (non-Docker) +# usage, change this to a path that makes sense on your machine or comment it +# out to fall back to the OS cache directory. Mounted to /data/cache in Docker. ABOGEN_TEMP_DIR=./storage/tmp # UID/GID used when running the Docker container. 1000:1000 matches most Linux hosts. diff --git a/README.md b/README.md index 3fdd8f2..b0488f0 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles | | `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration | | `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary files | +| `XDG_CACHE_HOME` | `/data/cache` | Base directory for cache data (used by many Python libraries) | +| `HF_HOME` | `/data/cache/huggingface` | Hugging Face model cache directory | +| `HUGGINGFACE_HUB_CACHE` | `/data/cache/huggingface` | Alias for Hugging Face hub cache | +| `TRANSFORMERS_CACHE` | `/data/cache/huggingface` | Transformers model cache directory | | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | @@ -71,7 +75,10 @@ When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`, `ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host directories you want mounted into the container. Compose maps them to `/config`, `/data/outputs`, and `/data/cache` respectively while exporting -those in-container paths to the application. Ensure each host directory exists +those in-container paths to the application. The cache mount is also shared +with common environment variables (`XDG_CACHE_HOME`, `HF_HOME`, +`HUGGINGFACE_HUB_CACHE`, `TRANSFORMERS_CACHE`) so libraries like Hugging Face +store downloads in the same writable location. Ensure each host directory exists and is writable by the UID/GID you configure before starting the stack. ### Docker Compose (GPU by default) diff --git a/abogen/utils.py b/abogen/utils.py index 1d004dd..c6835cf 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -158,32 +158,51 @@ def get_user_cache_root(): if last_error is not None: raise last_error + def _configure_cache_env(cache_path): + os.environ.setdefault("XDG_CACHE_HOME", cache_path) + + hf_cache = os.path.join(cache_path, "huggingface") + for env_var in ("HF_HOME", "HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"): + os.environ.setdefault(env_var, hf_cache) + + # Ensure Hugging Face cache directory exists so downloads succeed. + ensure_directory(hf_cache) + + if not os.environ.get("HOME"): + os.environ["HOME"] = os.path.dirname(cache_path) or "/" + + cache_root = None + override = os.environ.get("ABOGEN_TEMP_DIR") if override: try: - return ensure_directory(override) + cache_root = ensure_directory(override) except OSError as exc: logger.warning("ABOGEN_TEMP_DIR=%s is not writable: %s", override, exc) - from platformdirs import user_cache_dir + if cache_root is None: + from platformdirs import user_cache_dir - default_cache = user_cache_dir("abogen", appauthor=False, opinion=True) + default_cache = user_cache_dir("abogen", appauthor=False, opinion=True) - data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR") - fallback_paths = [ - default_cache, - os.path.join(data_root, "cache") if data_root else None, - "/data/cache", - "/tmp/abogen-cache", - ] + data_root = os.environ.get("ABOGEN_DATA") or os.environ.get("ABOGEN_DATA_DIR") + fallback_paths = [ + default_cache, + os.path.join(data_root, "cache") if data_root else None, + "/data/cache", + "/tmp/abogen-cache", + ] - try: - return _try_paths(*fallback_paths) - except OSError: - # Final safety net – attempt a tmp directory unique to this process. - tmp_candidate = os.path.join("/tmp", f"abogen-cache-{os.getpid()}") - logger.warning("Falling back to temp cache directory %s", tmp_candidate) - return ensure_directory(tmp_candidate) + try: + cache_root = _try_paths(*fallback_paths) + except OSError: + # Final safety net – attempt a tmp directory unique to this process. + tmp_candidate = os.path.join("/tmp", f"abogen-cache-{os.getpid()}") + logger.warning("Falling back to temp cache directory %s", tmp_candidate) + cache_root = ensure_directory(tmp_candidate) + + _configure_cache_env(cache_root) + return cache_root def get_user_cache_path(folder=None): diff --git a/docker-compose.yaml b/docker-compose.yaml index 828029e..4332c6a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,6 +23,11 @@ services: ABOGEN_OUTPUT_DIR: "/data/outputs" ABOGEN_OUTPUT_ROOT: "/data/outputs" ABOGEN_TEMP_DIR: "/data/cache" + HOME: "/data" + XDG_CACHE_HOME: "/data/cache" + HF_HOME: "/data/cache/huggingface" + HUGGINGFACE_HUB_CACHE: "/data/cache/huggingface" + TRANSFORMERS_CACHE: "/data/cache/huggingface" # --- GPU support ----------------------------------------------------- # These settings assume the NVIDIA Container Toolkit is installed. # Leave them in place for GPU acceleration; comment out the entire block From 523e55d8a419af4ab8a3d9f7efc793af37d5b33b Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 15:52:27 -0700 Subject: [PATCH 023/245] fix: Correct indentation for environment variables in docker-compose.yaml --- docker-compose.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4332c6a..7658840 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,11 +23,11 @@ services: ABOGEN_OUTPUT_DIR: "/data/outputs" ABOGEN_OUTPUT_ROOT: "/data/outputs" ABOGEN_TEMP_DIR: "/data/cache" - HOME: "/data" - XDG_CACHE_HOME: "/data/cache" - HF_HOME: "/data/cache/huggingface" - HUGGINGFACE_HUB_CACHE: "/data/cache/huggingface" - TRANSFORMERS_CACHE: "/data/cache/huggingface" + HOME: "/data" + XDG_CACHE_HOME: "/data/cache" + HF_HOME: "/data/cache/huggingface" + HUGGINGFACE_HUB_CACHE: "/data/cache/huggingface" + TRANSFORMERS_CACHE: "/data/cache/huggingface" # --- GPU support ----------------------------------------------------- # These settings assume the NVIDIA Container Toolkit is installed. # Leave them in place for GPU acceleration; comment out the entire block From 477c5055b46d4a8d61f3521d0790ab2a891d3957 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 16:14:49 -0700 Subject: [PATCH 024/245] feat: Update environment variables and Docker configuration for cache management and temporary directory settings --- .env.example | 9 +++++---- README.md | 16 ++++++---------- abogen/Dockerfile | 2 +- abogen/utils.py | 34 +++++++++++++++++++++++----------- docker-compose.yaml | 6 +----- pyproject.toml | 1 + 6 files changed, 37 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index 1b92287..8bcecc0 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,11 @@ ABOGEN_OUTPUT_DIR=./storage/output # Temporary working directory. When running in Docker, keep this inside the # mounted data volume (or another writable host path) so non-root users can -# write to it. Hugging Face caches and other temp files are redirected here via -# XDG_CACHE_HOME/HF_HOME when running under Docker. For local (non-Docker) -# usage, change this to a path that makes sense on your machine or comment it -# out to fall back to the OS cache directory. Mounted to /data/cache in Docker. +# write to it. Only audio conversion scratch files are staged here by default; +# other library caches remain inside the container volume. For local +# (non-Docker) usage, change this to a path that makes sense on your machine or +# comment it out to fall back to the OS cache directory. Mounted to /data/cache +# in Docker. ABOGEN_TEMP_DIR=./storage/tmp # UID/GID used when running the Docker container. 1000:1000 matches most Linux hosts. diff --git a/README.md b/README.md index b0488f0..66c0c3f 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,7 @@ Browse to http://localhost:8808. Uploaded source files are stored in `/data/uplo | `ABOGEN_OUTPUT_ROOT` | `/data/outputs` | Directory for generated audio and subtitles (legacy alias of `ABOGEN_OUTPUT_DIR`) | | `ABOGEN_OUTPUT_DIR` | `/data/outputs` | Container path for rendered audio/subtitles | | `ABOGEN_SETTINGS_DIR` | `/config` | Container path for JSON settings/configuration | -| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary files | -| `XDG_CACHE_HOME` | `/data/cache` | Base directory for cache data (used by many Python libraries) | -| `HF_HOME` | `/data/cache/huggingface` | Hugging Face model cache directory | -| `HUGGINGFACE_HUB_CACHE` | `/data/cache/huggingface` | Alias for Hugging Face hub cache | -| `TRANSFORMERS_CACHE` | `/data/cache/huggingface` | Transformers model cache directory | +| `ABOGEN_TEMP_DIR` | `/data/cache` (Docker) or platform cache dir | Container path for temporary audio working files | | `ABOGEN_UID` | `1000` | UID that the container should run as (matches host user) | | `ABOGEN_GID` | `1000` | GID that the container should run as (matches host group) | @@ -75,11 +71,11 @@ When running via Docker Compose, set `ABOGEN_SETTINGS_DIR`, `ABOGEN_OUTPUT_DIR`, and `ABOGEN_TEMP_DIR` in your `.env` file to the host directories you want mounted into the container. Compose maps them to `/config`, `/data/outputs`, and `/data/cache` respectively while exporting -those in-container paths to the application. The cache mount is also shared -with common environment variables (`XDG_CACHE_HOME`, `HF_HOME`, -`HUGGINGFACE_HUB_CACHE`, `TRANSFORMERS_CACHE`) so libraries like Hugging Face -store downloads in the same writable location. Ensure each host directory exists -and is writable by the UID/GID you configure before starting the stack. +those in-container paths to the application. Non-audio caches (e.g., Hugging +Face downloads) stick to the container's internal cache under `/tmp/abogen-home/.cache` +by default, so only conversion scratch data touches the mounted `ABOGEN_TEMP_DIR`. +Ensure each host directory exists and is writable by the UID/GID you configure +before starting the stack. ### Docker Compose (GPU by default) The repo includes `docker-compose.yaml`, which targets GPU hosts out of the box. Install the NVIDIA Container Toolkit and run: diff --git a/abogen/Dockerfile b/abogen/Dockerfile index 81a2954..7f52664 100644 --- a/abogen/Dockerfile +++ b/abogen/Dockerfile @@ -34,7 +34,7 @@ RUN pip install --upgrade pip \ else \ pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \ fi \ - && pip install --no-cache-dir . + && pip install --no-cache-dir . en-core-web-sm==3.8.0 ENV ABOGEN_HOST=0.0.0.0 \ ABOGEN_PORT=8808 diff --git a/abogen/utils.py b/abogen/utils.py index c6835cf..f4308b7 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -158,19 +158,31 @@ def get_user_cache_root(): if last_error is not None: raise last_error - def _configure_cache_env(cache_path): - os.environ.setdefault("XDG_CACHE_HOME", cache_path) + def _configure_cache_env(): + home_dir = os.environ.get("HOME") + if not home_dir: + home_dir = ensure_directory(os.path.join("/tmp", "abogen-home")) + os.environ["HOME"] = home_dir + else: + home_dir = ensure_directory(home_dir) - hf_cache = os.path.join(cache_path, "huggingface") - for env_var in ("HF_HOME", "HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"): + cache_base = os.environ.get("XDG_CACHE_HOME") + if cache_base: + cache_base = ensure_directory(cache_base) + else: + cache_base = ensure_directory(os.path.join(home_dir, ".cache")) + os.environ["XDG_CACHE_HOME"] = cache_base + + hf_cache = os.environ.get("HF_HOME") + if hf_cache: + hf_cache = ensure_directory(hf_cache) + else: + hf_cache = ensure_directory(os.path.join(cache_base, "huggingface")) + os.environ["HF_HOME"] = hf_cache + + for env_var in ("HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"): os.environ.setdefault(env_var, hf_cache) - # Ensure Hugging Face cache directory exists so downloads succeed. - ensure_directory(hf_cache) - - if not os.environ.get("HOME"): - os.environ["HOME"] = os.path.dirname(cache_path) or "/" - cache_root = None override = os.environ.get("ABOGEN_TEMP_DIR") @@ -201,7 +213,7 @@ def get_user_cache_root(): logger.warning("Falling back to temp cache directory %s", tmp_candidate) cache_root = ensure_directory(tmp_candidate) - _configure_cache_env(cache_root) + _configure_cache_env() return cache_root diff --git a/docker-compose.yaml b/docker-compose.yaml index 7658840..d572051 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,11 +23,7 @@ services: ABOGEN_OUTPUT_DIR: "/data/outputs" ABOGEN_OUTPUT_ROOT: "/data/outputs" ABOGEN_TEMP_DIR: "/data/cache" - HOME: "/data" - XDG_CACHE_HOME: "/data/cache" - HF_HOME: "/data/cache/huggingface" - HUGGINGFACE_HUB_CACHE: "/data/cache/huggingface" - TRANSFORMERS_CACHE: "/data/cache/huggingface" + HOME: "/tmp/abogen-home" # --- GPU support ----------------------------------------------------- # These settings assume the NVIDIA Container Toolkit is installed. # Leave them in place for GPU acceleration; comment out the entire block diff --git a/pyproject.toml b/pyproject.toml index a16632d..bf24a85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "misaki[zh]>=0.9.4", "ebooklib>=0.19", "beautifulsoup4>=4.13.4", + "en-core-web-sm==3.8.0", "PyMuPDF>=1.25.5", "platformdirs>=4.3.7", "soundfile>=0.13.1", From 0d74171bb58416d69615dfa21b5f7f4a8a5756b0 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 16:19:28 -0700 Subject: [PATCH 025/245] feat: Update en-core-web-sm dependency to use direct URL for installation --- abogen/Dockerfile | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/abogen/Dockerfile b/abogen/Dockerfile index 7f52664..3c7d53b 100644 --- a/abogen/Dockerfile +++ b/abogen/Dockerfile @@ -34,7 +34,8 @@ RUN pip install --upgrade pip \ else \ pip install torch torchvision torchaudio --index-url "$TORCH_INDEX_URL"; \ fi \ - && pip install --no-cache-dir . en-core-web-sm==3.8.0 + && pip install --no-cache-dir . \ + https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl ENV ABOGEN_HOST=0.0.0.0 \ ABOGEN_PORT=8808 diff --git a/pyproject.toml b/pyproject.toml index bf24a85..1cc84d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "misaki[zh]>=0.9.4", "ebooklib>=0.19", "beautifulsoup4>=4.13.4", - "en-core-web-sm==3.8.0", + "en-core-web-sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl", "PyMuPDF>=1.25.5", "platformdirs>=4.3.7", "soundfile>=0.13.1", From e76701ab325ac9d68f54eaa78229f444530e5ef1 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 16:23:57 -0700 Subject: [PATCH 026/245] feat: Add allow-direct-references setting to hatch metadata --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 1cc84d1..bc43ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ Documentation = "https://github.com/denizsafak/abogen" Repository = "https://github.com/denizsafak/abogen" Issues = "https://github.com/denizsafak/abogen/issues" +[tool.hatch.metadata] +allow-direct-references = true + [project.gui-scripts] abogen = "abogen.web.app:main" From fd8ede318ff68b948ca31128b809485bd08144db Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 16:34:01 -0700 Subject: [PATCH 027/245] feat: Implement internal cache path management for improved resource handling --- abogen/utils.py | 18 ++++++++++++++++++ abogen/web/conversion_runner.py | 4 +++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/abogen/utils.py b/abogen/utils.py index f4308b7..acd580e 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -183,6 +183,8 @@ def get_user_cache_root(): for env_var in ("HUGGINGFACE_HUB_CACHE", "TRANSFORMERS_CACHE"): os.environ.setdefault(env_var, hf_cache) + os.environ.setdefault("ABOGEN_INTERNAL_CACHE_ROOT", cache_base) + cache_root = None override = os.environ.get("ABOGEN_TEMP_DIR") @@ -217,6 +219,22 @@ def get_user_cache_root(): return cache_root +def get_internal_cache_root(): + root = os.environ.get("ABOGEN_INTERNAL_CACHE_ROOT") or os.environ.get("XDG_CACHE_HOME") + if root: + return ensure_directory(root) + home_dir = os.environ.get("HOME") or os.path.join("/tmp", "abogen-home") + home_dir = ensure_directory(home_dir) + return ensure_directory(os.path.join(home_dir, ".cache")) + + +def get_internal_cache_path(folder=None): + base = get_internal_cache_root() + if folder: + return ensure_directory(os.path.join(base, folder)) + return base + + def get_user_cache_path(folder=None): base = get_user_cache_root() if folder: diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index de9aa03..e51dbcf 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -17,6 +17,7 @@ from abogen.text_extractor import ExtractedChapter, extract_from_path from abogen.utils import ( calculate_text_length, create_process, + get_internal_cache_path, get_user_cache_path, get_user_output_path, load_config, @@ -310,7 +311,8 @@ def _open_audio_sink( fmt: Optional[str] = None, metadata: Optional[Dict[str, str]] = None, ) -> AudioSink: - static_ffmpeg.add_paths() + ffmpeg_cache = get_internal_cache_path("ffmpeg") + static_ffmpeg.add_paths(download_dir=ffmpeg_cache) fmt_value = (fmt or job.output_format).lower() if fmt_value in {"wav", "flac"}: From 43bee0b76e4eb8303d077270960815aeddf7f4ad Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 16:50:40 -0700 Subject: [PATCH 028/245] feat: Enhance audio sink with internal ffmpeg cache directory management --- abogen/web/conversion_runner.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index e51dbcf..b9c76ad 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import math +import os import re import subprocess from contextlib import ExitStack @@ -312,6 +313,14 @@ def _open_audio_sink( metadata: Optional[Dict[str, str]] = None, ) -> AudioSink: ffmpeg_cache = get_internal_cache_path("ffmpeg") + os.makedirs(ffmpeg_cache, exist_ok=True) + try: + import static_ffmpeg.run as static_ffmpeg_run # type: ignore + + static_ffmpeg_run.LOCK_FILE = os.path.join(ffmpeg_cache, "lock.file") + except Exception: + pass + static_ffmpeg.add_paths(download_dir=ffmpeg_cache) fmt_value = (fmt or job.output_format).lower() From c8e9eb6fd2aa17fd7bc81be6ce82b3193a946159 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 17:05:50 -0700 Subject: [PATCH 029/245] feat: Update audio sink to manage ffmpeg cache by platform --- abogen/web/conversion_runner.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index b9c76ad..37bb02e 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -5,6 +5,7 @@ import math import os import re import subprocess +import sys from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path @@ -312,16 +313,17 @@ def _open_audio_sink( fmt: Optional[str] = None, metadata: Optional[Dict[str, str]] = None, ) -> AudioSink: - ffmpeg_cache = get_internal_cache_path("ffmpeg") - os.makedirs(ffmpeg_cache, exist_ok=True) + ffmpeg_cache_root = get_internal_cache_path("ffmpeg") + platform_cache = os.path.join(ffmpeg_cache_root, sys.platform) + os.makedirs(platform_cache, exist_ok=True) try: import static_ffmpeg.run as static_ffmpeg_run # type: ignore - static_ffmpeg_run.LOCK_FILE = os.path.join(ffmpeg_cache, "lock.file") + static_ffmpeg_run.LOCK_FILE = os.path.join(ffmpeg_cache_root, "lock.file") except Exception: pass - static_ffmpeg.add_paths(download_dir=ffmpeg_cache) + static_ffmpeg.add_paths(weak=True, download_dir=platform_cache) fmt_value = (fmt or job.output_format).lower() if fmt_value in {"wav", "flac"}: From 85310ad916fd58874f443e274b1f54f113546c95 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 6 Oct 2025 18:05:12 -0700 Subject: [PATCH 030/245] feat: Implement chapter overrides and metadata merging in conversion process - Added `_coerce_truthy` function to handle truthy value coercion. - Introduced `_apply_chapter_overrides` to apply chapter modifications based on provided overrides. - Implemented `_merge_metadata` to combine extracted metadata with overrides, ensuring proper handling of None values. - Updated `run_conversion_job` to utilize new chapter override and metadata merging functionalities. - Modified `Job` class to store chapters as dictionaries for better flexibility. - Enhanced `ConversionService` to normalize chapter input and metadata tags. - Added comprehensive tests for chapter overrides and metadata merging to ensure functionality and correctness. --- abogen/text_extractor.py | 819 +++++++++++++++++++++++++++++--- abogen/web/conversion_runner.py | 150 +++++- abogen/web/service.py | 148 +++++- tests/test_chapter_overrides.py | 183 +++++++ 4 files changed, 1217 insertions(+), 83 deletions(-) create mode 100644 tests/test_chapter_overrides.py diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index 37ad876..e175051 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -1,18 +1,38 @@ from __future__ import annotations +import datetime +import logging +import re +import textwrap +import urllib.parse from dataclasses import dataclass, field from pathlib import Path -import re -from typing import List, Sequence +from typing import Dict, List, Optional, Tuple -import ebooklib -import fitz -import markdown -from bs4 import BeautifulSoup -from ebooklib import epub +import ebooklib # type: ignore[import] +import fitz # type: ignore[import] +import markdown # type: ignore[import] +from bs4 import BeautifulSoup, NavigableString # type: ignore[import] +from ebooklib import epub # type: ignore[import] from .utils import clean_text, detect_encoding +logger = logging.getLogger(__name__) + +METADATA_PATTERN = re.compile(r"<>", re.DOTALL) +CHAPTER_PATTERN = re.compile(r"<>", re.IGNORECASE) +METADATA_KEY_MAP: Dict[str, str] = { + "TITLE": "title", + "ARTIST": "artist", + "ALBUM": "album", + "YEAR": "year", + "ALBUM_ARTIST": "album_artist", + "ALBUMARTIST": "album_artist", + "COMPOSER": "composer", + "GENRE": "genre", + "DATE": "date", +} + @dataclass class ExtractedChapter: @@ -27,7 +47,7 @@ class ExtractedChapter: @dataclass class ExtractionResult: chapters: List[ExtractedChapter] - metadata: dict[str, str] = field(default_factory=dict) + metadata: Dict[str, str] = field(default_factory=dict) @property def combined_text(self) -> str: @@ -38,6 +58,24 @@ class ExtractionResult: return sum(chapter.characters for chapter in self.chapters) +@dataclass +class MetadataSource: + title: Optional[str] = None + authors: List[str] = field(default_factory=list) + description: Optional[str] = None + publisher: Optional[str] = None + publication_year: Optional[str] = None + + +@dataclass +class NavEntry: + src: str + title: str + doc_href: str + position: int + doc_order: int + + def extract_from_path(path: Path) -> ExtractionResult: suffix = path.suffix.lower() if suffix == ".txt": @@ -57,20 +95,27 @@ def _extract_plaintext(path: Path) -> ExtractionResult: return _extract_from_string(raw, default_title=path.stem) -METADATA_PATTERN = re.compile(r"<>", re.DOTALL) -CHAPTER_PATTERN = re.compile(r"<>", re.IGNORECASE) - - def _extract_from_string(raw: str, default_title: str) -> ExtractionResult: - metadata, body = _strip_metadata(raw) + raw_metadata, body = _strip_metadata(raw) chapters = _split_chapters(body, default_title) + normalized_tags = _normalize_metadata_keys(raw_metadata) + chapter_count = len(chapters) + artist_value = normalized_tags.get("artist") + authors = [name.strip() for name in artist_value.split(",") if name.strip()] if artist_value else [] + metadata_source = MetadataSource( + title=normalized_tags.get("title") or default_title, + authors=authors, + publication_year=normalized_tags.get("year"), + ) + metadata = _build_metadata_payload(metadata_source, chapter_count, "text", default_title) + metadata.update(normalized_tags) 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 _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() @@ -107,82 +152,710 @@ def _split_chapters(content: str, default_title: str) -> List[ExtractedChapter]: 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: +def _normalize_metadata_keys(metadata: Dict[str, str]) -> Dict[str, str]: + normalized: Dict[str, str] = {} + for key, value in metadata.items(): + if not value: continue - title = f"Page {index + 1}" - chapters.append(ExtractedChapter(title=title, text=text)) + mapped = METADATA_KEY_MAP.get(key.upper(), key.lower()) + normalized[mapped] = value + return normalized + + +def _build_metadata_payload( + metadata_source: MetadataSource, + chapter_count: int, + file_type: str, + default_title: str, +) -> Dict[str, str]: + now_year = str(datetime.datetime.now().year) + title = metadata_source.title.strip() if metadata_source.title else default_title + if not title: + title = default_title + authors = [author for author in metadata_source.authors if author.strip()] + if not authors: + authors = ["Unknown"] + authors_text = ", ".join(authors) + if chapter_count <= 0: + chapter_count = 1 + chapter_label = "Chapters" if file_type in {"epub", "markdown"} else "Pages" + metadata = { + "TITLE": title, + "ARTIST": authors_text, + "ALBUM": f"{title} ({chapter_count} {chapter_label})", + "YEAR": metadata_source.publication_year or now_year, + "ALBUM_ARTIST": authors_text, + "COMPOSER": "Narrator", + "GENRE": "Audiobook", + } + return _normalize_metadata_keys(metadata) + + +def _extract_pdf(path: Path) -> ExtractionResult: + metadata_source = MetadataSource() + chapters: List[ExtractedChapter] = [] + with fitz.open(str(path)) as document: + metadata_source = _collect_pdf_metadata(document) + for index, page in enumerate(document): + text = _clean_pdf_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) + metadata = _build_metadata_payload(metadata_source, len(chapters), "pdf", path.stem) + return ExtractionResult(chapters=chapters, metadata=metadata) + + +def _collect_pdf_metadata(document: fitz.Document) -> MetadataSource: + metadata = MetadataSource() + info = document.metadata or {} + if info.get("title"): + metadata.title = info["title"] + if info.get("author"): + metadata.authors = [info["author"]] + if info.get("subject"): + metadata.description = info["subject"] + if info.get("keywords"): + keywords = info["keywords"] + if metadata.description: + metadata.description = f"{metadata.description}\n\nKeywords: {keywords}" + else: + metadata.description = f"Keywords: {keywords}" + if info.get("creator"): + metadata.publisher = info["creator"] + for key in ("creationDate", "modDate"): + value = info.get(key) + if not value: + continue + match = re.search(r"D:(\d{4})", value) + if match: + metadata.publication_year = match.group(1) + break + return metadata + + +def _clean_pdf_text(text: str) -> str: + cleaned = clean_text(text) + cleaned = re.sub(r"\[\s*\d+\s*\]", "", cleaned) + cleaned = re.sub(r"^\s*\d+\s*$", "", cleaned, flags=re.MULTILINE) + cleaned = re.sub(r"\s+\d+\s*$", "", cleaned, flags=re.MULTILINE) + cleaned = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", cleaned, flags=re.MULTILINE) + return cleaned.strip() 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)) + metadata_source, chapters = _parse_markdown(raw, path.stem) if not chapters: - chapters.append(ExtractedChapter(title=path.stem, text=clean_text(raw))) - return ExtractionResult(chapters) + chapters = [ExtractedChapter(title=metadata_source.title or path.stem, text=clean_text(raw))] + metadata = _build_metadata_payload(metadata_source, len(chapters), "markdown", path.stem) + return ExtractionResult(chapters=chapters, metadata=metadata) -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 _parse_markdown(raw: str, default_title: str) -> Tuple[MetadataSource, List[ExtractedChapter]]: + metadata = MetadataSource() + text = textwrap.dedent(raw) + frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL) + if frontmatter_match: + frontmatter = frontmatter_match.group(1) + _parse_markdown_frontmatter(frontmatter, metadata) + text_body = text[frontmatter_match.end():] + else: + text_body = text + + md = markdown.Markdown(extensions=["toc", "fenced_code"]) + html = md.convert(text_body) + toc_tokens = getattr(md, "toc_tokens", None) or [] + + if not toc_tokens: + cleaned = clean_text(text_body) + title = metadata.title or default_title + chapters = [ExtractedChapter(title=title, text=cleaned)] if cleaned else [] + return metadata, chapters + + headers: List[dict] = [] + + def _flatten_tokens(tokens): + for token in tokens: + headers.append(token) + if token.get("children"): + _flatten_tokens(token["children"]) + + _flatten_tokens(toc_tokens) + + header_positions: List[Tuple[str, int, str]] = [] + for header in headers: + header_id = header.get("id") + if not header_id: + continue + id_pattern = f'id="{header_id}"' + pos = html.find(id_pattern) + if pos == -1: + continue + tag_start = html.rfind("<", 0, pos) + name = str(header.get("name", header_id)) + header_positions.append((header_id, tag_start, name)) + + header_positions.sort(key=lambda item: item[1]) + + chapters: List[ExtractedChapter] = [] + for index, (header_id, start, name) in enumerate(header_positions): + end = header_positions[index + 1][1] if index + 1 < len(header_positions) else len(html) + section_html = html[start:end] + section_soup = BeautifulSoup(section_html, "html.parser") + header_tag = section_soup.find(attrs={"id": header_id}) + if header_tag: + header_tag.decompose() + section_text = clean_text(section_soup.get_text()).strip() + if not section_text: + continue + chapters.append(ExtractedChapter(title=name.strip(), text=section_text)) + + if not metadata.title: + first_h1 = next((header for header in headers if header.get("level") == 1 and header.get("name")), None) + if first_h1: + metadata.title = str(first_h1["name"]) + + return metadata, chapters + + +def _parse_markdown_frontmatter(frontmatter: str, metadata: MetadataSource) -> None: + title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if title_match: + metadata.title = title_match.group(1).strip().strip('"\'') + + author_match = re.search(r"^author:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if author_match: + metadata.authors = [author_match.group(1).strip().strip('"\'')] + + desc_match = re.search(r"^description:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if desc_match: + metadata.description = desc_match.group(1).strip().strip('"\'') + + date_match = re.search(r"^date:\s*(.+)$", frontmatter, re.MULTILINE | re.IGNORECASE) + if date_match: + date_str = date_match.group(1).strip().strip('\"\'') + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + if year_match: + metadata.publication_year = year_match.group(0) 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") + extractor = EpubExtractor(path) + return extractor.extract() + + +class EpubExtractor: + def __init__(self, path: Path) -> None: + self.path = path + self.book = epub.read_epub(str(path)) + self.doc_content: Dict[str, str] = {} + self.spine_docs: List[str] = [] + + def extract(self) -> ExtractionResult: + metadata_source = self._collect_metadata() + try: + chapters = self._process_nav() + except Exception as exc: + logger.warning( + "EPUB navigation processing failed for %s: %s. Falling back to spine order.", + self.path.name, + exc, + exc_info=True, + ) + chapters = self._process_spine_fallback() + if not chapters: + chapters = [ExtractedChapter(title=self.path.stem, text="")] + metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem) + return ExtractionResult(chapters=chapters, metadata=metadata) + + def _collect_metadata(self) -> MetadataSource: + metadata = MetadataSource() + try: + title_items = self.book.get_metadata("DC", "title") + if title_items: + metadata.title = title_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB title metadata: %s", exc) + + try: + author_items = self.book.get_metadata("DC", "creator") + if author_items: + metadata.authors = [author[0] for author in author_items if author and author[0]] + except Exception as exc: + logger.debug("Failed to extract EPUB author metadata: %s", exc) + + try: + desc_items = self.book.get_metadata("DC", "description") + if desc_items: + metadata.description = desc_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB description metadata: %s", exc) + + try: + publisher_items = self.book.get_metadata("DC", "publisher") + if publisher_items: + metadata.publisher = publisher_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB publisher metadata: %s", exc) + + try: + date_items = self.book.get_metadata("DC", "date") + if date_items: + date_str = date_items[0][0] + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) + metadata.publication_year = year_match.group(0) if year_match else date_str + except Exception as exc: + logger.debug("Failed to extract EPUB publication year metadata: %s", exc) + + return metadata + + def _process_nav(self) -> List[ExtractedChapter]: + nav_item, nav_type = self._find_navigation_item() + if not nav_item or not nav_type: + raise ValueError("No navigation document found") + + parser_type = "html.parser" if nav_type == "html" else "xml" + nav_content = nav_item.get_content().decode("utf-8", errors="ignore") + nav_soup = BeautifulSoup(nav_content, parser_type) + + self.spine_docs = self._build_spine_docs() + doc_order = {href: index for index, href in enumerate(self.spine_docs)} + doc_order_decoded = {urllib.parse.unquote(href): index for href, index in doc_order.items()} + + nav_targets = self._collect_nav_targets(nav_soup, nav_type) + self._cache_relevant_documents(doc_order, nav_targets) + + ordered_entries: List[NavEntry] = [] + if nav_type == "ncx": + nav_map = nav_soup.find("navMap") + if not nav_map: + raise ValueError("NCX navigation missing ") + for nav_point in nav_map.find_all("navPoint", recursive=False): + self._parse_ncx_navpoint(nav_point, ordered_entries, doc_order, doc_order_decoded) + else: + toc_nav = nav_soup.find("nav", attrs={"epub:type": "toc"}) + if toc_nav is None: + for nav in nav_soup.find_all("nav"): + if nav.find("ol"): + toc_nav = nav + break + if toc_nav is None: + raise ValueError("NAV HTML missing TOC structure") + top_ol = toc_nav.find("ol", recursive=False) + if top_ol is None: + raise ValueError("TOC navigation missing
    ") + for li in top_ol.find_all("li", recursive=False): + self._parse_html_nav_li(li, ordered_entries, doc_order, doc_order_decoded) + + if not ordered_entries: + raise ValueError("No navigation entries found") + + ordered_entries.sort(key=lambda entry: (entry.doc_order, entry.position)) + chapters = self._slice_entries(ordered_entries) + self._append_prefix_content(ordered_entries, chapters) + return chapters + + def _process_spine_fallback(self) -> List[ExtractedChapter]: + chapters: List[ExtractedChapter] = [] + self.spine_docs = self._build_spine_docs() + self.doc_content = {} + + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href not in self.spine_docs: + continue + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + except Exception as exc: + logger.error("Error decoding EPUB document %s: %s", href, exc) + html_content = "" + self.doc_content[href] = html_content + + for index, doc_href in enumerate(self.spine_docs): + html_content = self.doc_content.get(doc_href, "") + if not html_content: + continue + text = self._html_to_text(html_content) + if not text: + continue + title = self._resolve_document_title(html_content, fallback=f"Untitled Chapter {index + 1}") + chapters.append(ExtractedChapter(title=title, text=text)) + return chapters + + def _find_navigation_item(self) -> Tuple[Optional[ebooklib.epub.EpubItem], Optional[str]]: + nav_item: Optional[ebooklib.epub.EpubItem] = None + nav_type: Optional[str] = None + + nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) + if nav_items: + preferred = next( + ( + item + for item in nav_items + if "nav" in item.get_name().lower() and item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if preferred: + nav_item = preferred + nav_type = "html" + else: + html_nav = next( + ( + item + for item in nav_items + if item.get_name().lower().endswith((".xhtml", ".html")) + ), + None, + ) + if html_nav: + nav_item = html_nav + nav_type = "html" + + if not nav_item and nav_items: + ncx_candidate = next( + (item for item in nav_items if item.get_name().lower().endswith(".ncx")), + None, + ) + if ncx_candidate: + nav_item = ncx_candidate + nav_type = "ncx" + + if not nav_item: + ncx_constant = getattr(epub, "ITEM_NCX", None) + if ncx_constant is not None: + ncx_items = list(self.book.get_items_of_type(ncx_constant)) + if ncx_items: + nav_item = ncx_items[0] + nav_type = "ncx" + + if not nav_item: + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + except Exception: + continue + if " List[str]: + docs: List[str] = [] + for spine_entry in self.book.spine: + item_id = spine_entry[0] + item = self.book.get_item_with_id(item_id) + if item: + docs.append(item.get_name()) + return docs + + def _collect_nav_targets(self, nav_soup: BeautifulSoup, nav_type: str) -> List[str]: + targets: List[str] = [] + if nav_type == "ncx": + for content_node in nav_soup.find_all("content"): + src = content_node.get("src") + if src: + targets.append(src.split("#", 1)[0]) + else: + for link in nav_soup.find_all("a"): + href = link.get("href") + if href: + targets.append(href.split("#", 1)[0]) + return targets + + def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None: + needed: set[str] = set(doc_order.keys()) + for target in nav_targets: + needed.add(target) + needed.add(urllib.parse.unquote(target)) + + self.doc_content = {} + for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): + href = item.get_name() + if href not in needed and urllib.parse.unquote(href) not in needed: + continue + try: + html_content = item.get_content().decode("utf-8", errors="ignore") + except Exception as exc: + logger.error("Error decoding EPUB document %s: %s", href, exc) + html_content = "" + self.doc_content[href] = html_content + + def _parse_ncx_navpoint( + self, + nav_point, + ordered_entries: List[NavEntry], + doc_order: Dict[str, int], + doc_order_decoded: Dict[str, int], + ) -> None: + nav_label = nav_point.find("navLabel") + content = nav_point.find("content") + title = ( + nav_label.find("text").get_text(strip=True) + if nav_label and nav_label.find("text") + else "Untitled Section" + ) + src = content.get("src") if content and content.has_attr("src") else None + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + if doc_key is not None and doc_idx is not None: + position = self._find_position_robust(doc_key, fragment) + ordered_entries.append( + NavEntry( + src=src, + title=title, + doc_href=doc_key, + position=position, + doc_order=doc_idx, + ) + ) + else: + logger.warning( + "Navigation entry '%s' points to '%s', which is not in the spine.", + title, + base_href, + ) + + for child_navpoint in nav_point.find_all("navPoint", recursive=False): + self._parse_ncx_navpoint(child_navpoint, ordered_entries, doc_order, doc_order_decoded) + + def _parse_html_nav_li( + self, + li_element, + ordered_entries: List[NavEntry], + doc_order: Dict[str, int], + doc_order_decoded: Dict[str, int], + ) -> None: + link = li_element.find("a", recursive=False) + span_text = li_element.find("span", recursive=False) + title = "Untitled Section" + + if link and link.has_attr("href"): + src = link["href"] + title = link.get_text(strip=True) or title + else: + src = None + if span_text: + title = span_text.get_text(strip=True) or title + else: + text = "".join(t for t in li_element.stripped_strings) + if text: + title = text + + title = title.strip() or "Untitled Section" + + if src: + base_href, fragment = src.split("#", 1) if "#" in src else (src, None) + doc_key, doc_idx = self._find_doc_key(base_href, doc_order, doc_order_decoded) + if doc_key is not None and doc_idx is not None: + position = self._find_position_robust(doc_key, fragment) + ordered_entries.append( + NavEntry( + src=src, + title=title, + doc_href=doc_key, + position=position, + doc_order=doc_idx, + ) + ) + else: + logger.warning( + "Navigation entry '%s' points to '%s', which is not in the spine.", + title, + base_href, + ) + + for child_ol in li_element.find_all("ol", recursive=False): + for child_li in child_ol.find_all("li", recursive=False): + self._parse_html_nav_li(child_li, ordered_entries, doc_order, doc_order_decoded) + + def _find_doc_key( + self, + base_href: str, + doc_order: Dict[str, int], + doc_order_decoded: Dict[str, int], + ) -> Tuple[Optional[str], Optional[int]]: + candidates = {base_href, urllib.parse.unquote(base_href)} + base_name = urllib.parse.unquote(base_href).split("/")[-1].lower() + for key in list(doc_order.keys()) + list(doc_order_decoded.keys()): + if key.split("/")[-1].lower() == base_name: + candidates.add(key) + for candidate in candidates: + if candidate in doc_order: + return candidate, doc_order[candidate] + if candidate in doc_order_decoded: + return candidate, doc_order_decoded[candidate] + return None, None + + def _find_position_robust(self, doc_href: str, fragment_id: Optional[str]) -> int: + if doc_href not in self.doc_content: + logger.warning("Document '%s' not found in cached EPUB content.", doc_href) + return 0 + html_content = self.doc_content[doc_href] + if not fragment_id: + return 0 + + try: + temp_soup = BeautifulSoup(f"
    {html_content}
    ", "html.parser") + target_element = temp_soup.find(id=fragment_id) + if target_element: + tag_str = str(target_element) + pos = html_content.find(tag_str[: min(len(tag_str), 200)]) + if pos != -1: + return pos + except Exception: + logger.debug("BeautifulSoup failed to locate id '%s' in %s", fragment_id, doc_href) + + safe_fragment_id = re.escape(fragment_id) + id_name_pattern = re.compile( + f"<[^>]+(?:id|name)\\s*=\\s*[\"']{safe_fragment_id}[\"']", + re.IGNORECASE, + ) + match = id_name_pattern.search(html_content) + if match: + return match.start() + + id_pos = html_content.find(f'id="{fragment_id}"') + name_pos = html_content.find(f'name="{fragment_id}"') + candidates = [pos for pos in (id_pos, name_pos) if pos != -1] + if candidates: + pos = min(candidates) + tag_start = html_content.rfind("<", 0, pos) + return tag_start if tag_start != -1 else pos + + logger.warning("Anchor '%s' not found in %s. Defaulting to start.", fragment_id, doc_href) + return 0 + + def _slice_entries(self, ordered_entries: List[NavEntry]) -> List[ExtractedChapter]: + chapters: List[ExtractedChapter] = [] + for index, entry in enumerate(ordered_entries): + next_entry = ordered_entries[index + 1] if index + 1 < len(ordered_entries) else None + slice_html = self._slice_entry(entry, next_entry) + text = self._html_to_text(slice_html) + if not text: + continue + title = entry.title or "Untitled Section" + chapters.append(ExtractedChapter(title=title, text=text)) + return chapters + + def _slice_entry( + self, + current_entry: NavEntry, + next_entry: Optional[NavEntry], + ) -> str: + current_doc = current_entry.doc_href + current_pos = current_entry.position + current_html = self.doc_content.get(current_doc, "") + if not current_html: + return "" + + if next_entry and next_entry.doc_href == current_doc: + return current_html[current_pos : next_entry.position] + + slice_html = current_html[current_pos:] + if next_entry: + docs_between = self._docs_between(current_doc, next_entry.doc_href) + for doc_href in docs_between: + slice_html += self.doc_content.get(doc_href, "") + next_doc_html = self.doc_content.get(next_entry.doc_href, "") + slice_html += next_doc_html[: next_entry.position] + else: + for doc_href in self._docs_between(current_doc, None): + slice_html += self.doc_content.get(doc_href, "") + + if not slice_html.strip(): + logger.warning( + "No content found for navigation source '%s'. Using full document fallback.", + current_entry.src, + ) + return current_html + return slice_html + + def _docs_between(self, current_doc: str, next_doc: Optional[str]) -> List[str]: + docs: List[str] = [] + try: + current_idx = self.spine_docs.index(current_doc) + except ValueError: + return docs + + if next_doc is None: + docs.extend(self.spine_docs[current_idx + 1 :]) + return docs + + try: + next_idx = self.spine_docs.index(next_doc) + except ValueError: + return docs + + if current_idx < next_idx: + docs.extend(self.spine_docs[current_idx + 1 : next_idx]) + elif current_idx > next_idx: + docs.extend(self.spine_docs[current_idx + 1 :]) + docs.extend(self.spine_docs[:next_idx]) + return docs + + def _append_prefix_content( + self, + ordered_entries: List[NavEntry], + chapters: List[ExtractedChapter], + ) -> None: + if not ordered_entries: + return + first_entry = ordered_entries[0] + first_doc = first_entry.doc_href + first_pos = first_entry.position + if first_pos <= 0: + return + + prefix_html = "" + try: + first_idx = self.spine_docs.index(first_doc) + except ValueError: + first_idx = -1 + + if first_idx > 0: + for doc_href in self.spine_docs[:first_idx]: + prefix_html += self.doc_content.get(doc_href, "") + prefix_html += self.doc_content.get(first_doc, "")[:first_pos] + prefix_text = self._html_to_text(prefix_html) + if prefix_text and (not chapters or prefix_text != chapters[0].text): + chapters.insert(0, ExtractedChapter(title="Introduction", text=prefix_text)) + + def _html_to_text(self, html: str) -> str: + if not html: + return "" + soup = BeautifulSoup(html, "html.parser") + for tag in soup.find_all(["p", "div"]): + tag.append("\n\n") 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}. " + number_text = f"{start + idx}) " if li.string: - li.string.replace_with(number + li.string) + li.string.replace_with(number_text + li.string) else: - li.insert(0, number) + li.insert(0, NavigableString(number_text)) + for tag in soup.find_all(["sup", "sub"]): + tag.decompose() 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) + return text.strip() - -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 + def _resolve_document_title(self, html_content: str, fallback: str) -> str: + soup = BeautifulSoup(html_content, "html.parser") + 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 diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 37bb02e..e8a4cad 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -9,7 +9,7 @@ import sys from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path -from typing import Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import numpy as np import soundfile as sf @@ -43,6 +43,126 @@ class AudioSink: write: Callable[[np.ndarray], None] +def _coerce_truthy(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + return default + if value is None: + return default + return bool(value) + + +def _apply_chapter_overrides( + extracted: List[ExtractedChapter], + overrides: List[Dict[str, Any]], +) -> tuple[List[ExtractedChapter], Dict[str, str], List[str]]: + if not overrides: + return [], {}, [] + + selected: List[ExtractedChapter] = [] + metadata_updates: Dict[str, str] = {} + diagnostics: List[str] = [] + + for position, payload in enumerate(overrides): + if not isinstance(payload, dict): + diagnostics.append( + f"Skipped chapter override at position {position + 1}: unsupported payload type {type(payload).__name__}." + ) + continue + + enabled = _coerce_truthy(payload.get("enabled", True)) + payload["enabled"] = enabled + if not enabled: + continue + + metadata_payload = payload.get("metadata") or {} + if isinstance(metadata_payload, dict): + for key, value in metadata_payload.items(): + if value is None: + continue + metadata_updates[str(key)] = str(value) + + base: Optional[ExtractedChapter] = None + idx_candidate = payload.get("index") + idx_normalized: Optional[int] = None + if isinstance(idx_candidate, int): + idx_normalized = idx_candidate + elif isinstance(idx_candidate, str): + try: + idx_normalized = int(idx_candidate) + except ValueError: + idx_normalized = None + if idx_normalized is not None and 0 <= idx_normalized < len(extracted): + base = extracted[idx_normalized] + payload["index"] = idx_normalized + + if base is None: + source_title = payload.get("source_title") + if isinstance(source_title, str): + base = next((chapter for chapter in extracted if chapter.title == source_title), None) + + if base is None: + candidate_title = payload.get("title") + if isinstance(candidate_title, str): + base = next((chapter for chapter in extracted if chapter.title == candidate_title), None) + + text_override = payload.get("text") + if text_override is not None: + text_value = str(text_override) + elif base is not None: + text_value = base.text + else: + diagnostics.append( + f"Skipped chapter override at position {position + 1}: no text provided and no matching source chapter found." + ) + continue + + title_override = payload.get("title") + if title_override is not None: + title_value = str(title_override) + elif base is not None: + title_value = base.title + else: + title_value = f"Chapter {position + 1}" + + if base and not payload.get("source_title"): + payload["source_title"] = base.title + + payload["title"] = title_value + payload["text"] = text_value + payload["characters"] = len(text_value) + payload.setdefault("order", payload.get("order", position)) + + selected.append(ExtractedChapter(title=title_value, text=text_value)) + + return selected, metadata_updates, diagnostics + + +def _merge_metadata( + extracted: Optional[Dict[str, str]], + overrides: Dict[str, Any], +) -> Dict[str, str]: + merged: Dict[str, str] = {} + if extracted: + for key, value in extracted.items(): + if value is None: + continue + merged[str(key)] = str(value) + for key, value in (overrides or {}).items(): + key_str = str(key) + if value is None: + merged.pop(key_str, None) + else: + merged[key_str] = str(value) + return merged + + def run_conversion_job(job: Job) -> None: job.add_log("Preparing conversion pipeline") canceller = _make_canceller(job) @@ -53,11 +173,29 @@ def run_conversion_job(job: Job) -> None: try: pipeline = _load_pipeline(job) extraction = extract_from_path(job.stored_path) - job.metadata_tags = extraction.metadata or {} + + metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) + if job.chapters: + selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides( + extraction.chapters, + job.chapters, + ) + for message in diagnostics: + job.add_log(message, level="warning") + if selected_chapters: + extraction.chapters = selected_chapters + metadata_overrides.update(chapter_metadata) + job.add_log( + f"Chapter overrides applied: {len(selected_chapters)} selected.", + level="info", + ) + else: + raise ValueError("No chapters were enabled in the requested job.") + + job.metadata_tags = _merge_metadata(extraction.metadata, metadata_overrides) total_characters = extraction.total_characters or calculate_text_length(extraction.combined_text) - if job.total_characters == 0: - job.total_characters = total_characters + job.total_characters = total_characters job.add_log(f"Total characters: {job.total_characters:,}") _apply_newline_policy(extraction.chapters, job.replace_single_newlines) @@ -237,9 +375,9 @@ def _select_device() -> str: def _prepare_output_dir(job: Job) -> Path: - from platformdirs import user_desktop_dir + from platformdirs import user_desktop_dir # type: ignore[import-not-found] - default_output = Path(get_user_cache_path("outputs")) + default_output = Path(str(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": diff --git a/abogen/web/service.py b/abogen/web/service.py index f8c097d..ac06705 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -6,7 +6,7 @@ import uuid from dataclasses import dataclass, field from enum import Enum from pathlib import Path -from typing import Callable, Dict, Iterable, List, Optional +from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping class JobStatus(str, Enum): @@ -64,7 +64,7 @@ class Job: logs: List[JobLog] = field(default_factory=list) error: Optional[str] = None result: JobResult = field(default_factory=JobResult) - chapters: List[str] = field(default_factory=list) + chapters: List[Dict[str, Any]] = field(default_factory=list) queue_position: Optional[int] = None cancel_requested: bool = False @@ -100,6 +100,18 @@ class Job: "voice_profile": self.voice_profile, "max_subtitle_words": self.max_subtitle_words, }, + "metadata_tags": dict(self.metadata_tags), + "chapters": [ + { + "id": entry.get("id"), + "index": entry.get("index"), + "order": entry.get("order"), + "title": entry.get("title"), + "enabled": bool(entry.get("enabled", True)), + "characters": len(str(entry.get("text", ""))), + } + for entry in self.chapters + ], } @@ -149,7 +161,7 @@ class ConversionService: replace_single_newlines: bool, subtitle_format: str, total_characters: int, - chapters: Optional[Iterable[str]] = None, + chapters: Optional[Iterable[Any]] = None, save_chapters_separately: bool = False, merge_chapters_at_end: bool = True, separate_chapters_format: str = "wav", @@ -157,8 +169,13 @@ class ConversionService: save_as_project: bool = False, voice_profile: Optional[str] = None, max_subtitle_words: int = 50, + metadata_tags: Optional[Mapping[str, Any]] = None, ) -> Job: job_id = uuid.uuid4().hex + normalized_metadata = self._normalize_metadata_tags(metadata_tags) + normalized_chapters = self._normalize_chapters(chapters) + if total_characters <= 0 and normalized_chapters: + total_characters = sum(len(str(entry.get("text", ""))) for entry in normalized_chapters) job = Job( id=job_id, original_filename=original_filename, @@ -180,9 +197,10 @@ class ConversionService: save_as_project=save_as_project, voice_profile=voice_profile, max_subtitle_words=max_subtitle_words, + metadata_tags=normalized_metadata, created_at=time.time(), total_characters=total_characters, - chapters=list(chapters or []), + chapters=normalized_chapters, ) with self._lock: self._jobs[job_id] = job @@ -322,6 +340,128 @@ class ConversionService: if job: job.queue_position = index + @staticmethod + def _coerce_bool(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "on"}: + return True + if lowered in {"false", "0", "no", "off"}: + return False + return default + if value is None: + return default + return bool(value) + + @staticmethod + def _coerce_optional_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _normalize_metadata_tags(values: Optional[Mapping[str, Any]]) -> Dict[str, str]: + if not values: + return {} + normalized: Dict[str, str] = {} + for key, raw_value in values.items(): + if raw_value is None: + continue + key_str = str(key).strip() + if not key_str: + continue + normalized[key_str] = str(raw_value) + return normalized + + @classmethod + def _normalize_chapters(cls, chapters: Optional[Iterable[Any]]) -> List[Dict[str, Any]]: + if not chapters: + return [] + + normalized: List[Dict[str, Any]] = [] + for order, raw in enumerate(chapters): + if raw is None: + continue + + if isinstance(raw, str): + raw_dict: Dict[str, Any] = {"title": raw} + elif isinstance(raw, dict): + raw_dict = dict(raw) + else: + continue + + entry: Dict[str, Any] = {} + + id_value = raw_dict.get("id") or raw_dict.get("chapter_id") or raw_dict.get("key") + if id_value is not None: + entry["id"] = str(id_value) + + index_value = ( + cls._coerce_optional_int(raw_dict.get("index")) + or cls._coerce_optional_int(raw_dict.get("original_index")) + or cls._coerce_optional_int(raw_dict.get("source_index")) + or cls._coerce_optional_int(raw_dict.get("chapter_index")) + ) + if index_value is not None: + entry["index"] = index_value + + order_value = ( + cls._coerce_optional_int(raw_dict.get("order")) + or cls._coerce_optional_int(raw_dict.get("position")) + or cls._coerce_optional_int(raw_dict.get("sort")) + or cls._coerce_optional_int(raw_dict.get("sort_order")) + ) + entry["order"] = order_value if order_value is not None else order + + source_title = ( + raw_dict.get("source_title") + or raw_dict.get("original_title") + or raw_dict.get("base_title") + ) + if source_title: + entry["source_title"] = str(source_title) + + title_value = ( + raw_dict.get("title") + or raw_dict.get("name") + or raw_dict.get("label") + or raw_dict.get("chapter") + ) + if title_value is not None: + entry["title"] = str(title_value) + elif source_title: + entry["title"] = str(source_title) + else: + entry["title"] = f"Chapter {order + 1}" + + text_value = raw_dict.get("text") + if text_value is None: + text_value = raw_dict.get("content") or raw_dict.get("body") or raw_dict.get("value") + if text_value is not None: + entry["text"] = str(text_value) + + enabled = cls._coerce_bool( + raw_dict.get("enabled", raw_dict.get("include", raw_dict.get("selected", True))), + True, + ) + if "disabled" in raw_dict and cls._coerce_bool(raw_dict.get("disabled"), False): + enabled = False + entry["enabled"] = enabled + + metadata_payload = raw_dict.get("metadata") or raw_dict.get("metadata_tags") + normalized_metadata = cls._normalize_metadata_tags(metadata_payload) + if normalized_metadata: + entry["metadata"] = normalized_metadata + + normalized.append(entry) + + return normalized + def default_storage_root() -> Path: base = Path.cwd() diff --git a/tests/test_chapter_overrides.py b/tests/test_chapter_overrides.py new file mode 100644 index 0000000..814d44b --- /dev/null +++ b/tests/test_chapter_overrides.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import sys +import types + + +def _install_dependency_stubs() -> None: + if "ebooklib" not in sys.modules: + ebooklib_stub = types.ModuleType("ebooklib") + epub_stub = types.ModuleType("ebooklib.epub") + setattr(ebooklib_stub, "epub", epub_stub) + sys.modules["ebooklib"] = ebooklib_stub + sys.modules["ebooklib.epub"] = epub_stub + + if "dotenv" not in sys.modules: + dotenv_stub = types.ModuleType("dotenv") + + def _noop(*_, **__): + return None + + setattr(dotenv_stub, "load_dotenv", _noop) + setattr(dotenv_stub, "find_dotenv", lambda *_, **__: "") + sys.modules["dotenv"] = dotenv_stub + + if "numpy" not in sys.modules: + numpy_stub = types.ModuleType("numpy") + + class _DummyArray(list): + pass + + def _zeros(shape, dtype=None): + size = 1 + if isinstance(shape, int): + size = shape + elif shape: + size = 1 + for dimension in shape: + size *= int(dimension) + return [0.0] * size + + setattr(numpy_stub, "ndarray", _DummyArray) + setattr(numpy_stub, "zeros", _zeros) + setattr(numpy_stub, "float32", "float32") + setattr(numpy_stub, "array", lambda data, dtype=None: data) + setattr(numpy_stub, "asarray", lambda data, dtype=None: data) + setattr(numpy_stub, "concatenate", lambda seq, axis=0: sum((list(item) for item in seq), [])) + sys.modules["numpy"] = numpy_stub + + if "soundfile" not in sys.modules: + soundfile_stub = types.ModuleType("soundfile") + + class _DummySoundFile: + def __init__(self, *_, **__): + pass + + def write(self, *_args, **_kwargs): + return None + + def close(self): + return None + + setattr(soundfile_stub, "SoundFile", _DummySoundFile) + setattr(soundfile_stub, "write", lambda *_args, **_kwargs: None) + sys.modules["soundfile"] = soundfile_stub + + if "fitz" not in sys.modules: + sys.modules["fitz"] = types.ModuleType("fitz") + + if "markdown" not in sys.modules: + markdown_stub = types.ModuleType("markdown") + + class _DummyMarkdown: + def __init__(self, *_, **__): + pass + + def convert(self, text: str) -> str: + return text + + setattr(markdown_stub, "Markdown", _DummyMarkdown) + sys.modules["markdown"] = markdown_stub + + if "bs4" not in sys.modules: + bs4_stub = types.ModuleType("bs4") + + class _DummySoup: + def __init__(self, *_, **__): + pass + + def select(self, *_, **__): + return [] + + def find_all(self, *_, **__): + return [] + + setattr(bs4_stub, "BeautifulSoup", _DummySoup) + setattr(bs4_stub, "NavigableString", str) + sys.modules["bs4"] = bs4_stub + + +_install_dependency_stubs() + +from abogen.text_extractor import ExtractedChapter +from abogen.web.conversion_runner import _apply_chapter_overrides, _merge_metadata + + +def _sample_chapters() -> list[ExtractedChapter]: + return [ + ExtractedChapter(title="Chapter 1", text="Original one"), + ExtractedChapter(title="Chapter 2", text="Original two"), + ExtractedChapter(title="Chapter 3", text="Original three"), + ] + + +def test_apply_chapter_overrides_with_custom_text() -> None: + overrides = [ + {"index": 0, "enabled": True, "title": "Intro", "text": "Hello world"}, + {"index": 1, "enabled": False}, + ] + + selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + + assert len(selected) == 1 + assert selected[0].title == "Intro" + assert selected[0].text == "Hello world" + assert overrides[0]["characters"] == len("Hello world") + assert metadata == {} + assert diagnostics == [] + + +def test_apply_chapter_overrides_uses_original_content_when_text_missing() -> None: + overrides = [ + {"index": 1, "enabled": True}, + ] + + selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + + assert len(selected) == 1 + assert selected[0].title == "Chapter 2" + assert selected[0].text == "Original two" + assert overrides[0]["text"] == "Original two" + assert overrides[0]["characters"] == len("Original two") + assert metadata == {} + assert diagnostics == [] + + +def test_apply_chapter_overrides_collects_metadata_updates() -> None: + overrides = [ + { + "index": 2, + "enabled": True, + "metadata": {"artist": "Test Author", "year": 2024}, + } + ] + + selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + + assert len(selected) == 1 + assert metadata == {"artist": "Test Author", "year": "2024"} + assert diagnostics == [] + + +def test_apply_chapter_overrides_reports_diagnostics_for_invalid_payload() -> None: + overrides = [ + {"enabled": True, "title": "Missing"}, + ] + + selected, metadata, diagnostics = _apply_chapter_overrides(_sample_chapters(), overrides) + + assert selected == [] + assert metadata == {} + assert diagnostics and "Skipped chapter override" in diagnostics[0] + + +def test_merge_metadata_prefers_overrides_and_drops_none_values() -> None: + extracted = {"title": "Original", "artist": "Someone"} + overrides = {"artist": "Another", "genre": "Fiction", "year": None} + + merged = _merge_metadata(extracted, overrides) + + assert merged["title"] == "Original" + assert merged["artist"] == "Another" + assert merged["genre"] == "Fiction" + assert "year" not in merged From 718a3fa1c0f9e42b19e28c84be4a8a56c1be2de3 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 05:34:53 -0700 Subject: [PATCH 031/245] feat: Enhance job management UI and add apostrophe normalization - Updated styles for job cards to include a paused state and improved title styling. - Modified job card template to display job status and progress more clearly, including pause/resume functionality. - Introduced a new script for managing chapter row states in the prepare job form, allowing for dynamic enabling/disabling of inputs. - Created a new template for preparing jobs, featuring a summary of metadata and chapter details. - Added a comprehensive apostrophe normalization module to handle various cases of apostrophe usage in text. --- abogen/kokoro_text_normalization.py | 334 ++++++++++++++++++++++++ abogen/text_extractor.py | 56 +++- abogen/web/conversion_runner.py | 86 +++++- abogen/web/routes.py | 241 ++++++++++++++++- abogen/web/service.py | 333 +++++++++++++++++++++++ abogen/web/static/prepare.js | 64 +++++ abogen/web/static/styles.css | 188 +++++++++++++ abogen/web/templates/partials/jobs.html | 30 ++- abogen/web/templates/prepare_job.html | 120 +++++++++ 9 files changed, 1426 insertions(+), 26 deletions(-) create mode 100644 abogen/kokoro_text_normalization.py create mode 100644 abogen/web/static/prepare.js create mode 100644 abogen/web/templates/prepare_job.html diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py new file mode 100644 index 0000000..3cc784e --- /dev/null +++ b/abogen/kokoro_text_normalization.py @@ -0,0 +1,334 @@ +from __future__ import annotations +import re +import unicodedata +from dataclasses import dataclass +from typing import List, Tuple, Iterable, Callable + +# ---------- Configuration Dataclass ---------- + +@dataclass +class ApostropheConfig: + contraction_mode: str = "expand" # expand|collapse|keep + possessive_mode: str = "keep" # keep|collapse + plural_possessive_mode: str = "collapse" # keep|collapse + irregular_possessive_mode: str = "keep" # keep|expand (expand just means keep or add hints; modify if needed) + sibilant_possessive_mode: str = "mark" # keep|mark|approx + fantasy_mode: str = "keep" # keep|mark|collapse_internal + acronym_possessive_mode: str = "keep" # keep|collapse_add_s + decades_mode: str = "expand" # keep|expand + leading_elision_mode: str = "expand" # keep|expand + ambiguous_past_modal_mode: str = "keep" # keep|expand_prefer_would|expand_prefer_had + add_phoneme_hints: bool = True # Whether to emit markers like ‹IZ› + fantasy_marker: str = "‹FAP›" # Marker inserted if fantasy_mode == mark + sibilant_iz_marker: str = "‹IZ›" # Marker for /ɪz/ insertion + joiner: str = "" # Replacement used when collapsing internal apostrophes + lowercase_for_matching: bool = True # Normalize to lower for rule matching (not output) + protect_cultural_names: bool = True # Always keep O'Brien, D'Angelo, etc. + +# ---------- Dictionaries / Patterns ---------- + +# Common contraction expansions (straightforward unambiguous) +CONTRACTIONS_EXACT = { + "it's": "it is", + "that's": "that is", + "what's": "what is", + "where's": "where is", + "who's": "who is", + "when's": "when is", + "how's": "how is", + "there's": "there is", + "here's": "here is", + "let's": "let us", + "i'm": "i am", + "you're": "you are", + "we're": "we are", + "they're": "they are", + "i've": "i have", + "you've": "you have", + "we've": "we have", + "they've": "they have", + "i'll": "i will", + "you'll": "you will", + "he'll": "he will", + "she'll": "she will", + "we'll": "we will", + "they'll": "they will", + "i'd": "i would", # ambiguous (had/would), treat default + "you'd": "you would", + "he'd": "he would", + "she'd": "she would", + "we'd": "we would", + "they'd": "they would", + "can't": "can not", # or "cannot" + "won't": "will not", + "don't": "do not", + "doesn't": "does not", + "didn't": "did not", + "isn't": "is not", + "aren't": "are not", + "wasn't": "was not", + "weren't": "were not", + "haven't": "have not", + "hasn't": "has not", + "hadn't": "had not", + "couldn't": "could not", + "shouldn't": "should not", + "wouldn't": "would not", + "mustn't": "must not", + "mightn't": "might not", + "shan't": "shall not", +} + +# For ambiguous 'd and 's we handle separately +AMBIGUOUS_D_BASES = {"i","you","he","she","we","they"} +AMBIGUOUS_S_BASES = {"it","that","what","where","who","when","how","there","here"} + +# Irregular possessives that are not formed by simple + 's logic +IRREGULAR_POSSESSIVES = { + "children's": "children's", + "men's": "men's", + "women's": "women's", + "people's": "people's", + "geese's": "geese's", + "mouse's": "mouse's", # singular irregular +} + +SIBILANT_END_RE = re.compile(r"(?:[sxz]|(?:ch|sh))$", re.IGNORECASE) + +DECADE_RE = re.compile(r"^'\d0s$", re.IGNORECASE) # '90s, '80s +LEADING_ELISION = { + "'tis": "it is", + "'twas": "it was", + "'cause": "because", + "'em": "them", + "'round": "around", + "'til": "until", +} + +CULTURAL_NAME_PATTERNS = [ + re.compile(r"^O'[A-Z][a-z]+$"), + re.compile(r"^D'[A-Z][a-z]+$"), + re.compile(r"^L'[A-Za-z].*$"), + re.compile(r"^Mc[A-Z].*$"), # not apostrophe, but often relevant (kept anyway) +] + +ACRONYM_POSSESSIVE_RE = re.compile(r"^[A-Z]{2,}'s$") + +INTERNAL_APOSTROPHE_RE = re.compile(r"[A-Za-z]'.+[A-Za-z]") # apostrophe not at edge + +WORD_TOKEN_RE = re.compile(r"[A-Za-z0-9'’]+|[^A-Za-z0-9\s]") + +APOSTROPHE_CHARS = "’`´ꞌʼ" + +# ---------- Utility Functions ---------- + +def normalize_unicode_apostrophes(text: str) -> str: + text = unicodedata.normalize("NFKC", text) + for ch in APOSTROPHE_CHARS: + text = text.replace(ch, "'") + return text + +def tokenize(text: str) -> List[str]: + # Simple tokenization preserving punctuation tokens + return WORD_TOKEN_RE.findall(text) + +def is_cultural_name(token: str, cfg: ApostropheConfig) -> bool: + if not cfg.protect_cultural_names: + return False + for pat in CULTURAL_NAME_PATTERNS: + if pat.match(token): + return True + return False + +def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: + """ + Classify apostrophe usage and propose normalized form. + Returns (category, normalized_token_or_same). + Categories: contraction, ambiguous_contraction_s, ambiguous_contraction_d, + plural_possessive, irregular_possessive, sibilant_possessive, + singular_possessive, acronym_possessive, decade, leading_elision, + fantasy_internal, other + """ + if "'" not in token: + return "other", token + + raw = token + low = token.lower() + + # 1. Decades + if DECADE_RE.match(token): + if cfg.decades_mode == "expand": + # '90s -> 1990s (you could also choose 90s) + return "decade", f"19{token[2:4]}s" + return "decade", token + + # 2. Leading elision + if low in LEADING_ELISION: + if cfg.leading_elision_mode == "expand": + return "leading_elision", LEADING_ELISION[low] + return "leading_elision", token + + # 3. Exact contraction + if low in CONTRACTIONS_EXACT: + if cfg.contraction_mode == "expand": + return "contraction", CONTRACTIONS_EXACT[low] + elif cfg.contraction_mode == "collapse": + # collapse: remove apostrophe only (it's -> its) + return "contraction", low.replace("'", "") + else: + return "contraction", token + + # 4. Ambiguous 'd + if low.endswith("'d"): + base = low[:-2] + if base in AMBIGUOUS_D_BASES: + if cfg.ambiguous_past_modal_mode == "expand_prefer_would": + return "ambiguous_contraction_d", base + " would" + elif cfg.ambiguous_past_modal_mode == "expand_prefer_had": + return "ambiguous_contraction_d", base + " had" + elif cfg.contraction_mode == "collapse": + return "ambiguous_contraction_d", base + "d" + return "ambiguous_contraction_d", token + + # 5. Ambiguous 's + if low.endswith("'s"): + base = low[:-2] + if base in AMBIGUOUS_S_BASES: + # treat as contraction 'is' under chosen mode + if cfg.contraction_mode == "expand": + return "ambiguous_contraction_s", base + " is" + elif cfg.contraction_mode == "collapse": + return "ambiguous_contraction_s", base + "s" + else: + return "ambiguous_contraction_s", token + + # 6. Irregular possessives (keep or expand logic) + if low in IRREGULAR_POSSESSIVES: + if cfg.irregular_possessive_mode == "keep": + return "irregular_possessive", token + else: + # 'expand': we might keep same or optionally add marker + return "irregular_possessive", token + + # 7. Plural possessive pattern dogs' + if re.match(r"^[A-Za-z0-9]+s'$", token): + if cfg.plural_possessive_mode == "collapse": + return "plural_possessive", token[:-1] # remove trailing apostrophe + return "plural_possessive", token + + # 8. Acronym possessive NASA's + if ACRONYM_POSSESSIVE_RE.match(token): + if cfg.acronym_possessive_mode == "collapse_add_s": + return "acronym_possessive", token.replace("'", "") + return "acronym_possessive", token + + # 9. Sibilant singular possessive boss's, church's + if low.endswith("'s"): + base = token[:-2] + if SIBILANT_END_RE.search(base): + if cfg.sibilant_possessive_mode == "keep": + return "sibilant_possessive", token + elif cfg.sibilant_possessive_mode == "approx": + # convert to base + "es" (boss's -> bosses) + # risk: loses possessive semantics visually + return "sibilant_possessive", base + "es" + elif cfg.sibilant_possessive_mode == "mark": + # remove apostrophe, add IZ marker + normalized = base + if cfg.add_phoneme_hints: + normalized += cfg.sibilant_iz_marker + else: + normalized += "es" + return "sibilant_possessive", normalized + + # 10. Generic singular possessive (\w+'s) + if re.match(r"^[A-Za-z0-9]+'s$", token): + if cfg.possessive_mode == "collapse": + # Just remove apostrophe + return "singular_possessive", token.replace("'", "") + return "singular_possessive", token + + # 11. Cultural names or fantasy internal + if is_cultural_name(token, cfg): + return "cultural_name", token + + # 12. Fantasy internal apostrophes + if INTERNAL_APOSTROPHE_RE.search(token): + if cfg.fantasy_mode == "keep": + return "fantasy_internal", token + elif cfg.fantasy_mode == "mark": + out = token + (cfg.fantasy_marker if cfg.add_phoneme_hints else "") + return "fantasy_internal", out + elif cfg.fantasy_mode == "collapse_internal": + # Remove internal apostrophes only + inner = re.sub(r"(?<=\w)'+(?=\w)", cfg.joiner, token) + return "fantasy_internal", inner + + # 13. Fallback: treat as other (maybe stray apostrophe) + if cfg.fantasy_mode == "collapse_internal": + # Remove any internal apostrophes + return "other", token.replace("'", cfg.joiner) + return "other", token + +def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tuple[str, List[Tuple[str,str,str]]]: + """ + Normalize apostrophes per config. + Returns normalized text AND a list of (original_token, category, normalized_token) + so you can debug or post-process (e.g., apply phoneme replacement for ‹IZ›). + """ + if cfg is None: + cfg = ApostropheConfig() + + text = normalize_unicode_apostrophes(text) + tokens = tokenize(text) + + results = [] + normalized_tokens: List[str] = [] + + for tok in tokens: + category, norm = classify_token(tok, cfg) + results.append((tok, category, norm)) + normalized_tokens.append(norm) + + # Simple rejoin heuristic: + # If token is purely punctuation, attach without extra space. + out_parts = [] + for i, (orig, cat, norm) in enumerate(results): + if i == 0: + out_parts.append(norm) + continue + prev = results[i-1][2] + if re.match(r"^[.,;:!?)]$", norm): + # Attach to previous + out_parts[-1] = out_parts[-1] + norm + elif re.match(r"^[(]$", norm): + out_parts.append(norm) + else: + # Normal separation + if not (re.match(r"^[.,;:!?)]$", prev) or prev.endswith("—")): + out_parts.append(" " + norm) + else: + out_parts.append(norm) + normalized_text = "".join(out_parts) + return normalized_text, results + +# ---------- Optional phoneme hint post-processing ---------- + +def apply_phoneme_hints(text: str, iz_marker="‹IZ›") -> str: + """ + Replace markers with an orthographic sequence that + your phonemizer will reliably convert to /ɪz/. + """ + return text.replace(iz_marker, " iz") + +# ---------- Example Usage ---------- + +if __name__ == "__main__": + sample = "Bob's boss's chair. The dogs' collars. It's cold. Ta'veren and Sha'hal. O'Brien's code in the '90s. Boss's orders." + config = ApostropheConfig() + norm_text, details = normalize_apostrophes(sample, config) + norm_text = apply_phoneme_hints(norm_text) + print("Original:", sample) + print("Normalized:", norm_text) + for orig, cat, norm in details: + print(f"{orig:15} -> {norm:15} [{cat}]") \ No newline at end of file diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index e175051..079bc98 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -2,6 +2,7 @@ from __future__ import annotations import datetime import logging +import mimetypes import re import textwrap import urllib.parse @@ -31,6 +32,9 @@ METADATA_KEY_MAP: Dict[str, str] = { "COMPOSER": "composer", "GENRE": "genre", "DATE": "date", + "PUBLISHER": "publisher", + "COMMENT": "comment", + "LANGUAGE": "language", } @@ -48,6 +52,8 @@ class ExtractedChapter: class ExtractionResult: chapters: List[ExtractedChapter] metadata: Dict[str, str] = field(default_factory=dict) + cover_image: Optional[bytes] = None + cover_mime: Optional[str] = None @property def combined_text(self) -> str: @@ -65,6 +71,7 @@ class MetadataSource: description: Optional[str] = None publisher: Optional[str] = None publication_year: Optional[str] = None + language: Optional[str] = None @dataclass @@ -188,6 +195,12 @@ def _build_metadata_payload( "COMPOSER": "Narrator", "GENRE": "Audiobook", } + if metadata_source.publisher: + metadata["PUBLISHER"] = metadata_source.publisher + if metadata_source.description: + metadata["COMMENT"] = metadata_source.description + if metadata_source.language: + metadata["LANGUAGE"] = metadata_source.language return _normalize_metadata_keys(metadata) @@ -370,7 +383,14 @@ class EpubExtractor: if not chapters: chapters = [ExtractedChapter(title=self.path.stem, text="")] metadata = _build_metadata_payload(metadata_source, len(chapters), "epub", self.path.stem) - return ExtractionResult(chapters=chapters, metadata=metadata) + metadata.setdefault("chapter_count", str(len(chapters))) + cover_image, cover_mime = self._extract_cover() + return ExtractionResult( + chapters=chapters, + metadata=metadata, + cover_image=cover_image, + cover_mime=cover_mime, + ) def _collect_metadata(self) -> MetadataSource: metadata = MetadataSource() @@ -411,8 +431,42 @@ class EpubExtractor: except Exception as exc: logger.debug("Failed to extract EPUB publication year metadata: %s", exc) + try: + language_items = self.book.get_metadata("DC", "language") + if language_items: + metadata.language = language_items[0][0] + except Exception as exc: + logger.debug("Failed to extract EPUB language metadata: %s", exc) + return metadata + def _extract_cover(self) -> Tuple[Optional[bytes], Optional[str]]: + try: + for item in self.book.get_items_of_type(ebooklib.ITEM_COVER): + data = item.get_content() + if data: + media_type = getattr(item, "media_type", None) + return data, media_type + except Exception as exc: + logger.debug("Failed to read dedicated EPUB cover image: %s", exc) + + try: + for item in self.book.get_items_of_type(ebooklib.ITEM_IMAGE): + name = item.get_name().lower() + if "cover" not in name and "front" not in name: + continue + data = item.get_content() + if not data: + continue + media_type = getattr(item, "media_type", None) + if not media_type: + media_type = mimetypes.guess_type(name)[0] + return data, media_type + except Exception as exc: + logger.debug("Failed to locate fallback EPUB cover image: %s", exc) + + return None, None + def _process_nav(self) -> List[ExtractedChapter]: nav_item, nav_type = self._find_navigation_item() if not nav_item or not nav_type: diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index e8a4cad..b794b52 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -15,6 +15,11 @@ import numpy as np import soundfile as sf import static_ffmpeg +from abogen.kokoro_text_normalization import ( + ApostropheConfig, + apply_phoneme_hints, + normalize_apostrophes, +) from abogen.text_extractor import ExtractedChapter, extract_from_path from abogen.utils import ( calculate_text_length, @@ -163,6 +168,35 @@ def _merge_metadata( return merged +_APOSTROPHE_CONFIG = ApostropheConfig() + + +def _normalize_for_pipeline(text: str) -> str: + normalized, _details = normalize_apostrophes(text, _APOSTROPHE_CONFIG) + if _APOSTROPHE_CONFIG.add_phoneme_hints: + return apply_phoneme_hints(normalized, iz_marker=_APOSTROPHE_CONFIG.sibilant_iz_marker) + return normalized + + +def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str: + if not override: + return job.voice or "" + + resolved = str(override.get("resolved_voice", "")).strip() + if resolved: + return resolved + + formula = str(override.get("voice_formula", "")).strip() + if formula: + return formula + + voice = str(override.get("voice", "")).strip() + if voice: + return voice + + return job.voice or "" + + def run_conversion_job(job: Job) -> None: job.add_log("Preparing conversion pipeline") canceller = _make_canceller(job) @@ -175,6 +209,7 @@ def run_conversion_job(job: Job) -> None: extraction = extract_from_path(job.stored_path) metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) + active_chapter_configs: List[Dict[str, Any]] = [] if job.chapters: selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides( extraction.chapters, @@ -189,6 +224,9 @@ def run_conversion_job(job: Job) -> None: f"Chapter overrides applied: {len(selected_chapters)} selected.", level="info", ) + active_chapter_configs = [ + entry for entry in job.chapters if _coerce_truthy(entry.get("enabled", True)) + ][: len(selected_chapters)] else: raise ValueError("No chapters were enabled in the requested job.") @@ -220,20 +258,34 @@ def run_conversion_job(job: Job) -> None: chapter_dir = audio_dir / "chapters" chapter_dir.mkdir(parents=True, exist_ok=True) - voice_spec = job.voice or "" - cached_voice = None - if "*" not in voice_spec: - cached_voice = _resolve_voice(pipeline, voice_spec, job.use_gpu) + base_voice_spec = (job.voice or "").strip() + voice_cache: Dict[str, Any] = {} + if base_voice_spec and "*" not in base_voice_spec: + voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu) processed_chars = 0 subtitle_index = 1 current_time = 0.0 total_chapters = len(extraction.chapters) + chapter_markers: List[Dict[str, Any]] = [] job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") for idx, chapter in enumerate(extraction.chapters, start=1): canceller() job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}") + chapter_start_time = current_time + chapter_override = ( + active_chapter_configs[idx - 1] if idx - 1 < len(active_chapter_configs) else None + ) + chapter_voice_spec = _chapter_voice_spec(job, chapter_override) + if not chapter_voice_spec: + chapter_voice_spec = base_voice_spec + + voice_choice = voice_cache.get(chapter_voice_spec) + if voice_choice is None: + voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu) + voice_cache[chapter_voice_spec] = voice_choice + chapter_sink_stack = ExitStack() chapter_sink: Optional[AudioSink] = None chapter_audio_path: Optional[Path] = None @@ -251,14 +303,11 @@ def run_conversion_job(job: Job) -> None: fmt=job.separate_chapters_format, ) - voice_choice = cached_voice if cached_voice is not None else _resolve_voice( - pipeline, voice_spec, job.use_gpu - ) - segments_emitted = 0 + tts_input = _normalize_for_pipeline(chapter.text) for segment in pipeline( - chapter.text, + tts_input, voice=voice_choice, speed=job.speed, split_pattern=SPLIT_PATTERN, @@ -303,6 +352,8 @@ def run_conversion_job(job: Job) -> None: if chapter_sink: chapter_sink_stack.close() + chapter_end_time = current_time + if chapter_audio_path is not None: job.result.artifacts[f"chapter_{idx:02d}"] = chapter_audio_path chapter_paths.append(chapter_audio_path) @@ -326,6 +377,17 @@ def run_conversion_job(job: Job) -> None: silence = np.zeros(silence_samples, dtype="float32") audio_sink.write(silence) current_time += job.silence_between_chapters + chapter_end_time = current_time + + chapter_markers.append( + { + "index": idx, + "title": chapter.title, + "start": chapter_start_time, + "end": chapter_end_time, + "voice": chapter_voice_spec, + } + ) if not audio_path and chapter_paths: job.result.audio_path = chapter_paths[0] @@ -333,7 +395,11 @@ def run_conversion_job(job: Job) -> None: 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") + metadata_payload = { + "metadata": job.metadata_tags, + "chapters": chapter_markers, + } + metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8") job.result.artifacts["metadata"] = metadata_file if job.save_as_project: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 618877b..38e7446 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -5,6 +5,7 @@ import json import mimetypes import os import threading +import time import uuid from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple, cast @@ -55,8 +56,9 @@ from abogen.voice_profiles import ( ) from abogen.voice_formulas import get_new_voice +from abogen.text_extractor import extract_from_path from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32 -from .service import ConversionService, Job, JobStatus +from .service import ConversionService, Job, JobStatus, PendingJob web_bp = Blueprint("web", __name__) api_bp = Blueprint("api", __name__) @@ -272,6 +274,28 @@ def _resolve_voice_choice( return resolved_voice, resolved_language, selected_profile +def _persist_cover_image(extraction_result: Any, stored_path: Path) -> tuple[Optional[Path], Optional[str]]: + cover_bytes = getattr(extraction_result, "cover_image", None) + if not cover_bytes: + return None, None + + mime = getattr(extraction_result, "cover_mime", None) + extension = mimetypes.guess_extension(mime or "") or ".png" + base_stem = Path(stored_path).stem or "cover" + candidate = stored_path.parent / f"{base_stem}_cover{extension}" + counter = 1 + while candidate.exists(): + candidate = stored_path.parent / f"{base_stem}_cover_{counter}{extension}" + counter += 1 + + try: + candidate.write_bytes(cover_bytes) + except OSError: + return None, None + + return candidate, mime + + 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]] = [] @@ -690,12 +714,54 @@ def enqueue_job() -> Response: 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)) + + extraction = None + try: + extraction = extract_from_path(stored_path) + except Exception as exc: # pragma: no cover - defensive + try: + stored_path.unlink(missing_ok=True) + except Exception: + pass + abort(400, f"Unable to read the supplied content: {exc}") + + if extraction is None: # pragma: no cover - defensive + abort(400, "Unable to read the supplied content") + + assert extraction is not None + + cover_path, cover_mime = _persist_cover_image(extraction, stored_path) + + metadata_tags = extraction.metadata or {} + total_chars = extraction.total_characters or calculate_text_length(extraction.combined_text) + chapters_payload: List[Dict[str, Any]] = [] + for index, chapter in enumerate(extraction.chapters): + chapters_payload.append( + { + "id": f"{index:04d}", + "index": index, + "title": chapter.title, + "text": chapter.text, + "characters": len(chapter.text), + "enabled": True, + } + ) + + if not chapters_payload: + chapters_payload.append( + { + "id": "0000", + "index": 0, + "title": original_name, + "text": "", + "characters": 0, + "enabled": True, + } + ) profiles = load_profiles() settings = _load_settings() @@ -737,7 +803,8 @@ def enqueue_job() -> Response: silence_between_chapters = settings["silence_between_chapters"] max_subtitle_words = settings["max_subtitle_words"] - job = service.enqueue( + pending = PendingJob( + id=uuid.uuid4().hex, original_filename=original_name, stored_path=stored_path, language=language, @@ -758,14 +825,149 @@ def enqueue_job() -> Response: save_as_project=save_as_project, voice_profile=selected_profile, max_subtitle_words=max_subtitle_words, + metadata_tags=metadata_tags, + chapters=chapters_payload, + created_at=time.time(), + cover_image_path=cover_path, + cover_image_mime=cover_mime, ) + + service.store_pending_job(pending) + return redirect(url_for("web.prepare_job", pending_id=pending.id)) + + +@web_bp.get("/jobs/prepare/") +def prepare_job(pending_id: str) -> str: + pending = _service().get_pending_job(pending_id) + if not pending: + abort(404) + pending = cast(PendingJob, pending) + return _render_prepare_page(pending) + + +@web_bp.post("/jobs/prepare/") +def finalize_job(pending_id: str) -> Response: + service = _service() + pending = service.get_pending_job(pending_id) + if not pending: + abort(404) + pending = cast(PendingJob, pending) + + profiles = serialize_profiles() + overrides: List[Dict[str, Any]] = [] + selected_total = 0 + errors: List[str] = [] + + for index, chapter in enumerate(pending.chapters): + enabled = request.form.get(f"chapter-{index}-enabled") == "on" + title_input = (request.form.get(f"chapter-{index}-title") or "").strip() + title = title_input or chapter.get("title") or f"Chapter {index + 1}" + voice_selection = request.form.get(f"chapter-{index}-voice", "__default") + formula_input = (request.form.get(f"chapter-{index}-formula") or "").strip() + + entry: Dict[str, Any] = { + "id": chapter.get("id") or f"{index:04d}", + "index": index, + "order": index, + "source_title": chapter.get("title") or title, + "title": title, + "text": chapter.get("text", ""), + "enabled": enabled, + } + entry["characters"] = len(entry["text"]) + + if enabled: + if voice_selection.startswith("voice:"): + entry["voice"] = voice_selection.split(":", 1)[1] + entry["resolved_voice"] = entry["voice"] + elif voice_selection.startswith("profile:"): + profile_name = voice_selection.split(":", 1)[1] + entry["voice_profile"] = profile_name + profile_entry = profiles.get(profile_name) or {} + formula_value = _formula_from_profile(profile_entry) + if formula_value: + entry["voice_formula"] = formula_value + entry["resolved_voice"] = formula_value + else: + errors.append(f"Profile '{profile_name}' has no configured voices.") + elif voice_selection == "formula": + if not formula_input: + errors.append(f"Provide a custom formula for chapter {index + 1}.") + else: + try: + _parse_voice_formula(formula_input) + except ValueError as exc: + errors.append(str(exc)) + else: + entry["voice_formula"] = formula_input + entry["resolved_voice"] = formula_input + selected_total += len(entry["text"] or "") + + overrides.append(entry) + pending.chapters[index] = dict(entry) + + if not any(item.get("enabled") for item in overrides): + return _render_prepare_page(pending, error="Select at least one chapter to convert.") + + if errors: + return _render_prepare_page(pending, error=" ".join(errors)) + + total_characters = selected_total or pending.total_characters + + service.pop_pending_job(pending_id) + + job = service.enqueue( + original_filename=pending.original_filename, + stored_path=pending.stored_path, + language=pending.language, + voice=pending.voice, + speed=pending.speed, + use_gpu=pending.use_gpu, + subtitle_mode=pending.subtitle_mode, + output_format=pending.output_format, + save_mode=pending.save_mode, + output_folder=pending.output_folder, + replace_single_newlines=pending.replace_single_newlines, + subtitle_format=pending.subtitle_format, + total_characters=total_characters, + chapters=overrides, + metadata_tags=pending.metadata_tags, + save_chapters_separately=pending.save_chapters_separately, + merge_chapters_at_end=pending.merge_chapters_at_end, + separate_chapters_format=pending.separate_chapters_format, + silence_between_chapters=pending.silence_between_chapters, + save_as_project=pending.save_as_project, + voice_profile=pending.voice_profile, + max_subtitle_words=pending.max_subtitle_words, + cover_image_path=pending.cover_image_path, + cover_image_mime=pending.cover_image_mime, + ) + return redirect(url_for("web.job_detail", job_id=job.id)) +@web_bp.post("/jobs/prepare//cancel") +def cancel_pending_job(pending_id: str) -> Response: + pending = _service().pop_pending_job(pending_id) + if pending and pending.stored_path.exists(): + try: + pending.stored_path.unlink() + except OSError: + pass + if pending and pending.cover_image_path and pending.cover_image_path.exists(): + try: + pending.cover_image_path.unlink() + except OSError: + pass + return redirect(url_for("web.index")) + + def _render_jobs_panel() -> str: jobs = _service().list_jobs() - active_jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}] - finished_jobs = [job for job in jobs if job.status not in {JobStatus.PENDING, JobStatus.RUNNING}] + active_statuses = {JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED} + active_jobs = [job for job in jobs if job.status in active_statuses] + active_jobs.sort(key=lambda job: ((job.queue_position or 10_000), -job.created_at)) + finished_jobs = [job for job in jobs if job.status not in active_statuses] return render_template( "partials/jobs.html", active_jobs=active_jobs, @@ -775,6 +977,16 @@ def _render_jobs_panel() -> str: ) +def _render_prepare_page(pending: PendingJob, *, error: Optional[str] = None) -> str: + return render_template( + "prepare_job.html", + pending=pending, + options=_template_options(), + settings=_load_settings(), + error=error, + ) + + @web_bp.get("/jobs/") def job_detail(job_id: str) -> str: job = _service().get_job(job_id) @@ -787,6 +999,22 @@ def job_detail(job_id: str) -> str: ) +@web_bp.post("/jobs//pause") +def pause_job(job_id: str) -> Response: + _service().pause(job_id) + if request.headers.get("HX-Request"): + return _render_jobs_panel() + return redirect(url_for("web.job_detail", job_id=job_id)) + + +@web_bp.post("/jobs//resume") +def resume_job(job_id: str) -> Response: + _service().resume(job_id) + if request.headers.get("HX-Request"): + return _render_jobs_panel() + return redirect(url_for("web.job_detail", job_id=job_id)) + + @web_bp.post("/jobs//cancel") def cancel_job(job_id: str) -> Response: _service().cancel(job_id) @@ -838,7 +1066,6 @@ def download_job(job_id: str) -> Response: def jobs_partial() -> str: return _render_jobs_panel() - @web_bp.get("/partials/jobs//logs") def job_logs_partial(job_id: str) -> str: job = _service().get_job(job_id) diff --git a/abogen/web/service.py b/abogen/web/service.py index ac06705..33da8d8 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import os import threading import time import uuid @@ -8,10 +10,22 @@ from enum import Enum from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping +from abogen.utils import get_internal_cache_path + + +def _create_set_event() -> threading.Event: + event = threading.Event() + event.set() + return event + + +STATE_VERSION = 2 + class JobStatus(str, Enum): PENDING = "pending" RUNNING = "running" + PAUSED = "paused" COMPLETED = "completed" FAILED = "failed" CANCELLED = "cancelled" @@ -67,6 +81,12 @@ class Job: chapters: List[Dict[str, Any]] = field(default_factory=list) queue_position: Optional[int] = None cancel_requested: bool = False + pause_requested: bool = False + paused: bool = False + resume_token: Optional[str] = None + pause_event: threading.Event = field(default_factory=_create_set_event, repr=False, compare=False) + cover_image_path: Optional[Path] = None + cover_image_mime: Optional[str] = None def add_log(self, message: str, level: str = "info") -> None: self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) @@ -108,6 +128,10 @@ class Job: "order": entry.get("order"), "title": entry.get("title"), "enabled": bool(entry.get("enabled", True)), + "voice": entry.get("voice"), + "voice_profile": entry.get("voice_profile"), + "voice_formula": entry.get("voice_formula"), + "resolved_voice": entry.get("resolved_voice"), "characters": len(str(entry.get("text", ""))), } for entry in self.chapters @@ -115,6 +139,36 @@ class Job: } +@dataclass +class PendingJob: + 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 + total_characters: int + save_chapters_separately: bool + merge_chapters_at_end: bool + separate_chapters_format: str + silence_between_chapters: float + save_as_project: bool + voice_profile: Optional[str] + max_subtitle_words: int + metadata_tags: Dict[str, Any] + chapters: List[Dict[str, Any]] + created_at: float + cover_image_path: Optional[Path] = None + cover_image_mime: Optional[str] = None + + class ConversionService: def __init__( self, @@ -134,7 +188,11 @@ class ConversionService: self._uploads_root = uploads_root or output_root / "uploads" self._runner = runner self._poll_interval = poll_interval + self._pending_jobs: Dict[str, PendingJob] = {} + self._state_path = Path(get_internal_cache_path("jobs")) / "queue_state.json" + self._state_path.parent.mkdir(parents=True, exist_ok=True) self._ensure_directories() + self._load_state() # Public API --------------------------------------------------------- def list_jobs(self) -> List[Job]: @@ -170,6 +228,8 @@ class ConversionService: voice_profile: Optional[str] = None, max_subtitle_words: int = 50, metadata_tags: Optional[Mapping[str, Any]] = None, + cover_image_path: Optional[Path] = None, + cover_image_mime: Optional[str] = None, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -201,6 +261,8 @@ class ConversionService: created_at=time.time(), total_characters=total_characters, chapters=normalized_chapters, + cover_image_path=cover_image_path, + cover_image_mime=cover_image_mime, ) with self._lock: self._jobs[job_id] = job @@ -211,6 +273,18 @@ class ConversionService: job.add_log("Job queued") return job + def store_pending_job(self, pending: PendingJob) -> None: + with self._lock: + self._pending_jobs[pending.id] = pending + + def get_pending_job(self, pending_id: str) -> Optional[PendingJob]: + with self._lock: + return self._pending_jobs.get(pending_id) + + def pop_pending_job(self, pending_id: str) -> Optional[PendingJob]: + with self._lock: + return self._pending_jobs.pop(pending_id, None) + def cancel(self, job_id: str) -> bool: with self._lock: job = self._jobs.get(job_id) @@ -219,12 +293,68 @@ class ConversionService: if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: return False job.cancel_requested = True + job.pause_requested = False + job.paused = False job.add_log("Cancellation requested", level="warning") + job.pause_event.set() if job.status == JobStatus.PENDING: job.status = JobStatus.CANCELLED self._queue.remove(job_id) job.finished_at = time.time() self._update_queue_positions_locked() + self._persist_state() + return True + + def pause(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 + if job.pause_requested or job.paused: + return True + + job.pause_requested = True + job.add_log("Pause requested; finishing current chunk before stopping.", level="warning") + + if job.status == JobStatus.PENDING: + if job_id in self._queue: + self._queue.remove(job_id) + self._update_queue_positions_locked() + job.status = JobStatus.PAUSED + job.paused = True + job.pause_event.clear() + self._persist_state() + return True + + def resume(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.pause_requested = False + + if job.status == JobStatus.PAUSED and job.started_at is None: + job.status = JobStatus.PENDING + job.paused = False + job.pause_event.set() + if job_id not in self._queue: + self._queue.insert(0, job_id) + self._update_queue_positions_locked() + self._wake_event.set() + job.add_log("Resume requested; returning job to queue.", level="info") + else: + job.paused = False + job.pause_event.set() + if job.status == JobStatus.PAUSED: + job.status = JobStatus.RUNNING + job.add_log("Resume requested", level="info") + + self._persist_state() return True def delete(self, job_id: str) -> bool: @@ -238,6 +368,7 @@ class ConversionService: if job_id in self._queue: self._queue.remove(job_id) self._update_queue_positions_locked() + self._persist_state() return True def clear_finished(self, *, statuses: Optional[Iterable[JobStatus]] = None) -> int: @@ -260,6 +391,7 @@ class ConversionService: if removed: self._update_queue_positions_locked() + self._persist_state() return removed def shutdown(self) -> None: @@ -312,9 +444,13 @@ class ConversionService: self._run_job(job) def _run_job(self, job: Job) -> None: + job.pause_event.set() + job.pause_requested = False + job.paused = False job.status = JobStatus.RUNNING job.started_at = time.time() job.add_log("Job started", level="info") + self._persist_state() try: self._runner(job) except Exception as exc: # pragma: no cover - defensive @@ -331,6 +467,8 @@ class ConversionService: job.add_log("Job completed", level="success") job.finished_at = time.time() finally: + job.pause_event.set() + self._persist_state() with self._lock: self._update_queue_positions_locked() @@ -339,6 +477,177 @@ class ConversionService: job = self._jobs.get(job_id) if job: job.queue_position = index + self._persist_state() + + # Persistence ------------------------------------------------------ + def _serialize_job(self, job: Job) -> Dict[str, Any]: + result_audio = str(job.result.audio_path) if job.result.audio_path else None + result_subtitles = [str(path) for path in job.result.subtitle_paths] + result_artifacts = {key: str(path) for key, path in job.result.artifacts.items()} + return { + "id": job.id, + "original_filename": job.original_filename, + "stored_path": str(job.stored_path), + "language": job.language, + "voice": job.voice, + "speed": job.speed, + "use_gpu": job.use_gpu, + "subtitle_mode": job.subtitle_mode, + "output_format": job.output_format, + "save_mode": job.save_mode, + "output_folder": str(job.output_folder) if job.output_folder else None, + "replace_single_newlines": job.replace_single_newlines, + "subtitle_format": job.subtitle_format, + "created_at": job.created_at, + "save_chapters_separately": job.save_chapters_separately, + "merge_chapters_at_end": job.merge_chapters_at_end, + "separate_chapters_format": job.separate_chapters_format, + "silence_between_chapters": job.silence_between_chapters, + "save_as_project": job.save_as_project, + "voice_profile": job.voice_profile, + "metadata_tags": job.metadata_tags, + "max_subtitle_words": job.max_subtitle_words, + "status": job.status.value, + "started_at": job.started_at, + "finished_at": job.finished_at, + "progress": job.progress, + "total_characters": job.total_characters, + "processed_characters": job.processed_characters, + "error": job.error, + "logs": [log.__dict__ for log in job.logs][-500:], + "result": { + "audio_path": result_audio, + "subtitle_paths": result_subtitles, + "artifacts": result_artifacts, + }, + "chapters": [dict(entry) for entry in job.chapters], + "queue_position": job.queue_position, + "cancel_requested": job.cancel_requested, + "pause_requested": job.pause_requested, + "paused": job.paused, + "resume_token": job.resume_token, + "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None, + "cover_image_mime": job.cover_image_mime, + } + + def _persist_state(self) -> None: + try: + with self._lock: + snapshot = { + "version": STATE_VERSION, + "jobs": [self._serialize_job(job) for job in self._jobs.values()], + "queue": list(self._queue), + } + tmp_path = self._state_path.with_suffix(".tmp") + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(snapshot, handle, indent=2) + os.replace(tmp_path, self._state_path) + except Exception: + # Persistence failures should not disrupt runtime; ignore. + pass + + def _deserialize_job(self, payload: Dict[str, Any]) -> Job: + stored_path = Path(payload["stored_path"]) + output_folder_raw = payload.get("output_folder") + output_folder = Path(output_folder_raw) if output_folder_raw else None + job = Job( + id=payload["id"], + original_filename=payload["original_filename"], + stored_path=stored_path, + language=payload.get("language", "a"), + voice=payload.get("voice", ""), + speed=float(payload.get("speed", 1.0)), + use_gpu=bool(payload.get("use_gpu", True)), + subtitle_mode=payload.get("subtitle_mode", "Disabled"), + output_format=payload.get("output_format", "wav"), + save_mode=payload.get("save_mode", "Save next to input file"), + output_folder=output_folder, + replace_single_newlines=bool(payload.get("replace_single_newlines", False)), + subtitle_format=payload.get("subtitle_format", "srt"), + created_at=float(payload.get("created_at", time.time())), + save_chapters_separately=bool(payload.get("save_chapters_separately", False)), + merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)), + separate_chapters_format=payload.get("separate_chapters_format", "wav"), + silence_between_chapters=float(payload.get("silence_between_chapters", 2.0)), + save_as_project=bool(payload.get("save_as_project", False)), + voice_profile=payload.get("voice_profile"), + metadata_tags=payload.get("metadata_tags", {}), + max_subtitle_words=int(payload.get("max_subtitle_words", 50)), + ) + job.status = JobStatus(payload.get("status", job.status.value)) + job.started_at = payload.get("started_at") + job.finished_at = payload.get("finished_at") + job.progress = float(payload.get("progress", 0.0)) + job.total_characters = int(payload.get("total_characters", 0)) + job.processed_characters = int(payload.get("processed_characters", 0)) + job.error = payload.get("error") + job.logs = [JobLog(**entry) for entry in payload.get("logs", [])] + result_payload = payload.get("result", {}) + audio_path_raw = result_payload.get("audio_path") + job.result.audio_path = Path(audio_path_raw) if audio_path_raw else None + job.result.subtitle_paths = [Path(item) for item in result_payload.get("subtitle_paths", [])] + job.result.artifacts = { + key: Path(value) for key, value in result_payload.get("artifacts", {}).items() + } + job.chapters = payload.get("chapters", []) + job.queue_position = payload.get("queue_position") + job.cancel_requested = bool(payload.get("cancel_requested", False)) + job.pause_requested = bool(payload.get("pause_requested", False)) + job.paused = bool(payload.get("paused", False)) + job.resume_token = payload.get("resume_token") + cover_path_raw = payload.get("cover_image_path") + job.cover_image_path = Path(cover_path_raw) if cover_path_raw else None + job.cover_image_mime = payload.get("cover_image_mime") + job.pause_event.set() + return job + + def _load_state(self) -> None: + if not self._state_path.exists(): + return + try: + with self._state_path.open("r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception: + return + + if payload.get("version") != STATE_VERSION: + return + + jobs_payload = payload.get("jobs", []) + queue_payload = payload.get("queue", []) + loaded_jobs: Dict[str, Job] = {} + requeue: List[str] = [] + + for entry in jobs_payload: + try: + job = self._deserialize_job(entry) + except Exception: + continue + + if job.status in {JobStatus.RUNNING, JobStatus.PAUSED}: + job.status = JobStatus.PENDING + job.add_log("Job restored after restart: resetting to pending queue.", level="warning") + job.progress = 0.0 + job.processed_characters = 0 + job.pause_requested = False + job.paused = False + job.pause_event.set() + requeue.append(job.id) + elif job.status == JobStatus.PENDING: + requeue.append(job.id) + + loaded_jobs[job.id] = job + + with self._lock: + self._jobs = loaded_jobs + self._queue = [job_id for job_id in queue_payload if job_id in loaded_jobs] + for job_id in requeue: + if job_id not in self._queue: + self._queue.append(job_id) + self._update_queue_positions_locked() + + if self._queue: + self._ensure_worker() @staticmethod def _coerce_bool(value: Any, default: bool = True) -> bool: @@ -458,6 +767,30 @@ class ConversionService: if normalized_metadata: entry["metadata"] = normalized_metadata + voice_value = raw_dict.get("voice") + if voice_value: + entry["voice"] = str(voice_value) + + profile_value = raw_dict.get("voice_profile") + if profile_value: + entry["voice_profile"] = str(profile_value) + + formula_value = raw_dict.get("voice_formula") or raw_dict.get("formula") + if formula_value: + entry["voice_formula"] = str(formula_value) + + resolved_value = raw_dict.get("resolved_voice") + if resolved_value: + entry["resolved_voice"] = str(resolved_value) + + if "characters" in raw_dict: + try: + entry["characters"] = int(raw_dict.get("characters", 0)) + except (TypeError, ValueError): + entry["characters"] = len(str(entry.get("text", ""))) + else: + entry["characters"] = len(str(entry.get("text", ""))) + normalized.append(entry) return normalized diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js new file mode 100644 index 0000000..b50f3a3 --- /dev/null +++ b/abogen/web/static/prepare.js @@ -0,0 +1,64 @@ +document.addEventListener("DOMContentLoaded", () => { + const form = document.querySelector(".prepare-form"); + if (!form) return; + + const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); + + const updateRowState = (row) => { + const enabled = row.querySelector('[data-role=chapter-enabled]'); + const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea")); + const isChecked = enabled ? enabled.checked : true; + row.dataset.disabled = isChecked ? "false" : "true"; + + inputs.forEach((input) => { + if (input === enabled) return; + input.disabled = !isChecked; + if (!isChecked) { + if (input.tagName === "SELECT") { + input.dataset.prevValue = input.value; + input.value = "__default"; + } + if (input.dataset.role === "formula-input") { + input.value = ""; + input.hidden = true; + input.setAttribute("aria-hidden", "true"); + } + } else if (input.tagName === "SELECT" && input.dataset.prevValue) { + input.value = input.dataset.prevValue; + } + }); + + const select = row.querySelector("select[data-role=voice-select]"); + toggleFormula(select); + }; + + const toggleFormula = (select) => { + if (!select) return; + const container = select.closest("[data-role=chapter-row]"); + const formulaInput = container.querySelector('[data-role=formula-input]'); + const isFormula = select.value === "formula"; + formulaInput.hidden = !isFormula; + formulaInput.setAttribute("aria-hidden", isFormula ? "false" : "true"); + if (!isFormula) { + formulaInput.value = ""; + } + if (isFormula) { + formulaInput.required = true; + } else { + formulaInput.required = false; + } + }; + + chapterRows.forEach((row) => { + const enabled = row.querySelector('[data-role=chapter-enabled]'); + if (enabled) { + enabled.addEventListener("change", () => updateRowState(row)); + updateRowState(row); + } + const select = row.querySelector("select[data-role=voice-select]"); + if (select) { + select.addEventListener("change", () => toggleFormula(select)); + toggleFormula(select); + } + }); +}); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index d966bfb..b9e2f61 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -441,6 +441,26 @@ body { gap: 0.75rem; } +.job-card[data-status="paused"] { + border-color: rgba(251, 191, 36, 0.45); + background: rgba(251, 191, 36, 0.08); +} + +.job-card__title { + font-size: 1.05rem; + font-weight: 600; + margin: 0; +} + +.job-card__title a { + color: inherit; + text-decoration: none; +} + +.job-card__title a:hover { + color: var(--accent); +} + .job-card__header { display: flex; justify-content: space-between; @@ -454,6 +474,10 @@ body { margin-top: 0.35rem; } +.job-card__meta--warning { + color: var(--warning); +} + .job-card__progress { display: grid; gap: 0.4rem; @@ -549,6 +573,12 @@ body { color: var(--success); } +.alert--error { + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); + color: var(--danger); +} + .button--danger { background: linear-gradient(135deg, var(--danger), #ef4444); box-shadow: 0 12px 30px rgba(239, 68, 68, 0.2); @@ -618,6 +648,164 @@ body { box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); } +.prepare-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.prepare-summary dl { + margin: 0; + display: grid; + gap: 0.6rem; +} + +.prepare-summary dt { + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.prepare-summary dd { + margin: 0; + font-weight: 500; +} + +.prepare-metadata h2 { + font-size: 1rem; + margin: 0 0 0.6rem; + color: var(--muted); +} + +.prepare-metadata ul { + margin: 0; + padding-left: 1.1rem; + display: grid; + gap: 0.35rem; + font-size: 0.95rem; +} + +.prepare-form { + display: grid; + gap: 1.5rem; +} + +.chapter-grid { + display: grid; + gap: 1rem; +} + +.chapter-card { + border: 1px solid rgba(148, 163, 184, 0.18); + border-radius: 20px; + padding: 1rem 1.25rem; + background: rgba(15, 23, 42, 0.32); + display: grid; + gap: 0.85rem; +} + +.chapter-card[data-disabled="true"] { + opacity: 0.5; +} + +.chapter-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.chapter-card__title { + font-weight: 600; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.chapter-card__checkbox { + display: inline-flex; + align-items: center; + gap: 0.6rem; +} + +.chapter-card__checkbox input { + width: 1rem; + height: 1rem; + accent-color: var(--accent); +} + +.chapter-card__metrics { + color: var(--muted); + font-size: 0.85rem; +} + +.chapter-card__body { + display: grid; + gap: 1rem; +} + +.chapter-card__field { + display: grid; + gap: 0.45rem; +} + +.chapter-card__field label { + font-size: 0.85rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.chapter-card__field input, +.chapter-card__field select { + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.45); + padding: 0.7rem 0.9rem; + color: var(--text); + font-size: 0.95rem; +} + +.chapter-card__preview summary { + cursor: pointer; + color: var(--accent); + font-weight: 500; +} + +.chapter-card__preview pre { + margin: 0.35rem 0 0; + padding: 0.75rem 0.9rem; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.55); + max-height: 200px; + overflow-y: auto; + font-size: 0.85rem; + line-height: 1.45; + white-space: pre-wrap; +} + +.chapter-card__formula { + max-width: 420px; +} + +.prepare-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.prepare-actions .button { + min-width: 190px; +} + +.prepare-actions .button--ghost { + border-color: rgba(148, 163, 184, 0.25); +} + .toggle-pill input:checked + span { background: rgba(56, 189, 248, 0.15); border-color: rgba(56, 189, 248, 0.4); diff --git a/abogen/web/templates/partials/jobs.html b/abogen/web/templates/partials/jobs.html index 756fd5f..67abc47 100644 --- a/abogen/web/templates/partials/jobs.html +++ b/abogen/web/templates/partials/jobs.html @@ -7,23 +7,37 @@ {% if active_jobs %}
      {% for job in active_jobs %} -
    • + {% set progress_value = ((job.progress or 0) * 100)|round(1) %} +
    • - {{ job.original_filename }} -
      {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }}
      + {{ job.original_filename }} +
      + {% if job.queue_position %}Position #{{ job.queue_position }} · {% endif %} + {% if job.voice_profile %}Profile: {{ job.voice_profile }}{% else %}Voice: {{ job.voice }}{% endif %} · {{ job.language }} +
      + {% if job.pause_requested and job.status == JobStatus.RUNNING %} +
      Pause requested — waiting for a safe point…
      + {% endif %}
      {{ job.status.value|title }}
      -
      +
      - {{ job.processed_characters }} / {{ job.total_characters or '—' }} + {{ progress_value }}% · {{ job.processed_characters }} / {{ job.total_characters or '—' }}
      @@ -48,7 +62,7 @@
    • - {{ job.original_filename }} + {{ job.original_filename }}
      Finished {{ job.finished_at|datetimeformat }}
      {{ job.status.value|title }} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html new file mode 100644 index 0000000..40d261a --- /dev/null +++ b/abogen/web/templates/prepare_job.html @@ -0,0 +1,120 @@ +{% extends "base.html" %} + +{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} + +{% block content %} +
      +
      Prepare audiobook
      +

      Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.

      + +
      +
      +
      +
      Source
      +
      {{ pending.original_filename }}
      +
      +
      +
      Language
      +
      {{ options.languages.get(pending.language, pending.language|upper) }}
      +
      +
      +
      Default voice
      +
      {% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
      +
      +
      +
      Total chapters
      +
      {{ pending.chapters|length }}
      +
      +
      +
      Characters
      +
      {{ pending.total_characters|default(0) }}
      +
      +
      + {% if pending.metadata_tags %} + + {% endif %} +
      + + {% if error %} +
      {{ error }}
      + {% endif %} + + +
      + {% for chapter in pending.chapters %} + {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} + {% set selected_option = '__default' %} + {% if chapter.voice_profile %} + {% set selected_option = 'profile:' ~ chapter.voice_profile %} + {% elif chapter.voice %} + {% set selected_option = 'voice:' ~ chapter.voice %} + {% elif chapter.voice_formula %} + {% set selected_option = 'formula' %} + {% endif %} +
      +
      +
      + +
      +
      + {{ chapter.characters }} characters +
      +
      +
      +
      + + +
      +
      +
      + Preview text +
      {{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}
      +
      +
      +
      + + + +
      +
      +
      + {% endfor %} +
      +
      + + +
      + +
      +
      +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} From ea1c7bd93e1bff4601fc14e184275045b96be2fa Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 06:01:29 -0700 Subject: [PATCH 032/245] feat: Add ffmetadata rendering and writing functions with tests for chapter inclusion --- abogen/web/conversion_runner.py | 163 +++++++++++++++++++++++++++++++- tests/test_ffmetadata.py | 40 ++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 tests/test_ffmetadata.py diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index b794b52..9003a52 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -6,6 +6,7 @@ import os import re import subprocess import sys +import tempfile from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path @@ -197,6 +198,142 @@ def _chapter_voice_spec(job: Job, override: Optional[Dict[str, Any]]) -> str: return job.voice or "" +def _escape_ffmetadata_value(value: str) -> str: + escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n") + escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#") + return escaped + + +def _render_ffmetadata(metadata: Dict[str, Any], chapters: List[Dict[str, Any]]) -> str: + lines: List[str] = [";FFMETADATA1"] + for key, value in (metadata or {}).items(): + if value is None: + continue + key_str = str(key).strip() + if not key_str: + continue + lines.append(f"{key_str}={_escape_ffmetadata_value(value)}") + + for chapter in chapters or []: + start = chapter.get("start") + end = chapter.get("end") + if start is None or end is None: + continue + try: + start_ms = max(0, int(round(float(start) * 1000))) + end_ms = int(round(float(end) * 1000)) + except (TypeError, ValueError): + continue + if end_ms <= start_ms: + end_ms = start_ms + 1 + lines.append("[CHAPTER]") + lines.append("TIMEBASE=1/1000") + lines.append(f"START={start_ms}") + lines.append(f"END={end_ms}") + title = chapter.get("title") + if title: + lines.append(f"title={_escape_ffmetadata_value(title)}") + voice = chapter.get("voice") + if voice: + lines.append(f"voice={_escape_ffmetadata_value(voice)}") + + return "\n".join(lines) + "\n" + + +def _write_ffmetadata_file( + audio_path: Path, + metadata: Dict[str, Any], + chapters: List[Dict[str, Any]], +) -> Optional[Path]: + content = _render_ffmetadata(metadata, chapters) + if content.strip() == ";FFMETADATA1": + return None + directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir()) + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + suffix=".ffmeta", + delete=False, + dir=str(directory), + ) as handle: + handle.write(content) + return Path(handle.name) + + +def _embed_m4b_metadata( + audio_path: Path, + metadata_payload: Dict[str, Any], + job: Job, +) -> None: + metadata_map = dict(metadata_payload.get("metadata") or {}) + chapter_entries = list(metadata_payload.get("chapters") or []) + ffmetadata_path = _write_ffmetadata_file(audio_path, metadata_map, chapter_entries) + cover_path: Optional[Path] = None + if job.cover_image_path: + candidate = Path(job.cover_image_path) + if candidate.exists(): + cover_path = candidate + + if not ffmetadata_path and not cover_path: + return + + job.add_log("Embedding chapters and cover metadata into m4b output") + + command: List[str] = ["ffmpeg", "-y", "-i", str(audio_path)] + metadata_index: Optional[int] = None + cover_index: Optional[int] = None + next_index = 1 + + if ffmetadata_path: + command += ["-i", str(ffmetadata_path)] + metadata_index = next_index + next_index += 1 + + if cover_path: + command += ["-i", str(cover_path)] + cover_index = next_index + next_index += 1 + + command += ["-map", "0:a"] + command += ["-c:a", "copy"] + + if cover_index is not None: + command += ["-map", f"{cover_index}:v:0"] + command += ["-c:v:0", "mjpeg"] + command += ["-disposition:v:0", "attached_pic"] + command += ["-metadata:s:v:0", "title=Cover Art"] + if job.cover_image_mime: + command += ["-metadata:s:v:0", f"mimetype={job.cover_image_mime}"] + + if metadata_index is not None: + command += ["-map_metadata", str(metadata_index)] + command += ["-map_chapters", str(metadata_index)] + else: + command += ["-map_metadata", "0"] + + command += ["-movflags", "+faststart+use_metadata_tags"] + + temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp") + command.append(str(temp_output)) + + process = create_process(command, text=True) + try: + return_code = process.wait() + finally: + if ffmetadata_path and ffmetadata_path.exists(): + try: + ffmetadata_path.unlink() + except OSError: + pass + + if return_code != 0: + if temp_output.exists(): + temp_output.unlink(missing_ok=True) + raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})") + + temp_output.replace(audio_path) + + def run_conversion_job(job: Job) -> None: job.add_log("Preparing conversion pipeline") canceller = _make_canceller(job) @@ -204,6 +341,9 @@ def run_conversion_job(job: Job) -> None: sink_stack = ExitStack() subtitle_writer: Optional[SubtitleWriter] = None chapter_paths: list[Path] = [] + chapter_markers: List[Dict[str, Any]] = [] + metadata_payload: Dict[str, Any] = {"metadata": {}, "chapters": []} + audio_output_path: Optional[Path] = None try: pipeline = _load_pipeline(job) extraction = extract_from_path(job.stored_path) @@ -266,7 +406,6 @@ def run_conversion_job(job: Job) -> None: subtitle_index = 1 current_time = 0.0 total_chapters = len(extraction.chapters) - chapter_markers: List[Dict[str, Any]] = [] job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") for idx, chapter in enumerate(extraction.chapters, start=1): @@ -392,13 +531,14 @@ def run_conversion_job(job: Job) -> None: if not audio_path and chapter_paths: job.result.audio_path = chapter_paths[0] + metadata_payload = { + "metadata": dict(job.metadata_tags or {}), + "chapters": chapter_markers, + } + if metadata_dir: metadata_dir.mkdir(parents=True, exist_ok=True) metadata_file = metadata_dir / "metadata.json" - metadata_payload = { - "metadata": job.metadata_tags, - "chapters": chapter_markers, - } metadata_file.write_text(json.dumps(metadata_payload, indent=2), encoding="utf-8") job.result.artifacts["metadata"] = metadata_file @@ -408,6 +548,8 @@ def run_conversion_job(job: Job) -> None: if job.status != JobStatus.CANCELLED: job.progress = 1.0 + audio_output_path = job.result.audio_path + except _JobCancelled: job.status = JobStatus.CANCELLED job.add_log("Job cancelled", level="warning") @@ -420,6 +562,17 @@ def run_conversion_job(job: Job) -> None: if subtitle_writer: subtitle_writer.close() + if ( + audio_output_path + and job.output_format.lower() == "m4b" + and not job.cancel_requested + and job.status not in {JobStatus.FAILED, JobStatus.CANCELLED} + ): + try: + _embed_m4b_metadata(audio_output_path, metadata_payload, job) + except Exception as exc: # pragma: no cover - best effort + job.add_log(f"Unable to embed metadata into m4b output: {exc}", level="warning") + def _load_pipeline(job: Job): cfg = load_config() diff --git a/tests/test_ffmetadata.py b/tests/test_ffmetadata.py new file mode 100644 index 0000000..82d05cc --- /dev/null +++ b/tests/test_ffmetadata.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path + +from abogen.web.conversion_runner import _render_ffmetadata, _write_ffmetadata_file + + +def test_render_ffmetadata_includes_chapters(tmp_path): + metadata = { + "title": "Sample Book", + "artist": "Author Name", + "comment": "Line one\nLine two", + "publisher": "ACME=Corp", + } + chapters = [ + {"start": 0.0, "end": 5.0, "title": "Intro", "voice": "voice_a"}, + {"start": 5.0, "end": 12.345, "title": "Chapter 2"}, + ] + + rendered = _render_ffmetadata(metadata, chapters) + + assert ";FFMETADATA1" in rendered + assert "title=Sample Book" in rendered + assert "artist=Author Name" in rendered + assert "comment=Line one\\nLine two" in rendered + assert "publisher=ACME\\=Corp" in rendered + assert rendered.count("[CHAPTER]") == 2 + assert "START=0" in rendered + assert "END=5000" in rendered + assert "voice=voice_a" in rendered + + audio_path = tmp_path / "book.m4b" + metadata_path = _write_ffmetadata_file(audio_path, metadata, chapters) + assert metadata_path is not None + assert metadata_path.exists() + + content = metadata_path.read_text(encoding="utf-8") + assert "END=12345" in content + + metadata_path.unlink() From 0dd74412d1e1f37bf3b6d5a6db162658fa2d872a Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 07:07:08 -0700 Subject: [PATCH 033/245] feat: Add chapter intro delay setting and implement in conversion process --- abogen/web/conversion_runner.py | 214 +++++++++++++++++--------- abogen/web/routes.py | 22 ++- abogen/web/service.py | 9 +- abogen/web/templates/job_detail.html | 1 + abogen/web/templates/prepare_job.html | 11 ++ abogen/web/templates/settings.html | 5 + 6 files changed, 191 insertions(+), 71 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 9003a52..793954f 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -204,6 +204,18 @@ def _escape_ffmetadata_value(value: str) -> str: return escaped +def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]: + args: List[str] = [] + for key, value in (metadata or {}).items(): + if value in (None, ""): + continue + key_str = str(key).strip() + if not key_str: + continue + args.extend(["-metadata", f"{key_str}={value}"]) + return args + + def _render_ffmetadata(metadata: Dict[str, Any], chapters: List[Dict[str, Any]]) -> str: lines: List[str] = [";FFMETADATA1"] for key, value in (metadata or {}).items(): @@ -274,10 +286,12 @@ def _embed_m4b_metadata( if candidate.exists(): cover_path = candidate - if not ffmetadata_path and not cover_path: + metadata_args = _metadata_to_ffmpeg_args(metadata_map) + + if not ffmetadata_path and not cover_path and not metadata_args: return - job.add_log("Embedding chapters and cover metadata into m4b output") + job.add_log("Embedding metadata into m4b output") command: List[str] = ["ffmpeg", "-y", "-i", str(audio_path)] metadata_index: Optional[int] = None @@ -311,6 +325,9 @@ def _embed_m4b_metadata( else: command += ["-map_metadata", "0"] + if metadata_args: + command.extend(metadata_args) + command += ["-movflags", "+faststart+use_metadata_tags"] temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp") @@ -332,6 +349,7 @@ def _embed_m4b_metadata( raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})") temp_output.replace(audio_path) + job.add_log("Embedded metadata and chapters into m4b output", level="info") def run_conversion_job(job: Job) -> None: @@ -408,6 +426,83 @@ def run_conversion_job(job: Job) -> None: total_chapters = len(extraction.chapters) job.add_log(f"Detected {total_chapters} chapter{'s' if total_chapters != 1 else ''}") + def emit_text( + text: str, + *, + voice_choice: Any, + chapter_sink: Optional[AudioSink], + preview_prefix: Optional[str] = None, + split_pattern: Optional[str] = SPLIT_PATTERN, + ) -> int: + nonlocal processed_chars, subtitle_index, current_time + normalized = _normalize_for_pipeline(text) + local_segments = 0 + + for segment in pipeline( + normalized, + voice=voice_choice, + speed=job.speed, + split_pattern=split_pattern, + ): + canceller() + graphemes_raw = getattr(segment, "graphemes", "") or "" + graphemes = graphemes_raw.strip() + + audio = _to_float32(getattr(segment, "audio", None)) + if audio.size == 0: + continue + + local_segments += 1 + 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) + else: + job.progress = 0.0 if processed_chars == 0 else 0.999 + + preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") + prefix = f"{preview_prefix} · " if preview_prefix else "" + job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}") + + if subtitle_writer and audio_sink and graphemes: + 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 + + return local_segments + + def append_silence( + duration_seconds: float, + *, + include_in_chapter: bool, + chapter_sink: Optional[AudioSink], + ) -> None: + nonlocal current_time + if duration_seconds <= 0: + return + samples = int(round(duration_seconds * SAMPLE_RATE)) + if samples <= 0: + return + silence = np.zeros(samples, dtype="float32") + if include_in_chapter and chapter_sink: + chapter_sink.write(silence) + if audio_sink: + audio_sink.write(silence) + current_time += duration_seconds + for idx, chapter in enumerate(extraction.chapters, start=1): canceller() job.add_log(f"Processing chapter {idx}/{total_chapters}: {chapter.title}") @@ -425,71 +520,54 @@ def run_conversion_job(job: Job) -> None: voice_choice = _resolve_voice(pipeline, chapter_voice_spec, job.use_gpu) voice_cache[chapter_voice_spec] = voice_choice - 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, - ) - segments_emitted = 0 - tts_input = _normalize_for_pipeline(chapter.text) - for segment in pipeline( - tts_input, - voice=voice_choice, - speed=job.speed, - split_pattern=SPLIT_PATTERN, - ): - canceller() - graphemes_raw = getattr(segment, "graphemes", "") or "" - graphemes = graphemes_raw.strip() + with ExitStack() as chapter_sink_stack: + chapter_sink: Optional[AudioSink] = None - audio = _to_float32(getattr(segment, "audio", None)) - if audio.size == 0: - continue - - segments_emitted += 1 - 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) - else: - job.progress = 0.0 if processed_chars == 0 else 0.999 - - preview_text = graphemes or (graphemes_raw[:80] if graphemes_raw else "[silence]") - job.add_log(f"{processed_chars:,}/{job.total_characters or '—'}: {preview_text[:80]}") - - if subtitle_writer and audio_sink and graphemes: - subtitle_writer.write_segment( - index=subtitle_index, - text=graphemes, - start=current_time, - end=current_time + duration, + 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, ) - subtitle_index += 1 - if audio_sink: - current_time += duration + speak_heading = bool(chapter.title.strip()) + if speak_heading: + stripped_title = chapter.title.strip() + if stripped_title: + first_line = next((line.strip() for line in chapter.text.splitlines() if line.strip()), "") + if first_line and first_line.casefold() == stripped_title.casefold(): + speak_heading = False - if chapter_sink: - chapter_sink_stack.close() + if speak_heading: + heading_segments = emit_text( + chapter.title, + voice_choice=voice_choice, + chapter_sink=chapter_sink, + preview_prefix=f"Chapter {idx} title", + split_pattern=SPLIT_PATTERN, + ) + segments_emitted += heading_segments + if heading_segments > 0 and job.chapter_intro_delay > 0: + append_silence( + job.chapter_intro_delay, + include_in_chapter=True, + chapter_sink=chapter_sink, + ) + + segments_emitted += emit_text( + chapter.text, + voice_choice=voice_choice, + chapter_sink=chapter_sink, + ) chapter_end_time = current_time @@ -511,12 +589,12 @@ def run_conversion_job(job: Job) -> None: 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 - chapter_end_time = current_time + append_silence( + job.silence_between_chapters, + include_in_chapter=False, + chapter_sink=None, + ) + chapter_end_time = current_time chapter_markers.append( { @@ -730,9 +808,7 @@ def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str base += ["-c:a", "copy"] if metadata: - for key, value in metadata.items(): - if value: - base += ["-metadata", f"{key}={value}"] + base.extend(_metadata_to_ffmpeg_args(metadata)) base.append(str(path)) return base diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 38e7446..ea90b7e 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -135,7 +135,7 @@ BOOLEAN_SETTINGS = { "save_as_project", } -FLOAT_SETTINGS = {"silence_between_chapters"} +FLOAT_SETTINGS = {"silence_between_chapters", "chapter_intro_delay"} INT_SETTINGS = {"max_subtitle_words"} @@ -156,6 +156,7 @@ def _settings_defaults() -> Dict[str, Any]: "save_as_project": False, "separate_chapters_format": "wav", "silence_between_chapters": 2.0, + "chapter_intro_delay": 0.5, "max_subtitle_words": 50, } @@ -420,6 +421,9 @@ def settings_page() -> Response | str: updated["silence_between_chapters"] = _coerce_float( form.get("silence_between_chapters"), defaults["silence_between_chapters"] ) + updated["chapter_intro_delay"] = _coerce_float( + form.get("chapter_intro_delay"), defaults["chapter_intro_delay"] + ) updated["max_subtitle_words"] = _coerce_int( form.get("max_subtitle_words"), defaults["max_subtitle_words"] ) @@ -801,6 +805,7 @@ def enqueue_job() -> Response: save_as_project = settings["save_as_project"] separate_chapters_format = settings["separate_chapters_format"] silence_between_chapters = settings["silence_between_chapters"] + chapter_intro_delay = settings["chapter_intro_delay"] max_subtitle_words = settings["max_subtitle_words"] pending = PendingJob( @@ -830,6 +835,7 @@ def enqueue_job() -> Response: created_at=time.time(), cover_image_path=cover_path, cover_image_mime=cover_mime, + chapter_intro_delay=chapter_intro_delay, ) service.store_pending_job(pending) @@ -854,6 +860,19 @@ def finalize_job(pending_id: str) -> Response: pending = cast(PendingJob, pending) profiles = serialize_profiles() + delay_value = pending.chapter_intro_delay + raw_delay = request.form.get("chapter_intro_delay") + if raw_delay is not None: + raw_normalized = raw_delay.strip() + if raw_normalized: + try: + delay_value = max(0.0, float(raw_normalized)) + except ValueError: + return _render_prepare_page(pending, error="Enter a valid number for the chapter intro delay.") + else: + delay_value = 0.0 + pending.chapter_intro_delay = delay_value + overrides: List[Dict[str, Any]] = [] selected_total = 0 errors: List[str] = [] @@ -941,6 +960,7 @@ def finalize_job(pending_id: str) -> Response: max_subtitle_words=pending.max_subtitle_words, cover_image_path=pending.cover_image_path, cover_image_mime=pending.cover_image_mime, + chapter_intro_delay=pending.chapter_intro_delay, ) return redirect(url_for("web.job_detail", job_id=job.id)) diff --git a/abogen/web/service.py b/abogen/web/service.py index 33da8d8..936250a 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -19,7 +19,7 @@ def _create_set_event() -> threading.Event: return event -STATE_VERSION = 2 +STATE_VERSION = 3 class JobStatus(str, Enum): @@ -69,6 +69,7 @@ class Job: voice_profile: Optional[str] = None metadata_tags: Dict[str, str] = field(default_factory=dict) max_subtitle_words: int = 50 + chapter_intro_delay: float = 0.5 status: JobStatus = JobStatus.PENDING started_at: Optional[float] = None finished_at: Optional[float] = None @@ -119,6 +120,7 @@ class Job: "save_as_project": self.save_as_project, "voice_profile": self.voice_profile, "max_subtitle_words": self.max_subtitle_words, + "chapter_intro_delay": self.chapter_intro_delay, }, "metadata_tags": dict(self.metadata_tags), "chapters": [ @@ -167,6 +169,7 @@ class PendingJob: created_at: float cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None + chapter_intro_delay: float = 0.5 class ConversionService: @@ -230,6 +233,7 @@ class ConversionService: metadata_tags: Optional[Mapping[str, Any]] = None, cover_image_path: Optional[Path] = None, cover_image_mime: Optional[str] = None, + chapter_intro_delay: float = 0.5, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -263,6 +267,7 @@ class ConversionService: chapters=normalized_chapters, cover_image_path=cover_image_path, cover_image_mime=cover_image_mime, + chapter_intro_delay=chapter_intro_delay, ) with self._lock: self._jobs[job_id] = job @@ -528,6 +533,7 @@ class ConversionService: "resume_token": job.resume_token, "cover_image_path": str(job.cover_image_path) if job.cover_image_path else None, "cover_image_mime": job.cover_image_mime, + "chapter_intro_delay": job.chapter_intro_delay, } def _persist_state(self) -> None: @@ -573,6 +579,7 @@ class ConversionService: voice_profile=payload.get("voice_profile"), metadata_tags=payload.get("metadata_tags", {}), max_subtitle_words=int(payload.get("max_subtitle_words", 50)), + chapter_intro_delay=float(payload.get("chapter_intro_delay", 0.5)), ) job.status = JobStatus(payload.get("status", job.status.value)) job.started_at = payload.get("started_at") diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index b23083f..7ae11c9 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -23,6 +23,7 @@
    • Merge at end: {{ 'Yes' if job.merge_chapters_at_end else 'No' }}
    • Separate chapter format: {{ job.separate_chapters_format|upper }}
    • Silence between chapters: {{ '%.1f'|format(job.silence_between_chapters) }}s
    • +
    • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
    • Max words per subtitle: {{ job.max_subtitle_words }}
    • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
    diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 40d261a..7d57da6 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -29,6 +29,10 @@
    Characters
    {{ pending.total_characters|default(0) }}
+
+
Chapter intro delay
+
{{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
+
{% if pending.metadata_tags %} +
+
+ + +

Set to 0 to disable the pause after speaking each chapter title.

+
+
diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 4708a34..f792090 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -86,6 +86,11 @@
+
+ + +

Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

+
From 02da72434b2c214dc4aa20b88aa17cdca7247443 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 08:47:18 -0700 Subject: [PATCH 034/245] feat: Implement chapter embedding in m4b files using mutagen and update dependencies --- abogen/web/conversion_runner.py | 74 ++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 793954f..c079531 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -272,6 +272,70 @@ def _write_ffmetadata_file( return Path(handle.name) +def _apply_m4b_chapters_with_mutagen( + audio_path: Path, + chapters: List[Dict[str, Any]], + job: Job, +) -> bool: + if not chapters: + return False + + try: + from fractions import Fraction + from mutagen.mp4 import MP4, MP4Chapter # type: ignore[import] + except ImportError: + job.add_log( + "Unable to write MP4 chapter atoms because mutagen is not installed.", + level="warning", + ) + return False + + try: + mp4 = MP4(str(audio_path)) + except Exception as exc: # pragma: no cover - defensive + job.add_log(f"Failed to open m4b for chapter embedding: {exc}", level="warning") + return False + + chapter_objects: List[MP4Chapter] = [] + for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))): + start_raw = entry.get("start") + if start_raw is None: + continue + try: + start_seconds = max(0.0, float(start_raw)) + except (TypeError, ValueError): + continue + + title_value = entry.get("title") + title_text = str(title_value) if title_value else f"Chapter {index + 1}" + + start_fraction = Fraction(int(round(start_seconds * 1000)), 1000) + chapter_atom = MP4Chapter(start_fraction, title_text) + + end_raw = entry.get("end") + if end_raw is not None: + try: + end_seconds = float(end_raw) + except (TypeError, ValueError): + end_seconds = None + if end_seconds is not None and end_seconds > start_seconds: + chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000) + + chapter_objects.append(chapter_atom) + + if not chapter_objects: + return False + + try: + mp4.chapters = chapter_objects + mp4.save() + except Exception as exc: # pragma: no cover - defensive + job.add_log(f"Failed to persist MP4 chapter atoms: {exc}", level="warning") + return False + + return True + + def _embed_m4b_metadata( audio_path: Path, metadata_payload: Dict[str, Any], @@ -299,7 +363,7 @@ def _embed_m4b_metadata( next_index = 1 if ffmetadata_path: - command += ["-i", str(ffmetadata_path)] + command += ["-f", "ffmetadata", "-i", str(ffmetadata_path)] metadata_index = next_index next_index += 1 @@ -351,6 +415,12 @@ def _embed_m4b_metadata( temp_output.replace(audio_path) job.add_log("Embedded metadata and chapters into m4b output", level="info") + mutagen_applied = _apply_m4b_chapters_with_mutagen(audio_path, chapter_entries, job) + if mutagen_applied: + job.add_log( + f"Applied {len(chapter_entries)} chapter markers via mutagen", level="info" + ) + def run_conversion_job(job: Job) -> None: job.add_log("Preparing conversion pipeline") @@ -780,7 +850,7 @@ def _open_audio_sink( def _write(data: np.ndarray) -> None: if job.cancel_requested or process.stdin is None: return - process.stdin.write(data.tobytes()) + process.stdin.write(data.tobytes()) # type: ignore[arg-type] return AudioSink(write=_write) diff --git a/pyproject.toml b/pyproject.toml index bc43ee5..ee9426f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "PyMuPDF>=1.25.5", "platformdirs>=4.3.7", "soundfile>=0.13.1", + "mutagen>=1.47.0", "pygame>=2.6.1", "charset_normalizer>=3.4.1", "chardet>=5.2.0", From a51fd2527183c16997f61dfd7e150d0e9014492f Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 10:23:42 -0700 Subject: [PATCH 035/245] feat: Enhance chapter selection and metadata handling in conversion process --- abogen/web/conversion_runner.py | 174 +++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 4 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index c079531..ed4da55 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -10,7 +10,7 @@ import tempfile from contextlib import ExitStack from dataclasses import dataclass from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, cast import numpy as np import soundfile as sf @@ -64,6 +64,128 @@ def _coerce_truthy(value: Any, default: bool = True) -> bool: return bool(value) +_SIGNIFICANT_LENGTH_THRESHOLDS: Dict[str, int] = {"epub": 1000, "markdown": 500} +_MIN_SHORT_CONTENT: Dict[str, int] = {"epub": 240, "markdown": 160} +_STRUCTURAL_KEYWORDS = ( + "preface", + "prologue", + "introduction", + "foreword", + "epilogue", + "afterword", + "appendix", + "acknowledgment", + "acknowledgement", +) +_STRUCTURAL_MIN_LENGTH = 120 +_MAX_SHORT_CHAPTERS = 2 + + +def _infer_file_type(path: Path) -> str: + suffix = path.suffix.lower() + if suffix == ".epub": + return "epub" + if suffix in {".md", ".markdown"}: + return "markdown" + if suffix == ".pdf": + return "pdf" + if suffix == ".txt": + return "text" + return suffix.lstrip(".") or "text" + + +def _looks_structural(title: str) -> bool: + lowered = title.strip().lower() + if not lowered: + return False + return any(keyword in lowered for keyword in _STRUCTURAL_KEYWORDS) + + +def _auto_select_relevant_chapters( + chapters: List[ExtractedChapter], + file_type: str, +) -> tuple[List[ExtractedChapter], List[tuple[str, int]]]: + if not chapters: + return [], [] + + normalized = file_type.lower() + threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(normalized, 0) + min_short = _MIN_SHORT_CONTENT.get(normalized, 0) + + kept: List[ExtractedChapter] = [] + skipped: List[tuple[str, int]] = [] + short_kept = 0 + + for chapter in chapters: + stripped = chapter.text.strip() + length = len(stripped) + if length == 0: + skipped.append((chapter.title, length)) + continue + + keep = False + if threshold == 0: + keep = True + elif length >= threshold: + keep = True + elif not kept: + keep = True + elif min_short and length >= min_short and short_kept < _MAX_SHORT_CHAPTERS: + keep = True + short_kept += 1 + elif _looks_structural(chapter.title) and length >= _STRUCTURAL_MIN_LENGTH: + keep = True + + if keep: + kept.append(chapter) + else: + skipped.append((chapter.title, length)) + + if kept: + return kept, skipped + + # Fallback: retain the longest non-empty chapter so conversion can proceed. + longest_idx = None + longest_length = 0 + for idx, chapter in enumerate(chapters): + stripped_length = len(chapter.text.strip()) + if stripped_length > longest_length: + longest_length = stripped_length + longest_idx = idx + + if longest_idx is None or longest_length == 0: + return [], [] + + fallback_chapter = chapters[longest_idx] + kept = [fallback_chapter] + skipped = [ + (chapter.title, len(chapter.text.strip())) + for idx, chapter in enumerate(chapters) + if idx != longest_idx and chapter.text.strip() + ] + return kept, skipped + + +def _chapter_label(file_type: str) -> str: + return "chapters" if file_type.lower() in {"epub", "markdown"} else "pages" + + +def _update_metadata_for_chapter_count(metadata: Dict[str, Any], count: int, file_type: str) -> None: + if not metadata or count <= 0: + return + + label = "Chapters" if file_type.lower() in {"epub", "markdown"} else "Pages" + metadata["chapter_count"] = str(count) + + pattern = re.compile(r"\(\d+\s+(Chapters?|Pages?)\)") + replacement = f"({count} {label})" + for key in ("album", "ALBUM"): + value = metadata.get(key) + if not isinstance(value, str): + continue + metadata[key] = pattern.sub(replacement, value) + + def _apply_chapter_overrides( extracted: List[ExtractedChapter], overrides: List[Dict[str, Any]], @@ -327,7 +449,7 @@ def _apply_m4b_chapters_with_mutagen( return False try: - mp4.chapters = chapter_objects + mp4.chapters = cast(Any, chapter_objects) mp4.save() except Exception as exc: # pragma: no cover - defensive job.add_log(f"Failed to persist MP4 chapter atoms: {exc}", level="warning") @@ -435,6 +557,37 @@ def run_conversion_job(job: Job) -> None: try: pipeline = _load_pipeline(job) extraction = extract_from_path(job.stored_path) + file_type = _infer_file_type(job.stored_path) + + if not job.chapters: + filtered, skipped_info = _auto_select_relevant_chapters(extraction.chapters, file_type) + original_count = len(extraction.chapters) + if filtered and len(filtered) < original_count: + extraction.chapters = filtered + _update_metadata_for_chapter_count(extraction.metadata, len(filtered), file_type) + threshold = _SIGNIFICANT_LENGTH_THRESHOLDS.get(file_type.lower()) + label = _chapter_label(file_type) + qualifier = f" (< {threshold} characters)" if threshold else "" + job.add_log( + f"Auto-selected {len(filtered)} of {original_count} {label} based on content{qualifier}.", + level="info", + ) + if skipped_info: + preview_count = 5 + preview = ", ".join( + f"{title or 'Untitled'} ({length})" for title, length in skipped_info[:preview_count] + ) + if len(skipped_info) > preview_count: + preview += ", …" + job.add_log( + f"Skipped {len(skipped_info)} short {label}: {preview}", + level="debug", + ) + elif not filtered: + job.add_log( + "Auto-selection did not identify usable chapters; retaining original set.", + level="warning", + ) metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) active_chapter_configs: List[Dict[str, Any]] = [] @@ -469,6 +622,13 @@ def run_conversion_job(job: Job) -> None: base_output_dir = _prepare_output_dir(job) project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(job, base_output_dir) + if job.output_format.lower() == "m4b" and not job.merge_chapters_at_end: + job.add_log( + "Forcing merged output for m4b format; ignoring 'merge chapters at end' setting.", + level="warning", + ) + job.merge_chapters_at_end = True + merged_required = job.merge_chapters_at_end or not job.save_chapters_separately audio_path: Optional[Path] = None audio_sink: Optional[AudioSink] = None @@ -718,8 +878,14 @@ def run_conversion_job(job: Job) -> None: ): try: _embed_m4b_metadata(audio_output_path, metadata_payload, job) - except Exception as exc: # pragma: no cover - best effort - job.add_log(f"Unable to embed metadata into m4b output: {exc}", level="warning") + except Exception as exc: # pragma: no cover - ensure failure propagates + job.add_log( + f"Failed to embed metadata into m4b output: {exc}", + level="error", + ) + raise RuntimeError( + f"Failed to embed metadata into m4b output: {exc}" + ) from exc def _load_pipeline(job: Job): From 42334e92a4821b013f09adfe6e17429f1120c38a Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 10:40:42 -0700 Subject: [PATCH 036/245] feat: Add format specification for m4b, mp4, and m4a files in metadata embedding --- abogen/web/conversion_runner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index ed4da55..7359a31 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -517,6 +517,8 @@ def _embed_m4b_metadata( command += ["-movflags", "+faststart+use_metadata_tags"] temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp") + if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}: + command += ["-f", "mp4"] command.append(str(temp_output)) process = create_process(command, text=True) From 4b3aa227b74304d43612697d759a804995544f81 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 11:04:00 -0700 Subject: [PATCH 037/245] feat: Update metadata handling to include chapter count and normalize keys for ffmpeg arguments --- abogen/text_extractor.py | 8 ++++++-- abogen/web/conversion_runner.py | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index 079bc98..69e0237 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -189,11 +189,12 @@ def _build_metadata_payload( metadata = { "TITLE": title, "ARTIST": authors_text, - "ALBUM": f"{title} ({chapter_count} {chapter_label})", + "ALBUM": title, "YEAR": metadata_source.publication_year or now_year, "ALBUM_ARTIST": authors_text, "COMPOSER": "Narrator", "GENRE": "Audiobook", + "CHAPTER_COUNT": str(chapter_count), } if metadata_source.publisher: metadata["PUBLISHER"] = metadata_source.publisher @@ -201,7 +202,10 @@ def _build_metadata_payload( metadata["COMMENT"] = metadata_source.description if metadata_source.language: metadata["LANGUAGE"] = metadata_source.language - return _normalize_metadata_keys(metadata) + normalized = _normalize_metadata_keys(metadata) + # Ensure chapter_count survives normalization even if upstream metadata provided it + normalized.setdefault("chapter_count", str(chapter_count)) + return normalized def _extract_pdf(path: Path) -> ExtractionResult: diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 7359a31..c42a2f2 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -334,7 +334,12 @@ def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]: key_str = str(key).strip() if not key_str: continue - args.extend(["-metadata", f"{key_str}={value}"]) + normalized_key = key_str.lower() + if normalized_key == "year": + ffmpeg_key = "date" + else: + ffmpeg_key = key_str + args.extend(["-metadata", f"{ffmpeg_key}={value}"]) return args From b35ab7b0027fc0040a228d5224c712dc3e399aef Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 13:49:53 -0700 Subject: [PATCH 038/245] feat: Implement dynamic state path determination and migration for queue state file --- abogen/web/service.py | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/abogen/web/service.py b/abogen/web/service.py index 936250a..cda9ced 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import os +import shutil import threading import time import uuid @@ -10,7 +11,7 @@ from enum import Enum from pathlib import Path from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping -from abogen.utils import get_internal_cache_path +from abogen.utils import get_internal_cache_path, get_user_settings_dir def _create_set_event() -> threading.Event: @@ -192,8 +193,7 @@ class ConversionService: self._runner = runner self._poll_interval = poll_interval self._pending_jobs: Dict[str, PendingJob] = {} - self._state_path = Path(get_internal_cache_path("jobs")) / "queue_state.json" - self._state_path.parent.mkdir(parents=True, exist_ok=True) + self._state_path = self._determine_state_path() self._ensure_directories() self._load_state() @@ -410,6 +410,7 @@ class ConversionService: def _ensure_directories(self) -> None: self._output_root.mkdir(parents=True, exist_ok=True) self._uploads_root.mkdir(parents=True, exist_ok=True) + self._state_path.parent.mkdir(parents=True, exist_ok=True) def _ensure_worker(self) -> None: with self._lock: @@ -552,6 +553,40 @@ class ConversionService: # Persistence failures should not disrupt runtime; ignore. pass + def _determine_state_path(self) -> Path: + override_file = os.environ.get("ABOGEN_QUEUE_STATE_PATH") + if override_file: + target_path = Path(override_file).expanduser() + target_path.parent.mkdir(parents=True, exist_ok=True) + return target_path + + override_dir = os.environ.get("ABOGEN_QUEUE_STATE_DIR") + if override_dir: + base_dir = Path(override_dir).expanduser() + else: + settings_override = os.environ.get("ABOGEN_SETTINGS_DIR") + if settings_override: + base_dir = Path(settings_override).expanduser() / "queue" + else: + try: + base_dir = Path(get_user_settings_dir()) / "queue" + except ModuleNotFoundError: + base_dir = Path(get_internal_cache_path("jobs")) + base_dir.mkdir(parents=True, exist_ok=True) + target_path = base_dir / "queue_state.json" + + legacy_path = Path(get_internal_cache_path("jobs")) / "queue_state.json" + if legacy_path.exists() and not target_path.exists(): + try: + shutil.move(str(legacy_path), target_path) + except Exception: + try: + shutil.copy2(str(legacy_path), target_path) + except Exception: + pass + + return target_path + def _deserialize_job(self, payload: Dict[str, Any]) -> Job: stored_path = Path(payload["stored_path"]) output_folder_raw = payload.get("output_folder") From 783dbcf8f24f6b2eb2aa39938b3735891f595fd1 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 14:19:49 -0700 Subject: [PATCH 039/245] feat: Add supplemental section detection for improved content selection in HandlerDialog --- abogen/book_handler.py | 107 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 73079bc..6ae8368 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -29,6 +29,77 @@ import urllib.parse import markdown import textwrap + +SUPPLEMENTAL_TITLE_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"\\btable\\s+of\\s+contents\\b", + r"^contents$", + r"\\babout\\s+the\\s+author(s)?\\b", + r"\\babout\\s+the\\s+illustrator\\b", + r"\\babout\\s+the\\s+translator\\b", + r"\\btitle\\s+page\\b", + r"\\bcopyright\\b", + r"\\bcolophon\\b", + r"\\bfront\\s*matter\\b", + r"\\bback\\s*matter\\b", + r"\\bindex\\b", + r"\\bglossary\\b", + r"\\bbibliograph", + r"\\backnowledg(e)?ments?\\b", + r"\\bpublication\\s+data\\b", + ] +] + +SUPPLEMENTAL_TEXT_PATTERNS = [ + re.compile(pattern, re.IGNORECASE) + for pattern in [ + r"all rights reserved", + r"no part of (this )?book may be reproduced", + r"isbn", + r"library of congress", + r"printed in", + r"cover design", + r"for more information", + r"this is a work of fiction", + r"copyright", + r"table of contents", + ] +] + + +def is_supplemental_section(title: str | None, text: str | None) -> bool: + """Return True if the title/content look like non-narrative front/back matter.""" + + title = (title or "").strip() + if title: + for pattern in SUPPLEMENTAL_TITLE_PATTERNS: + if pattern.search(title): + return True + + if not text: + return False + + snippet = text[:4000] + for pattern in SUPPLEMENTAL_TEXT_PATTERNS: + if pattern.search(snippet): + return True + + lines = [line.strip() for line in snippet.splitlines() if line.strip()] + if not lines: + return False + + preview = lines[:25] + short_lines = sum(1 for line in preview if len(line.split()) <= 4) + dotted_lines = sum(1 for line in preview if re.search(r"\.{3,}", line)) + numbered_lines = sum(1 for line in preview if re.search(r"\b\d{1,4}\b", line)) + + # Table-of-contents heuristic: many short entries with leaders or numbers. + if preview and short_lines / len(preview) >= 0.6 and (dotted_lines >= 2 or numbered_lines >= 4): + return True + + return False + # Setup logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" @@ -1541,6 +1612,12 @@ class HandlerDialog(QDialog): self._block_signals = False self._update_checked_set_from_tree() + def _should_skip_auto_select(self, title, identifier): + content = None + if identifier: + content = self.content_texts.get(identifier, "") + return is_supplemental_section(title, content) + def _run_epub_auto_check(self): iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -1550,6 +1627,12 @@ class HandlerDialog(QDialog): continue src = item.data(0, Qt.UserRole) + title = item.text(0) or "" + + if self._should_skip_auto_select(title, src): + item.setCheckState(0, Qt.Unchecked) + iterator += 1 + continue has_significant_content = src and self.content_lengths.get(src, 0) > 1000 is_parent = item.childCount() > 0 @@ -1561,12 +1644,16 @@ class HandlerDialog(QDialog): child = item.child(i) if child.flags() & Qt.ItemIsUserCheckable: child_src = child.data(0, Qt.UserRole) + child_title = child.text(0) or "" child_has_content = ( child_src and self.content_lengths.get(child_src, 0) > 0 ) child_is_parent = child.childCount() > 0 if child_has_content or child_is_parent: - child.setCheckState(0, Qt.Checked) + if self._should_skip_auto_select(child_title, child_src): + child.setCheckState(0, Qt.Unchecked) + else: + child.setCheckState(0, Qt.Checked) else: item.setCheckState(0, Qt.Unchecked) @@ -1582,6 +1669,12 @@ class HandlerDialog(QDialog): continue identifier = item.data(0, Qt.UserRole) + title = item.text(0) or "" + + if self._should_skip_auto_select(title, identifier): + item.setCheckState(0, Qt.Unchecked) + iterator += 1 + continue # Select chapters with content > 500 characters or parent items has_significant_content = identifier and self.content_lengths.get(identifier, 0) > 500 @@ -1595,12 +1688,16 @@ class HandlerDialog(QDialog): child = item.child(i) if child.flags() & Qt.ItemIsUserCheckable: child_identifier = child.data(0, Qt.UserRole) + child_title = child.text(0) or "" child_has_content = ( child_identifier and self.content_lengths.get(child_identifier, 0) > 0 ) child_is_parent = child.childCount() > 0 if child_has_content or child_is_parent: - child.setCheckState(0, Qt.Checked) + if self._should_skip_auto_select(child_title, child_identifier): + child.setCheckState(0, Qt.Unchecked) + else: + child.setCheckState(0, Qt.Checked) else: item.setCheckState(0, Qt.Unchecked) @@ -1629,6 +1726,12 @@ class HandlerDialog(QDialog): iterator += 1 continue + title = item.text(0) or "" + if self._should_skip_auto_select(title, identifier): + item.setCheckState(0, Qt.Unchecked) + iterator += 1 + continue + if ( not identifier.startswith("page_") or self.content_lengths.get(identifier, 0) > 0 From da569662472542c4c4aff7d245c1ea0ab8a256f6 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 14:51:21 -0700 Subject: [PATCH 040/245] feat: Refactor supplemental section detection and enhance auto-selection logic in HandlerDialog --- abogen/book_handler.py | 117 +++++++++++++++++------------------------ 1 file changed, 49 insertions(+), 68 deletions(-) diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 6ae8368..2a68c40 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -33,21 +33,21 @@ import textwrap SUPPLEMENTAL_TITLE_PATTERNS = [ re.compile(pattern, re.IGNORECASE) for pattern in [ - r"\\btable\\s+of\\s+contents\\b", + r"\btable\s+of\s+contents\b", r"^contents$", - r"\\babout\\s+the\\s+author(s)?\\b", - r"\\babout\\s+the\\s+illustrator\\b", - r"\\babout\\s+the\\s+translator\\b", - r"\\btitle\\s+page\\b", - r"\\bcopyright\\b", - r"\\bcolophon\\b", - r"\\bfront\\s*matter\\b", - r"\\bback\\s*matter\\b", - r"\\bindex\\b", - r"\\bglossary\\b", - r"\\bbibliograph", - r"\\backnowledg(e)?ments?\\b", - r"\\bpublication\\s+data\\b", + r"\babout\s+the\s+author(s)?\b", + r"\babout\s+the\s+illustrator\b", + r"\babout\s+the\s+translator\b", + r"\btitle\s+page\b", + r"\bcopyright\b", + r"\bcolophon\b", + r"\bfront\s*matter\b", + r"\bback\s*matter\b", + r"\bindex\b", + r"\bglossary\b", + r"\bbibliograph", + r"\backnowledg(e)?ments?\b", + r"\bpublication\s+data\b", ] ] @@ -1602,6 +1602,14 @@ class HandlerDialog(QDialog): def _run_auto_check(self): self._block_signals = True + # Start with all selectable items checked so users can quickly deselect as needed. + iterator = QTreeWidgetItemIterator(self.treeWidget) + while iterator.value(): + item = iterator.value() + if item.flags() & Qt.ItemIsUserCheckable: + item.setCheckState(0, Qt.Checked) + iterator += 1 + if self.file_type == "epub": self._run_epub_auto_check() elif self.file_type == "markdown": @@ -1618,6 +1626,13 @@ class HandlerDialog(QDialog): content = self.content_texts.get(identifier, "") return is_supplemental_section(title, content) + def _uncheck_descendants(self, item): + for i in range(item.childCount()): + child = item.child(i) + if child.flags() & Qt.ItemIsUserCheckable: + child.setCheckState(0, Qt.Unchecked) + self._uncheck_descendants(child) + def _run_epub_auto_check(self): iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -1631,36 +1646,23 @@ class HandlerDialog(QDialog): if self._should_skip_auto_select(title, src): item.setCheckState(0, Qt.Unchecked) + self._uncheck_descendants(item) iterator += 1 continue - has_significant_content = src and self.content_lengths.get(src, 0) > 1000 - is_parent = item.childCount() > 0 - - if has_significant_content or is_parent: - item.setCheckState(0, Qt.Checked) - if is_parent: - for i in range(item.childCount()): - child = item.child(i) - if child.flags() & Qt.ItemIsUserCheckable: - child_src = child.data(0, Qt.UserRole) - child_title = child.text(0) or "" - child_has_content = ( - child_src and self.content_lengths.get(child_src, 0) > 0 - ) - child_is_parent = child.childCount() > 0 - if child_has_content or child_is_parent: - if self._should_skip_auto_select(child_title, child_src): - child.setCheckState(0, Qt.Unchecked) - else: - child.setCheckState(0, Qt.Checked) - else: + if ( + src + and self.content_lengths.get(src, 0) == 0 + and item.childCount() == 0 + ): item.setCheckState(0, Qt.Unchecked) + iterator += 1 + continue iterator += 1 def _run_markdown_auto_check(self): - """Auto-select markdown chapters with significant content""" + """Auto-select markdown chapters while skipping supplemental or empty entries.""" iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1673,46 +1675,22 @@ class HandlerDialog(QDialog): if self._should_skip_auto_select(title, identifier): item.setCheckState(0, Qt.Unchecked) + self._uncheck_descendants(item) iterator += 1 continue - # Select chapters with content > 500 characters or parent items - has_significant_content = identifier and self.content_lengths.get(identifier, 0) > 500 - is_parent = item.childCount() > 0 - - if has_significant_content or is_parent: - item.setCheckState(0, Qt.Checked) - # Also check children if this is a parent - if is_parent: - for i in range(item.childCount()): - child = item.child(i) - if child.flags() & Qt.ItemIsUserCheckable: - child_identifier = child.data(0, Qt.UserRole) - child_title = child.text(0) or "" - child_has_content = ( - child_identifier and self.content_lengths.get(child_identifier, 0) > 0 - ) - child_is_parent = child.childCount() > 0 - if child_has_content or child_is_parent: - if self._should_skip_auto_select(child_title, child_identifier): - child.setCheckState(0, Qt.Unchecked) - else: - child.setCheckState(0, Qt.Checked) - else: + if ( + identifier + and self.content_lengths.get(identifier, 0) == 0 + and item.childCount() == 0 + ): item.setCheckState(0, Qt.Unchecked) + iterator += 1 + continue iterator += 1 def _run_pdf_auto_check(self): - if hasattr(self, "has_pdf_bookmarks") and not self.has_pdf_bookmarks: - iterator = QTreeWidgetItemIterator(self.treeWidget) - while iterator.value(): - item = iterator.value() - if item.flags() & Qt.ItemIsUserCheckable: - item.setCheckState(0, Qt.Checked) - iterator += 1 - return - iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): item = iterator.value() @@ -1729,6 +1707,7 @@ class HandlerDialog(QDialog): title = item.text(0) or "" if self._should_skip_auto_select(title, identifier): item.setCheckState(0, Qt.Unchecked) + self._uncheck_descendants(item) iterator += 1 continue @@ -1737,6 +1716,8 @@ class HandlerDialog(QDialog): or self.content_lengths.get(identifier, 0) > 0 ): item.setCheckState(0, Qt.Checked) + else: + item.setCheckState(0, Qt.Unchecked) iterator += 1 From 6181f12bd49eb6aa079a97286b4dee841e359e60 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 15:04:28 -0700 Subject: [PATCH 041/245] Refactor code to remove legacy UI, transitioning to a straight webapp --- abogen/book_handler.py | 2295 +--------------------- abogen/conversion.py | 1489 +-------------- abogen/gui.py | 3600 +---------------------------------- abogen/main.py | 135 +- abogen/queue_manager_gui.py | 652 +------ abogen/voice_formula_gui.py | 1451 +------------- 6 files changed, 62 insertions(+), 9560 deletions(-) diff --git a/abogen/book_handler.py b/abogen/book_handler.py index 2a68c40..4935302 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -1,2292 +1,9 @@ -import re -import html -import ebooklib -import base64 -import fitz # PyMuPDF for PDF support -from ebooklib import epub -from bs4 import BeautifulSoup, NavigableString -from PyQt5.QtWidgets import ( - QDialog, - QTreeWidget, - QTreeWidgetItem, - QDialogButtonBox, - QVBoxLayout, - QHBoxLayout, - QTextEdit, - QTreeWidgetItemIterator, - QSplitter, - QWidget, - QPushButton, - QCheckBox, - QMenu, - QLabel, -) -from PyQt5.QtCore import Qt -from abogen.utils import clean_text, calculate_text_length, detect_encoding -import os -import logging # Add logging -import urllib.parse -import markdown -import textwrap +"""Legacy PyQt-based chapter selection dialog has been removed.""" +from __future__ import annotations -SUPPLEMENTAL_TITLE_PATTERNS = [ - re.compile(pattern, re.IGNORECASE) - for pattern in [ - r"\btable\s+of\s+contents\b", - r"^contents$", - r"\babout\s+the\s+author(s)?\b", - r"\babout\s+the\s+illustrator\b", - r"\babout\s+the\s+translator\b", - r"\btitle\s+page\b", - r"\bcopyright\b", - r"\bcolophon\b", - r"\bfront\s*matter\b", - r"\bback\s*matter\b", - r"\bindex\b", - r"\bglossary\b", - r"\bbibliograph", - r"\backnowledg(e)?ments?\b", - r"\bpublication\s+data\b", - ] -] -SUPPLEMENTAL_TEXT_PATTERNS = [ - re.compile(pattern, re.IGNORECASE) - for pattern in [ - r"all rights reserved", - r"no part of (this )?book may be reproduced", - r"isbn", - r"library of congress", - r"printed in", - r"cover design", - r"for more information", - r"this is a work of fiction", - r"copyright", - r"table of contents", - ] -] - - -def is_supplemental_section(title: str | None, text: str | None) -> bool: - """Return True if the title/content look like non-narrative front/back matter.""" - - title = (title or "").strip() - if title: - for pattern in SUPPLEMENTAL_TITLE_PATTERNS: - if pattern.search(title): - return True - - if not text: - return False - - snippet = text[:4000] - for pattern in SUPPLEMENTAL_TEXT_PATTERNS: - if pattern.search(snippet): - return True - - lines = [line.strip() for line in snippet.splitlines() if line.strip()] - if not lines: - return False - - preview = lines[:25] - short_lines = sum(1 for line in preview if len(line.split()) <= 4) - dotted_lines = sum(1 for line in preview if re.search(r"\.{3,}", line)) - numbered_lines = sum(1 for line in preview if re.search(r"\b\d{1,4}\b", line)) - - # Table-of-contents heuristic: many short entries with leaders or numbers. - if preview and short_lines / len(preview) >= 0.6 and (dotted_lines >= 2 or numbered_lines >= 4): - return True - - return False - -# Setup logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) - - -class HandlerDialog(QDialog): - # Class variables to remember checkbox states between dialog instances - _save_chapters_separately = False - _merge_chapters_at_end = True - _save_as_project = False # New class variable for save_as_project option - - def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None): - super().__init__(parent) - - # Determine file type if not explicitly provided - if file_type: - self.file_type = file_type - elif book_path.lower().endswith(".pdf"): - self.file_type = "pdf" - elif book_path.lower().endswith((".md", ".markdown")): - self.file_type = "markdown" - else: - self.file_type = "epub" - self.book_path = book_path - - # Extract book name from file path - book_name = os.path.splitext(os.path.basename(book_path))[0] - - # Set window title based on file type and book name - item_type = "Chapters" if self.file_type in ["epub", "markdown"] else "Pages" - self.setWindowTitle(f'Select {item_type} - {book_name}') - self.resize(1200, 900) - self._block_signals = False # Flag to prevent recursive signals - # Configure window: remove help button and allow resizing - self.setWindowFlags( - Qt.Window | Qt.WindowCloseButtonHint | Qt.WindowMaximizeButtonHint - ) - self.setWindowModality(Qt.NonModal) - # Initialize save chapters flags from class variables - self.save_chapters_separately = HandlerDialog._save_chapters_separately - self.merge_chapters_at_end = HandlerDialog._merge_chapters_at_end - self.save_as_project = HandlerDialog._save_as_project - - # Load the book based on file type - try: - if self.file_type == "epub": - self.book = epub.read_epub(book_path) - elif self.file_type == "markdown": - self.book = None # Markdown doesn't use ebooklib - else: - self.book = None - except KeyError as e: - logging.error( - f"EPUB file is missing a referenced file: {e}. Skipping missing file." - ) - # Try to patch ebooklib to skip missing files (monkey-patch read_file) - import types - - orig_read_file = None - try: - from ebooklib import epub as _epub_module - - reader_class = _epub_module.EpubReader - orig_read_file = reader_class.read_file - - def safe_read_file(self, name): - try: - return orig_read_file(self, name) - except KeyError: - logging.warning( - f"Missing file in EPUB: {name}. Returning empty bytes." - ) - return b"" - - reader_class.read_file = safe_read_file - self.book = epub.read_epub(book_path) - reader_class.read_file = orig_read_file # Restore - except Exception as patch_e: - logging.error(f"Failed to patch ebooklib for missing files: {patch_e}") - raise e - self.pdf_doc = fitz.open(book_path) if self.file_type == "pdf" else None - self.markdown_text = None - if self.file_type == "markdown": - try: - encoding = detect_encoding(book_path) - with open(book_path, "r", encoding=encoding, errors="replace") as f: - self.markdown_text = f.read() - except Exception as e: - logging.error(f"Error reading markdown file: {e}") - self.markdown_text = "" - self.markdown_toc = [] # For storing parsed markdown TOC - - # Extract book metadata - self.book_metadata = self._extract_book_metadata() - - # Initialize UI elements that are used in other methods - self.save_chapters_checkbox = None - self.merge_chapters_checkbox = None - - # Build treeview - self.treeWidget = QTreeWidget(self) - self.treeWidget.setHeaderHidden(True) - self.treeWidget.setSelectionMode(QTreeWidget.ExtendedSelection) - self.treeWidget.setContextMenuPolicy(Qt.CustomContextMenu) - self.treeWidget.customContextMenuRequested.connect(self.on_tree_context_menu) - - # Initialize checked_chapters set - self.checked_chapters = set(checked_chapters) if checked_chapters else set() - - # For storing content and lengths - self.content_texts = {} - self.content_lengths = {} - - # Pre-process content based on file type - self._preprocess_content() - - # Add "Information" item at the beginning of the tree - info_item = QTreeWidgetItem(self.treeWidget, ["Information"]) - info_item.setData(0, Qt.UserRole, "info:bookinfo") - info_item.setFlags(info_item.flags() & ~Qt.ItemIsUserCheckable) - font = info_item.font(0) - font.setBold(True) - info_item.setFont(0, font) - - # Build tree based on file type - self._build_tree() - - # Hide expand/collapse decoration if there are no parent items - has_parents = False - for i in range(self.treeWidget.topLevelItemCount()): - if self.treeWidget.topLevelItem(i).childCount() > 0: - has_parents = True - break - self.treeWidget.setRootIsDecorated(has_parents) - - # Setup UI (creates save_chapters_checkbox and other UI elements) - self._setup_ui() - - # Run auto-check after UI is setup - if not self._are_provided_checks_relevant(): - self._run_auto_check() - - # Connect signals - self.treeWidget.currentItemChanged.connect(self.update_preview) - self.treeWidget.itemChanged.connect(self.handle_item_check) - self.treeWidget.itemChanged.connect(lambda _: self._update_checkbox_states()) - self.treeWidget.itemDoubleClicked.connect(self.handle_item_double_click) - - # Select first item and expand all - self.treeWidget.expandAll() - if self.treeWidget.topLevelItemCount() > 0: - self.treeWidget.setCurrentItem(self.treeWidget.topLevelItem(0)) - self.treeWidget.setFocus() - - # Update checkbox states - self._update_checkbox_states() - - def _preprocess_content(self): - """Pre-process content from the document""" - if self.file_type == "epub": - try: - self._process_epub_content_nav() # Use the new navigation-based method - except Exception as e: - logging.error( - f"Error processing EPUB with navigation: {e}. Falling back to TOC/spine.", - exc_info=True, - ) - # Fallback to a simpler spine-based processing if nav fails - self._process_epub_content_spine_fallback() - elif self.file_type == "markdown": - self._preprocess_markdown_content() - else: - self._preprocess_pdf_content() - - def _preprocess_pdf_content(self): - """Pre-process all page contents from PDF document""" - for page_num in range(len(self.pdf_doc)): - text = clean_text(self.pdf_doc[page_num].get_text()) - # Remove bracketed numbers (citations, footnotes) - text = re.sub(r"\[\s*\d+\s*\]", "", text) - - # Remove standalone page numbers (numbers alone on a line) - text = re.sub(r"^\s*\d+\s*$", "", text, flags=re.MULTILINE) - - # Remove page numbers at the end of paragraphs - # This pattern looks for digits surrounded by whitespace at the end of paragraphs - text = re.sub(r"\s+\d+\s*$", "", text, flags=re.MULTILINE) - - # Also remove page numbers followed by a hyphen or dash at paragraph end - # (common in headers/footers like "- 42 -") - text = re.sub(r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", "", text, flags=re.MULTILINE) - - page_id = f"page_{page_num + 1}" - self.content_texts[page_id] = text - self.content_lengths[page_id] = calculate_text_length(text) - - - def _preprocess_markdown_content(self): - if not self.markdown_text: - return - - # Generate TOC from the original (dedented) markdown BEFORE cleaning, - # so header ids/anchors are preserved for reliable position detection. - original_text = textwrap.dedent(self.markdown_text) - md = markdown.Markdown(extensions=['toc', 'fenced_code']) - html = md.convert(original_text) - self.markdown_toc = md.toc_tokens - - # Use cleaned text for stored content/length calculations - cleaned_full_text = clean_text(original_text) - - soup = BeautifulSoup(html, 'html.parser') - self.content_texts = {} - self.content_lengths = {} - - if not self.markdown_toc: - chapter_id = "markdown_content" - self.content_texts[chapter_id] = cleaned_full_text - self.content_lengths[chapter_id] = calculate_text_length(cleaned_full_text) - return - - all_headers = [] - def flatten_toc(toc_list): - for header in toc_list: - all_headers.append(header) - if header.get('children'): - flatten_toc(header['children']) - flatten_toc(self.markdown_toc) - - header_positions = [] - for header in all_headers: - header_id = header['id'] - id_pattern = f'id="{header_id}"' - pos = html.find(id_pattern) - if pos != -1: - tag_start = html.rfind('<', 0, pos) - header_positions.append({ - 'id': header_id, - 'start': tag_start, - 'name': header['name'] - }) - header_positions.sort(key=lambda x: x['start']) - - for i, header_pos in enumerate(header_positions): - header_id = header_pos['id'] - header_name = header_pos['name'] - content_start = header_pos['start'] - content_end = header_positions[i + 1]['start'] if i + 1 < len(header_positions) else len(html) - section_html = html[content_start:content_end] - section_soup = BeautifulSoup(section_html, 'html.parser') - header_tag = section_soup.find(attrs={'id': header_id}) - if header_tag: - header_tag.decompose() - # Clean section text for storage/lengths - section_text = clean_text(section_soup.get_text()).strip() - chapter_id = header_id - if section_text: - full_content = f"{header_name}\n\n{section_text}" - self.content_texts[chapter_id] = full_content - self.content_lengths[chapter_id] = calculate_text_length(full_content) - else: - self.content_texts[chapter_id] = header_name - self.content_lengths[chapter_id] = calculate_text_length(header_name) - - def _process_epub_content_spine_fallback(self): - """Fallback EPUB processing based purely on spine order.""" - logging.info("Using spine fallback for EPUB processing.") - self.doc_content = {} - spine_docs = [] - for spine_item_tuple in self.book.spine: - item_id = spine_item_tuple[0] - item = self.book.get_item_with_id(item_id) - if item: - spine_docs.append(item.get_name()) - else: - logging.warning(f"Spine item with id '{item_id}' not found.") - - # Cache content - for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT): - href = item.get_name() - if href in spine_docs: - try: - html_content = item.get_content().decode("utf-8", errors="ignore") - self.doc_content[href] = html_content - except Exception as e: - logging.error(f"Error decoding content for {href}: {e}") - self.doc_content[href] = "" - - # Create a simple TOC based on spine order - synthetic_toc = [] - self.content_texts = {} - self.content_lengths = {} - for i, doc_href in enumerate(spine_docs): - html_content = self.doc_content.get(doc_href, "") - if html_content: - soup = BeautifulSoup(html_content, "html.parser") - - # Handle ordered lists by prepending numbers to list items - for ol in soup.find_all("ol"): - # Get start attribute or default to 1 - start = int(ol.get("start", 1)) - for i, li in enumerate(ol.find_all("li", recursive=False)): - # Insert the number at the beginning of the list item - number_text = f"{start + i}) " - if li.string: - li.string.replace_with(number_text + li.string) - else: - li.insert(0, NavigableString(number_text)) - - # Remove sup and sub tags - for tag in soup.find_all(["sup", "sub"]): - tag.decompose() - - text = clean_text(soup.get_text()).strip() - if text: - self.content_texts[doc_href] = text - self.content_lengths[doc_href] = len(text) - - title = None - if soup.title and soup.title.string: - title = soup.title.string.strip() - elif (h1 := soup.find("h1")) and h1.get_text(strip=True): - title = h1.get_text(strip=True) - - if not title: - title = f"Untitled Chapter {i + 1}" - synthetic_toc.append( - (epub.Link(doc_href, title, doc_href), []) - ) # Wrap in tuple and empty list for compatibility - - # Replace book.toc with the synthetic one if it was empty or fallback was triggered - if not self.book.toc or not hasattr( - self, "processed_nav_structure" - ): # Check if nav processing failed - self.book.toc = synthetic_toc - logging.info(f"Generated synthetic TOC with {len(synthetic_toc)} entries.") - - def _process_epub_content_nav(self): - """ - Process EPUB content using ITEM_NAVIGATION (NAV HTML) or ITEM_NCX. - Globally orders navigation entries and slices content between them. - """ - logging.info( - "Attempting to process EPUB using navigation document (NAV/NCX)..." - ) - nav_item = None - nav_type = None - - # 1. Check ITEM_NAVIGATION for actual NAV HTML (.xhtml/.html) - nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) - if nav_items: - # Prefer files explicitly named 'nav.xhtml' or similar - preferred_nav = next( - ( - item - for item in nav_items - if "nav" in item.get_name().lower() - and item.get_name().lower().endswith((".xhtml", ".html")) - ), - None, - ) - if preferred_nav: - nav_item = preferred_nav - nav_type = "html" - logging.info(f"Found preferred NAV HTML item: {nav_item.get_name()}") - else: - # Check if any ITEM_NAVIGATION is actually HTML - html_nav = next( - ( - item - for item in nav_items - if item.get_name().lower().endswith((".xhtml", ".html")) - ), - None, - ) - if html_nav: - nav_item = html_nav - nav_type = "html" - logging.info( - f"Found NAV HTML item in ITEM_NAVIGATION: {html_nav.get_name()}" - ) - - # 2. If no NAV HTML found via ITEM_NAVIGATION, check if ITEM_NAVIGATION points to NCX - if not nav_item and nav_items: - ncx_in_nav = next( - ( - item - for item in nav_items - if item.get_name().lower().endswith(".ncx") - ), - None, - ) - if ncx_in_nav: - nav_item = ncx_in_nav - nav_type = "ncx" - logging.info( - f"Found NCX item via ITEM_NAVIGATION: {ncx_in_nav.get_name()}" - ) - - # 3. If still no nav_item, check for NCX or fallback to NAV HTML in all ITEM_DOCUMENTs - ncx_constant = getattr(epub, "ITEM_NCX", None) - if not nav_item and ncx_constant is not None: - ncx_items = list(self.book.get_items_of_type(ncx_constant)) - if ncx_items: - nav_item = ncx_items[0] - nav_type = "ncx" - logging.info(f"Found NCX item via ITEM_NCX: {nav_item.get_name()}") - # Fallback: search all ITEM_DOCUMENTs for a NAV HTML with
+
+ + +

Controls how chapters are split into TTS-ready chunks.

+
+
+ + +
+
+ +

Creates a synchronized EPUB alongside audio output.

+
diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index 7ae11c9..189ace9 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -26,6 +26,10 @@
  • Chapter intro delay: {{ '%.1f'|format(job.chapter_intro_delay) }}s
  • Max words per subtitle: {{ job.max_subtitle_words }}
  • Project folder: {{ 'Yes' if job.save_as_project else 'No' }}
  • +
  • Chunk granularity: {{ job.chunk_level|replace('_', ' ')|title }}
  • +
  • Speaker mode: {{ job.speaker_mode|replace('_', ' ')|title }}
  • +
  • Speaker analysis threshold: {{ job.speaker_analysis_threshold }}
  • +
  • Generate EPUB 3: {{ 'Yes' if job.generate_epub3 else 'No' }}
  • @@ -45,7 +49,97 @@
    +{% set analysis = job.speaker_analysis or {} %} +{% if analysis %} +{% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
    +
    Speaker analysis
    +
    +
    +

    Summary

    + {% set stats = analysis.get('stats', {}) %} +
      +
    • Total chunks: {{ stats.get('total_chunks', '—') }}
    • +
    • Explicit dialogue chunks: {{ stats.get('explicit_chunks', '—') }}
    • +
    • Active speakers: {{ stats.get('active_speakers', '—') }}
    • +
    • Unique speakers observed: {{ stats.get('unique_speakers', '—') }}
    • +
    • Suppressed speakers: {{ stats.get('suppressed', 0) }}
    • +
    +
    +
    +

    Detected speakers

    + {% set speakers = analysis.get('speakers', {}) %} + {% set narrator_id = analysis.get('narrator', 'narrator') %} + {% if speakers %} +
      + {% for speaker_id, payload in speakers.items() if speaker_id != narrator_id and not payload.get('suppressed') %} + {% set spoken_name = payload.get('pronunciation') or payload.get('label') or speaker_id|replace('_', ' ')|title %} + {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} +
    • +
      + {{ payload.get('label', speaker_id|replace('_', ' ')|title) }} + +
      +
      + {{ payload.get('count', 0) }} chunks + Confidence: {{ payload.get('confidence', 'low')|title }} + {% if payload.get('pronunciation') %} + Pronunciation: {{ payload.get('pronunciation') }} + {% endif %} +
      + {% set quotes = payload.get('sample_quotes', []) %} + {% if quotes %} +
      + Sample quotes +
        + {% for quote in quotes %} +
      • {{ quote }}
      • + {% endfor %} +
      +
      + {% endif %} +
    • + {% endfor %} +
    + {% else %} +

    No additional speakers detected.

    + {% endif %} + {% set suppressed = analysis.get('suppressed_details') or analysis.get('suppressed', []) %} + {% if suppressed %} +

    + Suppressed speakers: + {% if suppressed[0] is string %} + {{ suppressed | join(', ') }} + {% else %} + {{ suppressed | map(attribute='label') | join(', ') }} + {% endif %} +

    + {% endif %} +
    +
    +
    +{% endif %} +
    {% include "partials/logs.html" %}
    {% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 7d57da6..9aa99aa 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -33,7 +33,88 @@
    Chapter intro delay
    {{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
    +
    +
    Chunk granularity
    +
    {{ pending.chunk_level|replace('_', ' ')|title }}
    +
    +
    +
    Speaker mode
    +
    {{ pending.speaker_mode|replace('_', ' ')|title }}
    +
    +
    +
    Speaker analysis threshold
    +
    {{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
    +
    +
    +
    EPUB 3 package
    +
    {% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
    +
    + {% set analysis = pending.speaker_analysis or {} %} + {% set analysis_speakers = analysis.get('speakers', {}) %} + {% if analysis_speakers %} + {% set active = namespace(items=[]) %} + {% for sid, payload in analysis_speakers.items() %} + {% if sid != 'narrator' and not payload.get('suppressed') %} + {% set _ = active.items.append(payload) %} + {% endif %} + {% endfor %} +
    +

    Detected speakers

    + {% if active.items %} +
      + {% for speaker in active.items|sort(attribute='label') %} +
    • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
    • + {% endfor %} +
    + {% else %} +

    No additional speakers met the threshold yet. All dialogue will use the narrator voice.

    + {% endif %} +
    + {% endif %} + {% set roster = pending.speakers or {} %} + {% if roster %} + {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
    +

    Speaker pronunciation guide

    +

    Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

    +
      + {% for speaker_id, speaker in roster.items() %} + {% set spoken_name = speaker.pronunciation or speaker.label %} + {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} +
    • +
      + {{ speaker.label }} + +
      + + {% if speaker.get('analysis_count') %} +

      {{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}

      + {% endif %} +
    • + {% endfor %} +
    +
    + {% endif %} {% if pending.metadata_tags %}
    +
    + + +

    Paragraphs work well for long-form narration; sentences give finer subtitle sync.

    +
    +
    + + +
    +
    + + +

    Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.

    +

    Set to 0 to disable the pause after speaking each chapter title.

    +
    + +
    @@ -127,5 +236,6 @@ {% block scripts %} {{ super() }} + {% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index f792090..c775efc 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -73,6 +73,10 @@ Save as Project With Metadata +
    @@ -83,6 +87,32 @@
    + + +
    +
    + + +
    +
    + + +

    Speakers detected fewer times than this fallback to the narrator voice.

    +
    +
    + + +

    Sentence template used when previewing name pronunciation. Include {{ '{{name}}' }} where the speaker name should be inserted.

    +
    +
    diff --git a/docs/epub3_upgrade_plan.md b/docs/epub3_upgrade_plan.md new file mode 100644 index 0000000..d343a5a --- /dev/null +++ b/docs/epub3_upgrade_plan.md @@ -0,0 +1,208 @@ +# EPUB 3 Upgrade Plan + +## Overview +Elevate Abogen to produce rich EPUB 3 packages with synchronized narration, configurable TTS chunking, and groundwork for multi-speaker voice assignment. This document records the objectives, architectural adjustments, data model changes, UI flows, and implementation phases required to deliver the upgrade. + +## Goals +- Generate EPUB 3 output that preserves source metadata and embeds audio narration via media overlays. +- Allow users to choose the chunking granularity (paragraph vs. sentence) used for TTS synthesis and media-overlay alignment. +- Introduce speaker assignments for every chunk, starting with a single narrator but paving the way for multi-speaker control. +- Prototype practical, lightweight strategies for detecting likely speakers and estimating their dialogue frequency. + +## Non-goals / Out-of-scope +- Full multi-speaker editing UI (beyond gating the option). +- Automatic voice-casting or LLM-based dialogue attribution. +- Desktop GUI resurrection (web UI remains primary). + +## Current Architecture Snapshot +| Area | Notes | +| --- | --- | +| Text ingestion | `abogen/text_extractor.py` outputs `ExtractionResult` with chapter-level text. +| Job prep UI | `web/routes.py` builds `PendingJob` objects and renders chapter selection. +| Audio pipeline | `web/conversion_runner.py` creates per-job audio artifacts; chunking is effectively paragraph-level. +| Metadata | `ExtractionResult.metadata` feeds into FF metadata and output tagging, but not yet into EPUB packaging. + +## Feature 1 – EPUB 3 Output with Narration +### Requirements +- Preserve original EPUB metadata (Dublin Core entries, TOC, cover art). +- Package synthesized audio and SMIL media overlays aligned to chosen chunk granularity. +- Provide EPUB as an additional selectable output alongside current audio/subtitle formats. + +### Proposed Components +1. **`abogen/epub3/exporter.py`** (new module) + - Responsibilities: build XHTML spine with IDs, generate overlay SMIL files, write OPF manifest/spine, assemble zip package. + - Status: **Implemented** — `build_epub3_package` emits EPUB 3 archives with media overlays driven by chunk metadata. + - Dependencies: reuse `ebooklib` for reading source metadata; use `zipfile` for packaging; optional `lxml` for DOM manipulation. +2. **`EPUB3PackageBuilder` class** + - Inputs: extraction payload, chunk collection (with IDs, speaker mapping, timing metadata), audio asset paths, source metadata. + - Outputs: path to generated EPUB. +3. **Metadata preservation** + - Copy from source `ExtractionResult.metadata` and EPUB navigation if available. + - Ensure custom fields (e.g., chapter count) survive. +4. **Media overlay generation** + - Create one SMIL per content doc or per chapter, depending on chunk count. + - `` nodes reference chunk IDs and audio clip times. +5. **Configuration surface** + - Add “EPUB 3 (audio + text)” to output format selector (or a dedicated toggle under project settings). + +### Data Flow +``` +extract_from_path -> Chapter payload + |-> chunker (sentence/paragraph) + |-> chunk IDs + audio segments (timestamps from runner) +Conversion runner -> audio files + timing index +EPUB3PackageBuilder -> manifest, spine, SMIL, zip +``` + +### Open Questions +- Should we embed audio inside the EPUB or link externally? (Plan: embed to comply with spec.) +- How to handle very large audio assets? Consider splitting per chapter to keep file sizes manageable. + +## Feature 2 – Configurable Chunking +### Requirements +- Users select chunking level (paragraph or sentence) before audio generation. +- Pipeline produces stable, unique IDs for each chunk regardless of level. +- Provide chunk metadata (text, speaker, offsets) to both TTS and EPUB exporter. + +### Proposed Architecture +1. **Chunk Model** + ```python + @dataclass + class Chunk: + id: str + chapter_index: int + order: int + level: Literal["paragraph", "sentence"] + text: str + speaker_id: str + approx_characters: int + ``` +2. **Chunker Service (`abogen/chunking.py`)** + - Accepts chapter text and desired level. + - Uses spaCy (already bundled via `en-core-web-sm`) for sentence segmentation; fallback to regex when model unavailable. + - Emits `Chunk` objects with deterministic IDs (e.g., `chap{chapter_index:04d}_para{paragraph_idx:03d}_sent{sentence_idx:03d}`). +3. **Integration points** + - `web/routes.py` -> apply chunker when building `PendingJob` instead of storing raw paragraphs only. + - `PendingJob` / `Job` dataclasses -> include `chunks` list and `chunk_level` enum. + - `conversion_runner` -> iterate over `chunks` when synthesizing audio, producing per-chunk audio and capturing actual duration for overlay. +4. **Settings persistence** + - Extend config with `chunking_level` default; expose in UI (radio buttons or select). + +### Testing +- Unit tests for chunk splitting across languages, punctuation, abbreviations. +- Property-based tests ensuring concatenated chunks reproduce original text (except whitespace normalization). + +## Feature 3 – Speaker Assignment Foundations +### Requirements +- Every chunk must carry a `speaker_id` (default `narrator`). +- UI offers new option: “Single Speaker” (proceeds) vs. “Multi-Speaker (Coming Soon)” (blocks and shows message). +- Data model anticipates future multi-speaker support. + +### Implementation Outline +1. **Data Model Changes** + - `Chunk.speaker_id` default `"narrator"`. + - `PendingJob` & `Job` store `speakers` metadata (dictionary of speaker descriptors). + - `JobResult` optionally includes `chunk_speakers.json` artifact for downstream use. +2. **UI Adjustments** + - On upload form (`index.html` / JS), add selector for speaker mode. + - If “Multi-Speaker” chosen, display tooltip/modal: “Coming soon; please choose Single Speaker to continue.” disable submission. + - In `prepare_job.html`, display speaker info column (read-only for now). +3. **Serialization** + - Update JSON API routes to include speaker data. + - Update queue/job detail templates to show chunk level & speaker summary. + +### Testing +- Add web route tests ensuring multi-speaker path blocks progression. +- Verify job persistence includes `speaker_id` fields. + +## Feature 4 – Speaker Detection Strategies +### Objectives +Build groundwork for lightweight, deterministic speaker inference to inform future multi-speaker mode. + +### User Stories +1. **As a producer**, I can run an automated analysis on a book to see the list of likely speakers and how often they talk, so I can decide where multiple voices make sense. + - _Acceptance_: System outputs a JSON report containing speaker IDs/names, occurrence counts, representative excerpts, and confidence tier. Report stored with job artifacts and downloadable from job detail page. +2. **As a producer**, I can set a minimum occurrence threshold so that infrequent speakers automatically fall back to the narrator voice. + - _Acceptance_: Analysis respects configurable threshold; speakers below it are tagged as `default_narrator` in the report. +3. **As a developer/operator**, I can trigger the analysis via CLI or background task without blocking the main conversion pipeline. + - _Acceptance_: Command `abogen analyze-speakers ` (or background queue hook) runs in isolation, returns exit code 0 on success, emits metrics/logs for CI. + +### Strategy Ideas +1. **Quotation-bound heuristic** + - Split paragraphs on dialogue quotes. + - Use verb cues ("said", "asked") to associate names preceding/following quotes. +2. **Name detection via NER** + - Use spaCy’s entity recognition to spot `PERSON` entities inside dialogue spans. + - Maintain frequency counts per name. +3. **Speaker dictionary** + - Pre-build mapping of common narrator cues ("he said", "Mary replied") to propagate speaker assignment across adjacent sentences. +4. **Pronoun fallback with gender hints** + - Map pronouns to most recent speaker mention; degrade gracefully when ambiguous. +5. **Thresholding mechanism** + - After counting occurrences, expose a threshold slider (future UI) to decide when to allocate unique voices vs. default narrator. +6. **Diagnostics** + - Provide summary report: top N speaker candidates, counts, unresolved dialogue segments. + +### Implementation Staging +1. **Phase 1 – Analysis Engine (Backend)** + - Build `speaker_analysis.py` module implementing heuristics, returning structured results. + - Add CLI entry point `abogen-speaker-analyze` for standalone runs. + - Persist analysis artifacts (`speakers.json`, `speaker_excerpts.csv`) alongside job data when invoked post-extraction. + - Tests: unit tests for heuristic functions; snapshot tests for sample novels. +2. **Phase 2 – Configuration & Thresholding** + - Extend settings UI with optional “speaker analysis threshold” control (numeric). + - Update analysis module to accept threshold; mark low-frequency speakers as narrator. + - Emit summary digest (top speakers, narrator fallback count) in job logs. +3. **Phase 3 – UI Surfacing** + - Display analysis summary on job detail page (charts/table). + - Offer download link for raw JSON/CSV artifacts. + - Provide warning banner when analysis confidence is low (e.g., high unmatched dialogue percentage). +4. **Phase 4 – Integration Hooks** + - Wire analysis output into chunk speaker assignments (without yet enabling multi-speaker playback). + - Store mapping in `Job.speakers` metadata for future voice routing. + +### Technical Notes +- Reuse spaCy `en_core_web_sm` for entity recognition; allow pluggable models per language. +- Maintain rolling context window to resolve pronouns (e.g., last two named speakers). +- Provide instrumentation (timings, counts) to assess heuristic accuracy on sample corpora. +- Design analysis output schema versioning (`speaker_analysis_version`) to support iterative improvements. + +## UI & Configuration Updates +| Screen | Update | +| --- | --- | +| Upload form (`index.html`) | Add chunking level selector and speaker mode buttons. | +| Prepare job (`prepare_job.html`) | Display chunk level, IDs, speaker column; allow future editing hooks. | +| Settings modal | Persist defaults for chunking level and speaker mode. | + +## Data Model Checklist +- [x] Update `PendingJob` and `Job` dataclasses with `chunk_level`, `chunks`, `speakers` metadata. +- [x] Ensure serialization persists these fields in queue state file. +- [x] Persist chunk timing metadata from TTS (start/end timestamps). + +## Testing Strategy +- Unit tests for chunker and speaker heuristics. +- Integration tests: enqueue job with sentence-level chunking, assert chunk IDs and speaker metadata. +- Regression tests: ensure existing paragraph-level jobs still succeed. +- Acceptance tests for EPUB exporter: validate manifest, spine, and SMIL structure against schema (use `epubcheck` in CI if feasible). + +## Migration & Compat +- Bump state version in `ConversionService` when augmenting job schema; include migration logic for legacy queues. +- Provide CLI flag to reprocess older jobs without speaker metadata. +- Document new dependencies (e.g., `lxml`, optional spaCy models for languages beyond English). + +## Implementation Phases +1. **Foundation** – Introduce chunk model, chunker service, speaker defaults. +2. **Pipeline integration** – Update job lifecycle and TTS runner to work with chunks. +3. **EPUB exporter** – Build packaging module, connect to pipeline. +4. **UI polish** – Expose settings, guard multi-speaker path, surface diagnostics. +5. **Speaker analysis tool** – Prototype heuristics and reporting. + +## Open Questions +- How to handle non-EPUB inputs (PDF/TXT) when exporting EPUB 3? (Possible: generate synthetic XHTML with normalized chapters.) +- Storage impact of embedding per-chunk audio – do we need compression or streaming strategies? +- Internationalization: sentence segmentation quality varies; need language-specific models. + +## Next Steps +- Review plan with stakeholders for scope confirmation. +- Break down Phase 1 into actionable tickets (chunker, data model migration, UI toggle). +- Estimate resource requirements for EPUB packaging and testing (including epubcheck integration). diff --git a/tests/test_chunk_helpers.py b/tests/test_chunk_helpers.py new file mode 100644 index 0000000..70755cc --- /dev/null +++ b/tests/test_chunk_helpers.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from abogen.web.conversion_runner import _chunk_voice_spec, _group_chunks_by_chapter + + +def test_group_chunks_by_chapter_orders_and_groups() -> None: + chunks = [ + {"chapter_index": "0", "chunk_index": "5", "text": "tail"}, + {"chapter_index": 0, "chunk_index": 1, "text": "body"}, + {"chapter_index": 1, "chunk_index": 0, "text": "next"}, + ] + + grouped = _group_chunks_by_chapter(chunks) + + assert [entry["text"] for entry in grouped[0]] == ["body", "tail"] + assert grouped[1][0]["text"] == "next" + + +def test_chunk_voice_spec_prefers_chunk_overrides() -> None: + job = SimpleNamespace(voice="base_voice", speakers={}) + chunk = {"voice": "override_voice", "speaker_id": "narrator"} + + assert _chunk_voice_spec(job, chunk, "fallback") == "override_voice" + + +def test_chunk_voice_spec_falls_back_to_speaker_voice() -> None: + job = SimpleNamespace(voice="base_voice", speakers={"narrator": {"voice": "speaker_voice"}}) + chunk = {"speaker_id": "narrator"} + + assert _chunk_voice_spec(job, chunk, "fallback") == "speaker_voice" + + +def test_chunk_voice_spec_uses_fallback_when_no_overrides() -> None: + job = SimpleNamespace(voice="base_voice", speakers={}) + chunk = {"speaker_id": "unknown"} + + assert _chunk_voice_spec(job, chunk, "fallback") == "fallback" diff --git a/tests/test_epub_exporter.py b/tests/test_epub_exporter.py new file mode 100644 index 0000000..ad9bfc2 --- /dev/null +++ b/tests/test_epub_exporter.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import zipfile + +from abogen.epub3.exporter import build_epub3_package +from abogen.text_extractor import ExtractedChapter, ExtractionResult + + +def _make_sample_extraction() -> ExtractionResult: + return ExtractionResult( + chapters=[ + ExtractedChapter(title="Chapter 1", text="Hello world."), + ExtractedChapter(title="Chapter 2", text="Another passage."), + ], + metadata={"title": "Sample Book", "artist": "Test Author", "language": "en"}, + ) + + +def test_build_epub3_package_creates_expected_structure(tmp_path) -> None: + extraction = _make_sample_extraction() + chunks = [ + { + "id": "chap0000_p0000", + "chapter_index": 0, + "chunk_index": 0, + "text": "Hello world.", + "speaker_id": "narrator", + }, + { + "id": "chap0001_p0000", + "chapter_index": 1, + "chunk_index": 0, + "text": "Another passage.", + "speaker_id": "narrator", + }, + ] + chunk_markers = [ + {"id": "chap0000_p0000", "chapter_index": 0, "chunk_index": 0, "start": 0.0, "end": 1.2}, + {"id": "chap0001_p0000", "chapter_index": 1, "chunk_index": 0, "start": 1.2, "end": 2.4}, + ] + chapter_markers = [ + {"index": 1, "title": "Chapter 1", "start": 0.0, "end": 1.2}, + {"index": 2, "title": "Chapter 2", "start": 1.2, "end": 2.4}, + ] + metadata_tags = {"title": "Sample Book", "artist": "Test Author", "language": "en"} + + audio_path = tmp_path / "sample.mp3" + audio_path.write_bytes(b"ID3 test audio") + + output_path = tmp_path / "output.epub" + result_path = build_epub3_package( + output_path=output_path, + book_id="job-123", + extraction=extraction, + metadata_tags=metadata_tags, + chapter_markers=chapter_markers, + chunk_markers=chunk_markers, + chunks=chunks, + audio_path=audio_path, + speaker_mode="single", + ) + + assert result_path == output_path + assert output_path.exists() + + with zipfile.ZipFile(output_path) as archive: + names = set(archive.namelist()) + assert "mimetype" in names + assert archive.read("mimetype") == b"application/epub+zip" + assert "META-INF/container.xml" in names + assert "OEBPS/content.opf" in names + assert "OEBPS/nav.xhtml" in names + assert "OEBPS/audio/sample.mp3" in names + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + assert "Hello world." in chapter_doc + smil_doc = archive.read("OEBPS/smil/chapter_0001.smil").decode("utf-8") + assert "clipBegin=\"00:00:00.000\"" in smil_doc + opf_doc = archive.read("OEBPS/content.opf").decode("utf-8") + assert "media-overlay" in opf_doc + assert "media:duration" in opf_doc + assert "abogen:speakerMode" in opf_doc + + +def test_build_epub3_package_handles_missing_markers(tmp_path) -> None: + extraction = _make_sample_extraction() + metadata_tags = {"title": "Sample Book", "artist": "Test Author", "language": "en"} + audio_path = tmp_path / "audio.mp3" + audio_path.write_bytes(b"ID3 audio") + output_path = tmp_path / "output.epub" + + result_path = build_epub3_package( + output_path=output_path, + book_id="job-456", + extraction=extraction, + metadata_tags=metadata_tags, + chapter_markers=[], + chunk_markers=[], + chunks=[], + audio_path=audio_path, + speaker_mode="single", + ) + + with zipfile.ZipFile(result_path) as archive: + nav_doc = archive.read("OEBPS/nav.xhtml").decode("utf-8") + assert "Chapter 1" in nav_doc + chapter_doc = archive.read("OEBPS/text/chapter_0001.xhtml").decode("utf-8") + assert "Hello world." in chapter_doc \ No newline at end of file diff --git a/tests/test_service.py b/tests/test_service.py index a744617..4c449b2 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -54,4 +54,8 @@ def test_service_processes_job(tmp_path): 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" \ No newline at end of file + assert job.result.audio_path == outputs / f"{job.id}.wav" + assert job.chunk_level == "paragraph" + assert job.speaker_mode == "single" + assert job.chunks == [] + assert not job.generate_epub3 \ No newline at end of file From b0cfd8d687d1296d4a271210ac9384ee43bcc906 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 7 Oct 2025 18:08:28 -0700 Subject: [PATCH 044/245] fix: Remove unnecessary newline in _reassign function --- abogen/speaker_analysis.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index a170d04..3a6dcfa 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -293,5 +293,4 @@ def _safe_int(value: Any, default: int = 0) -> int: def _reassign(assignments: Dict[str, str], old: str, new: str) -> None: for key, value in list(assignments.items()): if value == old: - assignments[key] = new -```}, \ No newline at end of file + assignments[key] = new \ No newline at end of file From 3b07df97080223d021c9f8935fb64b4730bc1b68 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 05:43:49 -0700 Subject: [PATCH 045/245] feat: Add title and suffix abbreviation expansion, ensure terminal punctuation, and enhance speaker analysis functionality --- abogen/kokoro_text_normalization.py | 95 ++++++ abogen/web/conversion_runner.py | 4 + abogen/web/routes.py | 405 ++++++++++++++++---------- abogen/web/service.py | 7 + abogen/web/static/prepare.js | 15 + abogen/web/templates/prepare_job.html | 17 ++ tests/test_text_normalization.py | 29 ++ 7 files changed, 419 insertions(+), 153 deletions(-) create mode 100644 tests/test_text_normalization.py diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 3cc784e..6067a8b 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -120,6 +120,34 @@ WORD_TOKEN_RE = re.compile(r"[A-Za-z0-9'’]+|[^A-Za-z0-9\s]") APOSTROPHE_CHARS = "’`´ꞌʼ" +TERMINAL_PUNCTUATION = {".", "?", "!", "…", ";", ":"} +CLOSING_PUNCTUATION = '"\'”’)]}»›' +ELLIPSIS_SUFFIXES = ("...", "…") +_LINE_SPLIT_RE = re.compile(r"(\n+)") + +TITLE_ABBREVIATIONS = { + "mr": "mister", + "mrs": "missus", + "ms": "miz", + "dr": "doctor", + "prof": "professor", + "rev": "reverend", +} + +SUFFIX_ABBREVIATIONS = { + "jr": "junior", + "sr": "senior", +} + +_TITLE_PATTERN = re.compile( + r"\b(?P" + "|".join(sorted(TITLE_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + re.IGNORECASE, +) +_SUFFIX_PATTERN = re.compile( + r"\b(?P" + "|".join(sorted(SUFFIX_ABBREVIATIONS.keys(), key=len, reverse=True)) + r")\.", + re.IGNORECASE, +) + # ---------- Utility Functions ---------- def normalize_unicode_apostrophes(text: str) -> str: @@ -132,6 +160,73 @@ def tokenize(text: str) -> List[str]: # Simple tokenization preserving punctuation tokens return WORD_TOKEN_RE.findall(text) + +def _match_casing(template: str, replacement: str) -> str: + if template.isupper(): + return replacement.upper() + if template[:1].isupper() and template[1:].islower(): + return replacement.capitalize() + if template[:1].isupper(): + # Mixed case (e.g., Mc), fall back to title case + return replacement.capitalize() + return replacement + + +def expand_titles_and_suffixes(text: str) -> str: + def _replace(match: re.Match[str], mapping: dict[str, str]) -> str: + abbr = match.group("abbr") + lookup = mapping.get(abbr.lower()) + if not lookup: + return match.group(0) + return _match_casing(abbr, lookup) + + text = _TITLE_PATTERN.sub(lambda m: _replace(m, TITLE_ABBREVIATIONS), text) + text = _SUFFIX_PATTERN.sub(lambda m: _replace(m, SUFFIX_ABBREVIATIONS), text) + return text + + +def ensure_terminal_punctuation(text: str) -> str: + def _amend(segment: str) -> str: + if not segment or not segment.strip(): + return segment + + stripped = segment.rstrip() + trailing_ws = segment[len(stripped) :] + + match = re.match(rf"^(.*?)([{re.escape(CLOSING_PUNCTUATION)}]*)$", stripped) + if not match: + return segment + + body, closers = match.groups() + if not body: + return segment + + normalized_body = body.rstrip() + trailing_body_ws = body[len(normalized_body) :] + + if normalized_body.endswith(ELLIPSIS_SUFFIXES): + return normalized_body + trailing_body_ws + closers + trailing_ws + + last_char = normalized_body[-1] + if last_char in TERMINAL_PUNCTUATION: + return normalized_body + trailing_body_ws + closers + trailing_ws + + return normalized_body + "." + trailing_body_ws + closers + trailing_ws + + parts = _LINE_SPLIT_RE.split(text) + amended: List[str] = [] + for part in parts: + if not part: + continue + if part.startswith("\n"): + amended.append(part) + else: + amended.append(_amend(part)) + if not parts: + return _amend(text) + return "".join(amended) + + def is_cultural_name(token: str, cfg: ApostropheConfig) -> bool: if not cfg.protect_cultural_names: return False diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index b822e17..906d079 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -21,6 +21,8 @@ from abogen.epub3.exporter import build_epub3_package from abogen.kokoro_text_normalization import ( ApostropheConfig, apply_phoneme_hints, + expand_titles_and_suffixes, + ensure_terminal_punctuation, normalize_apostrophes, ) from abogen.text_extractor import ExtractedChapter, extract_from_path @@ -298,6 +300,8 @@ _APOSTROPHE_CONFIG = ApostropheConfig() def _normalize_for_pipeline(text: str) -> str: normalized, _details = normalize_apostrophes(text, _APOSTROPHE_CONFIG) + normalized = expand_titles_and_suffixes(normalized) + normalized = ensure_terminal_punctuation(normalized) if _APOSTROPHE_CONFIG.add_phoneme_hints: return apply_phoneme_hints(normalized, iz_marker=_APOSTROPHE_CONFIG.sibilant_iz_marker) return normalized diff --git a/abogen/web/routes.py b/abogen/web/routes.py index e4b6063..59eb3e2 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -13,7 +13,6 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple, cast from flask import ( Blueprint, - Response, abort, current_app, jsonify, @@ -23,6 +22,7 @@ from flask import ( send_file, url_for, ) +from flask.typing import ResponseReturnValue from werkzeug.utils import secure_filename import numpy as np @@ -154,11 +154,13 @@ def _prepare_speaker_metadata( voice_profile: Optional[str], threshold: int, existing_roster: Optional[Mapping[str, Any]] = None, + run_analysis: bool = True, ) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any]]: chunk_list = [dict(chunk) for chunk in chunks] threshold_value = max(1, int(threshold)) + analysis_enabled = speaker_mode == "multi" and run_analysis - if speaker_mode != "multi": + if not analysis_enabled: for chunk in chunk_list: chunk["speaker_id"] = "narrator" chunk["speaker_label"] = "Narrator" @@ -239,6 +241,132 @@ def _prepare_speaker_metadata( return chunk_list, roster, analysis_payload +def _apply_prepare_form( + pending: PendingJob, form: Mapping[str, Any] +) -> tuple[ChunkLevel, List[Dict[str, Any]], List[Dict[str, Any]], List[str], int]: + raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() + if raw_chunk_level not in _CHUNK_LEVEL_VALUES: + raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph" + pending.chunk_level = raw_chunk_level + chunk_level_literal = cast(ChunkLevel, pending.chunk_level) + + raw_speaker_mode = (form.get("speaker_mode") or pending.speaker_mode or "single").strip().lower() + if raw_speaker_mode not in _SPEAKER_MODE_VALUES: + raw_speaker_mode = "single" + pending.speaker_mode = raw_speaker_mode + + pending.generate_epub3 = _coerce_bool(form.get("generate_epub3"), False) + + threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD) + raw_threshold = form.get("speaker_analysis_threshold") + if raw_threshold is not None: + pending.speaker_analysis_threshold = _coerce_int( + raw_threshold, + threshold_default, + minimum=1, + maximum=25, + ) + else: + pending.speaker_analysis_threshold = threshold_default + + if not pending.speakers: + narrator: Dict[str, Any] = { + "id": "narrator", + "label": "Narrator", + "voice": pending.voice, + } + if pending.voice_profile: + narrator["voice_profile"] = pending.voice_profile + pending.speakers = {"narrator": narrator} + else: + existing_narrator = pending.speakers.get("narrator") + if isinstance(existing_narrator, dict): + existing_narrator.setdefault("id", "narrator") + existing_narrator["label"] = existing_narrator.get("label", "Narrator") + existing_narrator["voice"] = pending.voice + if pending.voice_profile: + existing_narrator["voice_profile"] = pending.voice_profile + pending.speakers["narrator"] = existing_narrator + + if isinstance(pending.speakers, dict): + for speaker_id, payload in list(pending.speakers.items()): + if not isinstance(payload, dict): + continue + field_key = f"speaker-{speaker_id}-pronunciation" + raw_value = form.get(field_key, "") + pronunciation = raw_value.strip() + if pronunciation: + payload["pronunciation"] = pronunciation + else: + payload.pop("pronunciation", None) + + profiles = serialize_profiles() + errors: List[str] = [] + raw_delay = form.get("chapter_intro_delay") + if raw_delay is not None: + raw_normalized = raw_delay.strip() + if raw_normalized: + try: + pending.chapter_intro_delay = max(0.0, float(raw_normalized)) + except ValueError: + errors.append("Enter a valid number for the chapter intro delay.") + else: + pending.chapter_intro_delay = 0.0 + + overrides: List[Dict[str, Any]] = [] + selected_total = 0 + + for index, chapter in enumerate(pending.chapters): + enabled = form.get(f"chapter-{index}-enabled") == "on" + title_input = (form.get(f"chapter-{index}-title") or "").strip() + title = title_input or chapter.get("title") or f"Chapter {index + 1}" + voice_selection = form.get(f"chapter-{index}-voice", "__default") + formula_input = (form.get(f"chapter-{index}-formula") or "").strip() + + entry: Dict[str, Any] = { + "id": chapter.get("id") or f"{index:04d}", + "index": index, + "order": index, + "source_title": chapter.get("title") or title, + "title": title, + "text": chapter.get("text", ""), + "enabled": enabled, + } + entry["characters"] = len(entry["text"]) + + if enabled: + if voice_selection.startswith("voice:"): + entry["voice"] = voice_selection.split(":", 1)[1] + entry["resolved_voice"] = entry["voice"] + elif voice_selection.startswith("profile:"): + profile_name = voice_selection.split(":", 1)[1] + entry["voice_profile"] = profile_name + profile_entry = profiles.get(profile_name) or {} + formula_value = _formula_from_profile(profile_entry) + if formula_value: + entry["voice_formula"] = formula_value + entry["resolved_voice"] = formula_value + else: + errors.append(f"Profile '{profile_name}' has no configured voices.") + elif voice_selection == "formula": + if not formula_input: + errors.append(f"Provide a custom formula for chapter {index + 1}.") + else: + try: + _parse_voice_formula(formula_input) + except ValueError as exc: + errors.append(str(exc)) + else: + entry["voice_formula"] = formula_input + entry["resolved_voice"] = formula_input + selected_total += len(entry["text"] or "") + + overrides.append(entry) + pending.chapters[index] = dict(entry) + + enabled_overrides = [entry for entry in overrides if entry.get("enabled")] + + return chunk_level_literal, overrides, enabled_overrides, errors, selected_total _SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [ (re.compile(r"\btitle\s+page\b"), 3.0), (re.compile(r"\bcopyright\b"), 2.4), @@ -689,7 +817,7 @@ def queue_page() -> str: @web_bp.route("/settings", methods=["GET", "POST"]) -def settings_page() -> Response | str: +def settings_page() -> ResponseReturnValue: options = _template_options() current_settings = _load_settings() @@ -766,7 +894,7 @@ def voice_profiles_page() -> str: @web_bp.post("/voices") -def save_voice_profile_route() -> Response: +def save_voice_profile_route() -> ResponseReturnValue: name = request.form.get("name", "").strip() language = request.form.get("language", "a").strip() or "a" formula = request.form.get("formula", "").strip() @@ -780,18 +908,18 @@ def save_voice_profile_route() -> Response: @web_bp.post("/voices//delete") -def delete_voice_profile_route(name: str) -> Response: +def delete_voice_profile_route(name: str) -> ResponseReturnValue: delete_profile(name) return redirect(url_for("web.voice_profiles_page")) @api_bp.get("/voice-profiles") -def api_list_voice_profiles() -> Response: +def api_list_voice_profiles() -> ResponseReturnValue: return jsonify(_profiles_payload()) @api_bp.post("/voice-profiles") -def api_save_voice_profile() -> Response: +def api_save_voice_profile() -> ResponseReturnValue: payload = request.get_json(force=True, silent=False) name = (payload.get("name") or "").strip() if not name: @@ -819,13 +947,13 @@ def api_save_voice_profile() -> Response: @api_bp.delete("/voice-profiles/") -def api_delete_voice_profile(name: str) -> Response: +def api_delete_voice_profile(name: str) -> ResponseReturnValue: remove_profile(name) return jsonify({"ok": True, **_profiles_payload()}) @api_bp.post("/voice-profiles//duplicate") -def api_duplicate_voice_profile(name: str) -> Response: +def api_duplicate_voice_profile(name: str) -> ResponseReturnValue: payload = request.get_json(silent=True) or {} new_name = (payload.get("name") or payload.get("new_name") or "").strip() if not new_name: @@ -835,13 +963,18 @@ def api_duplicate_voice_profile(name: str) -> Response: @api_bp.post("/voice-profiles/import") -def api_import_voice_profiles() -> Response: +def api_import_voice_profiles() -> ResponseReturnValue: replace = False data: Optional[Dict[str, Any]] = None if "file" in request.files: file_storage = request.files["file"] try: - data = json.load(file_storage) + file_storage.stream.seek(0) + raw_bytes = file_storage.read() + text_payload = raw_bytes.decode("utf-8") + data = json.loads(text_payload) + except UnicodeDecodeError as exc: + abort(400, f"JSON file must be UTF-8 encoded: {exc}") except Exception as exc: # pragma: no cover - defensive abort(400, f"Invalid JSON file: {exc}") replace = request.form.get("replace_existing") in {"true", "1", "on"} @@ -863,7 +996,7 @@ def api_import_voice_profiles() -> Response: @api_bp.get("/voice-profiles/export") -def api_export_voice_profiles() -> Response: +def api_export_voice_profiles() -> ResponseReturnValue: names_param = request.args.get("names") names = None if names_param: @@ -882,7 +1015,7 @@ def api_export_voice_profiles() -> Response: @api_bp.post("/voice-profiles/preview") -def api_preview_voice_mix() -> Response: +def api_preview_voice_mix() -> ResponseReturnValue: payload = request.get_json(force=True, silent=False) language = (payload.get("language") or "a").strip() or "a" text = (payload.get("text") or "").strip() @@ -1010,7 +1143,7 @@ def api_preview_voice_mix() -> Response: @api_bp.post("/speaker-preview") -def api_speaker_preview() -> Response: +def api_speaker_preview() -> ResponseReturnValue: payload = request.get_json(force=True, silent=False) text = (payload.get("text") or "").strip() voice_spec = (payload.get("voice") or "").strip() @@ -1106,7 +1239,7 @@ def api_speaker_preview() -> Response: @web_bp.post("/jobs") -def enqueue_job() -> Response: +def enqueue_job() -> ResponseReturnValue: service = _service() uploads_dir = Path(current_app.config["UPLOAD_FOLDER"]) uploads_dir.mkdir(parents=True, exist_ok=True) @@ -1252,6 +1385,7 @@ def enqueue_job() -> Response: maximum=25, ) + initial_analysis = speaker_mode_value != "multi" processed_chunks, speakers, analysis_payload = _prepare_speaker_metadata( chapters=selected_chapter_sources, chunks=raw_chunks, @@ -1259,6 +1393,7 @@ def enqueue_job() -> Response: voice=voice, voice_profile=selected_profile or None, threshold=analysis_threshold, + run_analysis=initial_analysis, ) pending = PendingJob( @@ -1296,6 +1431,7 @@ def enqueue_job() -> Response: speakers=speakers, speaker_analysis=analysis_payload, speaker_analysis_threshold=analysis_threshold, + analysis_requested=False, ) service.store_pending_job(pending) @@ -1311,142 +1447,44 @@ def prepare_job(pending_id: str) -> str: return _render_prepare_page(pending) -@web_bp.post("/jobs/prepare/") -def finalize_job(pending_id: str) -> Response: +@web_bp.post("/jobs/prepare//analyze") +def analyze_pending_job(pending_id: str) -> ResponseReturnValue: service = _service() pending = service.get_pending_job(pending_id) if not pending: abort(404) pending = cast(PendingJob, pending) - raw_chunk_level = (request.form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() - if raw_chunk_level not in _CHUNK_LEVEL_VALUES: - raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph" - pending.chunk_level = raw_chunk_level - chunk_level_literal = cast(ChunkLevel, pending.chunk_level) + chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form( + pending, request.form + ) - raw_speaker_mode = (request.form.get("speaker_mode") or pending.speaker_mode or "single").strip().lower() - if raw_speaker_mode not in _SPEAKER_MODE_VALUES: - raw_speaker_mode = "single" - pending.speaker_mode = raw_speaker_mode + if errors: + return _render_prepare_page(pending, error=" ".join(errors)) - pending.generate_epub3 = _coerce_bool(request.form.get("generate_epub3"), False) - - threshold_default = getattr(pending, "speaker_analysis_threshold", _DEFAULT_ANALYSIS_THRESHOLD) - raw_threshold = request.form.get("speaker_analysis_threshold") - if raw_threshold is not None: - pending.speaker_analysis_threshold = _coerce_int( - raw_threshold, - threshold_default, - minimum=1, - maximum=25, - ) - else: - pending.speaker_analysis_threshold = threshold_default - - if not pending.speakers: - narrator: Dict[str, Any] = { - "id": "narrator", - "label": "Narrator", - "voice": pending.voice, - } - if pending.voice_profile: - narrator["voice_profile"] = pending.voice_profile - pending.speakers = {"narrator": narrator} - else: - existing_narrator = pending.speakers.get("narrator") - if isinstance(existing_narrator, dict): - existing_narrator.setdefault("id", "narrator") - existing_narrator["label"] = existing_narrator.get("label", "Narrator") - existing_narrator["voice"] = pending.voice - if pending.voice_profile: - existing_narrator["voice_profile"] = pending.voice_profile - pending.speakers["narrator"] = existing_narrator - - if isinstance(pending.speakers, dict): - for speaker_id, payload in list(pending.speakers.items()): - if not isinstance(payload, dict): - continue - field_key = f"speaker-{speaker_id}-pronunciation" - raw_value = request.form.get(field_key, "") - pronunciation = raw_value.strip() - if pronunciation: - payload["pronunciation"] = pronunciation - else: - payload.pop("pronunciation", None) - - profiles = serialize_profiles() - delay_value = pending.chapter_intro_delay - raw_delay = request.form.get("chapter_intro_delay") - if raw_delay is not None: - raw_normalized = raw_delay.strip() - if raw_normalized: - try: - delay_value = max(0.0, float(raw_normalized)) - except ValueError: - return _render_prepare_page(pending, error="Enter a valid number for the chapter intro delay.") - else: - delay_value = 0.0 - pending.chapter_intro_delay = delay_value - - overrides: List[Dict[str, Any]] = [] - selected_total = 0 - errors: List[str] = [] - - for index, chapter in enumerate(pending.chapters): - enabled = request.form.get(f"chapter-{index}-enabled") == "on" - title_input = (request.form.get(f"chapter-{index}-title") or "").strip() - title = title_input or chapter.get("title") or f"Chapter {index + 1}" - voice_selection = request.form.get(f"chapter-{index}-voice", "__default") - formula_input = (request.form.get(f"chapter-{index}-formula") or "").strip() - - entry: Dict[str, Any] = { - "id": chapter.get("id") or f"{index:04d}", - "index": index, - "order": index, - "source_title": chapter.get("title") or title, - "title": title, - "text": chapter.get("text", ""), - "enabled": enabled, - } - entry["characters"] = len(entry["text"]) - - if enabled: - if voice_selection.startswith("voice:"): - entry["voice"] = voice_selection.split(":", 1)[1] - entry["resolved_voice"] = entry["voice"] - elif voice_selection.startswith("profile:"): - profile_name = voice_selection.split(":", 1)[1] - entry["voice_profile"] = profile_name - profile_entry = profiles.get(profile_name) or {} - formula_value = _formula_from_profile(profile_entry) - if formula_value: - entry["voice_formula"] = formula_value - entry["resolved_voice"] = formula_value - else: - errors.append(f"Profile '{profile_name}' has no configured voices.") - elif voice_selection == "formula": - if not formula_input: - errors.append(f"Provide a custom formula for chapter {index + 1}.") - else: - try: - _parse_voice_formula(formula_input) - except ValueError as exc: - errors.append(str(exc)) - else: - entry["voice_formula"] = formula_input - entry["resolved_voice"] = formula_input - selected_total += len(entry["text"] or "") - - overrides.append(entry) - pending.chapters[index] = dict(entry) - - enabled_overrides = [entry for entry in overrides if entry.get("enabled")] - if not enabled_overrides: + if pending.speaker_mode != "multi": + setattr(pending, "analysis_requested", False) pending.chunks = [] - return _render_prepare_page(pending, error="Select at least one chapter to convert.") + pending.speaker_analysis = {} + return _render_prepare_page( + pending, + error="Switch to multi-speaker mode to analyze speakers.", + ) + + if not enabled_overrides: + setattr(pending, "analysis_requested", False) + pending.chunks = [] + pending.speaker_analysis = {} + return _render_prepare_page(pending, error="Select at least one chapter to analyze.") raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) + + existing_roster: Optional[Mapping[str, Any]] + if getattr(pending, "analysis_requested", False): + existing_roster = pending.speakers + else: + existing_roster = None + processed_chunks, roster, analysis_payload = _prepare_speaker_metadata( chapters=enabled_overrides, chunks=raw_chunks, @@ -1454,15 +1492,69 @@ def finalize_job(pending_id: str) -> Response: voice=pending.voice, voice_profile=pending.voice_profile, threshold=pending.speaker_analysis_threshold, - existing_roster=pending.speakers, + existing_roster=existing_roster, + run_analysis=True, + ) + + pending.chunks = processed_chunks + pending.speakers = roster + pending.speaker_analysis = analysis_payload + setattr(pending, "analysis_requested", True) + if selected_total: + pending.total_characters = selected_total + + service.store_pending_job(pending) + + return _render_prepare_page(pending, notice="Speaker analysis updated.") + + +@web_bp.post("/jobs/prepare/") +def finalize_job(pending_id: str) -> ResponseReturnValue: + service = _service() + pending = service.get_pending_job(pending_id) + if not pending: + abort(404) + pending = cast(PendingJob, pending) + + chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form( + pending, request.form + ) + + if errors: + return _render_prepare_page(pending, error=" ".join(errors)) + + if pending.speaker_mode != "multi": + setattr(pending, "analysis_requested", False) + + if not enabled_overrides: + pending.chunks = [] + return _render_prepare_page(pending, error="Select at least one chapter to convert.") + + raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) + analysis_active = pending.speaker_mode == "multi" and getattr(pending, "analysis_requested", False) + if analysis_active: + existing_roster: Optional[Mapping[str, Any]] = pending.speakers + else: + narrator_only: Dict[str, Any] = {} + if isinstance(pending.speakers, dict): + narrator_payload = pending.speakers.get("narrator") + if isinstance(narrator_payload, Mapping): + narrator_only["narrator"] = dict(narrator_payload) + existing_roster = narrator_only or None + processed_chunks, roster, analysis_payload = _prepare_speaker_metadata( + chapters=enabled_overrides, + chunks=raw_chunks, + speaker_mode=pending.speaker_mode, + voice=pending.voice, + voice_profile=pending.voice_profile, + threshold=pending.speaker_analysis_threshold, + existing_roster=existing_roster, + run_analysis=analysis_active, ) pending.chunks = processed_chunks pending.speakers = roster pending.speaker_analysis = analysis_payload - if errors: - return _render_prepare_page(pending, error=" ".join(errors)) - total_characters = selected_total or pending.total_characters service.pop_pending_job(pending_id) @@ -1500,13 +1592,14 @@ def finalize_job(pending_id: str) -> Response: speaker_analysis=analysis_payload, speaker_analysis_threshold=pending.speaker_analysis_threshold, generate_epub3=pending.generate_epub3, + analysis_requested=getattr(pending, "analysis_requested", False), ) return redirect(url_for("web.queue_page")) @web_bp.post("/jobs/prepare//cancel") -def cancel_pending_job(pending_id: str) -> Response: +def cancel_pending_job(pending_id: str) -> ResponseReturnValue: pending = _service().pop_pending_job(pending_id) if pending and pending.stored_path.exists(): try: @@ -1536,13 +1629,19 @@ def _render_jobs_panel() -> str: ) -def _render_prepare_page(pending: PendingJob, *, error: Optional[str] = None) -> str: +def _render_prepare_page( + pending: PendingJob, + *, + error: Optional[str] = None, + notice: Optional[str] = None, +) -> str: return render_template( "prepare_job.html", pending=pending, options=_template_options(), settings=_load_settings(), error=error, + notice=notice, ) @@ -1559,7 +1658,7 @@ def job_detail(job_id: str) -> str: @web_bp.post("/jobs//pause") -def pause_job(job_id: str) -> Response: +def pause_job(job_id: str) -> ResponseReturnValue: _service().pause(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1567,7 +1666,7 @@ def pause_job(job_id: str) -> Response: @web_bp.post("/jobs//resume") -def resume_job(job_id: str) -> Response: +def resume_job(job_id: str) -> ResponseReturnValue: _service().resume(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1575,7 +1674,7 @@ def resume_job(job_id: str) -> Response: @web_bp.post("/jobs//cancel") -def cancel_job(job_id: str) -> Response: +def cancel_job(job_id: str) -> ResponseReturnValue: _service().cancel(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1583,7 +1682,7 @@ def cancel_job(job_id: str) -> Response: @web_bp.post("/jobs//delete") -def delete_job(job_id: str) -> Response: +def delete_job(job_id: str) -> ResponseReturnValue: _service().delete(job_id) if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1591,7 +1690,7 @@ def delete_job(job_id: str) -> Response: @web_bp.post("/jobs/clear-finished") -def clear_finished_jobs() -> Response: +def clear_finished_jobs() -> ResponseReturnValue: _service().clear_finished() if request.headers.get("HX-Request"): return _render_jobs_panel() @@ -1599,7 +1698,7 @@ def clear_finished_jobs() -> Response: @web_bp.get("/jobs//download") -def download_job(job_id: str) -> Response: +def download_job(job_id: str) -> ResponseReturnValue: job = _service().get_job(job_id) if job is None or job.status != JobStatus.COMPLETED: abort(404) @@ -1634,7 +1733,7 @@ def job_logs_partial(job_id: str) -> str: @api_bp.get("/jobs/") -def job_json(job_id: str) -> Response: +def job_json(job_id: str) -> ResponseReturnValue: job = _service().get_job(job_id) if job is None: abort(404) diff --git a/abogen/web/service.py b/abogen/web/service.py index 6f6c64d..f140f6d 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -97,6 +97,7 @@ class Job: generate_epub3: bool = False speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 + analysis_requested: bool = False def add_log(self, message: str, level: str = "info") -> None: self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) @@ -154,6 +155,7 @@ class Job: "generate_epub3": self.generate_epub3, "speaker_analysis": dict(self.speaker_analysis), "speaker_analysis_threshold": self.speaker_analysis_threshold, + "analysis_requested": self.analysis_requested, } @@ -193,6 +195,7 @@ class PendingJob: generate_epub3: bool = False speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 + analysis_requested: bool = False class ConversionService: @@ -263,6 +266,7 @@ class ConversionService: generate_epub3: bool = False, speaker_analysis: Optional[Mapping[str, Any]] = None, speaker_analysis_threshold: int = 3, + analysis_requested: bool = False, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -305,6 +309,7 @@ class ConversionService: generate_epub3=bool(generate_epub3), speaker_analysis=dict(speaker_analysis or {}), speaker_analysis_threshold=int(speaker_analysis_threshold or 3), + analysis_requested=bool(analysis_requested), ) with self._lock: self._jobs[job_id] = job @@ -581,6 +586,7 @@ class ConversionService: "generate_epub3": job.generate_epub3, "speaker_analysis": dict(job.speaker_analysis), "speaker_analysis_threshold": job.speaker_analysis_threshold, + "analysis_requested": job.analysis_requested, } def _persist_state(self) -> None: @@ -697,6 +703,7 @@ class ConversionService: job.speaker_analysis_threshold = int( payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3) ) + job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested)) job.pause_event.set() return job diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index b50f3a3..329c9a2 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -61,4 +61,19 @@ document.addEventListener("DOMContentLoaded", () => { toggleFormula(select); } }); + + const analyzeButton = form.querySelector('[data-role="analyze-button"]'); + const speakerModeSelect = form.querySelector("#speaker_mode"); + const updateAnalyzeVisibility = () => { + if (!analyzeButton || !speakerModeSelect) return; + const isMulti = speakerModeSelect.value === "multi"; + analyzeButton.hidden = !isMulti; + analyzeButton.setAttribute("aria-hidden", isMulti ? "false" : "true"); + analyzeButton.disabled = !isMulti; + }; + + if (analyzeButton && speakerModeSelect) { + speakerModeSelect.addEventListener("change", updateAnalyzeVisibility); + updateAnalyzeVisibility(); + } }); diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 9aa99aa..0d9ec72 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -71,6 +71,11 @@

    No additional speakers met the threshold yet. All dialogue will use the narrator voice.

    {% endif %}
    + {% elif pending.speaker_mode == 'multi' and not pending.analysis_requested %} +
    +

    Detected speakers

    +

    Press Analyze speakers after selecting chapters to discover recurring voices.

    +
    {% endif %} {% set roster = pending.speakers or {} %} {% if roster %} @@ -130,6 +135,9 @@ {% if error %}
    {{ error }}
    {% endif %} + {% if notice %} +
    {{ notice }}
    + {% endif %}
    @@ -226,6 +234,15 @@
    +
    diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py new file mode 100644 index 0000000..ba79cb8 --- /dev/null +++ b/tests/test_text_normalization.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from abogen.web.conversion_runner import _normalize_for_pipeline + + +def test_title_abbreviations_are_expanded(): + text = "Dr. Watson met Mr. Holmes and Ms. Hudson." + normalized = _normalize_for_pipeline(text) + assert "Doctor" in normalized + assert "Mister" in normalized + assert "Miz" in normalized + + +def test_suffix_abbreviations_are_expanded_with_case_preserved(): + text = "John Doe Jr. spoke to JANE DOE SR. about the estate." + normalized = _normalize_for_pipeline(text) + assert "John Doe Junior" in normalized + assert "JANE DOE SENIOR" in normalized + + +def test_missing_terminal_punctuation_is_added(): + normalized = _normalize_for_pipeline("Chapter 1") + assert normalized.endswith(".") + + +def test_terminal_punctuation_respects_closing_quotes(): + normalized = _normalize_for_pipeline('"Chapter 1"') + compact = normalized.replace(" ", "") + assert compact.endswith('."') From 1bae37477bd834b0014e98b55ad2a7e48e12ac5d Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 07:09:12 -0700 Subject: [PATCH 046/245] feat: Enhance speaker analysis with gender inference and update related tests --- .gitignore | 1 + abogen/speaker_analysis.py | 67 +++++-- abogen/text_extractor.py | 35 ++-- abogen/web/routes.py | 6 +- abogen/web/static/styles.css | 81 +++++++-- abogen/web/templates/prepare_job.html | 242 +++++++++++++------------- tests/conftest.py | 13 ++ tests/test_speaker_analysis.py | 44 +++++ tests/test_text_extractor.py | 27 +++ 9 files changed, 361 insertions(+), 155 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_speaker_analysis.py create mode 100644 tests/test_text_extractor.py diff --git a/.gitignore b/.gitignore index f60d968..257db4c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ storage/ build/ dist/ .old/ +test_assets/ diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index 3a6dcfa..adf2916 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -28,14 +28,16 @@ _DIALOGUE_VERBS = ( ) _VERB_PATTERN = "(?:" + "|".join(_DIALOGUE_VERBS) + ")" -_NAME_FRAGMENT = r"[A-Z][A-Za-z'\-]+" +_NAME_FRAGMENT = r"[A-ZÀ-ÖØ-Þ][\w'’\-]*" _NAME_PATTERN = rf"{_NAME_FRAGMENT}(?:\s+{_NAME_FRAGMENT})*" _COLON_PATTERN = re.compile(rf"^\s*({_NAME_PATTERN})\s*:\s*(.+)$") _NAME_BEFORE_VERB = re.compile(rf"({_NAME_PATTERN})\s+{_VERB_PATTERN}\b", re.IGNORECASE) _VERB_BEFORE_NAME = re.compile(rf"{_VERB_PATTERN}\s+({_NAME_PATTERN})", re.IGNORECASE) _PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE) -_QUOTE_PATTERN = re.compile(r'"([^"\\]*(?:\\.[^"\\]*)*)"') +_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]') +_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE) +_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE) _CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3} @@ -48,6 +50,9 @@ class SpeakerGuess: confidence: str = "low" sample_quotes: List[str] = field(default_factory=list) suppressed: bool = False + gender: str = "unknown" + male_votes: int = 0 + female_votes: int = 0 def register_occurrence(self, confidence: str, quote: Optional[str]) -> None: self.count += 1 @@ -60,6 +65,13 @@ class SpeakerGuess: if len(self.sample_quotes) > 3: self.sample_quotes = self.sample_quotes[:3] + def register_gender(self, male_votes: int, female_votes: int) -> None: + if male_votes: + self.male_votes += male_votes + if female_votes: + self.female_votes += female_votes + self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender) + def as_dict(self) -> Dict[str, Any]: return { "id": self.speaker_id, @@ -68,6 +80,7 @@ class SpeakerGuess: "confidence": self.confidence, "sample_quotes": list(self.sample_quotes), "suppressed": self.suppressed, + "gender": self.gender, } @@ -131,14 +144,23 @@ def analyze_speakers( assignments[chunk_id] = speaker_id unique_speakers.add(speaker_id) - label = _normalize_label(speaker_id) - record_id = label_index.get(label) - if record_id is None: - record_id = _dedupe_slug(_slugify(label), speaker_guesses) - label_index[label] = record_id - speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) - guess = speaker_guesses[record_id] + male_votes, female_votes = _count_gender_votes(text) + + if speaker_id in speaker_guesses: + record_id = speaker_id + guess = speaker_guesses[record_id] + label = guess.label + else: + label = _normalize_label(speaker_id) + record_id = label_index.get(label) + if record_id is None: + record_id = _dedupe_slug(_slugify(label), speaker_guesses) + label_index[label] = record_id + speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label) + guess = speaker_guesses[record_id] guess.register_occurrence(confidence, quote) + if record_id != narrator_id and (male_votes or female_votes): + guess.register_gender(male_votes, female_votes) if record_id != speaker_id: # Maintain mapping to canonical ID in assignments. assignments[chunk_id] = record_id @@ -293,4 +315,29 @@ def _safe_int(value: Any, default: int = 0) -> int: def _reassign(assignments: Dict[str, str], old: str, new: str) -> None: for key, value in list(assignments.items()): if value == old: - assignments[key] = new \ No newline at end of file + assignments[key] = new + + +def _count_gender_votes(text: str) -> Tuple[int, int]: + if not text: + return 0, 0 + male = len(_MALE_PRONOUN_PATTERN.findall(text)) + female = len(_FEMALE_PRONOUN_PATTERN.findall(text)) + return male, female + + +def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: + if male_votes == 0 and female_votes == 0: + return current if current != "unknown" else "unknown" + + male_threshold = max(2, female_votes + 1) + female_threshold = max(2, male_votes + 1) + + if male_votes >= male_threshold: + return "male" + if female_votes >= female_threshold: + return "female" + + if current in {"male", "female"}: + return current + return "unknown" \ No newline at end of file diff --git a/abogen/text_extractor.py b/abogen/text_extractor.py index 69e0237..9938942 100644 --- a/abogen/text_extractor.py +++ b/abogen/text_extractor.py @@ -8,7 +8,7 @@ import textwrap import urllib.parse from dataclasses import dataclass, field from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast import ebooklib # type: ignore[import] import fitz # type: ignore[import] @@ -16,7 +16,7 @@ import markdown # type: ignore[import] from bs4 import BeautifulSoup, NavigableString # type: ignore[import] from ebooklib import epub # type: ignore[import] -from .utils import clean_text, detect_encoding +from .utils import calculate_text_length, clean_text, detect_encoding logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ class ExtractedChapter: @property def characters(self) -> int: - return len(self.text) + return calculate_text_length(self.text) @dataclass @@ -192,7 +192,7 @@ def _build_metadata_payload( "ALBUM": title, "YEAR": metadata_source.publication_year or now_year, "ALBUM_ARTIST": authors_text, - "COMPOSER": "Narrator", + "COMPOSER": authors_text, "GENRE": "Audiobook", "CHAPTER_COUNT": str(chapter_count), } @@ -213,8 +213,10 @@ def _extract_pdf(path: Path) -> ExtractionResult: chapters: List[ExtractedChapter] = [] with fitz.open(str(path)) as document: metadata_source = _collect_pdf_metadata(document) - for index, page in enumerate(document): - text = _clean_pdf_text(page.get_text()) + pages = cast(Iterable[fitz.Page], document) + for index, page in enumerate(pages): + page_obj = cast(Any, page) + text = _clean_pdf_text(page_obj.get_text()) if not text: continue title = f"Page {index + 1}" @@ -544,8 +546,8 @@ class EpubExtractor: chapters.append(ExtractedChapter(title=title, text=text)) return chapters - def _find_navigation_item(self) -> Tuple[Optional[ebooklib.epub.EpubItem], Optional[str]]: - nav_item: Optional[ebooklib.epub.EpubItem] = None + def _find_navigation_item(self) -> Tuple[Optional[epub.EpubItem], Optional[str]]: + nav_item: Optional[epub.EpubItem] = None nav_type: Optional[str] = None nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION)) @@ -621,12 +623,14 @@ class EpubExtractor: for content_node in nav_soup.find_all("content"): src = content_node.get("src") if src: - targets.append(src.split("#", 1)[0]) + src_value = str(src) + targets.append(src_value.split("#", 1)[0]) else: for link in nav_soup.find_all("a"): href = link.get("href") if href: - targets.append(href.split("#", 1)[0]) + href_value = str(href) + targets.append(href_value.split("#", 1)[0]) return targets def _cache_relevant_documents(self, doc_order: Dict[str, int], nav_targets: List[str]) -> None: @@ -896,11 +900,16 @@ class EpubExtractor: for tag in soup.find_all(["p", "div"]): tag.append("\n\n") for ol in soup.find_all("ol"): - start = int(ol.get("start", 1)) + start_attr = ol.get("start") + try: + start = int(str(start_attr)) if start_attr is not None else 1 + except (TypeError, ValueError): + start = 1 for idx, li in enumerate(ol.find_all("li", recursive=False)): number_text = f"{start + idx}) " - if li.string: - li.string.replace_with(number_text + li.string) + existing = li.string + if isinstance(existing, NavigableString): + existing.replace_with(NavigableString(number_text + str(existing))) else: li.insert(0, NavigableString(number_text)) for tag in soup.find_all(["sup", "sub"]): diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 59eb3e2..e3c46d5 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -332,7 +332,7 @@ def _apply_prepare_form( "text": chapter.get("text", ""), "enabled": enabled, } - entry["characters"] = len(entry["text"]) + entry["characters"] = calculate_text_length(entry["text"]) if enabled: if voice_selection.startswith("voice:"): @@ -359,7 +359,7 @@ def _apply_prepare_form( else: entry["voice_formula"] = formula_input entry["resolved_voice"] = formula_input - selected_total += len(entry["text"] or "") + selected_total += entry["characters"] overrides.append(entry) pending.chapters[index] = dict(entry) @@ -1299,7 +1299,7 @@ def enqueue_job() -> ResponseReturnValue: "index": index, "title": chapter.title, "text": chapter.text, - "characters": len(chapter.text), + "characters": calculate_text_length(chapter.text), "enabled": enabled, } ) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 05146e9..242198d 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -113,6 +113,7 @@ body { box-shadow: 0 20px 50px rgba(8, 15, 32, 0.35); position: relative; overflow: hidden; + z-index: 0; } .card::before { @@ -121,6 +122,7 @@ body { inset: 0; background: radial-gradient(circle at 0% 0%, rgba(56, 189, 248, 0.15), transparent 55%); pointer-events: none; + z-index: -1; } .card__title { @@ -142,11 +144,17 @@ body { align-items: start; } +.form-grid { + grid-template-columns: minmax(0, 420px) minmax(0, 1fr); + align-items: start; + gap: 2rem; +} + .form-grid > .grid { align-items: start; - max-width: 460px; - width: min(100%, 460px); - justify-self: start; + max-width: none; + width: 100%; + justify-self: stretch; } .button { @@ -650,29 +658,50 @@ body { .prepare-summary { display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1.5rem; - margin-bottom: 1.5rem; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + gap: 2rem; + margin-bottom: 2rem; + align-items: start; } -.prepare-summary dl { +.prepare-summary__stats dl { margin: 0; display: grid; gap: 0.6rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } -.prepare-summary dt { +.prepare-summary__stats dt { font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); } -.prepare-summary dd { +.prepare-summary__stats dd { margin: 0; font-weight: 500; } +.prepare-summary__insights { + display: grid; + gap: 1.25rem; + align-content: start; +} + +.prepare-summary__stats dl > div { + display: grid; + gap: 0.25rem; +} + +.prepare-summary__insights > .prepare-analysis, +.prepare-summary__insights > .prepare-metadata { + border: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(15, 23, 42, 0.35); + border-radius: 18px; + padding: 1rem 1.25rem; +} + .prepare-speakers { margin-top: 2rem; border-top: 1px solid var(--panel-border); @@ -918,6 +947,26 @@ body { color: var(--accent); } +@media (max-width: 1024px) { + .prepare-summary { + grid-template-columns: 1fr; + } + + .prepare-summary__stats dl { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + } +} + +@media (max-width: 880px) { + .form-grid { + grid-template-columns: 1fr; + } + + .voice-preview { + width: 100%; + } +} + .hint { margin: 0; font-size: 0.8rem; @@ -1481,9 +1530,10 @@ progress.progress::-moz-progress-bar { gap: 0.9rem; padding: 1rem 1.25rem; border-radius: 18px; - border: 1px solid rgba(148, 163, 184, 0.18); - background: rgba(15, 23, 42, 0.35); - max-width: 460px; + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(15, 23, 42, 0.6); + box-shadow: 0 16px 40px rgba(8, 15, 32, 0.45); + width: min(100%, 520px); } .field--slider { @@ -1548,6 +1598,13 @@ progress.progress::-moz-progress-bar { border: 1px solid rgba(148, 163, 184, 0.18); } +.voice-preview textarea { + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.25); + background: rgba(11, 18, 34, 0.85); + min-height: 140px; +} + .voice-status { min-height: 1.2rem; font-size: 0.9rem; diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 0d9ec72..66f3dd8 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -7,130 +7,138 @@
    Prepare audiobook

    Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.

    + {% set analysis = pending.speaker_analysis or {} %} + {% set analysis_speakers = analysis.get('speakers', {}) %} + {% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %} + {% set has_metadata = pending.metadata_tags %}
    -
    -
    -
    Source
    -
    {{ pending.original_filename }}
    +
    +
    +
    +
    Source
    +
    {{ pending.original_filename }}
    +
    +
    +
    Language
    +
    {{ options.languages.get(pending.language, pending.language|upper) }}
    +
    +
    +
    Default voice
    +
    {% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
    +
    +
    +
    Total chapters
    +
    {{ pending.chapters|length }}
    +
    +
    +
    Characters
    +
    {{ '{:,}'.format(pending.total_characters|default(0)) }}
    +
    +
    +
    Chapter intro delay
    +
    {{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
    +
    +
    +
    Chunk granularity
    +
    {{ pending.chunk_level|replace('_', ' ')|title }}
    +
    +
    +
    Speaker mode
    +
    {{ pending.speaker_mode|replace('_', ' ')|title }}
    +
    +
    +
    Speaker analysis threshold
    +
    {{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
    +
    +
    +
    EPUB 3 package
    +
    {% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
    +
    +
    +
    + {% if analysis_speakers or show_analysis_prompt or has_metadata %} +
    + {% if analysis_speakers %} + {% set active = namespace(items=[]) %} + {% for sid, payload in analysis_speakers.items() %} + {% if sid != 'narrator' and not payload.get('suppressed') %} + {% set _ = active.items.append(payload) %} + {% endif %} + {% endfor %} +
    +

    Detected speakers

    + {% if active.items %} +
      + {% for speaker in active.items|sort(attribute='label') %} +
    • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
    • + {% endfor %} +
    + {% else %} +

    No additional speakers met the threshold yet. All dialogue will use the narrator voice.

    + {% endif %}
    -
    -
    Language
    -
    {{ options.languages.get(pending.language, pending.language|upper) }}
    + {% elif show_analysis_prompt %} +
    +

    Detected speakers

    +

    Press Analyze speakers after selecting chapters to discover recurring voices.

    -
    -
    Default voice
    -
    {% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
    -
    -
    -
    Total chapters
    -
    {{ pending.chapters|length }}
    -
    -
    -
    Characters
    -
    {{ pending.total_characters|default(0) }}
    -
    -
    -
    Chapter intro delay
    -
    {{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
    -
    -
    -
    Chunk granularity
    -
    {{ pending.chunk_level|replace('_', ' ')|title }}
    -
    -
    -
    Speaker mode
    -
    {{ pending.speaker_mode|replace('_', ' ')|title }}
    -
    -
    -
    Speaker analysis threshold
    -
    {{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
    -
    -
    -
    EPUB 3 package
    -
    {% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
    -
    -
    - {% set analysis = pending.speaker_analysis or {} %} - {% set analysis_speakers = analysis.get('speakers', {}) %} - {% if analysis_speakers %} - {% set active = namespace(items=[]) %} - {% for sid, payload in analysis_speakers.items() %} - {% if sid != 'narrator' and not payload.get('suppressed') %} - {% set _ = active.items.append(payload) %} {% endif %} - {% endfor %} -
    -

    Detected speakers

    - {% if active.items %} -
      - {% for speaker in active.items|sort(attribute='label') %} -
    • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
    • - {% endfor %} -
    - {% else %} -

    No additional speakers met the threshold yet. All dialogue will use the narrator voice.

    + {% if has_metadata %} + {% endif %}
    - {% elif pending.speaker_mode == 'multi' and not pending.analysis_requested %} -
    -

    Detected speakers

    -

    Press Analyze speakers after selecting chapters to discover recurring voices.

    -
    - {% endif %} - {% set roster = pending.speakers or {} %} - {% if roster %} - {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} -
    -

    Speaker pronunciation guide

    -

    Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

    -
      - {% for speaker_id, speaker in roster.items() %} - {% set spoken_name = speaker.pronunciation or speaker.label %} - {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} -
    • -
      - {{ speaker.label }} - -
      - - {% if speaker.get('analysis_count') %} -

      {{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}

      - {% endif %} -
    • - {% endfor %} -
    -
    - {% endif %} - {% if pending.metadata_tags %} - {% endif %}
    + {% set roster = pending.speakers or {} %} + {% if roster %} + {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
    +

    Speaker pronunciation guide

    +

    Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

    +
      + {% for speaker_id, speaker in roster.items() %} + {% set spoken_name = speaker.pronunciation or speaker.label %} + {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} +
    • +
      + {{ speaker.label }} + +
      + + {% if speaker.get('analysis_count') %} +

      {{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}

      + {% endif %} +
    • + {% endfor %} +
    +
    + {% endif %} {% if error %}
    {{ error }}
    @@ -160,7 +168,7 @@
    - {{ chapter.characters }} characters + {{ '{:,}'.format(chapter.characters) }} characters
    diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d662d3d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +import importlib +import sys + +# Ensure real optional dependencies are imported before tests that install stubs +# so that available packages (like ebooklib, bs4) aren't replaced with dummy modules. +for module_name in ("ebooklib", "bs4"): + if module_name not in sys.modules: + try: + importlib.import_module(module_name) + except Exception: + # On environments without the optional dependency, downstream tests + # will install lightweight stubs as needed. + pass diff --git a/tests/test_speaker_analysis.py b/tests/test_speaker_analysis.py new file mode 100644 index 0000000..986bd1f --- /dev/null +++ b/tests/test_speaker_analysis.py @@ -0,0 +1,44 @@ +from abogen.speaker_analysis import analyze_speakers + + +def _chapters(): + return [ + { + "id": "0001", + "index": 0, + "title": "Test", + "text": "", + "enabled": True, + } + ] + + +def _chunk(text: str, idx: int) -> dict: + return { + "id": f"chunk-{idx}", + "chapter_index": 0, + "chunk_index": idx, + "text": text, + } + + +def test_analyze_speakers_infers_gender_from_pronouns(): + chunks = [ + _chunk("\"Greetings,\" said John. He adjusted his hat as he smiled.", 0), + _chunk("\"Hello,\" said Mary. She straightened her dress as she introduced herself.", 1), + _chunk("\"Nice to meet you,\" said Alex.", 2), + ] + + analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0) + + john = analysis.speakers.get("john") + mary = analysis.speakers.get("mary") + alex = analysis.speakers.get("alex") + + assert john is not None + assert mary is not None + assert alex is not None + + assert john.gender == "male" + assert mary.gender == "female" + assert alex.gender == "unknown" diff --git a/tests/test_text_extractor.py b/tests/test_text_extractor.py new file mode 100644 index 0000000..1faa27e --- /dev/null +++ b/tests/test_text_extractor.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from abogen.text_extractor import extract_from_path +from abogen.utils import calculate_text_length + + +ASSET = Path("test_assets/alexandre-dumas_the-count-of-monte-cristo_chapman-and-hall.epub") + + +def test_epub_character_counts_align_with_calculated_total(): + result = extract_from_path(ASSET) + + combined_total = calculate_text_length(result.combined_text) + chapter_total = sum(chapter.characters for chapter in result.chapters) + + assert result.total_characters == combined_total == chapter_total + + +def test_epub_metadata_composer_matches_artist(): + result = extract_from_path(ASSET) + + composer = result.metadata.get("composer") or result.metadata.get("COMPOSER") + artist = result.metadata.get("artist") or result.metadata.get("ARTIST") + + assert composer + assert composer == artist + assert composer != "Narrator" From d01887f31bd5f59d4ef48b1c2db396286e5d6094 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 11:19:52 -0700 Subject: [PATCH 047/245] feat: Add speaker configuration management and UI enhancements - Introduced a new speaker configuration page with the ability to create, edit, and delete speaker presets. - Added a step indicator to guide users through the audiobook workflow. - Enhanced the audiobook creation process by allowing users to select speaker presets and configure individual speaker settings. - Implemented dynamic UI elements for managing speaker rows, including adding and removing speakers. - Updated existing templates to integrate speaker configuration features and improve user experience. - Added JavaScript functionality for managing speaker rows and ensuring proper form handling. - Created a new module for handling speaker configuration data storage and retrieval. --- abogen/speaker_analysis.py | 61 ++- abogen/speaker_configs.py | 228 ++++++++++ abogen/web/routes.py | 579 ++++++++++++++++++++++++-- abogen/web/service.py | 8 + abogen/web/static/prepare.js | 74 ++++ abogen/web/static/speaker-configs.js | 97 +++++ abogen/web/static/styles.css | 318 +++++++++++++- abogen/web/templates/base.html | 1 + abogen/web/templates/index.html | 24 ++ abogen/web/templates/prepare_job.html | 431 +++++++++++++------ abogen/web/templates/queue.html | 24 +- abogen/web/templates/speakers.html | 178 ++++++++ 12 files changed, 1850 insertions(+), 173 deletions(-) create mode 100644 abogen/speaker_configs.py create mode 100644 abogen/web/static/speaker-configs.js create mode 100644 abogen/web/templates/speakers.html diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index adf2916..9f2352c 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -38,6 +38,36 @@ _PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE) _QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]') _MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE) _FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE) +_PRONOUN_LABELS = { + "he", + "she", + "they", + "them", + "theirs", + "their", + "themselves", + "him", + "his", + "himself", + "her", + "hers", + "herself", + "we", + "us", + "our", + "ours", + "ourselves", + "i", + "me", + "my", + "mine", + "myself", + "you", + "your", + "yours", + "yourself", + "yourselves", +} _CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3} @@ -216,8 +246,11 @@ def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optio colon_match = _COLON_PATTERN.match(normalized) if colon_match: raw_label = colon_match.group(1) + cleaned = _normalize_candidate_name(raw_label) + if cleaned is None: + return None, "low", colon_match.group(2).strip() quote = colon_match.group(2).strip() - return raw_label, "high", quote + return cleaned, "high", quote quote = _extract_quote(normalized) if not quote: @@ -227,7 +260,9 @@ def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optio candidate = _match_name_near_quote(before, after) if candidate: - return candidate, "high", quote + cleaned = _normalize_candidate_name(candidate) + if cleaned: + return cleaned, "high", quote if last_explicit: pronoun_after = _PRONOUN_PATTERN.search(after) @@ -273,10 +308,13 @@ def _match_name_near_quote(before: str, after: str) -> Optional[str]: def _looks_like_name(value: str) -> bool: - parts = value.strip().split() + normalized = _normalize_candidate_name(value) + if not normalized: + return False + parts = normalized.split() if not parts: return False - return all(part[0].isupper() for part in parts) + return all(part and part[0].isupper() for part in parts) def _extract_quote(text: str) -> Optional[str]: @@ -340,4 +378,17 @@ def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: if current in {"male", "female"}: return current - return "unknown" \ No newline at end of file + return "unknown" + + +def _normalize_candidate_name(raw: str) -> Optional[str]: + if not raw: + return None + cleaned = raw.strip().strip('"“”\'’.,:;!') + cleaned = re.sub(r"\s+", " ", cleaned).strip() + if not cleaned: + return None + lowered = cleaned.lower() + if lowered in _PRONOUN_LABELS: + return None + return cleaned \ No newline at end of file diff --git a/abogen/speaker_configs.py b/abogen/speaker_configs.py new file mode 100644 index 0000000..54641b4 --- /dev/null +++ b/abogen/speaker_configs.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import json +import os +import random +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional + +from abogen.constants import LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL +from abogen.utils import get_user_config_path + +_CONFIG_WRAPPER_KEY = "abogen_speaker_configs" + + +def _config_path() -> str: + config_path = get_user_config_path() + config_dir = os.path.dirname(config_path) + os.makedirs(config_dir, exist_ok=True) + return os.path.join(config_dir, "speaker_configs.json") + + +def load_configs() -> Dict[str, Dict[str, Any]]: + path = _config_path() + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception: + return {} + if isinstance(payload, dict) and _CONFIG_WRAPPER_KEY in payload: + payload = payload[_CONFIG_WRAPPER_KEY] + if not isinstance(payload, dict): + return {} + sanitized: Dict[str, Dict[str, Any]] = {} + for name, entry in payload.items(): + if not isinstance(name, str) or not isinstance(entry, dict): + continue + sanitized[name] = _sanitize_config(entry) + return sanitized + + +def save_configs(configs: Dict[str, Dict[str, Any]]) -> None: + path = _config_path() + sanitized: Dict[str, Dict[str, Any]] = {} + for name, entry in configs.items(): + if not isinstance(name, str) or not name.strip(): + continue + sanitized[name] = _sanitize_config(entry) + with open(path, "w", encoding="utf-8") as handle: + json.dump({_CONFIG_WRAPPER_KEY: sanitized}, handle, indent=2, sort_keys=True) + + +def get_config(name: str) -> Optional[Dict[str, Any]]: + name = (name or "").strip() + if not name: + return None + configs = load_configs() + data = configs.get(name) + return dict(data) if isinstance(data, dict) else None + + +def upsert_config(name: str, payload: Dict[str, Any]) -> Dict[str, Any]: + name = (name or "").strip() + if not name: + raise ValueError("Configuration name is required") + configs = load_configs() + configs[name] = _sanitize_config(payload or {}) + save_configs(configs) + return configs[name] + + +def delete_config(name: str) -> None: + name = (name or "").strip() + if not name: + return + configs = load_configs() + if name in configs: + del configs[name] + save_configs(configs) + + +def _sanitize_config(entry: Dict[str, Any]) -> Dict[str, Any]: + language = str(entry.get("language") or "a").strip() or "a" + speakers_raw = entry.get("speakers") + if not isinstance(speakers_raw, dict): + speakers_raw = {} + speakers: Dict[str, Any] = {} + for speaker_id, payload in speakers_raw.items(): + if not isinstance(speaker_id, str) or not isinstance(payload, dict): + continue + record = _sanitize_speaker({"id": speaker_id, **payload}) + speakers[record["id"]] = record + allowed_languages = entry.get("languages") or entry.get("allowed_languages") or [] + if not isinstance(allowed_languages, list): + allowed_languages = [] + normalized_langs = [] + for code in allowed_languages: + if isinstance(code, str) and code: + normalized_langs.append(code.lower()) + default_voice = entry.get("default_voice") + if not isinstance(default_voice, str): + default_voice = "" + return { + "language": language.lower(), + "languages": normalized_langs, + "default_voice": default_voice, + "speakers": speakers, + "version": int(entry.get("version", 1)), + "notes": entry.get("notes") if isinstance(entry.get("notes"), str) else "", + } + + +def slugify_label(label: str) -> str: + normalized = (label or "").strip().lower() + if not normalized: + return "speaker" + slug = "".join(ch if ch.isalnum() else "_" for ch in normalized) + slug = "_".join(filter(None, slug.split("_"))) + return slug or "speaker" + + +def _sanitize_speaker(entry: Dict[str, Any]) -> Dict[str, Any]: + label = (entry.get("label") or entry.get("name") or "").strip() + gender = (entry.get("gender") or "unknown").strip().lower() + if gender not in {"male", "female", "unknown"}: + gender = "unknown" + voice = entry.get("voice") + voice_profile = entry.get("voice_profile") + voice_formula = entry.get("voice_formula") + voice_languages = entry.get("languages") or [] + if not isinstance(voice_languages, list): + voice_languages = [] + normalized_langs = [] + for code in voice_languages: + if isinstance(code, str) and code: + normalized_langs.append(code.lower()) + randomize = bool(entry.get("randomize")) + resolved_voice = entry.get("resolved_voice") or voice_formula or voice + resolved_label = label or entry.get("id") or "" + slug = entry.get("id") if isinstance(entry.get("id"), str) else slugify_label(resolved_label) + return { + "id": slug, + "label": resolved_label, + "gender": gender, + "voice": voice if isinstance(voice, str) else "", + "voice_profile": voice_profile if isinstance(voice_profile, str) else "", + "voice_formula": voice_formula if isinstance(voice_formula, str) else "", + "resolved_voice": resolved_voice if isinstance(resolved_voice, str) else "", + "languages": normalized_langs, + "randomize": randomize, + } + + +def list_configs() -> List[Dict[str, Any]]: + configs = load_configs() + ordered = [] + for name in sorted(configs): + entry = configs[name] + ordered.append({"name": name, **entry}) + return ordered + + +@dataclass +class RandomVoiceOptions: + gender: str + allowed_languages: List[str] = field(default_factory=list) + fallback_voice: Optional[str] = None + + +def random_voice( + *, + gender: str, + allowed_languages: Optional[Iterable[str]] = None, + fallback_voice: Optional[str] = None, + exclude: Optional[Iterable[str]] = None, +) -> Optional[str]: + gender = (gender or "unknown").lower() + allowed = [code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code] + excluded = {voice for voice in (exclude or []) if isinstance(voice, str)} + + def _voice_gender(code: str) -> str: + if len(code) >= 2: + marker = code[1] + if marker == "m": + return "male" + if marker == "f": + return "female" + return "unknown" + + def _voice_language(code: str) -> str: + return code[0] if code else "a" + + candidates: List[str] = [] + for voice in VOICES_INTERNAL: + if voice in excluded: + continue + voice_gender = _voice_gender(voice) + voice_language = _voice_language(voice) + if allowed and voice_language not in allowed: + continue + if gender in {"male", "female"} and voice_gender != gender: + continue + candidates.append(voice) + + if not candidates and allowed: + # retry without language restriction, preserving gender + for voice in VOICES_INTERNAL: + if voice in excluded: + continue + voice_gender = _voice_gender(voice) + if gender in {"male", "female"} and voice_gender != gender: + continue + candidates.append(voice) + + if not candidates: + candidates = [voice for voice in VOICES_INTERNAL if voice not in excluded] + + if not candidates: + return fallback_voice + + choice = random.choice(candidates) + return choice or fallback_voice + + +def describe_language(code: str) -> str: + code = (code or "a").lower() + return LANGUAGE_DESCRIPTIONS.get(code, code.upper()) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index e3c46d5..06f313b 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -59,6 +59,16 @@ from abogen.voice_profiles import ( from abogen.voice_formulas import get_new_voice from abogen.speaker_analysis import analyze_speakers +from abogen.speaker_configs import ( + delete_config, + get_config, + list_configs, + load_configs, + random_voice, + save_configs, + upsert_config, + slugify_label, +) from abogen.text_extractor import extract_from_path from .conversion_runner import SPLIT_PATTERN, SAMPLE_RATE, _select_device, _to_float32 from .service import ConversionService, Job, JobStatus, PendingJob @@ -86,9 +96,6 @@ _SPEAKER_MODE_VALUES = {option["value"] for option in _SPEAKER_MODE_OPTIONS} _DEFAULT_ANALYSIS_THRESHOLD = 3 -_MAX_ANALYSIS_SPEAKERS = 6 - - def _build_narrator_roster( voice: str, voice_profile: Optional[str], @@ -120,11 +127,19 @@ def _build_speaker_roster( base_voice: str, voice_profile: Optional[str], existing: Optional[Mapping[str, Any]] = None, + order: Optional[Iterable[str]] = None, ) -> Dict[str, Any]: roster = _build_narrator_roster(base_voice, voice_profile, existing) existing_map: Dict[str, Any] = dict(existing) if isinstance(existing, Mapping) else {} speakers = analysis.get("speakers", {}) if isinstance(analysis, dict) else {} - for speaker_id, payload in speakers.items(): + ordered_ids: Iterable[str] + if order is not None: + ordered_ids = [sid for sid in order if sid in speakers] + else: + ordered_ids = speakers.keys() + + for speaker_id in ordered_ids: + payload = speakers.get(speaker_id, {}) if speaker_id == "narrator": continue if payload.get("suppressed"): @@ -136,6 +151,7 @@ def _build_speaker_roster( "voice": base_voice, "analysis_confidence": payload.get("confidence"), "analysis_count": payload.get("count"), + "gender": payload.get("gender", "unknown"), } if isinstance(previous, Mapping): for key in ("voice", "voice_profile", "voice_formula", "resolved_voice", "pronunciation"): @@ -145,18 +161,286 @@ def _build_speaker_roster( return roster +def _match_configured_speaker( + config_speakers: Mapping[str, Any], + roster_id: str, + roster_label: str, +) -> Optional[Mapping[str, Any]]: + if not config_speakers: + return None + entry = config_speakers.get(roster_id) + if entry: + return cast(Mapping[str, Any], entry) + slug = slugify_label(roster_label) + if slug != roster_id and slug in config_speakers: + return cast(Mapping[str, Any], config_speakers[slug]) + lower_label = roster_label.strip().lower() + for record in config_speakers.values(): + if not isinstance(record, Mapping): + continue + if str(record.get("label", "")).strip().lower() == lower_label: + return record + return None + + +def _apply_speaker_config_to_roster( + roster: Mapping[str, Any], + config: Optional[Mapping[str, Any]], + *, + allow_randomize: bool = False, + persist_changes: bool = False, +) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]: + if not isinstance(roster, Mapping): + return {}, [], None + updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)} + if not config: + return updated_roster, [], None + + speakers_map = config.get("speakers") + if not isinstance(speakers_map, Mapping): + return updated_roster, [], None + + config_languages = config.get("languages") + if isinstance(config_languages, list): + allowed_languages = [code for code in config_languages if isinstance(code, str) and code] + else: + allowed_languages = [] + + default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else "" + used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None} + + config_changed = False + new_config_payload: Dict[str, Any] = { + "language": config.get("language", "a"), + "languages": allowed_languages, + "default_voice": default_voice, + "speakers": dict(speakers_map), + "version": config.get("version", 1), + "notes": config.get("notes", ""), + } + + speakers_payload = new_config_payload["speakers"] + + for speaker_id, roster_entry in updated_roster.items(): + if speaker_id == "narrator": + continue + label = str(roster_entry.get("label") or speaker_id) + config_entry = _match_configured_speaker(speakers_map, speaker_id, label) + if config_entry is None: + continue + voice_id = str(config_entry.get("voice") or "").strip() + voice_profile = str(config_entry.get("voice_profile") or "").strip() + voice_formula = str(config_entry.get("voice_formula") or "").strip() + resolved_voice = str(config_entry.get("resolved_voice") or "").strip() + languages = config_entry.get("languages") if isinstance(config_entry.get("languages"), list) else [] + randomize_flag = bool(config_entry.get("randomize")) + + chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice") + if allow_randomize and randomize_flag: + exclusion = used_voices | {voice_id, resolved_voice} + randomized = random_voice( + gender=config_entry.get("gender", "unknown"), + allowed_languages=languages or allowed_languages, + fallback_voice=default_voice or roster_entry.get("voice"), + exclude=exclusion, + ) + if randomized: + chosen_voice = randomized + voice_id = randomized + resolved_voice = randomized + config_changed = True + + if chosen_voice: + roster_entry["resolved_voice"] = chosen_voice + roster_entry["voice"] = chosen_voice if not voice_profile and not voice_formula else roster_entry.get("voice", chosen_voice) + if voice_profile: + roster_entry["voice_profile"] = voice_profile + if voice_formula: + roster_entry["voice_formula"] = voice_formula + roster_entry["resolved_voice"] = voice_formula + if not voice_formula and not voice_profile and resolved_voice: + roster_entry["resolved_voice"] = resolved_voice + roster_entry["config_languages"] = languages or allowed_languages + + # persist updates back to config payload if required + if persist_changes: + slug = config_entry.get("id") or slugify_label(label) + speakers_payload[slug] = { + "id": slug, + "label": label, + "gender": config_entry.get("gender", "unknown"), + "voice": voice_id, + "voice_profile": voice_profile, + "voice_formula": voice_formula, + "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id), + "languages": languages, + "randomize": randomize_flag, + } + + new_config = new_config_payload if (persist_changes and config_changed) else None + return updated_roster, allowed_languages, new_config + + +def _filter_voice_catalog( + catalog: Iterable[Mapping[str, Any]], + *, + gender: str, + allowed_languages: Optional[Iterable[str]] = None, +) -> List[str]: + allowed_set = {code.lower() for code in (allowed_languages or []) if isinstance(code, str) and code} + gender_normalized = (gender or "unknown").lower() + gender_code = "" + if gender_normalized == "male": + gender_code = "m" + elif gender_normalized == "female": + gender_code = "f" + + matches: List[str] = [] + seen: set[str] = set() + + def _consider(entry: Mapping[str, Any]) -> None: + voice_id = entry.get("id") + if not isinstance(voice_id, str) or not voice_id: + return + if voice_id in seen: + return + seen.add(voice_id) + matches.append(voice_id) + + primary: List[Mapping[str, Any]] = [] + fallback: List[Mapping[str, Any]] = [] + for entry in catalog: + if not isinstance(entry, Mapping): + continue + voice_lang = str(entry.get("language", "")).lower() + voice_gender_code = str(entry.get("gender_code", "")).lower() + if allowed_set and voice_lang not in allowed_set: + continue + if gender_code and voice_gender_code != gender_code: + fallback.append(entry) + continue + primary.append(entry) + + for entry in primary: + _consider(entry) + + if not matches: + for entry in fallback: + _consider(entry) + + if not matches: + for entry in catalog: + if isinstance(entry, Mapping): + _consider(entry) + + return matches + + +def _inject_recommended_voices( + roster: Mapping[str, Any], + *, + fallback_languages: Optional[Iterable[str]] = None, +) -> None: + voice_catalog = _build_voice_catalog() + fallback_list = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + for speaker_id, payload in roster.items(): + if not isinstance(payload, dict): + continue + languages = payload.get("config_languages") + if isinstance(languages, list) and languages: + language_list = languages + else: + language_list = fallback_list + gender = str(payload.get("gender", "unknown")) + payload["recommended_voices"] = _filter_voice_catalog( + voice_catalog, + gender=gender, + allowed_languages=language_list, + ) + + +def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str, Any], List[str]]: + getter = getattr(form, "getlist", None) + + def _get_list(name: str) -> List[str]: + if callable(getter): + values = cast(Iterable[Any], getter(name)) + return [str(value).strip() for value in values if value] + raw_value = form.get(name) + if isinstance(raw_value, str): + return [item.strip() for item in raw_value.split(",") if item.strip()] + return [] + + name = (form.get("config_name") or "").strip() + language = str(form.get("config_language") or "a").strip() or "a" + allowed_languages = [code.lower() for code in _get_list("config_languages")] + default_voice = (form.get("config_default_voice") or "").strip() + notes = (form.get("config_notes") or "").strip() + version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999) + + speaker_rows = _get_list("speaker_rows") + speakers: Dict[str, Dict[str, Any]] = {} + for row_key in speaker_rows: + prefix = f"speaker-{row_key}-" + label = (form.get(prefix + "label") or "").strip() + if not label: + continue + raw_gender = (form.get(prefix + "gender") or "unknown").strip().lower() + gender = raw_gender if raw_gender in {"male", "female", "unknown"} else "unknown" + voice = (form.get(prefix + "voice") or "").strip() + voice_profile = (form.get(prefix + "profile") or "").strip() + voice_formula = (form.get(prefix + "formula") or "").strip() + randomize_flag = form.get(prefix + "randomize") in {"on", "1", "true"} + languages = [code.lower() for code in _get_list(prefix + "languages")] + speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label) + speakers[speaker_id] = { + "id": speaker_id, + "label": label, + "gender": gender, + "voice": voice, + "voice_profile": voice_profile, + "voice_formula": voice_formula, + "resolved_voice": voice_formula or voice, + "languages": languages, + "randomize": randomize_flag, + } + + payload = { + "language": language, + "languages": allowed_languages, + "default_voice": default_voice, + "speakers": speakers, + "notes": notes, + "version": version, + } + + errors: List[str] = [] + if not name: + errors.append("Configuration name is required.") + if not speakers: + errors.append("Add at least one speaker to the configuration.") + + return name, payload, errors + + def _prepare_speaker_metadata( *, chapters: List[Dict[str, Any]], chunks: List[Dict[str, Any]], + analysis_chunks: Optional[List[Dict[str, Any]]] = None, speaker_mode: str, voice: str, voice_profile: Optional[str], threshold: int, existing_roster: Optional[Mapping[str, Any]] = None, run_analysis: bool = True, -) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any]]: + speaker_config: Optional[Mapping[str, Any]] = None, + apply_config: bool = False, + allow_randomize: bool = False, + persist_config: bool = False, +) -> tuple[List[Dict[str, Any]], Dict[str, Any], Dict[str, Any], List[str], Optional[Dict[str, Any]]]: chunk_list = [dict(chunk) for chunk in chunks] + analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)] threshold_value = max(1, int(threshold)) analysis_enabled = speaker_mode == "multi" and run_analysis @@ -191,12 +475,25 @@ def _prepare_speaker_metadata( narrator_pron = roster["narrator"].get("pronunciation") if narrator_pron: analysis_payload["speakers"]["narrator"]["pronunciation"] = narrator_pron - return chunk_list, roster, analysis_payload + return chunk_list, roster, analysis_payload, [], None analysis_result = analyze_speakers( - chapters, chunk_list, threshold=threshold_value, max_speakers=_MAX_ANALYSIS_SPEAKERS + chapters, + analysis_source, + threshold=threshold_value, + max_speakers=0, ) analysis_payload = analysis_result.to_dict() + speakers_payload = analysis_payload.get("speakers", {}) + ordered_ids = [ + sid + for sid, meta in sorted( + ((sid, meta) for sid, meta in speakers_payload.items() if sid != "narrator"), + key=lambda item: item[1].get("count", 0), + reverse=True, + ) + ] + analysis_payload["ordered_speakers"] = ordered_ids assignments = analysis_payload.get("assignments", {}) suppressed_ids = analysis_payload.get("suppressed", []) suppressed_details: List[Dict[str, Any]] = [] @@ -222,7 +519,33 @@ def _prepare_speaker_metadata( } ) analysis_payload["suppressed_details"] = suppressed_details - roster = _build_speaker_roster(analysis_payload, voice, voice_profile, existing=existing_roster) + roster = _build_speaker_roster( + analysis_payload, + voice, + voice_profile, + existing=existing_roster, + order=analysis_payload.get("ordered_speakers"), + ) + applied_languages: List[str] = [] + updated_config: Optional[Dict[str, Any]] = None + if apply_config and speaker_config: + roster, applied_languages, updated_config = _apply_speaker_config_to_roster( + roster, + speaker_config, + allow_randomize=allow_randomize, + persist_changes=persist_config, + ) + speakers_payload = analysis_payload.get("speakers") + if isinstance(speakers_payload, dict): + for roster_id, roster_payload in roster.items(): + speaker_meta = speakers_payload.get(roster_id) + if isinstance(speaker_meta, dict): + for key in ("voice", "voice_profile", "voice_formula", "resolved_voice"): + value = roster_payload.get(key) + if value: + speaker_meta[key] = value + if applied_languages: + analysis_payload["config_languages"] = applied_languages speakers_payload = analysis_payload.get("speakers") if isinstance(speakers_payload, dict): for roster_id, roster_payload in roster.items(): @@ -231,6 +554,13 @@ def _prepare_speaker_metadata( if pronunciation_value: speakers_payload[roster_id]["pronunciation"] = pronunciation_value + fallback_languages = applied_languages or [] + if not fallback_languages: + config_langs = analysis_payload.get("config_languages") + if isinstance(config_langs, list): + fallback_languages = [code for code in config_langs if isinstance(code, str)] + _inject_recommended_voices(roster, fallback_languages=fallback_languages) + for chunk in chunk_list: chunk_id = str(chunk.get("id")) speaker_id = assignments.get(chunk_id, "narrator") @@ -238,12 +568,22 @@ def _prepare_speaker_metadata( speaker_meta = roster.get(speaker_id) chunk["speaker_label"] = speaker_meta.get("label") if isinstance(speaker_meta, dict) else speaker_id - return chunk_list, roster, analysis_payload + return chunk_list, roster, analysis_payload, applied_languages, updated_config def _apply_prepare_form( pending: PendingJob, form: Mapping[str, Any] -) -> tuple[ChunkLevel, List[Dict[str, Any]], List[Dict[str, Any]], List[str], int]: +) -> tuple[ + ChunkLevel, + List[Dict[str, Any]], + List[Dict[str, Any]], + List[str], + int, + str, + bool, + bool, + bool, +]: raw_chunk_level = (form.get("chunk_level") or pending.chunk_level or "paragraph").strip().lower() if raw_chunk_level not in _CHUNK_LEVEL_VALUES: raw_chunk_level = pending.chunk_level if pending.chunk_level in _CHUNK_LEVEL_VALUES else "paragraph" @@ -288,6 +628,16 @@ def _apply_prepare_form( existing_narrator["voice_profile"] = pending.voice_profile pending.speakers["narrator"] = existing_narrator + selected_config = (form.get("applied_speaker_config") or "").strip() + randomize_requested = str(form.get("randomize_speaker_config", "")).strip() in {"1", "true", "on"} + apply_config_requested = ( + str(form.get("apply_speaker_config", "")).strip() in {"1", "true", "on"} + or randomize_requested + ) + persist_config_requested = str(form.get("save_speaker_config", "")).strip() in {"1", "true", "on"} + + pending.applied_speaker_config = selected_config or None + if isinstance(pending.speakers, dict): for speaker_id, payload in list(pending.speakers.items()): if not isinstance(payload, dict): @@ -300,6 +650,29 @@ def _apply_prepare_form( else: payload.pop("pronunciation", None) + voice_value = (form.get(f"speaker-{speaker_id}-voice") or "").strip() + if voice_value: + payload["voice"] = voice_value + payload["resolved_voice"] = voice_value + else: + payload.pop("voice", None) + payload.pop("resolved_voice", None) + + randomize_flag = form.get(f"speaker-{speaker_id}-randomize") in {"on", "1", "true"} + payload["randomize"] = randomize_flag + + lang_key = f"speaker-{speaker_id}-languages" + languages: List[str] = [] + getter = getattr(form, "getlist", None) + if callable(getter): + values = cast(Iterable[str], getter(lang_key)) + languages = [code.strip() for code in values if code] + else: + raw_langs = form.get(lang_key) + if isinstance(raw_langs, str): + languages = [item.strip() for item in raw_langs.split(",") if item.strip()] + payload["config_languages"] = languages + profiles = serialize_profiles() errors: List[str] = [] raw_delay = form.get("chapter_intro_delay") @@ -366,7 +739,17 @@ def _apply_prepare_form( enabled_overrides = [entry for entry in overrides if entry.get("enabled")] - return chunk_level_literal, overrides, enabled_overrides, errors, selected_total + return ( + chunk_level_literal, + overrides, + enabled_overrides, + errors, + selected_total, + selected_config, + randomize_requested, + apply_config_requested, + persist_config_requested, + ) _SUPPLEMENT_TITLE_PATTERNS: List[tuple[re.Pattern[str], float]] = [ (re.compile(r"\btitle\s+page\b"), 3.0), (re.compile(r"\bcopyright\b"), 2.4), @@ -487,6 +870,7 @@ def _build_voice_catalog() -> List[Dict[str, str]]: "language": language_code, "language_label": LANGUAGE_DESCRIPTIONS.get(language_code, language_code.upper()), "gender": gender_map.get(gender_code, "Unknown"), + "gender_code": gender_code, "display_name": rest.replace("_", " ").title() if rest else voice_id, } ) @@ -506,6 +890,7 @@ def _template_options() -> Dict[str, Any]: "formula": _formula_from_profile(entry or {}) or "", } ) + voice_catalog = _build_voice_catalog() return { "languages": LANGUAGE_DESCRIPTIONS, "voices": VOICES_INTERNAL, @@ -515,9 +900,11 @@ def _template_options() -> Dict[str, Any]: "voice_profiles": ordered_profiles, "voice_profile_options": profile_options, "separate_formats": ["wav", "flac", "mp3", "opus"], - "voice_catalog": _build_voice_catalog(), + "voice_catalog": voice_catalog, + "voice_catalog_map": {entry["id"]: entry for entry in voice_catalog}, "sample_voice_texts": SAMPLE_VOICE_TEXTS, "voice_profiles_data": profiles, + "speaker_configs": list_configs(), "chunk_levels": _CHUNK_LEVEL_OPTIONS, "speaker_modes": _SPEAKER_MODE_OPTIONS, "speaker_analysis_threshold": current_settings.get( @@ -893,6 +1280,66 @@ def voice_profiles_page() -> str: return render_template("voices.html", options=options) +@web_bp.route("/speakers", methods=["GET", "POST"]) +def speaker_configs_page() -> ResponseReturnValue: + options = _template_options() + configs = list_configs() + message = None + error = None + + if request.method == "POST": + name, config_payload, errors = _extract_speaker_config_form(request.form) + editing_payload = config_payload + editing_name = name + if errors: + error = " ".join(errors) + context = { + "options": options, + "configs": configs, + "editing_name": editing_name, + "editing": editing_payload, + "message": message, + "error": error, + } + return render_template("speakers.html", **context) + upsert_config(name, config_payload) + return redirect(url_for("web.speaker_configs_page", config=name, saved="1")) + + editing_name = request.args.get("config") or "" + editing_payload = get_config(editing_name) if editing_name else None + if editing_payload is None and configs: + editing_name = configs[0]["name"] + editing_payload = get_config(editing_name) + if editing_payload is None: + editing_payload = { + "language": "a", + "languages": [], + "default_voice": "", + "speakers": {}, + "notes": "", + "version": 1, + } + + if request.args.get("saved") == "1": + message = "Speaker configuration saved." + + context = { + "options": options, + "configs": configs, + "editing_name": editing_name, + "editing": editing_payload, + "message": message, + "error": error, + } + return render_template("speakers.html", **context) + + +@web_bp.post("/speakers//delete") +def delete_speaker_config_route(name: str) -> ResponseReturnValue: + delete_config(name) + return redirect(url_for("web.speaker_configs_page")) + + @web_bp.post("/voices") def save_voice_profile_route() -> ResponseReturnValue: name = request.form.get("name", "").strip() @@ -1325,6 +1772,16 @@ def enqueue_job() -> ResponseReturnValue: base_voice = request.form.get("voice", "af_alloy") profile_selection = (request.form.get("voice_profile") or "__standard").strip() custom_formula_raw = request.form.get("voice_formula", "").strip() + selected_speaker_config = (request.form.get("speaker_config") or "").strip() + speaker_config_payload = get_config(selected_speaker_config) if selected_speaker_config else None + config_randomize_default = False + if isinstance(speaker_config_payload, Mapping): + speakers_map = speaker_config_payload.get("speakers") + if isinstance(speakers_map, Mapping): + for config_entry in speakers_map.values(): + if isinstance(config_entry, Mapping) and config_entry.get("randomize"): + config_randomize_default = True + break if profile_selection in {"__standard", ""}: profile_name = "" @@ -1377,6 +1834,7 @@ def enqueue_job() -> ResponseReturnValue: selected_chapter_sources = [entry for entry in chapters_payload if entry.get("enabled")] raw_chunks = build_chunks_for_chapters(selected_chapter_sources, level=chunk_level_literal) + analysis_chunks = build_chunks_for_chapters(selected_chapter_sources, level="sentence") analysis_threshold = _coerce_int( settings.get("speaker_analysis_threshold"), @@ -1385,15 +1843,19 @@ def enqueue_job() -> ResponseReturnValue: maximum=25, ) - initial_analysis = speaker_mode_value != "multi" - processed_chunks, speakers, analysis_payload = _prepare_speaker_metadata( + initial_analysis = speaker_mode_value == "multi" + processed_chunks, speakers, analysis_payload, config_languages, _ = _prepare_speaker_metadata( chapters=selected_chapter_sources, chunks=raw_chunks, + analysis_chunks=analysis_chunks, speaker_mode=speaker_mode_value, voice=voice, voice_profile=selected_profile or None, threshold=analysis_threshold, run_analysis=initial_analysis, + speaker_config=speaker_config_payload, + apply_config=bool(speaker_config_payload), + allow_randomize=config_randomize_default, ) pending = PendingJob( @@ -1431,10 +1893,17 @@ def enqueue_job() -> ResponseReturnValue: speakers=speakers, speaker_analysis=analysis_payload, speaker_analysis_threshold=analysis_threshold, - analysis_requested=False, + analysis_requested=initial_analysis, ) service.store_pending_job(pending) + pending.applied_speaker_config = selected_speaker_config or None + if config_languages: + pending.speaker_voice_languages = list(config_languages) + elif isinstance(speaker_config_payload, Mapping): + languages = speaker_config_payload.get("languages") + if isinstance(languages, list): + pending.speaker_voice_languages = [code for code in languages if isinstance(code, str)] return redirect(url_for("web.prepare_job", pending_id=pending.id)) @@ -1455,9 +1924,17 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: abort(404) pending = cast(PendingJob, pending) - chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form( - pending, request.form - ) + ( + chunk_level_literal, + overrides, + enabled_overrides, + errors, + selected_total, + selected_config, + randomize_requested, + apply_config_requested, + persist_config_requested, + ) = _apply_prepare_form(pending, request.form) if errors: return _render_prepare_page(pending, error=" ".join(errors)) @@ -1478,6 +1955,9 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: return _render_prepare_page(pending, error="Select at least one chapter to analyze.") raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) + analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") + analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") + analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") existing_roster: Optional[Mapping[str, Any]] if getattr(pending, "analysis_requested", False): @@ -1485,27 +1965,44 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: else: existing_roster = None - processed_chunks, roster, analysis_payload = _prepare_speaker_metadata( + config_name = pending.applied_speaker_config or selected_config + speaker_config_payload = get_config(config_name) if config_name else None + processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata( chapters=enabled_overrides, chunks=raw_chunks, + analysis_chunks=analysis_chunks, speaker_mode=pending.speaker_mode, voice=pending.voice, voice_profile=pending.voice_profile, threshold=pending.speaker_analysis_threshold, existing_roster=existing_roster, run_analysis=True, + speaker_config=speaker_config_payload, + apply_config=apply_config_requested or bool(speaker_config_payload), + allow_randomize=randomize_requested, + persist_config=persist_config_requested, ) pending.chunks = processed_chunks pending.speakers = roster pending.speaker_analysis = analysis_payload + if config_languages: + pending.speaker_voice_languages = list(config_languages) + config_name = getattr(pending, "applied_speaker_config", None) + if updated_config and isinstance(config_name, str) and config_name: + configs = load_configs() + configs[config_name] = updated_config + save_configs(configs) setattr(pending, "analysis_requested", True) if selected_total: pending.total_characters = selected_total service.store_pending_job(pending) - return _render_prepare_page(pending, notice="Speaker analysis updated.") + notice_message = "Speaker analysis updated." + if persist_config_requested and config_name: + notice_message = "Speaker analysis updated and configuration saved." + return _render_prepare_page(pending, notice=notice_message) @web_bp.post("/jobs/prepare/") @@ -1516,9 +2013,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: abort(404) pending = cast(PendingJob, pending) - chunk_level_literal, overrides, enabled_overrides, errors, selected_total = _apply_prepare_form( - pending, request.form - ) + ( + chunk_level_literal, + overrides, + enabled_overrides, + errors, + selected_total, + selected_config, + randomize_requested, + apply_config_requested, + persist_config_requested, + ) = _apply_prepare_form(pending, request.form) if errors: return _render_prepare_page(pending, error=" ".join(errors)) @@ -1531,6 +2036,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: return _render_prepare_page(pending, error="Select at least one chapter to convert.") raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) + analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") analysis_active = pending.speaker_mode == "multi" and getattr(pending, "analysis_requested", False) if analysis_active: existing_roster: Optional[Mapping[str, Any]] = pending.speakers @@ -1541,19 +2047,33 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if isinstance(narrator_payload, Mapping): narrator_only["narrator"] = dict(narrator_payload) existing_roster = narrator_only or None - processed_chunks, roster, analysis_payload = _prepare_speaker_metadata( + config_name = pending.applied_speaker_config or selected_config + speaker_config_payload = get_config(config_name) if config_name else None + processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata( chapters=enabled_overrides, chunks=raw_chunks, + analysis_chunks=analysis_chunks, speaker_mode=pending.speaker_mode, voice=pending.voice, voice_profile=pending.voice_profile, threshold=pending.speaker_analysis_threshold, existing_roster=existing_roster, run_analysis=analysis_active, + speaker_config=speaker_config_payload, + apply_config=apply_config_requested or bool(speaker_config_payload), + allow_randomize=randomize_requested, + persist_config=persist_config_requested, ) pending.chunks = processed_chunks pending.speakers = roster pending.speaker_analysis = analysis_payload + if config_languages: + pending.speaker_voice_languages = list(config_languages) + config_name = getattr(pending, "applied_speaker_config", None) + if updated_config and isinstance(config_name, str) and config_name: + configs = load_configs() + configs[config_name] = updated_config + save_configs(configs) total_characters = selected_total or pending.total_characters @@ -1594,6 +2114,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: generate_epub3=pending.generate_epub3, analysis_requested=getattr(pending, "analysis_requested", False), ) + if config_languages: + job.speaker_voice_languages = list(config_languages) + config_name = getattr(pending, "applied_speaker_config", None) + if updated_config and isinstance(config_name, str) and config_name: + job.applied_speaker_config = config_name + configs = load_configs() + configs[config_name] = updated_config + save_configs(configs) + elif isinstance(config_name, str) and config_name: + job.applied_speaker_config = config_name + job.speaker_voice_languages = job.speaker_voice_languages or list(pending.speaker_voice_languages) return redirect(url_for("web.queue_page")) diff --git a/abogen/web/service.py b/abogen/web/service.py index f140f6d..380a7fa 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -98,6 +98,10 @@ class Job: speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 analysis_requested: bool = False + speaker_voice_languages: List[str] = field(default_factory=list) + applied_speaker_config: Optional[str] = None + speaker_voice_languages: List[str] = field(default_factory=list) + applied_speaker_config: Optional[str] = None def add_log(self, message: str, level: str = "info") -> None: self.logs.append(JobLog(timestamp=time.time(), message=message, level=level)) @@ -156,6 +160,8 @@ class Job: "speaker_analysis": dict(self.speaker_analysis), "speaker_analysis_threshold": self.speaker_analysis_threshold, "analysis_requested": self.analysis_requested, + "speaker_voice_languages": list(self.speaker_voice_languages), + "applied_speaker_config": self.applied_speaker_config, } @@ -196,6 +202,8 @@ class PendingJob: speaker_analysis: Dict[str, Any] = field(default_factory=dict) speaker_analysis_threshold: int = 3 analysis_requested: bool = False + speaker_voice_languages: List[str] = field(default_factory=list) + applied_speaker_config: Optional[str] = None class ConversionService: diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 329c9a2..091e090 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -76,4 +76,78 @@ document.addEventListener("DOMContentLoaded", () => { speakerModeSelect.addEventListener("change", updateAnalyzeVisibility); updateAnalyzeVisibility(); } + + const updatePreviewVoice = (select) => { + const container = select.closest(".speaker-list__item"); + if (!container) return; + const previewButton = container.querySelector('[data-role="speaker-preview"]'); + if (!previewButton) return; + const defaultVoice = select.dataset.defaultVoice || previewButton.dataset.voice || ""; + const currentVoice = select.disabled ? defaultVoice : (select.value || defaultVoice); + previewButton.dataset.voice = currentVoice || defaultVoice; + }; + + const handleRandomizeToggle = (checkbox) => { + const container = checkbox.closest(".speaker-list__item"); + if (!container) return; + const select = container.querySelector('[data-role="speaker-voice"]'); + if (!select) return; + if (checkbox.checked) { + if (!select.dataset.prevManual) { + select.dataset.prevManual = select.value; + } + select.dataset.suppressRandomize = "1"; + select.disabled = true; + select.value = ""; + select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + } else { + const previous = select.dataset.prevManual || ""; + select.disabled = false; + select.dataset.suppressRandomize = "1"; + select.value = previous; + select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + } + }; + + const voiceSelects = Array.from(form.querySelectorAll('[data-role="speaker-voice"]')); + voiceSelects.forEach((select) => { + select.addEventListener("change", (event) => { + const target = event.target; + const container = target.closest(".speaker-list__item"); + if (container && !target.dataset.suppressRandomize) { + const randomToggle = container.querySelector('[data-role="randomize-toggle"]'); + if (randomToggle && randomToggle.checked && target.value) { + randomToggle.checked = false; + handleRandomizeToggle(randomToggle); + } + } + updatePreviewVoice(target); + }); + updatePreviewVoice(select); + }); + + const randomizeToggles = Array.from(form.querySelectorAll('[data-role="randomize-toggle"]')); + randomizeToggles.forEach((checkbox) => { + handleRandomizeToggle(checkbox); + checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox)); + }); + + form.addEventListener("click", (event) => { + const chip = event.target.closest('[data-role="recommended-voice"]'); + if (!chip) return; + event.preventDefault(); + const container = chip.closest(".speaker-list__item"); + if (!container) return; + const select = container.querySelector('[data-role="speaker-voice"]'); + if (!select) return; + const randomToggle = container.querySelector('[data-role="randomize-toggle"]'); + if (randomToggle && randomToggle.checked) { + randomToggle.checked = false; + handleRandomizeToggle(randomToggle); + } + select.value = chip.dataset.voice || ""; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); }); diff --git a/abogen/web/static/speaker-configs.js b/abogen/web/static/speaker-configs.js new file mode 100644 index 0000000..4185f21 --- /dev/null +++ b/abogen/web/static/speaker-configs.js @@ -0,0 +1,97 @@ +const initSpeakerConfigsPage = () => { + const form = document.getElementById("speaker-config-form"); + if (!form) return; + + const rowsContainer = form.querySelector('[data-role="speaker-rows"]'); + const template = document.getElementById("speaker-row-template"); + const addButtons = form.querySelectorAll('[data-action="add-speaker"]'); + + const ensureEmptyState = () => { + if (!rowsContainer) return; + const hasRows = rowsContainer.querySelector('[data-role="speaker-row"]'); + let emptyState = rowsContainer.querySelector('[data-role="empty-state"]'); + if (hasRows && emptyState) { + emptyState.remove(); + emptyState = null; + } + if (!hasRows && !emptyState) { + const placeholder = document.createElement("div"); + placeholder.className = "speaker-config-rows__empty"; + placeholder.dataset.role = "empty-state"; + placeholder.textContent = "No speakers yet. Add your first character."; + rowsContainer.appendChild(placeholder); + } + }; + + const hydrateRow = (fragment, key) => { + const elements = fragment.querySelectorAll("[name], [id], label[for], [data-row-id]"); + elements.forEach((el) => { + if (el.name) { + el.name = el.name.replace(/__ROW__/g, key); + } + if (el.id) { + el.id = el.id.replace(/__ROW__/g, key); + } + if (el.tagName === "LABEL") { + const forValue = el.getAttribute("for"); + if (forValue) { + el.setAttribute("for", forValue.replace(/__ROW__/g, key)); + } + } + if (el.dataset && el.dataset.rowId) { + el.dataset.rowId = key; + } + }); + + const hiddenId = fragment.querySelector(`input[name="speaker-${key}-id"]`); + if (hiddenId && !hiddenId.value) { + hiddenId.value = key; + } + const rowMarkers = fragment.querySelectorAll('input[name="speaker_rows"]'); + rowMarkers.forEach((marker) => { + marker.value = key; + }); + }; + + const addRow = () => { + if (!template || !rowsContainer) return; + const key = `row-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + const fragment = template.content.cloneNode(true); + hydrateRow(fragment, key); + rowsContainer.appendChild(fragment); + ensureEmptyState(); + const newRow = rowsContainer.querySelector(`[data-row-id="${key}"]`); + if (newRow) { + const input = newRow.querySelector("input[type=text]"); + if (input) { + input.focus(); + } + } + }; + + addButtons.forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + addRow(); + }); + }); + + rowsContainer?.addEventListener("click", (event) => { + const removeButton = event.target.closest('[data-action="remove-speaker"]'); + if (!removeButton) return; + event.preventDefault(); + const row = removeButton.closest('[data-role="speaker-row"]'); + if (row) { + row.remove(); + ensureEmptyState(); + } + }); + + ensureEmptyState(); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initSpeakerConfigsPage, { once: true }); +} else { + initSpeakerConfigsPage(); +} diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 242198d..bfe751d 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -125,6 +125,65 @@ body { z-index: -1; } +.step-indicator { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.5rem; + align-items: center; +} + +.step-indicator__item { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.85rem; + border-radius: 999px; + background: rgba(148, 163, 184, 0.12); + color: var(--muted); + font-size: 0.85rem; + border: 1px solid rgba(148, 163, 184, 0.18); +} + +.step-indicator__index { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 600; + border: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(15, 23, 42, 0.65); +} + +.step-indicator__label { + font-weight: 500; + letter-spacing: 0.02em; +} + +.step-indicator__item.is-active { + background: rgba(56, 189, 248, 0.18); + color: #fff; + border-color: rgba(56, 189, 248, 0.35); +} + +.step-indicator__item.is-active .step-indicator__index { + background: linear-gradient(135deg, var(--accent), var(--accent-strong)); + border-color: transparent; + color: #fff; +} + +.step-indicator__item.is-complete { + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + border-color: rgba(56, 189, 248, 0.25); +} + +.step-indicator__item.is-complete .step-indicator__index { + border-color: rgba(56, 189, 248, 0.35); +} + .card__title { font-size: 1.4rem; font-weight: 600; @@ -157,6 +216,10 @@ body { justify-self: stretch; } +.form-grid > .grid:last-child { + width: min(100%, 540px); +} + .button { appearance: none; border: none; @@ -223,6 +286,7 @@ body { .field select, .field textarea { width: 100%; + box-sizing: border-box; border-radius: 14px; border: 1px solid rgba(148, 163, 184, 0.2); background: rgba(15, 23, 42, 0.45); @@ -310,6 +374,16 @@ body { color: var(--danger); } +.badge--info { + background: rgba(56, 189, 248, 0.18); + color: var(--accent); +} + +.badge--muted { + background: rgba(148, 163, 184, 0.15); + color: var(--muted); +} + .log-list { list-style: none; margin: 0; @@ -529,7 +603,9 @@ body { } .field--file input[type="file"] { - width: 100%; + width: min(100%, 420px); + max-width: 420px; + align-self: flex-start; border-radius: 14px; border: 1px dashed rgba(148, 163, 184, 0.35); background: rgba(15, 23, 42, 0.45); @@ -711,6 +787,53 @@ body { gap: 1rem; } +.prepare-speaker-config { + margin-top: 2rem; + border-top: 1px solid var(--panel-border); + padding-top: 1.5rem; + display: grid; + gap: 1rem; +} + +.prepare-speaker-config__header { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.prepare-speaker-config__grid { + display: grid; + gap: 1.25rem; + grid-template-columns: minmax(0, 360px) minmax(0, 1fr); + align-items: center; +} + +.prepare-speaker-config__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: flex-end; +} + +.prepare-speaker-config__actions .button { + white-space: nowrap; +} + +.prepare-speaker-config__field .hint { + margin-top: 0.2rem; +} + +@media (max-width: 900px) { + .prepare-speaker-config__grid { + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + } + .prepare-speaker-config__actions { + justify-content: flex-start; + } +} + .speaker-list { list-style: none; margin: 0; @@ -802,6 +925,60 @@ body { box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2); } +.speaker-list__field select { + border-radius: 12px; + border: 1px solid var(--panel-border); + background: rgba(15, 23, 42, 0.6); + padding: 0.55rem 0.75rem; + color: var(--text); +} + +.speaker-list__field select[multiple] { + min-height: 6rem; +} + +.speaker-list__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.8rem; + margin-bottom: 0.3rem; +} + +.speaker-list__controls { + display: grid; + gap: 1rem; +} + +.speaker-list__inline { + display: flex; + align-items: center; +} + +.speaker-list__recommendations { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.25rem; +} + +.chip { + border: 1px solid rgba(56, 189, 248, 0.35); + background: rgba(56, 189, 248, 0.12); + color: var(--accent); + border-radius: 999px; + padding: 0.3rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease; +} + +.chip:hover { + border-color: var(--accent); + transform: translateY(-1px); +} + .speaker-list__stats { margin: 0; color: var(--muted); @@ -871,19 +1048,143 @@ body { accent-color: var(--accent); } -.chapter-card__metrics { - color: var(--muted); - font-size: 0.85rem; +.speaker-configs__layout { + display: grid; + gap: 2rem; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + align-items: start; } -.chapter-card__body { +.speaker-configs__sidebar { display: grid; gap: 1rem; } -.chapter-card__field { +.speaker-configs__sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.speaker-configs__list { + list-style: none; + margin: 0; + padding: 0; display: grid; - gap: 0.45rem; + gap: 0.75rem; +} + +.speaker-configs__entry { + border: 1px solid var(--panel-border); + border-radius: 18px; + padding: 0.9rem 1rem; + background: rgba(15, 23, 42, 0.35); + display: grid; + gap: 0.35rem; +} + +.speaker-configs__entry.is-active { + border-color: rgba(56, 189, 248, 0.45); + background: rgba(56, 189, 248, 0.12); +} + +.speaker-configs__entry a { + color: #fff; + font-weight: 600; + text-decoration: none; +} + +.speaker-configs__entry a:hover { + color: var(--accent); +} + +.speaker-configs__meta { + color: var(--muted); + font-size: 0.85rem; +} + +.speaker-configs__delete { + margin: 0; + justify-self: flex-start; +} + +.speaker-configs__empty { + padding: 0.75rem 0; + color: var(--muted); + font-style: italic; +} + +.speaker-config-form__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.speaker-config-form .field--full { + grid-column: 1 / -1; +} + +.speaker-config-rows { + margin-top: 2rem; + display: grid; + gap: 1rem; +} + +.speaker-config-rows__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.speaker-config-rows__list { + display: grid; + gap: 1rem; +} + +.speaker-config-rows__empty { + border: 1px dashed rgba(148, 163, 184, 0.35); + border-radius: 16px; + padding: 1rem; + color: var(--muted); + font-style: italic; +} + +.speaker-config-row { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 18px; + background: rgba(15, 23, 42, 0.4); + padding: 1rem 1.25rem; + display: grid; + gap: 1rem; +} + +.speaker-config-row__grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.speaker-config-row__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.speaker-configs__actions { + margin-top: 2rem; + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.button--small { + padding: 0.5rem 0.85rem; + font-size: 0.85rem; } .chapter-card__field label { @@ -1603,6 +1904,9 @@ progress.progress::-moz-progress-bar { border: 1px solid rgba(148, 163, 184, 0.25); background: rgba(11, 18, 34, 0.85); min-height: 140px; + width: min(100%, 420px); + max-width: 420px; + align-self: flex-start; } .voice-status { diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index bc8a09f..f7de887 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -19,6 +19,7 @@ {% set endpoint = request.endpoint or '' %} Dashboard Voice Mixer + Speakers Queue Settings diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index d7cf990..31e17c4 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -4,6 +4,20 @@ {% block content %}
    +
    + + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +

    Create a New Audiobook

    @@ -45,6 +59,16 @@
    +
    + + +

    Optional: reuse a saved roster to keep character voices consistent.

    +
    diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 66f3dd8..b3da873 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -4,150 +4,309 @@ {% block content %}
    +
    + + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +
    Prepare audiobook
    -

    Review the detected chapters, choose which ones to render, and optionally override the voice per chapter before sending the job to the queue.

    +

    Review the detected chapters, tune the speaker roster, and optionally override the voice per chapter before sending the job to the queue.

    {% set analysis = pending.speaker_analysis or {} %} {% set analysis_speakers = analysis.get('speakers', {}) %} {% set show_analysis_prompt = pending.speaker_mode == 'multi' and not pending.analysis_requested %} {% set has_metadata = pending.metadata_tags %} -
    -
    -
    -
    -
    Source
    -
    {{ pending.original_filename }}
    -
    -
    -
    Language
    -
    {{ options.languages.get(pending.language, pending.language|upper) }}
    -
    -
    -
    Default voice
    -
    {% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
    -
    -
    -
    Total chapters
    -
    {{ pending.chapters|length }}
    -
    -
    -
    Characters
    -
    {{ '{:,}'.format(pending.total_characters|default(0)) }}
    -
    -
    -
    Chapter intro delay
    -
    {{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
    -
    -
    -
    Chunk granularity
    -
    {{ pending.chunk_level|replace('_', ' ')|title }}
    -
    -
    -
    Speaker mode
    -
    {{ pending.speaker_mode|replace('_', ' ')|title }}
    -
    -
    -
    Speaker analysis threshold
    -
    {{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
    -
    -
    -
    EPUB 3 package
    -
    {% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
    -
    -
    -
    - {% if analysis_speakers or show_analysis_prompt or has_metadata %} -
    - {% if analysis_speakers %} - {% set active = namespace(items=[]) %} - {% for sid, payload in analysis_speakers.items() %} - {% if sid != 'narrator' and not payload.get('suppressed') %} - {% set _ = active.items.append(payload) %} - {% endif %} - {% endfor %} -
    -

    Detected speakers

    - {% if active.items %} -
      - {% for speaker in active.items|sort(attribute='label') %} -
    • {{ speaker.label }} · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }}
    • - {% endfor %} -
    - {% else %} -

    No additional speakers met the threshold yet. All dialogue will use the narrator voice.

    - {% endif %} -
    - {% elif show_analysis_prompt %} -
    -

    Detected speakers

    -

    Press Analyze speakers after selecting chapters to discover recurring voices.

    -
    - {% endif %} - {% if has_metadata %} - - {% endif %} -
    - {% endif %} -
    {% set roster = pending.speakers or {} %} - {% if roster %} - {% set preview_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} -
    -

    Speaker pronunciation guide

    -

    Add a phonetic spelling (IPA or plain text) so pronunciations sound right. Leave blank to use the written label.

    -
      - {% for speaker_id, speaker in roster.items() %} - {% set spoken_name = speaker.pronunciation or speaker.label %} - {% set preview_text = preview_template | replace("{{name}}", spoken_name) %} -
    • -
      - {{ speaker.label }} - -
      - - {% if speaker.get('analysis_count') %} -

      {{ speaker.analysis_count }} detected lines · confidence {{ speaker.analysis_confidence|default('low')|title }}

      - {% endif %} -
    • - {% endfor %} -
    -
    - {% endif %} - - {% if error %} -
    {{ error }}
    - {% endif %} - {% if notice %} -
    {{ notice }}
    - {% endif %} + {% set speaker_configs = options.speaker_configs or [] %} + {% set applied_languages = pending.speaker_voice_languages or analysis.get('config_languages', []) or [] %} + {% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +
    +
    +
    +
    +
    Source
    +
    {{ pending.original_filename }}
    +
    +
    +
    Language
    +
    {{ options.languages.get(pending.language, pending.language|upper) }}
    +
    +
    +
    Default voice
    +
    {% if pending.voice_profile %}Profile · {{ pending.voice_profile }}{% else %}{{ pending.voice }}{% endif %}
    +
    +
    +
    Total chapters
    +
    {{ pending.chapters|length }}
    +
    +
    +
    Characters
    +
    {{ '{:,}'.format(pending.total_characters|default(0)) }}
    +
    +
    +
    Chapter intro delay
    +
    {{ '%.1f'|format(pending.chapter_intro_delay) }} seconds
    +
    +
    +
    Chunk granularity
    +
    {{ pending.chunk_level|replace('_', ' ')|title }}
    +
    +
    +
    Speaker mode
    +
    {{ pending.speaker_mode|replace('_', ' ')|title }}
    +
    +
    +
    Speaker analysis threshold
    +
    {{ pending.speaker_analysis_threshold }} {{ 'mention' if pending.speaker_analysis_threshold == 1 else 'mentions' }}
    +
    +
    +
    EPUB 3 package
    +
    {% if pending.generate_epub3 %}Enabled{% else %}Disabled{% endif %}
    +
    + {% if applied_languages %} +
    +
    Config languages
    +
    {{ applied_languages | map('upper') | join(', ') }}
    +
    + {% endif %} +
    +
    + {% if analysis_speakers or show_analysis_prompt or has_metadata %} +
    + {% if analysis_speakers %} + {% set ordered_ids = analysis.get('ordered_speakers', []) %} +
    +

    Detected speakers

    + {% if ordered_ids %} +
      + {% for sid in ordered_ids %} + {% set speaker = analysis_speakers.get(sid) %} + {% if speaker %} +
    • + {{ speaker.label }} + {% if speaker.gender and speaker.gender != 'unknown' %} + · {{ speaker.gender|title }} + {% endif %} + · {{ speaker.count }} lines · confidence {{ speaker.confidence|title }} +
    • + {% endif %} + {% endfor %} +
    + {% else %} +

    No additional speakers met the threshold yet. All dialogue will use the narrator voice.

    + {% endif %} +
    + {% elif show_analysis_prompt %} +
    +

    Detected speakers

    +

    Press Analyze speakers after selecting chapters to discover recurring voices.

    +
    + {% endif %} + {% if has_metadata %} + + {% endif %} +
    + {% endif %} +
    + + {% if error %} +
    {{ error }}
    + {% endif %} + {% if notice %} +
    {{ notice }}
    + {% endif %} + +
    +
    +

    Speaker configuration

    +

    Reuse saved presets to keep character voices consistent between projects.

    +
    +
    + +
    + + + +
    +
    +
    + + {% if roster %} +
    +

    Speaker settings

    +

    Set pronunciations, lock specific voices, or limit the randomizer by language and gender.

    +
      + {% for speaker_id, speaker in roster.items() %} + {% set spoken_name = speaker.pronunciation or speaker.label %} + {% set preview_text = recommended_template | replace("{{name}}", spoken_name) %} + {% set selected_voice = speaker.resolved_voice or speaker.voice %} + {% set allowed_langs = speaker.config_languages or speaker.languages or [] %} + {% set seen = namespace(values=[]) %} +
    • +
      + {{ speaker.label }} + +
      +
      + {% if speaker.gender and speaker.gender != 'unknown' %} + {{ speaker.gender|title }} + {% else %} + Gender unknown + {% endif %} + {% if speaker.get('analysis_count') %} + {{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence + {% endif %} +
      + +
      + +
      + +
      + +
      + {% if speaker.recommended_voices %} +
      + Suggested: + {% for voice_id in speaker.recommended_voices[:6] %} + {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} + + {% endfor %} +
      + {% endif %} +
    • + {% endfor %} +
    +
    + {% endif %} +
    {% for chapter in pending.chapters %} {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} @@ -206,6 +365,7 @@ {% endfor %}
    +
    @@ -241,6 +401,7 @@
    +
    +
    +
    +{% endmacro %} + +{% block title %}Speaker presets{% endblock %} + +{% block content %} +
    +
    + + 1 + Upload + + + 2 + Speakers + + + 3 + Queue + +
    +
    Speaker presets
    +

    Store recurring casts, control randomization defaults, and keep voices consistent between books.

    + + {% if message %} +
    {{ message }}
    + {% endif %} + {% if error %} +
    {{ error }}
    + {% endif %} + +
    + +
    +
    + +
    + + + + + +
    + +
    +
    +

    Speakers

    + +
    +

    Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.

    +
    + {% set speakers_map = editing.speakers or {} %} + {% if speakers_map %} + {% for speaker_id, speaker in speakers_map|dictsort(attribute='1.label') %} + {{ speaker_row(speaker_id, speaker, options) }} + {% endfor %} + {% else %} +
    No speakers yet. Add your first character.
    + {% endif %} +
    +
    + +
    + + Back to dashboard +
    +
    +
    +
    + + +
    +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} From 881717c4cb49d8f9a6ba26db46fdce599abb7138 Mon Sep 17 00:00:00 2001 From: JB Date: Wed, 8 Oct 2025 12:50:50 -0700 Subject: [PATCH 048/245] Refactor templates for audiobook preparation and settings - Updated navigation in base.html to include a link to the queue section. - Modified index.html to change step labels and added a jobs panel for real-time updates. - Enhanced prepare_job.html with a wizard-style step navigation and improved speaker configuration options. - Added new fields for speaker analysis settings and chapter options in prepare_job.html. - Introduced a voice selection modal for better user experience in speaker selection. - Updated settings.html to allow selection of randomizer languages for speakers. - Cleaned up speakers.html by removing unnecessary language selection fields and ensuring consistency in speaker configuration. --- abogen/speaker_analysis.py | 25 +- abogen/web/routes.py | 96 +++- abogen/web/static/prepare.js | 451 +++++++++++++++++ abogen/web/templates/base.html | 8 +- abogen/web/templates/index.html | 16 +- abogen/web/templates/prepare_job.html | 664 +++++++++++++++----------- abogen/web/templates/settings.html | 10 + abogen/web/templates/speakers.html | 22 +- 8 files changed, 953 insertions(+), 339 deletions(-) diff --git a/abogen/speaker_analysis.py b/abogen/speaker_analysis.py index 9f2352c..b66ccd8 100644 --- a/abogen/speaker_analysis.py +++ b/abogen/speaker_analysis.py @@ -359,9 +359,28 @@ def _reassign(assignments: Dict[str, str], old: str, new: str) -> None: def _count_gender_votes(text: str) -> Tuple[int, int]: if not text: return 0, 0 - male = len(_MALE_PRONOUN_PATTERN.findall(text)) - female = len(_FEMALE_PRONOUN_PATTERN.findall(text)) - return male, female + + male_votes = 0.0 + for token in _MALE_PRONOUN_PATTERN.findall(text): + lowered = token.lower() + if lowered in {"he", "himself"}: + male_votes += 1.0 + elif lowered == "his": + male_votes += 0.75 + else: # him + male_votes += 0.6 + + female_votes = 0.0 + for token in _FEMALE_PRONOUN_PATTERN.findall(text): + lowered = token.lower() + if lowered in {"she", "herself"}: + female_votes += 1.0 + elif lowered == "hers": + female_votes += 0.75 + else: # her + female_votes += 0.4 + + return int(round(male_votes)), int(round(female_votes)) def _derive_gender(male_votes: int, female_votes: int, current: str) -> str: diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 06f313b..60d482c 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -189,26 +189,44 @@ def _apply_speaker_config_to_roster( *, allow_randomize: bool = False, persist_changes: bool = False, + fallback_languages: Optional[Iterable[str]] = None, ) -> Tuple[Dict[str, Any], List[str], Optional[Dict[str, Any]]]: if not isinstance(roster, Mapping): - return {}, [], None + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return {}, effective_languages, None updated_roster: Dict[str, Any] = {key: dict(value) for key, value in roster.items() if isinstance(value, Mapping)} if not config: - return updated_roster, [], None + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return updated_roster, effective_languages, None speakers_map = config.get("speakers") if not isinstance(speakers_map, Mapping): - return updated_roster, [], None + effective_languages = [code for code in (fallback_languages or []) if isinstance(code, str) and code] + return updated_roster, effective_languages, None config_languages = config.get("languages") if isinstance(config_languages, list): allowed_languages = [code for code in config_languages if isinstance(code, str) and code] else: allowed_languages = [] + if not allowed_languages and fallback_languages: + allowed_languages = [code for code in fallback_languages if isinstance(code, str) and code] default_voice = config.get("default_voice") if isinstance(config.get("default_voice"), str) else "" used_voices = {entry.get("resolved_voice") or entry.get("voice") for entry in updated_roster.values()} - {None} + narrator_voice = "" + narrator_entry = updated_roster.get("narrator") if isinstance(updated_roster, Mapping) else None + if isinstance(narrator_entry, Mapping): + narrator_voice = str( + narrator_entry.get("resolved_voice") + or narrator_entry.get("voice") + or narrator_entry.get("default_voice") + or "" + ).strip() + if narrator_voice: + used_voices.add(narrator_voice) + config_changed = False new_config_payload: Dict[str, Any] = { "language": config.get("language", "a"), @@ -236,11 +254,13 @@ def _apply_speaker_config_to_roster( randomize_flag = bool(config_entry.get("randomize")) chosen_voice = resolved_voice or voice_formula or voice_id or roster_entry.get("voice") - if allow_randomize and randomize_flag: - exclusion = used_voices | {voice_id, resolved_voice} + usable_languages = languages or allowed_languages + should_randomize = randomize_flag and (allow_randomize or not chosen_voice) + if should_randomize: + exclusion = used_voices | {voice_id, resolved_voice, narrator_voice} randomized = random_voice( gender=config_entry.get("gender", "unknown"), - allowed_languages=languages or allowed_languages, + allowed_languages=usable_languages, fallback_voice=default_voice or roster_entry.get("voice"), exclude=exclusion, ) @@ -249,6 +269,7 @@ def _apply_speaker_config_to_roster( voice_id = randomized resolved_voice = randomized config_changed = True + used_voices.add(randomized) if chosen_voice: roster_entry["resolved_voice"] = chosen_voice @@ -260,7 +281,10 @@ def _apply_speaker_config_to_roster( roster_entry["resolved_voice"] = voice_formula if not voice_formula and not voice_profile and resolved_voice: roster_entry["resolved_voice"] = resolved_voice - roster_entry["config_languages"] = languages or allowed_languages + roster_entry["config_languages"] = usable_languages or [] + + if chosen_voice: + used_voices.add(chosen_voice) # persist updates back to config payload if required if persist_changes: @@ -273,7 +297,7 @@ def _apply_speaker_config_to_roster( "voice_profile": voice_profile, "voice_formula": voice_formula, "resolved_voice": roster_entry.get("resolved_voice", resolved_voice or voice_id), - "languages": languages, + "languages": usable_languages, "randomize": randomize_flag, } @@ -373,7 +397,7 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str name = (form.get("config_name") or "").strip() language = str(form.get("config_language") or "a").strip() or "a" - allowed_languages = [code.lower() for code in _get_list("config_languages")] + allowed_languages = [] default_voice = (form.get("config_default_voice") or "").strip() notes = (form.get("config_notes") or "").strip() version = _coerce_int(form.get("config_version"), 1, minimum=1, maximum=9999) @@ -391,7 +415,6 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str voice_profile = (form.get(prefix + "profile") or "").strip() voice_formula = (form.get(prefix + "formula") or "").strip() randomize_flag = form.get(prefix + "randomize") in {"on", "1", "true"} - languages = [code.lower() for code in _get_list(prefix + "languages")] speaker_id = (form.get(prefix + "id") or "").strip() or slugify_label(label) speakers[speaker_id] = { "id": speaker_id, @@ -401,7 +424,7 @@ def _extract_speaker_config_form(form: Mapping[str, Any]) -> Tuple[str, Dict[str "voice_profile": voice_profile, "voice_formula": voice_formula, "resolved_voice": voice_formula or voice, - "languages": languages, + "languages": [], "randomize": randomize_flag, } @@ -443,6 +466,12 @@ def _prepare_speaker_metadata( analysis_source = [dict(chunk) for chunk in (analysis_chunks or chunks)] threshold_value = max(1, int(threshold)) analysis_enabled = speaker_mode == "multi" and run_analysis + settings_state = _load_settings() + global_random_languages = [ + code + for code in settings_state.get("speaker_random_languages", []) + if isinstance(code, str) and code + ] if not analysis_enabled: for chunk in chunk_list: @@ -534,6 +563,7 @@ def _prepare_speaker_metadata( speaker_config, allow_randomize=allow_randomize, persist_changes=persist_config, + fallback_languages=global_random_languages, ) speakers_payload = analysis_payload.get("speakers") if isinstance(speakers_payload, dict): @@ -544,8 +574,18 @@ def _prepare_speaker_metadata( value = roster_payload.get(key) if value: speaker_meta[key] = value + effective_languages: List[str] = [] if applied_languages: - analysis_payload["config_languages"] = applied_languages + effective_languages = applied_languages + elif isinstance(analysis_payload.get("config_languages"), list): + effective_languages = [ + code for code in analysis_payload.get("config_languages", []) if isinstance(code, str) and code + ] + elif global_random_languages: + effective_languages = list(global_random_languages) + + if effective_languages: + analysis_payload["config_languages"] = effective_languages speakers_payload = analysis_payload.get("speakers") if isinstance(speakers_payload, dict): for roster_id, roster_payload in roster.items(): @@ -554,11 +594,7 @@ def _prepare_speaker_metadata( if pronunciation_value: speakers_payload[roster_id]["pronunciation"] = pronunciation_value - fallback_languages = applied_languages or [] - if not fallback_languages: - config_langs = analysis_payload.get("config_languages") - if isinstance(config_langs, list): - fallback_languages = [code for code in config_langs if isinstance(code, str)] + fallback_languages = effective_languages or [] _inject_recommended_voices(roster, fallback_languages=fallback_languages) for chunk in chunk_list: @@ -962,6 +998,7 @@ def _settings_defaults() -> Dict[str, Any]: "generate_epub3": False, "speaker_analysis_threshold": _DEFAULT_ANALYSIS_THRESHOLD, "speaker_pronunciation_sentence": "This is {{name}} speaking.", + "speaker_random_languages": [], } @@ -1031,6 +1068,13 @@ def _normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> if isinstance(value, str) and value in _SPEAKER_MODE_VALUES: return value return defaults[key] + if key == "speaker_random_languages": + if isinstance(value, (list, tuple, set)): + return [code for code in value if isinstance(code, str) and code in LANGUAGE_DESCRIPTIONS] + if isinstance(value, str): + parts = [item.strip().lower() for item in value.split(",") if item.strip()] + return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] + return defaults.get(key, []) return value if value is not None else defaults.get(key) @@ -1195,12 +1239,13 @@ def index() -> str: "index.html", options=_template_options(), settings=_load_settings(), + jobs_panel=_render_jobs_panel(), ) @web_bp.get("/queue") -def queue_page() -> str: - return render_template("queue.html", jobs_panel=_render_jobs_panel()) +def queue_page() -> ResponseReturnValue: + return redirect(url_for("web.index", _anchor="queue")) @web_bp.route("/settings", methods=["GET", "POST"]) @@ -1256,6 +1301,13 @@ def settings_page() -> ResponseReturnValue: sentence_value = defaults["speaker_pronunciation_sentence"] updated["speaker_pronunciation_sentence"] = sentence_value + random_languages = [ + code.lower() + for code in form.getlist("speaker_random_languages") + if isinstance(code, str) and code.lower() in LANGUAGE_DESCRIPTIONS + ] + updated["speaker_random_languages"] = random_languages + cfg = load_config() or {} cfg.update(updated) save_config(cfg) @@ -2126,7 +2178,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: job.applied_speaker_config = config_name job.speaker_voice_languages = job.speaker_voice_languages or list(pending.speaker_voice_languages) - return redirect(url_for("web.queue_page")) + return redirect(url_for("web.index", _anchor="queue")) @web_bp.post("/jobs/prepare//cancel") @@ -2142,7 +2194,7 @@ def cancel_pending_job(pending_id: str) -> ResponseReturnValue: pending.cover_image_path.unlink() except OSError: pass - return redirect(url_for("web.index")) + return redirect(url_for("web.index", _anchor="queue")) def _render_jobs_panel() -> str: @@ -2225,7 +2277,7 @@ def clear_finished_jobs() -> ResponseReturnValue: _service().clear_finished() if request.headers.get("HX-Request"): return _render_jobs_panel() - return redirect(url_for("web.queue_page")) + return redirect(url_for("web.index", _anchor="queue")) @web_bp.get("/jobs//download") diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 091e090..eab0cff 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -2,6 +2,22 @@ document.addEventListener("DOMContentLoaded", () => { const form = document.querySelector(".prepare-form"); if (!form) return; + const parseJSONScript = (id) => { + const el = document.getElementById(id); + if (!el) return null; + try { + const content = el.textContent || ""; + return content ? JSON.parse(content) : null; + } catch (error) { + console.warn(`Failed to parse JSON script for ${id}`, error); + return null; + } + }; + + const voiceCatalog = parseJSONScript("voice-catalog-data") || []; + const languageMap = parseJSONScript("voice-language-map") || {}; + const voiceCatalogMap = new Map(voiceCatalog.map((voice) => [voice.id, voice])); + const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); const updateRowState = (row) => { @@ -123,6 +139,9 @@ document.addEventListener("DOMContentLoaded", () => { handleRandomizeToggle(randomToggle); } } + if (!target.dataset.suppressRandomize) { + target.dataset.prevManual = target.value || ""; + } updatePreviewVoice(target); }); updatePreviewVoice(select); @@ -134,7 +153,435 @@ document.addEventListener("DOMContentLoaded", () => { checkbox.addEventListener("change", () => handleRandomizeToggle(checkbox)); }); + const wizard = document.querySelector('[data-role="prepare-wizard"]'); + if (wizard) { + const stepOrder = ["chapters", "speakers"]; + const indicatorOrder = ["setup", ...stepOrder]; + const indicator = wizard.querySelector('[data-role="wizard-indicator"]'); + const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : []; + const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]')); + const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); + const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); + const panels = new Map(); + stepOrder.forEach((step) => { + const panel = wizard.querySelector(`[data-step-panel="${step}"]`); + if (panel) { + panels.set(step, panel); + panel.hidden = step !== "chapters"; + panel.setAttribute("aria-hidden", step === "chapters" ? "false" : "true"); + } + }); + + const unlockedSteps = new Set(["chapters"]); + let currentStep = "chapters"; + + const updateIndicator = (activeStep) => { + const activeIndex = indicatorOrder.indexOf(activeStep); + indicatorSteps.forEach((item) => { + const key = item.dataset.stepKey; + if (!key) return; + const index = indicatorOrder.indexOf(key); + item.classList.remove("is-active", "is-complete"); + if (index < activeIndex) { + item.classList.add("is-complete"); + } else if (index === activeIndex) { + item.classList.add("is-active"); + } + }); + }; + + const unlockStep = (step) => { + if (unlockedSteps.has(step)) { + return; + } + unlockedSteps.add(step); + navButtons.forEach((button) => { + if (button.dataset.stepTarget === step) { + button.disabled = false; + button.removeAttribute("aria-disabled"); + button.dataset.state = "unlocked"; + } + }); + }; + + const setStep = (step) => { + if (!panels.has(step)) { + return; + } + currentStep = step; + wizard.dataset.activeStep = step; + panels.forEach((panel, key) => { + const isActive = key === step; + panel.hidden = !isActive; + panel.setAttribute("aria-hidden", isActive ? "false" : "true"); + }); + navButtons.forEach((button) => { + const target = button.dataset.stepTarget; + if (!target) return; + const isActive = target === step; + button.classList.toggle("is-active", isActive); + if (button.dataset.state === "locked" && !unlockedSteps.has(target)) { + button.disabled = true; + button.setAttribute("aria-disabled", "true"); + } else { + button.disabled = false; + button.removeAttribute("aria-disabled"); + } + }); + updateIndicator(step); + }; + + navButtons.forEach((button) => { + button.addEventListener("click", () => { + if (button.disabled) { + return; + } + const target = button.dataset.stepTarget; + if (!target) return; + unlockStep(target); + setStep(target); + }); + }); + + nextButtons.forEach((button) => { + button.addEventListener("click", () => { + const target = button.dataset.stepTarget || "speakers"; + unlockStep(target); + setStep(target); + }); + }); + + prevButtons.forEach((button) => { + button.addEventListener("click", () => { + const target = button.dataset.stepTarget || "chapters"; + setStep(target); + }); + }); + + setStep(currentStep); + } + + const voiceModal = document.querySelector('[data-role="voice-modal"]'); + let activeGenderFilter = ""; + + const modalState = { + speakerItem: null, + samples: [], + recommended: new Set(), + selectedVoice: "", + defaultVoice: "", + previewSettings: { language: "a", speed: "1", useGpu: "true" }, + }; + + const resetModalState = () => { + modalState.speakerItem = null; + modalState.samples = []; + modalState.recommended = new Set(); + modalState.selectedVoice = ""; + modalState.defaultVoice = ""; + modalState.previewSettings = { language: "a", speed: "1", useGpu: "true" }; + }; + + const renderVoiceList = (elements) => { + if (!elements) return; + const { list, searchInput, languageSelect } = elements; + if (!list) return; + list.innerHTML = ""; + const term = (searchInput?.value || "").trim().toLowerCase(); + const languageFilter = languageSelect?.value || ""; + const filtered = voiceCatalog.filter((voice) => { + if (languageFilter && voice.language !== languageFilter) return false; + if (activeGenderFilter && voice.gender_code !== activeGenderFilter) return false; + if (term) { + const haystacks = [voice.display_name, voice.id, voice.language_label, languageMap[voice.language]] + .filter(Boolean) + .map((value) => value.toLowerCase()); + if (!haystacks.some((value) => value.includes(term))) { + return false; + } + } + return true; + }).sort((a, b) => { + const aRecommended = modalState.recommended.has(a.id) ? 0 : 1; + const bRecommended = modalState.recommended.has(b.id) ? 0 : 1; + if (aRecommended !== bRecommended) { + return aRecommended - bRecommended; + } + return a.display_name.localeCompare(b.display_name); + }); + + if (!filtered.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "voice-browser__empty"; + emptyItem.textContent = "No voices matched your filters."; + list.appendChild(emptyItem); + return; + } + + filtered.forEach((voice) => { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "voice-browser__entry"; + button.dataset.role = "voice-modal-item"; + button.dataset.voiceId = voice.id; + if (modalState.selectedVoice === voice.id) { + button.setAttribute("aria-current", "true"); + } + if (modalState.recommended.has(voice.id)) { + button.dataset.recommended = "true"; + } + const nameSpan = document.createElement("span"); + nameSpan.className = "voice-browser__entry-name"; + nameSpan.textContent = voice.display_name; + const metaSpan = document.createElement("span"); + metaSpan.className = "voice-browser__entry-meta"; + metaSpan.textContent = `${voice.language_label} · ${voice.gender}`; + button.appendChild(nameSpan); + button.appendChild(metaSpan); + item.appendChild(button); + list.appendChild(item); + }); + }; + + const renderSamples = (elements) => { + if (!elements) return; + const { container } = elements; + if (!container) return; + container.innerHTML = ""; + + if (!modalState.samples.length) { + const empty = document.createElement("p"); + empty.className = "hint"; + empty.textContent = "No sample paragraphs available yet."; + container.appendChild(empty); + return; + } + + modalState.samples.forEach((text, index) => { + const sample = document.createElement("article"); + sample.className = "voice-browser__sample"; + sample.dataset.sampleIndex = String(index); + if (index === 0) { + sample.dataset.active = "true"; + } + + const paragraph = document.createElement("p"); + paragraph.textContent = text; + const actions = document.createElement("div"); + actions.className = "voice-browser__sample-actions"; + + const previewButton = document.createElement("button"); + previewButton.type = "button"; + previewButton.className = "button button--ghost button--small"; + previewButton.dataset.role = "speaker-preview"; + previewButton.dataset.previewText = text; + previewButton.dataset.language = modalState.previewSettings.language; + previewButton.dataset.speed = modalState.previewSettings.speed; + previewButton.dataset.useGpu = modalState.previewSettings.useGpu; + previewButton.dataset.voice = modalState.selectedVoice || modalState.defaultVoice || ""; + previewButton.textContent = "Preview sample"; + + actions.appendChild(previewButton); + sample.appendChild(paragraph); + sample.appendChild(actions); + container.appendChild(sample); + }); + }; + + const updateModalVoiceMeta = (elements) => { + if (!elements) return; + const { nameLabel, metaLabel } = elements; + if (!nameLabel || !metaLabel) return; + if (!modalState.selectedVoice) { + nameLabel.textContent = "Select a voice to preview"; + metaLabel.textContent = ""; + return; + } + const voice = voiceCatalogMap.get(modalState.selectedVoice); + if (!voice) { + nameLabel.textContent = modalState.selectedVoice; + metaLabel.textContent = ""; + return; + } + nameLabel.textContent = voice.display_name; + metaLabel.textContent = `${voice.language_label} · ${voice.gender}`; + }; + + const refreshModal = (elements) => { + renderVoiceList(elements); + renderSamples(elements); + updateModalVoiceMeta(elements); + if (elements?.applyButton) { + elements.applyButton.disabled = !modalState.selectedVoice; + } + }; + + const openVoiceBrowser = (speakerItem, sampleIndex = 0) => { + if (!voiceModal) return; + modalState.speakerItem = speakerItem; + const select = speakerItem.querySelector('[data-role="speaker-voice"]'); + const previewTrigger = speakerItem.querySelector('[data-role="speaker-preview"]'); + modalState.defaultVoice = select?.dataset.defaultVoice || previewTrigger?.dataset.voice || ""; + modalState.selectedVoice = select && !select.disabled ? (select.value || modalState.defaultVoice) : modalState.defaultVoice; + modalState.previewSettings = { + language: previewTrigger?.dataset.language || "a", + speed: previewTrigger?.dataset.speed || "1", + useGpu: previewTrigger?.dataset.useGpu || "true", + }; + + const samplesTemplate = speakerItem.querySelector('template[data-role="speaker-samples"]'); + let samples = []; + if (samplesTemplate) { + try { + const raw = samplesTemplate.innerHTML || "[]"; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + samples = parsed.filter((value) => typeof value === "string" && value.trim().length); + } + } catch (error) { + console.warn("Unable to parse speaker samples", error); + } + } + if (previewTrigger?.dataset.previewText) { + samples.unshift(previewTrigger.dataset.previewText); + } + const uniqueSamples = Array.from(new Set(samples)); + if (sampleIndex > 0 && sampleIndex < uniqueSamples.length) { + const [picked] = uniqueSamples.splice(sampleIndex, 1); + uniqueSamples.unshift(picked); + } + modalState.samples = uniqueSamples; + modalState.recommended = new Set( + Array.from(speakerItem.querySelectorAll('[data-role="recommended-voice"]')).map((btn) => btn.dataset.voice).filter(Boolean) + ); + activeGenderFilter = ""; + + const elements = { + list: voiceModal.querySelector('[data-role="voice-modal-list"]'), + searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), + languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), + genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), + container: voiceModal.querySelector('[data-role="voice-modal-samples"]'), + applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'), + nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), + metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), + }; + + if (elements.searchInput) elements.searchInput.value = ""; + if (elements.languageSelect) elements.languageSelect.value = ""; + elements.genderButtons.forEach((button) => { + button.setAttribute("aria-pressed", button.dataset.value === "" ? "true" : "false"); + }); + + refreshModal(elements); + + voiceModal.hidden = false; + voiceModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + if (elements.searchInput) { + setTimeout(() => elements.searchInput.focus({ preventScroll: true }), 0); + } + }; + + const closeVoiceBrowser = () => { + if (!voiceModal || voiceModal.hidden) return; + voiceModal.hidden = true; + voiceModal.removeAttribute("data-open"); + document.body.classList.remove("modal-open"); + resetModalState(); + }; + + if (voiceModal) { + const elements = { + list: voiceModal.querySelector('[data-role="voice-modal-list"]'), + searchInput: voiceModal.querySelector('[data-role="voice-modal-search"]'), + languageSelect: voiceModal.querySelector('[data-role="voice-modal-language"]'), + genderButtons: Array.from(voiceModal.querySelectorAll('[data-role="voice-modal-gender"]')), + container: voiceModal.querySelector('[data-role="voice-modal-samples"]'), + applyButton: voiceModal.querySelector('[data-role="voice-modal-apply"]'), + nameLabel: voiceModal.querySelector('[data-role="voice-modal-selected-name"]'), + metaLabel: voiceModal.querySelector('[data-role="voice-modal-selected-meta"]'), + }; + + if (elements.searchInput) { + elements.searchInput.addEventListener("input", () => renderVoiceList(elements)); + } + if (elements.languageSelect) { + elements.languageSelect.addEventListener("change", () => renderVoiceList(elements)); + } + elements.genderButtons.forEach((button) => { + button.addEventListener("click", () => { + activeGenderFilter = button.dataset.value || ""; + elements.genderButtons.forEach((btn) => btn.setAttribute("aria-pressed", btn === button ? "true" : "false")); + renderVoiceList(elements); + }); + }); + if (elements.list) { + elements.list.addEventListener("click", (event) => { + const target = event.target.closest('[data-role="voice-modal-item"]'); + if (!target) return; + event.preventDefault(); + modalState.selectedVoice = target.dataset.voiceId || ""; + refreshModal(elements); + }); + } + if (elements.applyButton) { + elements.applyButton.addEventListener("click", (event) => { + event.preventDefault(); + if (!modalState.speakerItem || !modalState.selectedVoice) { + return; + } + const select = modalState.speakerItem.querySelector('[data-role="speaker-voice"]'); + if (!select) return; + const randomToggle = modalState.speakerItem.querySelector('[data-role="randomize-toggle"]'); + if (randomToggle && randomToggle.checked) { + randomToggle.checked = false; + handleRandomizeToggle(randomToggle); + } + select.disabled = false; + select.dataset.suppressRandomize = "1"; + select.value = modalState.selectedVoice; + select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + select.dataset.prevManual = modalState.selectedVoice; + closeVoiceBrowser(); + }); + } + voiceModal.addEventListener("click", (event) => { + if (event.target.closest('[data-role="voice-modal-close"]')) { + event.preventDefault(); + closeVoiceBrowser(); + } + }); + if (elements.container) { + elements.container.addEventListener("click", (event) => { + const sample = event.target.closest(".voice-browser__sample"); + if (!sample) return; + elements.container.querySelectorAll(".voice-browser__sample").forEach((node) => node.removeAttribute("data-active")); + sample.dataset.active = "true"; + }); + } + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !voiceModal.hidden) { + closeVoiceBrowser(); + } + }); + + renderVoiceList(elements); + } + form.addEventListener("click", (event) => { + const modalTrigger = event.target.closest('[data-role="open-voice-browser"]'); + if (modalTrigger) { + event.preventDefault(); + const container = modalTrigger.closest(".speaker-list__item"); + if (!container) return; + const sampleIndex = Number.parseInt(modalTrigger.dataset.sampleIndex || "0", 10); + openVoiceBrowser(container, Number.isNaN(sampleIndex) ? 0 : sampleIndex); + return; + } + const chip = event.target.closest('[data-role="recommended-voice"]'); if (!chip) return; event.preventDefault(); @@ -147,7 +594,11 @@ document.addEventListener("DOMContentLoaded", () => { randomToggle.checked = false; handleRandomizeToggle(randomToggle); } + select.disabled = false; + select.dataset.suppressRandomize = "1"; select.value = chip.dataset.voice || ""; select.dispatchEvent(new Event("change", { bubbles: true })); + delete select.dataset.suppressRandomize; + select.dataset.prevManual = select.value || ""; }); }); diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index f7de887..8d85eda 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -17,11 +17,11 @@
    diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 31e17c4..899e262 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -7,15 +7,15 @@
    1 - Upload + Upload & settings 2 - Speakers + Chapters 3 - Queue + Speakers

    Create a New Audiobook

    @@ -132,6 +132,16 @@ + +
    +
    + {{ jobs_panel|safe }} +
    +
    {% endblock %} {% block scripts %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index b3da873..67fdcce 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -3,19 +3,19 @@ {% block title %}Prepare · {{ pending.original_filename }}{% endblock %} {% block content %} -
    -
    - +
    +
    + 1 - Upload + Upload & settings - + 2 - Speakers + Chapters - + 3 - Queue + Speakers
    Prepare audiobook
    @@ -113,16 +113,6 @@

    Press Analyze speakers after selecting chapters to discover recurring voices.

    {% endif %} - {% if has_metadata %} - - {% endif %} {% endif %} @@ -134,294 +124,396 @@
    {{ notice }}
    {% endif %} -
    -
    -

    Speaker configuration

    -

    Reuse saved presets to keep character voices consistent between projects.

    -
    -
    - -
    - - - -
    -
    +
    + +
    - {% if roster %} -
    -

    Speaker settings

    -

    Set pronunciations, lock specific voices, or limit the randomizer by language and gender.

    -
      - {% for speaker_id, speaker in roster.items() %} - {% set spoken_name = speaker.pronunciation or speaker.label %} - {% set preview_text = recommended_template | replace("{{name}}", spoken_name) %} - {% set selected_voice = speaker.resolved_voice or speaker.voice %} - {% set allowed_langs = speaker.config_languages or speaker.languages or [] %} - {% set seen = namespace(values=[]) %} -
    • -
      - {{ speaker.label }} - +
      +
      +
      +

      Step 2 · Select chapters

      +

      Choose which chapters to convert and tweak conversion options.

      +
      + +
      + {% for chapter in pending.chapters %} + {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} + {% set selected_option = '__default' %} + {% if chapter.voice_profile %} + {% set selected_option = 'profile:' ~ chapter.voice_profile %} + {% elif chapter.voice %} + {% set selected_option = 'voice:' ~ chapter.voice %} + {% elif chapter.voice_formula %} + {% set selected_option = 'formula' %} + {% endif %} +
      +
      +
      + +
      +
      + {{ '{:,}'.format(chapter.characters) }} characters +
      +
      +
      +
      + + +
      +
      +
      + Preview text +
      {{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}
      +
      +
      +
      + + + +
      +
      +
      + {% endfor %} +
      + +
      +
      + + +

      Paragraphs work well for long-form narration; sentences give finer subtitle sync.

      -
      - {% if speaker.gender and speaker.gender != 'unknown' %} - {{ speaker.gender|title }} - {% else %} - Gender unknown - {% endif %} - {% if speaker.get('analysis_count') %} - {{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence - {% endif %} +
      + +
      - -
      -
      + +
    • - {% endfor %} -
    -
    - {% endif %} - -
    - {% for chapter in pending.chapters %} - {% set is_enabled = chapter.enabled is not defined or chapter.enabled %} - {% set selected_option = '__default' %} - {% if chapter.voice_profile %} - {% set selected_option = 'profile:' ~ chapter.voice_profile %} - {% elif chapter.voice %} - {% set selected_option = 'voice:' ~ chapter.voice %} - {% elif chapter.voice_formula %} - {% set selected_option = 'formula' %} - {% endif %} -
    -
    -
    - -
    -
    - {{ '{:,}'.format(chapter.characters) }} characters -
    -
    -
    -
    - - -
    -
    -
    - Preview text -
    {{ chapter.text[:2000] }}{% if chapter.text|length > 2000 %}…{% endif %}
    -
    -
    -
    - - -
    -
    - {% endfor %} -
    -
    -
    - - -

    Paragraphs work well for long-form narration; sentences give finer subtitle sync.

    -
    -
    - - -
    -
    - - -

    Only speakers that appear at least this many times will keep unique voices in multi-speaker mode.

    -
    -
    - - -

    Set to 0 to disable the pause after speaking each chapter title.

    -
    -
    - -
    -
    + {% if roster %} +
    +

    Speaker settings

    +

    Set pronunciations, lock specific voices, and audition sample paragraphs to hear casting choices.

    +
      + {% for speaker_id, speaker in roster.items() %} + {% set spoken_name = speaker.pronunciation or speaker.label %} + {% set preview_text = recommended_template | replace("{{name}}", spoken_name) %} + {% set selected_voice = speaker.resolved_voice or speaker.voice %} + {% set seen = namespace(values=[]) %} + {% set sample_quotes = speaker.sample_quotes or [] %} +
    • +
      + {{ speaker.label }} + +
      + +
      + {% if speaker.gender and speaker.gender != 'unknown' %} + {{ speaker.gender|title }} + {% else %} + Gender unknown + {% endif %} + {% if speaker.get('analysis_count') %} + {{ speaker.analysis_count }} lines · {{ speaker.analysis_confidence|default('low')|title }} confidence + {% endif %} + {% if settings.speaker_random_languages %} + Randomizer: {{ settings.speaker_random_languages | map('upper') | join(', ') }} + {% endif %} +
      + +
      +
      + + +
      +
      + +
      +
      +
      + Sample paragraphs + {% if sample_quotes %} + {% for quote in sample_quotes %} +
      +

      {{ quote }}

      +
      + + +
      +
      + {% endfor %} + {% else %} +

      No paragraphs captured yet. Analyze speakers to collect dialogue samples.

      + {% endif %} +
      + {% if speaker.recommended_voices %} +
      + Suggested: + {% for voice_id in speaker.recommended_voices[:6] %} + {% set voice_meta = options.voice_catalog_map.get(voice_id) or {} %} + + {% endfor %} +
      + {% endif %} +
    • + {% endfor %} +
    +
    + {% else %} +

    No additional speakers detected yet. The narrator voice will be used for all dialogue.

    + {% endif %} -
    - - - +
    + + + + +
    +
    + {% endblock %} {% block scripts %} {{ super() }} + + {% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index c775efc..f82a53b 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -102,6 +102,16 @@ {% endfor %} +
    + + {% set selected_languages = settings.speaker_random_languages or [] %} + +

    Limits random voice selection for speakers marked as random. Leave unused to allow any language.

    +
    diff --git a/abogen/web/templates/speakers.html b/abogen/web/templates/speakers.html index 8fa8a45..c308924 100644 --- a/abogen/web/templates/speakers.html +++ b/abogen/web/templates/speakers.html @@ -1,7 +1,6 @@ {% extends "base.html" %} {% macro speaker_row(row_key, speaker, options) %} - {% set languages = speaker.languages or speaker.config_languages or [] %} {% set gender = (speaker.gender or 'unknown') %} {% set randomize = speaker.randomize %}
    @@ -29,15 +28,6 @@ {% endfor %} -
    -
    diff --git a/abogen/web/templates/speakers.html b/abogen/web/templates/speakers.html index c308924..f8dc2bc 100644 --- a/abogen/web/templates/speakers.html +++ b/abogen/web/templates/speakers.html @@ -2,7 +2,6 @@ {% macro speaker_row(row_key, speaker, options) %} {% set gender = (speaker.gender or 'unknown') %} - {% set randomize = speaker.randomize %}
    @@ -30,10 +29,6 @@
    Speaker presets
    -

    Store recurring casts, control randomization defaults, and keep voices consistent between books.

    +

    Store recurring casts and keep voices consistent between books.

    {% if message %}
    {{ message }}
    @@ -125,7 +120,7 @@

    Speakers

    -

    Add each recurring character, set their gender, and decide whether to randomize a compatible voice automatically.

    +

    Add each recurring character, set their gender, and choose a preferred voice.

    {% set speakers_map = editing.speakers or {} %} {% if speakers_map %} @@ -147,7 +142,7 @@
    {% endblock %} From 9828730061ec6a9904f8e8724a436486dad967b1 Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 9 Oct 2025 11:01:31 -0700 Subject: [PATCH 052/245] feat: Enhance error logging in conversion job and service with detailed traceback information --- abogen/web/conversion_runner.py | 54 +++++++++++++++++++++++++++++++-- abogen/web/service.py | 10 +++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 906d079..06d85e0 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -7,6 +7,7 @@ import re import subprocess import sys import tempfile +import traceback from collections import defaultdict from contextlib import ExitStack from dataclasses import dataclass @@ -624,6 +625,10 @@ def run_conversion_job(job: Job) -> None: chunk_markers: List[Dict[str, Any]] = [] metadata_payload: Dict[str, Any] = {} audio_output_path: Optional[Path] = None + extraction: Optional[Any] = None + pipeline: Any = None + chunk_groups: Dict[int, List[Dict[str, Any]]] = {} + active_chapter_configs: List[Dict[str, Any]] = [] try: pipeline = _load_pipeline(job) extraction = extract_from_path(job.stored_path) @@ -660,8 +665,6 @@ def run_conversion_job(job: Job) -> None: ) metadata_overrides: Dict[str, Any] = dict(job.metadata_tags or {}) - active_chapter_configs: List[Dict[str, Any]] = [] - chunk_groups: Dict[int, List[Dict[str, Any]]] = {} if job.chapters: selected_chapters, chapter_metadata, diagnostics = _apply_chapter_overrides( extraction.chapters, @@ -1061,7 +1064,52 @@ def run_conversion_job(job: Job) -> None: 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") + exc_type = exc.__class__.__name__ + job.add_log(f"Job failed ({exc_type}): {exc}", level="error") + + chapter_count: Any + if extraction is not None and hasattr(extraction, "chapters"): + try: + chapter_count = len(getattr(extraction, "chapters", []) or []) + except Exception: # pragma: no cover - defensive fallback + chapter_count = "unavailable" + else: + chapter_count = "unavailable" + + try: + chunk_group_count = len(chunk_groups) + chunk_total = sum(len(items) for items in chunk_groups.values()) + except Exception: # pragma: no cover - defensive fallback + chunk_group_count = "unavailable" + chunk_total = "unavailable" + + job.add_log( + "Context => chunk_level=%s, chapters=%s, chunk_groups=%s, chunks=%s" + % (job.chunk_level, chapter_count, chunk_group_count, chunk_total), + level="debug", + ) + + first_nonempty_group = next((items for items in chunk_groups.values() if items), None) + if first_nonempty_group: + first_chunk = dict(first_nonempty_group[0]) + sample_text = str(first_chunk.get("text") or "")[:160].replace("\n", " ") + job.add_log( + "First chunk sample => id=%s, speaker=%s, chars=%s, preview=%s" + % ( + first_chunk.get("id") or first_chunk.get("chunk_index"), + first_chunk.get("speaker_id", "narrator"), + len(str(first_chunk.get("text") or "")), + sample_text, + ), + level="debug", + ) + + tb_lines = traceback.format_exception(exc.__class__, exc, exc.__traceback__) + for line in tb_lines[:20]: + trimmed = line.rstrip() + if trimmed: + for snippet in trimmed.splitlines(): + job.add_log(f"TRACE: {snippet}", level="debug") finally: sink_stack.close() if subtitle_writer: diff --git a/abogen/web/service.py b/abogen/web/service.py index 380a7fa..32d17d5 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -6,6 +6,7 @@ import shutil import threading import time import uuid +import traceback from dataclasses import dataclass, field from enum import Enum from pathlib import Path @@ -513,7 +514,14 @@ class ConversionService: job.error = str(exc) job.status = JobStatus.FAILED job.finished_at = time.time() - job.add_log(f"Job failed: {exc}", level="error") + exc_type = exc.__class__.__name__ + job.add_log(f"Job failed ({exc_type}): {exc}", level="error") + tb_lines = traceback.format_exception(exc.__class__, exc, exc.__traceback__) + for line in tb_lines[:20]: + trimmed = line.rstrip() + if trimmed: + for snippet in trimmed.splitlines(): + job.add_log(f"TRACE: {snippet}", level="debug") else: if job.cancel_requested: job.status = JobStatus.CANCELLED From 205c435e721bf4105dc97053064ef7f048433514 Mon Sep 17 00:00:00 2001 From: JB Date: Thu, 9 Oct 2025 11:46:38 -0700 Subject: [PATCH 053/245] feat: Add retry functionality for jobs and update UI to support job retries --- abogen/web/routes.py | 11 +++++ abogen/web/service.py | 65 +++++++++++++++++++++++++ abogen/web/templates/job_detail.html | 6 +++ abogen/web/templates/partials/jobs.html | 3 ++ 4 files changed, 85 insertions(+) diff --git a/abogen/web/routes.py b/abogen/web/routes.py index da1be77..002b286 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -2225,6 +2225,7 @@ def job_detail(job_id: str) -> str: "job_detail.html", job=job, options=_template_options(), + JobStatus=JobStatus, ) @@ -2260,6 +2261,16 @@ def delete_job(job_id: str) -> ResponseReturnValue: return redirect(url_for("web.index")) +@web_bp.post("/jobs//retry") +def retry_job(job_id: str) -> ResponseReturnValue: + new_job = _service().retry(job_id) + if request.headers.get("HX-Request"): + return _render_jobs_panel() + if new_job: + return redirect(url_for("web.job_detail", job_id=new_job.id)) + return redirect(url_for("web.job_detail", job_id=job_id)) + + @web_bp.post("/jobs/clear-finished") def clear_finished_jobs() -> ResponseReturnValue: _service().clear_finished() diff --git a/abogen/web/service.py b/abogen/web/service.py index 32d17d5..f7618ce 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -413,6 +413,71 @@ class ConversionService: self._persist_state() return True + def retry(self, job_id: str) -> Optional[Job]: + with self._lock: + job = self._jobs.get(job_id) + if job is None: + return None + if job.status not in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}: + job.add_log( + "Retry requested while job still active; ignoring.", + level="warning", + ) + return None + + stored_path = job.stored_path + if not isinstance(stored_path, Path): + stored_path = Path(str(stored_path)) + + if not stored_path.exists(): + job.add_log( + f"Retry requested but source file is missing: {stored_path}", + level="error", + ) + return None + + new_job = self.enqueue( + original_filename=job.original_filename, + stored_path=stored_path, + language=job.language, + voice=job.voice, + speed=job.speed, + use_gpu=job.use_gpu, + subtitle_mode=job.subtitle_mode, + output_format=job.output_format, + save_mode=job.save_mode, + output_folder=job.output_folder, + replace_single_newlines=job.replace_single_newlines, + subtitle_format=job.subtitle_format, + total_characters=job.total_characters, + chapters=job.chapters, + save_chapters_separately=job.save_chapters_separately, + merge_chapters_at_end=job.merge_chapters_at_end, + separate_chapters_format=job.separate_chapters_format, + silence_between_chapters=job.silence_between_chapters, + save_as_project=job.save_as_project, + voice_profile=job.voice_profile, + max_subtitle_words=job.max_subtitle_words, + metadata_tags=job.metadata_tags, + cover_image_path=job.cover_image_path, + cover_image_mime=job.cover_image_mime, + chapter_intro_delay=job.chapter_intro_delay, + chunk_level=job.chunk_level, + chunks=job.chunks, + speakers=job.speakers, + speaker_mode=job.speaker_mode, + generate_epub3=job.generate_epub3, + speaker_analysis=job.speaker_analysis, + speaker_analysis_threshold=job.speaker_analysis_threshold, + analysis_requested=job.analysis_requested, + ) + + new_job.speaker_voice_languages = list(job.speaker_voice_languages) + new_job.applied_speaker_config = job.applied_speaker_config + new_job.add_log(f"Retry created from job {job.id}", level="info") + job.add_log(f"Retry scheduled as job {new_job.id}", level="info") + return new_job + def delete(self, job_id: str) -> bool: with self._lock: job = self._jobs.get(job_id) diff --git a/abogen/web/templates/job_detail.html b/abogen/web/templates/job_detail.html index 189ace9..1e1164e 100644 --- a/abogen/web/templates/job_detail.html +++ b/abogen/web/templates/job_detail.html @@ -42,9 +42,15 @@ {% if job.result.audio_path %}

    Download audio

    {% endif %} + {% if job.status in [JobStatus.PENDING, JobStatus.RUNNING, JobStatus.PAUSED] %} + {% elif job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED] %} +
    + +
    + {% endif %} diff --git a/abogen/web/templates/partials/jobs.html b/abogen/web/templates/partials/jobs.html index 67abc47..7c3ba9a 100644 --- a/abogen/web/templates/partials/jobs.html +++ b/abogen/web/templates/partials/jobs.html @@ -69,6 +69,9 @@ + +
    Download {% endif %} + {% set reader_source = None %} + {% if job.status == JobStatus.COMPLETED and job.result %} + {% if job.result.epub_path %} + {% set reader_source = job.result.epub_path %} + {% elif job.result.artifacts and job.result.artifacts.get('epub3') %} + {% set reader_source = job.result.artifacts.get('epub3') %} + {% endif %} + {% endif %} + {% if reader_source %} + + {% endif %}
    diff --git a/abogen/web/templates/reader_embed.html b/abogen/web/templates/reader_embed.html new file mode 100644 index 0000000..9fa12fb --- /dev/null +++ b/abogen/web/templates/reader_embed.html @@ -0,0 +1,411 @@ + + + + + + {{ job.original_filename }} · Reader + + + +
    +
    + + +
    {{ job.original_filename }}
    + +
    +
    +
    +
    + +
    + + + + From 258c3549f77bd992a3014c68514f56351e6985eb Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 10 Oct 2025 07:42:13 -0700 Subject: [PATCH 059/245] feat: Enhance reader styling and accessibility with improved layout and focus indicators --- abogen/web/templates/reader_embed.html | 29 +++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/abogen/web/templates/reader_embed.html b/abogen/web/templates/reader_embed.html index 9fa12fb..13d6383 100644 --- a/abogen/web/templates/reader_embed.html +++ b/abogen/web/templates/reader_embed.html @@ -112,6 +112,23 @@ #reader { flex: 1 1 auto; + padding: 1.5rem; + overflow-y: auto; + overflow-x: hidden; + line-height: 1.6; + font-size: 1.05rem; + scroll-behavior: smooth; + } + + #reader:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; + } + + #reader p, + #reader li, + #reader blockquote { + max-width: 60ch; } .reader-footer { @@ -146,7 +163,7 @@
    -
    +
    @@ -37,97 +38,143 @@
    - -
    -
    - {{ jobs_panel|safe }} -
    -
    {% endblock %} {% block scripts %} {{ super() }} + {% endblock %} diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index f82a53b..347b8ae 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -13,9 +13,58 @@
    - Output Defaults + Narration Defaults
    - + + +

    Used whenever “Standard voice” is selected for a new job.

    +
    +
    + + +
    +
    + + +
    +
    + + +

    Speakers detected fewer times fall back to the narrator voice.

    +
    +
    + + +

    Include {{ '{{name}}' }} where the speaker name should be inserted.

    +
    +
    + + {% set selected_languages = settings.speaker_random_languages or [] %} + +

    Limits random voice selection for speakers marked as random. Leave empty to allow any language.

    +
    +
    + +
    + Audio & Delivery +
    +
    - - -
    -
    - - -

    Used when “Standard voice” is selected on the dashboard.

    -
    -
    - +

    Default output: {{ default_output_dir }}

    -
    - -
    - Processing +
    + + +
    - - +
    +
    + + +
    +
    + + +

    Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

    +
    +
    + +
    + Subtitles & Text +
    + + +
    +
    + + +
    +
    +
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - {% set selected_languages = settings.speaker_random_languages or [] %} - -

    Limits random voice selection for speakers marked as random. Leave unused to allow any language.

    -
    -
    - - -

    Speakers detected fewer times than this fallback to the narrator voice.

    -
    -
    - - -

    Sentence template used when previewing name pronunciation. Include {{ '{{name}}' }} where the speaker name should be inserted.

    -
    -
    - - -
    -
    - - -

    Inserted between the spoken chapter title and the chapter content. Set to 0 to disable.

    -
    -
    - - +
    + +
    + Performance +
    +
    - +
    diff --git a/pyproject.toml b/pyproject.toml index ee9426f..b267a69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,8 @@ dependencies = [ "Markdown>=3.9", "Flask>=3.0.3", "numpy>=1.24.0", - "gpustat>=1.1.1" + "gpustat>=1.1.1", + "num2words>=0.5.13" ] classifiers = [ diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py index f6eebca..203c695 100644 --- a/tests/test_text_normalization.py +++ b/tests/test_text_normalization.py @@ -63,3 +63,8 @@ def test_normalize_roman_titles_preserves_separators() -> None: assert normalized[0] == " 4. The Trial" assert normalized[1] == "5 - The Verdict" assert normalized[2].startswith("6\nAftermath") + + +def test_grouped_numbers_are_spelled_out() -> None: + normalized = _normalize_for_pipeline("The vault holds 35,000 credits") + assert "thirty-five thousand" in normalized.lower() From 20327faf0c0f94972b2c0da62059d3ded2bfbd13 Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 10 Oct 2025 18:07:18 -0700 Subject: [PATCH 067/245] feat: Enhance file upload experience with drag-and-drop support and improved UI for upload dropzone --- abogen/utils.py | 80 ++++++++++++++----- abogen/web/static/dashboard.js | 137 ++++++++++++++++++++++++++++++++ abogen/web/static/styles.css | 79 ++++++++++++++++++ abogen/web/templates/index.html | 25 ++---- tests/test_utils_cache.py | 55 +++++++++++++ 5 files changed, 337 insertions(+), 39 deletions(-) create mode 100644 tests/test_utils_cache.py diff --git a/abogen/utils.py b/abogen/utils.py index acd580e..a41cd66 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -8,6 +8,7 @@ import subprocess import sys import warnings from threading import Thread +from typing import Dict, Optional from functools import lru_cache @@ -29,12 +30,22 @@ warnings.filterwarnings("ignore") def detect_encoding(file_path): - import chardet - import charset_normalizer + try: + import chardet # type: ignore[import-not-found] + except ImportError: # pragma: no cover - optional dependency + chardet = None # type: ignore[assignment] + + try: + import charset_normalizer # type: ignore[import-not-found] + except ImportError: # pragma: no cover - optional dependency + charset_normalizer = None # type: ignore[assignment] + with open(file_path, "rb") as f: raw_data = f.read() detected_encoding = None for detectors in (charset_normalizer, chardet): + if detectors is None: + continue try: result = detectors.detect(raw_data)["encoding"] except Exception: @@ -92,7 +103,10 @@ def get_resource_path(package, resource): def get_version(): """Return the current version of the application.""" try: - with open(get_resource_path("/", "VERSION"), "r") as f: + version_path = get_resource_path("/", "VERSION") + if not version_path: + raise FileNotFoundError("VERSION resource missing") + with open(version_path, "r") as f: return f.read().strip() except Exception: return "Unknown" @@ -158,7 +172,14 @@ def get_user_cache_root(): if last_error is not None: raise last_error - def _configure_cache_env(): + def _configure_cache_env(root: Optional[str]) -> None: + temp_root = None + if root: + try: + temp_root = ensure_directory(root) + except OSError: + temp_root = None + home_dir = os.environ.get("HOME") if not home_dir: home_dir = ensure_directory(os.path.join("/tmp", "abogen-home")) @@ -169,6 +190,9 @@ def get_user_cache_root(): cache_base = os.environ.get("XDG_CACHE_HOME") if cache_base: cache_base = ensure_directory(cache_base) + elif temp_root: + cache_base = temp_root + os.environ["XDG_CACHE_HOME"] = cache_base else: cache_base = ensure_directory(os.path.join(home_dir, ".cache")) os.environ["XDG_CACHE_HOME"] = cache_base @@ -176,6 +200,9 @@ def get_user_cache_root(): hf_cache = os.environ.get("HF_HOME") if hf_cache: hf_cache = ensure_directory(hf_cache) + elif temp_root: + hf_cache = ensure_directory(os.path.join(temp_root, "huggingface")) + os.environ["HF_HOME"] = hf_cache else: hf_cache = ensure_directory(os.path.join(cache_base, "huggingface")) os.environ["HF_HOME"] = hf_cache @@ -185,7 +212,7 @@ def get_user_cache_root(): os.environ.setdefault("ABOGEN_INTERNAL_CACHE_ROOT", cache_base) - cache_root = None + cache_root: Optional[str] = None override = os.environ.get("ABOGEN_TEMP_DIR") if override: @@ -215,7 +242,10 @@ def get_user_cache_root(): logger.warning("Falling back to temp cache directory %s", tmp_candidate) cache_root = ensure_directory(tmp_candidate) - _configure_cache_env() + if cache_root is None: + raise RuntimeError("Unable to determine cache directory") + + _configure_cache_env(cache_root) return cache_root @@ -260,7 +290,7 @@ def get_user_output_path(folder=None): return base -_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes +_sleep_procs: Dict[str, Optional[subprocess.Popen[str]]] = {"Darwin": None, "Linux": None} # Store sleep prevention processes def clean_text(text, *args, **kwargs): @@ -319,11 +349,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False): kwargs["stdin"] = stdin if platform.system() == "Windows": - startupinfo = subprocess.STARTUPINFO() - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - startupinfo.wShowWindow = subprocess.SW_HIDE + startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined] + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined] + startupinfo.wShowWindow = subprocess.SW_HIDE # type: ignore[attr-defined] kwargs.update( - {"startupinfo": startupinfo, "creationflags": subprocess.CREATE_NO_WINDOW} + { + "startupinfo": startupinfo, + "creationflags": subprocess.CREATE_NO_WINDOW, # type: ignore[attr-defined] + } ) # Print the command being executed @@ -398,8 +431,8 @@ def calculate_text_length(text): def get_gpu_acceleration(enabled): try: - import torch - from torch.cuda import is_available as cuda_available + import torch # type: ignore[import-not-found] + from torch.cuda import is_available as cuda_available # type: ignore[import-not-found] if not enabled: return "GPU available but using CPU.", False @@ -437,7 +470,7 @@ def prevent_sleep_start(): if system == "Windows": import ctypes - ctypes.windll.kernel32.SetThreadExecutionState( + ctypes.windll.kernel32.SetThreadExecutionState( # type: ignore[attr-defined] 0x80000000 | 0x00000001 | 0x00000040 ) elif system == "Darwin": @@ -471,18 +504,21 @@ def prevent_sleep_end(): if system == "Windows": import ctypes - ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS - elif system in ("Darwin", "Linux") and _sleep_procs[system]: - try: - _sleep_procs[system].terminate() - _sleep_procs[system] = None - except Exception: - pass + ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # type: ignore[attr-defined] + elif system in ("Darwin", "Linux"): + proc = _sleep_procs.get(system) + if proc: + try: + proc.terminate() + except Exception: + pass + finally: + _sleep_procs[system] = None def load_numpy_kpipeline(): import numpy as np - from kokoro import KPipeline + from kokoro import KPipeline # type: ignore[import-not-found] return np, KPipeline diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index 0d75aaf..313e4c6 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -7,6 +7,9 @@ const initDashboard = () => { const readerTitle = readerModal?.querySelector('#reader-modal-title') || null; const defaultReaderHint = readerHint?.textContent || ""; const scope = uploadModal || document; + const sourceFileInput = scope.querySelector('#source_file'); + const dropzone = document.querySelector('[data-role="upload-dropzone"]'); + const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); const parseJSONScript = (id) => { const element = document.getElementById(id); @@ -32,6 +35,71 @@ const initDashboard = () => { const previewAudio = scope.querySelector('[data-role="voice-preview-audio"]'); const sampleVoiceTexts = parseJSONScript('voice-sample-texts') || {}; + const setDropzoneStatus = (message, state = "") => { + if (!dropzoneFilename) return; + if (!message) { + dropzoneFilename.hidden = true; + dropzoneFilename.textContent = ""; + dropzoneFilename.removeAttribute("data-state"); + return; + } + dropzoneFilename.hidden = false; + dropzoneFilename.textContent = message; + if (state) { + dropzoneFilename.dataset.state = state; + } else { + dropzoneFilename.removeAttribute("data-state"); + } + }; + + const updateDropzoneFilename = () => { + if (!sourceFileInput) { + setDropzoneStatus(""); + return; + } + const file = sourceFileInput.files && sourceFileInput.files[0]; + if (file) { + setDropzoneStatus(`Selected: ${file.name}`); + } else { + setDropzoneStatus(""); + } + }; + + const assignDroppedFile = (file) => { + if (!sourceFileInput || !file) { + return false; + } + try { + if (typeof DataTransfer === "undefined") { + throw new Error("DataTransfer API unavailable"); + } + const transfer = new DataTransfer(); + transfer.items.add(file); + sourceFileInput.files = transfer.files; + sourceFileInput.dispatchEvent(new Event("change", { bubbles: true })); + try { + sourceFileInput.focus({ preventScroll: true }); + } catch (error) { + // Ignore focus errors + } + return true; + } catch (error) { + console.warn("Unable to assign dropped file to input", error); + setDropzoneStatus("Drag & drop isn't supported here. Click to choose a file instead.", "error"); + return false; + } + }; + + const setDropzoneActive = (isActive) => { + if (!dropzone) return; + dropzone.classList.toggle("is-dragging", isActive); + if (isActive) { + dropzone.dataset.state = "drag"; + } else { + delete dropzone.dataset.state; + } + }; + let lastTrigger = null; let readerTrigger = null; let previewAbortController = null; @@ -151,6 +219,13 @@ const initDashboard = () => { } }); + if (sourceFileInput) { + sourceFileInput.addEventListener("change", updateDropzoneFilename); + updateDropzoneFilename(); + } else { + setDropzoneStatus(""); + } + const resolveSampleText = (language) => { const fallback = typeof sampleVoiceTexts === "object" && sampleVoiceTexts?.a ? sampleVoiceTexts.a @@ -352,6 +427,68 @@ const initDashboard = () => { }); } + if (dropzone) { + let dragDepth = 0; + + dropzone.addEventListener("dragenter", (event) => { + event.preventDefault(); + dragDepth += 1; + setDropzoneActive(true); + }); + + dropzone.addEventListener("dragover", (event) => { + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = "copy"; + } + }); + + const handleDragLeave = (event) => { + if (event && dropzone.contains(event.relatedTarget)) { + return; + } + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + setDropzoneActive(false); + } + }; + + dropzone.addEventListener("dragleave", (event) => { + handleDragLeave(event); + }); + + dropzone.addEventListener("dragend", () => { + dragDepth = 0; + setDropzoneActive(false); + }); + + dropzone.addEventListener("drop", (event) => { + event.preventDefault(); + dragDepth = 0; + setDropzoneActive(false); + const files = event.dataTransfer && event.dataTransfer.files; + if (!files || !files.length) { + return; + } + openUploadModal(dropzone); + assignDroppedFile(files[0]); + }); + + dropzone.addEventListener("click", (event) => { + if (event.target.closest('[data-role="open-upload-modal"]')) { + return; + } + openUploadModal(dropzone); + }); + + dropzone.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openUploadModal(dropzone); + } + }); + } + [voiceSelect, profileSelect, formulaInput, languageSelect, speedInput].forEach((input) => { if (!input) return; const eventName = input === formulaInput ? "input" : "change"; diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 799d2b0..817de9e 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -130,12 +130,90 @@ body { gap: 1.25rem; } +.upload-card__dropzone { + border: 1.5px dashed rgba(148, 163, 184, 0.3); + border-radius: 24px; + background: rgba(15, 23, 42, 0.28); + padding: 2.75rem 2rem; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + cursor: pointer; + transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease; + outline: none; +} + +.upload-card__dropzone:hover, +.upload-card__dropzone:focus-visible { + border-color: rgba(56, 189, 248, 0.6); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); +} + +.upload-card__dropzone.is-dragging { + border-color: rgba(56, 189, 248, 0.85); + background: rgba(15, 23, 42, 0.45); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.18); +} + +.upload-card__dropzone-content { + display: grid; + gap: 0.75rem; + justify-items: center; + max-width: 420px; +} + +.upload-card__icon { + font-size: 2rem; + line-height: 1; + padding: 0.55rem 0.7rem; + border-radius: 999px; + background: rgba(56, 189, 248, 0.12); + color: var(--accent); +} + +.upload-card__headline { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.upload-card__hint { + margin: 0; + color: var(--muted); +} + +.upload-card__filename { + margin: 0; + color: var(--accent); + font-weight: 500; +} + +.upload-card__filename[data-state="error"] { + color: var(--danger); +} + +.upload-card__dropzone .button { + margin-top: 0.25rem; +} + .card__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; } +.card__actions .button { + padding: 0.7rem 1.1rem; + font-size: 0.95rem; + border-radius: 12px; + box-shadow: 0 8px 22px rgba(56, 189, 248, 0.18); +} + +.card__actions .button:hover { + box-shadow: 0 12px 26px rgba(14, 165, 233, 0.24); +} + .card--modal { padding: 0; border-radius: 28px; @@ -213,6 +291,7 @@ body { flex-direction: column; gap: 1.5rem; flex: 1 1 auto; + min-height: 0; } .modal__footer { diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index 98e470b..cc7be16 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -4,25 +4,16 @@ {% block content %}
    -
    - - 1 - Upload & settings - - - 2 - Chapters - - - 3 - Speakers - -

    Create a New Audiobook

    Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.

    -
    - - View queue +
    +
    + +

    Drop your manuscript to begin

    +

    Drag & drop a supported file here, or click to choose one in the next step.

    + + +
    diff --git a/tests/test_utils_cache.py b/tests/test_utils_cache.py new file mode 100644 index 0000000..2d35d18 --- /dev/null +++ b/tests/test_utils_cache.py @@ -0,0 +1,55 @@ +import os +import sys +from pathlib import Path +from typing import Iterable + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + + +@pytest.fixture(autouse=True) +def clear_utils_cache(): + import abogen.utils as utils + + getattr(utils.get_user_cache_root, "cache_clear")() + yield + getattr(utils.get_user_cache_root, "cache_clear")() + + +def _clear_env(monkeypatch: pytest.MonkeyPatch, keys: Iterable[str]) -> None: + for key in keys: + monkeypatch.delenv(key, raising=False) + + +def test_abogen_temp_dir_configures_hf_cache(monkeypatch, tmp_path): + import abogen.utils as utils + + cache_root = tmp_path / "cache-root" + home_dir = tmp_path / "home" + + monkeypatch.setenv("ABOGEN_TEMP_DIR", str(cache_root)) + monkeypatch.setenv("HOME", str(home_dir)) + _clear_env( + monkeypatch, + ( + "XDG_CACHE_HOME", + "HF_HOME", + "HUGGINGFACE_HUB_CACHE", + "TRANSFORMERS_CACHE", + "ABOGEN_INTERNAL_CACHE_ROOT", + ), + ) + + root = utils.get_user_cache_root() + + expected_root = os.path.abspath(str(cache_root)) + expected_hf = os.path.join(expected_root, "huggingface") + + assert root == expected_root + assert os.environ["XDG_CACHE_HOME"] == expected_root + assert os.environ["HF_HOME"] == expected_hf + assert os.environ["HUGGINGFACE_HUB_CACHE"] == expected_hf + assert os.environ["TRANSFORMERS_CACHE"] == expected_hf From 60fe7e7ffb7def063a35985e732dea141ef59eba Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 10 Oct 2025 18:48:01 -0700 Subject: [PATCH 068/245] feat: Enhance upload form layout with flexible display and improved section handling --- abogen/web/static/styles.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 817de9e..eee94f1 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -688,10 +688,19 @@ body { outline-offset: 2px; } +.upload-form { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; +} + .upload-form__sections { display: flex; flex-direction: column; gap: 1.5rem; + flex: 1 1 auto; + min-height: 0; } .form-section { From aed0df1b090f35c6d33f1147902d759ef585aaac Mon Sep 17 00:00:00 2001 From: JB Date: Fri, 10 Oct 2025 19:18:26 -0700 Subject: [PATCH 069/245] feat: Enhance dashboard and prepare wizard functionality with speaker step handling and UI updates --- abogen/web/static/dashboard.js | 9 ++- abogen/web/static/prepare.js | 97 ++++++++++++++++++++++----- abogen/web/static/styles.css | 2 +- abogen/web/templates/index.html | 2 +- abogen/web/templates/prepare_job.html | 30 +++++++-- 5 files changed, 112 insertions(+), 28 deletions(-) diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index 313e4c6..1634a3d 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -205,14 +205,19 @@ const initDashboard = () => { }); document.addEventListener("click", (event) => { - const readerClose = event.target.closest('[data-role="reader-modal-close"]'); + const target = event.target; + if (!(target instanceof Element)) { + return; + } + + const readerClose = target.closest('[data-role="reader-modal-close"]'); if (readerClose) { event.preventDefault(); closeReaderModal(); return; } - const readerTriggerBtn = event.target.closest('[data-role="open-reader"]'); + const readerTriggerBtn = target.closest('[data-role="open-reader"]'); if (readerTriggerBtn) { event.preventDefault(); openReaderModal(readerTriggerBtn); diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index 1e16171..7f97455 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -234,16 +234,20 @@ document.addEventListener("DOMContentLoaded", () => { const activeStepInput = form.querySelector('[data-role="active-step-input"]'); const wizard = document.querySelector('[data-role="prepare-wizard"]'); if (wizard) { - const stepOrder = ["chapters", "speakers"]; - const indicatorOrder = ["setup", ...stepOrder]; - const indicator = wizard.querySelector('[data-role="wizard-indicator"]'); - const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : []; - const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]')); - const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); - const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); - const panels = new Map(); - const initialStep = wizard.dataset.initialStep || "chapters"; - const speakerModeSelect = form.querySelector("#speaker_mode"); + const stepOrder = ["chapters", "speakers"]; + const indicatorOrder = ["setup", ...stepOrder]; + const indicator = wizard.querySelector('[data-role="wizard-indicator"]'); + const indicatorSteps = indicator ? Array.from(indicator.querySelectorAll('[data-role="wizard-step"]')) : []; + const navButtons = Array.from(wizard.querySelectorAll('[data-role="prepare-step-nav"] [data-step-target]')); + const nextButtons = Array.from(wizard.querySelectorAll('[data-role="step-next"]')); + const prevButtons = Array.from(wizard.querySelectorAll('[data-role="step-prev"]')); + const panels = new Map(); + const initialStep = wizard.dataset.initialStep || "chapters"; + const speakerModeSelect = form.querySelector("#speaker_mode"); + const speakerIndicator = indicator ? indicator.querySelector('[data-step-key="speakers"]') : null; + const chapterActions = wizard.querySelector('[data-role="chapter-actions"]'); + const continueButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="continue"]') : null; + const finalizeButton = chapterActions ? chapterActions.querySelector('[data-chapter-action="finalize"]') : null; stepOrder.forEach((step) => { const panel = wizard.querySelector(`[data-step-panel="${step}"]`); if (panel) { @@ -311,23 +315,27 @@ document.addEventListener("DOMContentLoaded", () => { }; const setStep = (step) => { - if (!panels.has(step)) { + let targetStep = step; + if (targetStep === "speakers" && shouldSkipSpeakersStep()) { + targetStep = "chapters"; + } + if (!panels.has(targetStep)) { return; } - currentStep = step; - wizard.dataset.activeStep = step; + currentStep = targetStep; + wizard.dataset.activeStep = targetStep; if (activeStepInput) { - activeStepInput.value = step; + activeStepInput.value = targetStep; } panels.forEach((panel, key) => { - const isActive = key === step; + const isActive = key === targetStep; panel.hidden = !isActive; panel.setAttribute("aria-hidden", isActive ? "false" : "true"); }); navButtons.forEach((button) => { const target = button.dataset.stepTarget; if (!target) return; - const isActive = target === step; + const isActive = target === targetStep; button.classList.toggle("is-active", isActive); if (button.dataset.state === "locked" && !unlockedSteps.has(target)) { button.disabled = true; @@ -337,7 +345,46 @@ document.addEventListener("DOMContentLoaded", () => { button.removeAttribute("aria-disabled"); } }); - updateIndicator(step); + updateIndicator(targetStep); + }; + + const updateSpeakerControls = () => { + const skipSpeakers = shouldSkipSpeakersStep(); + if (wizard) { + wizard.dataset.speakerStep = skipSpeakers ? "disabled" : "enabled"; + } + if (speakerIndicator) { + if (skipSpeakers) { + speakerIndicator.hidden = true; + speakerIndicator.setAttribute("aria-hidden", "true"); + } else { + speakerIndicator.hidden = false; + speakerIndicator.removeAttribute("aria-hidden"); + } + } + if (continueButton) { + if (skipSpeakers) { + continueButton.hidden = true; + continueButton.setAttribute("aria-hidden", "true"); + continueButton.setAttribute("tabindex", "-1"); + } else { + continueButton.hidden = false; + continueButton.removeAttribute("aria-hidden"); + continueButton.removeAttribute("tabindex"); + } + } + if (finalizeButton) { + if (skipSpeakers) { + finalizeButton.hidden = false; + finalizeButton.removeAttribute("aria-hidden"); + } else { + finalizeButton.hidden = true; + finalizeButton.setAttribute("aria-hidden", "true"); + } + } + if (skipSpeakers && currentStep === "speakers") { + setStep("chapters"); + } }; const submitForAnalysis = () => { @@ -399,7 +446,23 @@ document.addEventListener("DOMContentLoaded", () => { }); }); + if (speakerModeSelect) { + speakerModeSelect.addEventListener("change", () => { + updateSpeakerControls(); + }); + } + + if (finalizeButton) { + finalizeButton.addEventListener("click", (event) => { + if (shouldSkipSpeakersStep()) { + event.preventDefault(); + submitFinalize(); + } + }); + } + setStep(currentStep); + updateSpeakerControls(); } const voiceModal = document.querySelector('[data-role="voice-modal"]'); diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index eee94f1..d30b78b 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -298,7 +298,7 @@ body { display: flex; justify-content: flex-end; gap: 0.75rem; - padding: 0 2rem 2rem; + padding: 1.25rem 2rem 2.75rem; border-top: 1px solid rgba(148, 163, 184, 0.12); background: linear-gradient(180deg, transparent, rgba(15, 23, 42, 0.3)); } diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index cc7be16..9db1777 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -169,7 +169,7 @@
    - +
    diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 1b56178..c23efdf 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -4,9 +4,13 @@ {% block content %} {% set wizard_step = (active_step or 'chapters') %} +{% set is_multi_speaker = pending.speaker_mode == 'multi' %} +{% if wizard_step == 'speakers' and not is_multi_speaker %} + {% set wizard_step = 'chapters' %} +{% endif %} {% set is_chapters = wizard_step == 'chapters' %} {% set is_speakers = wizard_step == 'speakers' %} -
    +
    1 @@ -16,7 +20,7 @@ 2 Chapters - + @@ -148,12 +152,25 @@
    -
    - +
    + +
    - - -{% include "partials/upload_modal.html" with pending=pending, readonly=True, active_step='chapters' %} +{% with pending=pending, readonly=True, active_step='chapters' %} + {% include "partials/upload_modal.html" %} +{% endwith %} {% endblock %} {% block scripts %} diff --git a/abogen/web/templates/prepare_speakers.html b/abogen/web/templates/prepare_speakers.html index 10d7fb0..c248681 100644 --- a/abogen/web/templates/prepare_speakers.html +++ b/abogen/web/templates/prepare_speakers.html @@ -411,7 +411,9 @@
    -{% include "partials/upload_modal.html" with pending=pending, readonly=True, active_step='speakers' %} +{% with pending=pending, readonly=True, active_step='speakers' %} + {% include "partials/upload_modal.html" %} +{% endwith %} {% endblock %} {% block scripts %} From 610091c0d96204a1178b6592a6aa01352c0e8447 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 11 Oct 2025 12:29:52 -0700 Subject: [PATCH 079/245] feat: Implement upload modal event dispatching and enhance wizard modal navigation --- abogen/web/static/dashboard.js | 11 ++ abogen/web/static/prepare.js | 102 +++++++++++- abogen/web/static/styles.css | 64 +++++++- .../web/templates/partials/upload_modal.html | 3 +- abogen/web/templates/prepare_chapters.html | 150 ++++++++++-------- abogen/web/templates/prepare_speakers.html | 100 ++++++------ 6 files changed, 302 insertions(+), 128 deletions(-) diff --git a/abogen/web/static/dashboard.js b/abogen/web/static/dashboard.js index 5dd3c78..c8339ae 100644 --- a/abogen/web/static/dashboard.js +++ b/abogen/web/static/dashboard.js @@ -102,6 +102,15 @@ const initDashboard = () => { let previewObjectUrl = null; let suppressPauseStatus = false; + const dispatchUploadModalEvent = (type, detail = {}) => { + const eventName = `upload-modal:${type}`; + if (uploadModal) { + uploadModal.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true })); + return; + } + document.dispatchEvent(new CustomEvent(eventName, { detail })); + }; + const openUploadModal = (trigger) => { if (!uploadModal) return; lastTrigger = trigger || null; @@ -112,6 +121,7 @@ const initDashboard = () => { if (focusTarget instanceof HTMLElement) { focusTarget.focus({ preventScroll: true }); } + dispatchUploadModalEvent("open", { trigger: lastTrigger }); }; const closeUploadModal = () => { @@ -124,6 +134,7 @@ const initDashboard = () => { if (lastTrigger && lastTrigger instanceof HTMLElement) { lastTrigger.focus({ preventScroll: true }); } + dispatchUploadModalEvent("close", { trigger: lastTrigger }); }; openModalButtons.forEach((button) => { diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index bd6cfd0..ba69004 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -2,6 +2,55 @@ document.addEventListener("DOMContentLoaded", () => { const form = document.querySelector(".prepare-form"); if (!form) return; + const wizardModal = document.querySelector('[data-role="wizard-modal"]'); + const uploadModal = document.querySelector('[data-role="upload-modal"]'); + const wizardPreviousButtons = Array.from(document.querySelectorAll('[data-role="wizard-previous"]')); + const openUploadTriggers = Array.from(document.querySelectorAll('[data-role="open-upload-modal"]')); + + const showWizardModal = () => { + if (!wizardModal) return; + wizardModal.hidden = false; + wizardModal.dataset.open = "true"; + wizardModal.removeAttribute("aria-hidden"); + document.body.classList.add("modal-open"); + }; + + const hideWizardModal = () => { + if (!wizardModal) return; + wizardModal.hidden = true; + delete wizardModal.dataset.open; + wizardModal.setAttribute("aria-hidden", "true"); + }; + + const triggerUploadModal = () => { + const existingTrigger = openUploadTriggers.find((button) => button !== null); + if (existingTrigger) { + existingTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true })); + return; + } + if (!uploadModal) return; + uploadModal.hidden = false; + uploadModal.dataset.open = "true"; + document.body.classList.add("modal-open"); + const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal; + if (focusTarget instanceof HTMLElement) { + focusTarget.focus({ preventScroll: true }); + } + }; + + showWizardModal(); + + document.addEventListener("upload-modal:open", hideWizardModal); + document.addEventListener("upload-modal:close", showWizardModal); + + wizardPreviousButtons.forEach((button) => { + button.addEventListener("click", (event) => { + event.preventDefault(); + hideWizardModal(); + triggerUploadModal(); + }); + }); + const parseJSONScript = (id) => { const el = document.getElementById(id); if (!el) return null; @@ -184,12 +233,39 @@ document.addEventListener("DOMContentLoaded", () => { const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); + const setRowExpansion = (row, expanded) => { + if (!row) return; + const details = row.querySelector('[data-role="chapter-details"]'); + const toggle = row.querySelector('[data-role="chapter-toggle"]'); + const isExpanded = Boolean(expanded); + row.dataset.expanded = isExpanded ? "true" : "false"; + if (details) { + details.hidden = !isExpanded; + details.setAttribute("aria-hidden", isExpanded ? "false" : "true"); + } + if (toggle) { + toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false"); + toggle.setAttribute("aria-label", isExpanded ? "Hide chapter details" : "Show chapter details"); + } + }; + + const toggleRowExpansion = (row, force) => { + if (!row) return; + const current = row.dataset.expanded === "true"; + const next = typeof force === "boolean" ? force : !current; + setRowExpansion(row, next); + }; + + const isRowEnabled = (row) => { + const checkbox = row?.querySelector('[data-role="chapter-enabled"]'); + return checkbox ? checkbox.checked : true; + }; + const updateRowState = (row) => { const enabled = row.querySelector('[data-role=chapter-enabled]'); const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea")); const warning = row.querySelector('[data-role=chapter-warning]'); - const snippet = row.querySelector('[data-role="chapter-snippet"]'); - const details = row.querySelector('[data-role="chapter-details"]'); + const toggle = row.querySelector('[data-role="chapter-toggle"]'); const isChecked = enabled ? enabled.checked : true; row.dataset.disabled = isChecked ? "false" : "true"; @@ -214,14 +290,13 @@ document.addEventListener("DOMContentLoaded", () => { const select = row.querySelector("select[data-role=voice-select]"); toggleFormula(select); - if (snippet) { - snippet.hidden = !isChecked; - snippet.setAttribute("aria-hidden", isChecked ? "false" : "true"); + if (!isChecked) { + setRowExpansion(row, false); } - if (details) { - details.hidden = !isChecked; - details.setAttribute("aria-hidden", isChecked ? "false" : "true"); + if (toggle) { + toggle.disabled = !isChecked; + toggle.setAttribute("aria-disabled", isChecked ? "false" : "true"); } if (warning) { @@ -248,6 +323,7 @@ document.addEventListener("DOMContentLoaded", () => { }; chapterRows.forEach((row) => { + setRowExpansion(row, row.dataset.expanded === "true"); const enabled = row.querySelector('[data-role=chapter-enabled]'); if (enabled) { enabled.addEventListener("change", () => updateRowState(row)); @@ -258,6 +334,16 @@ document.addEventListener("DOMContentLoaded", () => { select.addEventListener("change", () => toggleFormula(select)); toggleFormula(select); } + const toggleButton = row.querySelector('[data-role="chapter-toggle"]'); + if (toggleButton) { + toggleButton.addEventListener("click", () => { + if (!isRowEnabled(row)) { + setRowExpansion(row, false); + return; + } + toggleRowExpansion(row); + }); + } }); const updatePreviewVoice = (select) => { diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index d8b7fa7..af47c9c 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -521,6 +521,11 @@ body { padding: 3rem 0 5rem; } + .wizard-page--modal { + padding: 0; + min-height: 100vh; + } + .wizard-card { width: min(1100px, calc(100% - 2.5rem)); margin: 0 auto; @@ -1789,13 +1794,70 @@ button.step-indicator__item:focus-visible { opacity: 0.5; } -.chapter-card__header { +.chapter-card__summary { display: flex; justify-content: space-between; align-items: flex-start; gap: 1.25rem; } +.chapter-card__summary:focus-within { + outline: none; +} + +.chapter-card__toggle { + border: none; + background: rgba(148, 163, 184, 0.1); + color: var(--muted); + width: 32px; + height: 32px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.chapter-card__toggle:hover, +.chapter-card__toggle:focus-visible { + background: rgba(148, 163, 184, 0.2); + color: #fff; +} + +.chapter-card__toggle[disabled] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +.chapter-card__toggle:focus-visible { + outline: 2px solid rgba(56, 189, 248, 0.45); + outline-offset: 2px; +} + +.chapter-card__toggle-icon { + display: inline-flex; + font-size: 1rem; + transition: transform 0.2s ease; +} + +.chapter-card[data-expanded="true"] .chapter-card__toggle-icon { + transform: rotate(180deg); +} + +.chapter-card__details { + display: grid; + gap: 1rem; +} + +.chapter-card__snippet { + margin: 0; + font-size: 0.95rem; + line-height: 1.5; + color: var(--muted); +} + .chapter-card__title { font-weight: 600; display: flex; diff --git a/abogen/web/templates/partials/upload_modal.html b/abogen/web/templates/partials/upload_modal.html index 9cc2a6c..6241037 100644 --- a/abogen/web/templates/partials/upload_modal.html +++ b/abogen/web/templates/partials/upload_modal.html @@ -8,6 +8,7 @@ {% elif settings_dict %} {% set is_multi = settings_dict.get('speaker_mode', 'single') == 'multi' %} {% endif %} +{% set total_steps = 3 if is_multi else 2 %} {% set language_value = pending.language if pending else settings_dict.get('language', '') %} {% if not language_value %} {% set sorted_languages = options.languages|dictsort %} @@ -65,7 +66,7 @@ {% endif %} - +

    Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.

    diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html index fbbd2d0..f3ef178 100644 --- a/abogen/web/templates/prepare_chapters.html +++ b/abogen/web/templates/prepare_chapters.html @@ -3,46 +3,50 @@ {% block title %}Prepare · {{ pending.original_filename }}{% endblock %} {% set is_multi_speaker = pending.speaker_mode == 'multi' %} +{% set total_steps = 3 if is_multi_speaker else 2 %} {% block content %} -
    -
    - -
    +
    +
    -
    -
    - - -
    - -
    +
    + + +
    + +
    +
    {% with pending=pending, readonly=True, active_step='chapters' %} diff --git a/abogen/web/templates/prepare_speakers.html b/abogen/web/templates/prepare_speakers.html index c248681..34f0bba 100644 --- a/abogen/web/templates/prepare_speakers.html +++ b/abogen/web/templates/prepare_speakers.html @@ -7,42 +7,46 @@ {% set analysis_speakers = analysis.get('speakers', {}) %} {% set speaker_configs = options.speaker_configs or [] %} {% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} +{% set total_steps = 3 if is_multi_speaker else 2 %} {% block content %} -
    -
    - +
    +
    + - {% with pending=pending, readonly=True, active_step='speakers' %} From 93fe48f6016a3edeb2a4f5d9bd62dcde5095b404 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 11 Oct 2025 14:14:19 -0700 Subject: [PATCH 080/245] feat: Implement pronunciation store with SQLite backend - Added a new module for managing pronunciation overrides using SQLite. - Implemented functions to load, save, search, and delete pronunciation overrides. - Introduced schema for storing overrides and metadata. - Added thread-safe access to the database with RLock. - Created a utility for normalizing tokens for consistent storage and retrieval. refactor: Overhaul entities step in the preparation wizard - Renamed Step 3 from "Speakers" to "Entities" across all templates and routes. - Introduced sub-navigation with tabs for "People", "Entities", and "Manual Overrides". - Enhanced UI to display detected entities and allow manual overrides for pronunciations. - Implemented search functionality for manual overrides with AJAX support. - Updated frontend logic to manage tab interactions and voice selections. docs: Add detailed plan for entities step overhaul - Documented requirements, implementation strategies, and testing plans for the entities step. - Outlined the integration of POS tagging and entity recognition using spaCy. - Provided a comprehensive overview of the manual overrides workflow and data persistence strategies. --- abogen/entity_analysis.py | 401 ++++++++++++ abogen/pronunciation_store.py | 228 +++++++ abogen/web/routes.py | 455 ++++++++++++- abogen/web/service.py | 31 +- abogen/web/static/prepare.js | 609 +++++++++++++++++- abogen/web/templates/index.html | 2 +- .../web/templates/partials/upload_modal.html | 12 +- abogen/web/templates/prepare_chapters.html | 8 +- abogen/web/templates/prepare_entities.html | 580 +++++++++++++++++ abogen/web/templates/prepare_job.html | 2 +- abogen/web/templates/prepare_speakers.html | 259 -------- docs/entities_step_overhaul_plan.md | 152 +++++ pyproject.toml | 1 + 13 files changed, 2440 insertions(+), 300 deletions(-) create mode 100644 abogen/entity_analysis.py create mode 100644 abogen/pronunciation_store.py create mode 100644 abogen/web/templates/prepare_entities.html create mode 100644 docs/entities_step_overhaul_plan.md diff --git a/abogen/entity_analysis.py b/abogen/entity_analysis.py new file mode 100644 index 0000000..d0256e4 --- /dev/null +++ b/abogen/entity_analysis.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +import hashlib +import os +import re +import threading +import time +from collections import Counter +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple + +try: # pragma: no cover - fallback when spaCy not available during tests + import spacy # type: ignore[import-not-found] +except Exception: # pragma: no cover - spaCy optional during runtime bootstrap + spacy = None + +_Language = Any # type: ignore[misc,assignment] +Doc = Any # type: ignore[misc,assignment] +Span = Any # type: ignore[misc,assignment] + + +_TITLE_PREFIXES = ( + "mr", + "mrs", + "ms", + "miss", + "dr", + "prof", + "sir", + "madam", + "lady", + "lord", + "capt", + "captain", + "col", + "colonel", + "maj", + "major", + "sgt", + "sergeant", + "rev", + "father", + "mother", + "brother", + "sister", +) + +_STOP_LABELS = { + "the", + "that", + "this", + "those", + "these", + "there", + "here", + "then", + "and", + "but", + "or", + "nor", + "so", + "yet", +} + +_TITLE_PATTERN = re.compile(r"^(?:" + "|".join(re.escape(prefix) for prefix in _TITLE_PREFIXES) + r")\.?\s+", re.IGNORECASE) +_POSSESSIVE_PATTERN = re.compile(r"(?:'s|’s|\u2019s)$", re.IGNORECASE) +_NON_WORD_PATTERN = re.compile(r"[^\w\s'-]+") +_MULTI_SPACE_PATTERN = re.compile(r"\s+") + + +@dataclass(slots=True) +class EntityRecord: + key: Tuple[str, str] + label: str + kind: str + category: str + count: int = 0 + samples: List[Dict[str, Any]] = field(default_factory=list) + chapter_indices: set[int] = field(default_factory=set) + forms: Counter = field(default_factory=Counter) + first_position: Optional[Tuple[int, int]] = None + + def register(self, *, chapter_index: int, position: int, text: str, sentence: Optional[str]) -> None: + self.count += 1 + self.chapter_indices.add(chapter_index) + self.forms[text] += 1 + if self.first_position is None: + self.first_position = (chapter_index, position) + if sentence and len(self.samples) < 5: + payload = { + "excerpt": sentence.strip(), + "chapter_index": chapter_index, + } + if payload not in self.samples: + self.samples.append(payload) + + def as_dict(self, ordinal: int) -> Dict[str, Any]: + chapter_indices = sorted(self.chapter_indices) + first_chapter = chapter_indices[0] if chapter_indices else None + return { + "id": f"{self.category}_{ordinal}", + "label": self.label, + "normalized": self.key[1], + "category": self.category, + "kind": self.kind, + "count": self.count, + "samples": list(self.samples), + "chapter_indices": chapter_indices, + "first_chapter": first_chapter, + "forms": self.forms.most_common(6), + } + + +@dataclass(slots=True) +class EntityExtractionResult: + summary: Dict[str, Any] + cache_key: str + elapsed: float + errors: List[str] + + +class EntityModelError(RuntimeError): + pass + + +_MODEL_CACHE: Dict[str, Any] = {} +_MODEL_LOCK = threading.RLock() + + +def _resolve_model_name(language: str) -> str: + override = os.environ.get("ABOGEN_SPACY_MODEL") + if override: + return override.strip() + lowered = language.strip().lower() + if lowered.startswith("en"): + return "en_core_web_sm" + return "en_core_web_sm" + + +def _load_model(language: str) -> Any: + if spacy is None: + raise EntityModelError("spaCy is not available. Install spaCy to enable entity extraction.") + + model_name = _resolve_model_name(language) + cache_key = model_name.lower() + with _MODEL_LOCK: + if cache_key in _MODEL_CACHE: + return _MODEL_CACHE[cache_key] + try: + nlp = spacy.load(model_name) # type: ignore[arg-type] + except OSError as exc: # pragma: no cover - external dependency failure + raise EntityModelError( + f"spaCy model '{model_name}' is not installed. Download it with " + "`python -m spacy download en_core_web_sm`." + ) from exc + nlp.max_length = max(nlp.max_length, 2_000_000) + _MODEL_CACHE[cache_key] = nlp + return nlp + + +def _normalize_label(text: str) -> str: + if not text: + return "" + stripped = text.strip().strip("\"'`“”’") + if not stripped: + return "" + stripped = _TITLE_PATTERN.sub("", stripped) + stripped = _POSSESSIVE_PATTERN.sub("", stripped) + stripped = _NON_WORD_PATTERN.sub(" ", stripped) + stripped = _MULTI_SPACE_PATTERN.sub(" ", stripped) + stripped = stripped.strip() + if not stripped or stripped.lower() in _STOP_LABELS: + return "" + parts = stripped.split() + if not parts: + return "" + if len(parts) == 1 and len(parts[0]) <= 1: + return "" + # Normalise casing: preserve uppercase abbreviations, otherwise title case. + normalized_parts = [] + for index, part in enumerate(parts): + if part.isupper(): + normalized_parts.append(part) + elif part[:1].isupper(): + normalized_parts.append(part[:1].upper() + part[1:]) + elif index == 0: + normalized_parts.append(part[:1].upper() + part[1:]) + else: + normalized_parts.append(part) + normalized = " ".join(normalized_parts).strip() + if normalized.lower() in _STOP_LABELS: + return "" + return normalized + + +def _token_key(value: str) -> str: + return _MULTI_SPACE_PATTERN.sub(" ", value.lower().strip()).strip() + + +def _iter_named_entities(doc: Any) -> Iterable[Any]: # type: ignore[override] + for ent in getattr(doc, "ents", ()): + if ent.label_ == "": + continue + yield ent + + +def _extract_propn_tokens(doc: Any) -> Iterable[Any]: # type: ignore[override] + seen: set[Tuple[int, int]] = set() + for ent in getattr(doc, "ents", ()): # guard multi-token spans + seen.add((ent.start, ent.end)) + for token in doc: + if token.pos_ != "PROPN": + continue + span_key = (token.i, token.i + 1) + if span_key in seen: + continue + if token.is_stop: + continue + text = token.text.strip() + if not text: + continue + if token.ent_type_: + continue + yield doc[token.i : token.i + 1] + + +def _empty_result(cache_key: str, error: Optional[str] = None) -> EntityExtractionResult: + payload = { + "people": [], + "entities": [], + "index": {"tokens": []}, + "stats": { + "tokens": 0, + "chapters": 0, + "processed": False, + }, + "model": None, + } + errors = [error] if error else [] + return EntityExtractionResult(summary=payload, cache_key=cache_key, elapsed=0.0, errors=errors) + + +def extract_entities( + chapters: Iterable[Mapping[str, Any]], + *, + language: str = "en", +) -> EntityExtractionResult: + start = time.perf_counter() + normalized_language = language or "en" + combined_hasher = hashlib.sha1() + chapter_texts: List[Tuple[int, str]] = [] + for idx, chapter in enumerate(chapters): + text = chapter.get("text") if isinstance(chapter, Mapping) else None + text_value = str(text or "") + chapter_texts.append((idx, text_value)) + if text_value: + combined_hasher.update(text_value.encode("utf-8", "ignore")) + cache_key = combined_hasher.hexdigest() + + if not chapter_texts: + return _empty_result(cache_key) + + try: + nlp = _load_model(normalized_language) + except EntityModelError as exc: + return _empty_result(cache_key, str(exc)) + + records: Dict[Tuple[str, str], EntityRecord] = {} + tokens_for_index: Dict[str, Dict[str, Any]] = {} + processed_tokens = 0 + + for chapter_index, text in chapter_texts: + trimmed = text.strip() + if not trimmed: + continue + if len(trimmed) + 1024 > nlp.max_length: + nlp.max_length = len(trimmed) + 1024 + doc = nlp(trimmed) + + def _register_span(span: Any, category_hint: Optional[str] = None) -> None: + nonlocal processed_tokens + cleaned = _normalize_label(span.text) + if not cleaned: + return + key = _token_key(cleaned) + if not key: + return + category = category_hint or ("people" if span.label_ == "PERSON" else "entities") + record_key = (category, key) + record = records.get(record_key) + if record is None: + record = EntityRecord( + key=record_key, + label=cleaned, + kind=span.label_ or ("PROPN" if category == "entities" else "PERSON"), + category=category, + ) + records[record_key] = record + sentence = span.sent.text if hasattr(span, "sent") and span.sent is not None else None + record.register( + chapter_index=chapter_index, + position=span.start, + text=span.text, + sentence=sentence, + ) + processed_tokens += 1 + index_entry = tokens_for_index.get(key) + if index_entry is None: + index_entry = { + "token": record.label, + "normalized": key, + "category": category, + "count": 0, + "samples": [], + } + tokens_for_index[key] = index_entry + index_entry["count"] += 1 + if sentence and len(index_entry["samples"]) < 3: + if sentence not in index_entry["samples"]: + index_entry["samples"].append(sentence) + + for ent in _iter_named_entities(doc): + _register_span(ent) + + for span in _extract_propn_tokens(doc): + _register_span(span, category_hint="entities") + + elapsed = time.perf_counter() - start + + people_records = [record for record in records.values() if record.category == "people"] + entity_records = [record for record in records.values() if record.category == "entities"] + + people_records.sort(key=lambda rec: (-rec.count, rec.label)) + entity_records.sort(key=lambda rec: (-rec.count, rec.label)) + + people_payload = [record.as_dict(index + 1) for index, record in enumerate(people_records)] + entity_payload = [record.as_dict(index + 1) for index, record in enumerate(entity_records)] + + index_payload = sorted(tokens_for_index.values(), key=lambda item: (-item["count"], item["token"])) + + summary = { + "people": people_payload, + "entities": entity_payload, + "index": {"tokens": index_payload}, + "stats": { + "tokens": processed_tokens, + "chapters": len(chapter_texts), + "processed": True, + }, + "model": { + "name": getattr(nlp, "meta", {}).get("name", "unknown"), + "version": getattr(nlp, "meta", {}).get("version", "unknown"), + "lang": getattr(nlp, "meta", {}).get("lang", normalized_language), + }, + } + + return EntityExtractionResult(summary=summary, cache_key=cache_key, elapsed=elapsed, errors=[]) + + +def search_tokens(index: Mapping[str, Any], query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + tokens = index.get("tokens") if isinstance(index, Mapping) else None + if not isinstance(tokens, list) or not query: + return [] + normalized = query.strip().lower() + if not normalized: + return tokens[:limit] + results: List[Dict[str, Any]] = [] + for entry in tokens: + token_label = str(entry.get("token", "")) + normalized_label = token_label.lower() + if normalized in normalized_label or normalized in str(entry.get("normalized", "")): + results.append(entry) + if len(results) >= limit: + break + return results + + +def merge_override(summary: Mapping[str, Any], overrides: Mapping[str, Mapping[str, Any]]) -> Dict[str, Any]: + if not isinstance(summary, Mapping): + return {"people": [], "entities": []} + merged_summary: Dict[str, Any] = dict(summary) + for key in ("people", "entities"): + items = summary.get(key) + if not isinstance(items, list): + continue + merged_items: List[Dict[str, Any]] = [] + for entry in items: + if not isinstance(entry, Mapping): + continue + normalized = _token_key(str(entry.get("normalized") or entry.get("label") or "")) + merged = dict(entry) + if normalized and normalized in overrides: + merged_override = dict(overrides[normalized]) + merged["override"] = merged_override + merged_items.append(merged) + merged_summary[key] = merged_items + return merged_summary + + +def normalize_token(token: str) -> str: + return _token_key(_normalize_label(token)) diff --git a/abogen/pronunciation_store.py b/abogen/pronunciation_store.py new file mode 100644 index 0000000..a6f0f4e --- /dev/null +++ b/abogen/pronunciation_store.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from .entity_analysis import normalize_token +from .utils import get_internal_cache_path, get_user_settings_dir + +_DB_LOCK = threading.RLock() +_SCHEMA_VERSION = 1 + + +def _store_path() -> Path: + try: + base_dir = Path(get_user_settings_dir()) + except ModuleNotFoundError: + base_dir = Path(get_internal_cache_path("pronunciations")) + target = base_dir / "pronunciations.db" + target.parent.mkdir(parents=True, exist_ok=True) + return target + + +def _connect() -> sqlite3.Connection: + connection = sqlite3.connect(_store_path()) + connection.execute("PRAGMA journal_mode=WAL") + connection.execute("PRAGMA foreign_keys=ON") + return connection + + +def _ensure_schema(conn: sqlite3.Connection) -> None: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + normalized TEXT NOT NULL, + token TEXT NOT NULL, + language TEXT NOT NULL, + pronunciation TEXT, + voice TEXT, + notes TEXT, + context TEXT, + usage_count INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + UNIQUE(normalized, language) + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ) + """ + ) + row = conn.execute("SELECT value FROM metadata WHERE key='schema_version'").fetchone() + if row is None: + conn.execute( + "INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)", + (_SCHEMA_VERSION,), + ) + conn.commit() + + +def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]: + return { + "id": row["id"], + "normalized": row["normalized"], + "token": row["token"], + "language": row["language"], + "pronunciation": row["pronunciation"], + "voice": row["voice"], + "notes": row["notes"], + "context": row["context"], + "usage_count": row["usage_count"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + +def load_overrides(language: str, tokens: Iterable[str]) -> Dict[str, Dict[str, Any]]: + normalized_tokens = {normalize_token(token) for token in tokens if token} + if not normalized_tokens: + return {} + placeholders = ",".join("?" for _ in normalized_tokens) + with _DB_LOCK: + conn = _connect() + try: + _ensure_schema(conn) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + f"SELECT * FROM overrides WHERE language=? AND normalized IN ({placeholders})", + (language, *normalized_tokens), + ) + results: Dict[str, Dict[str, Any]] = {} + for row in cursor.fetchall(): + payload = _row_to_dict(row) + results[payload["normalized"]] = payload + return results + finally: + conn.close() + + +def search_overrides(language: str, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + if not query: + return [] + pattern = f"%{query.lower()}%" + with _DB_LOCK: + conn = _connect() + try: + _ensure_schema(conn) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT * FROM overrides + WHERE language = ? AND (normalized LIKE ? OR LOWER(token) LIKE ?) + ORDER BY usage_count DESC, updated_at DESC + LIMIT ? + """, + (language, pattern, pattern, limit), + ) + return [_row_to_dict(row) for row in cursor.fetchall()] + finally: + conn.close() + + +def save_override( + *, + language: str, + token: str, + pronunciation: Optional[str] = None, + voice: Optional[str] = None, + notes: Optional[str] = None, + context: Optional[str] = None, +) -> Dict[str, Any]: + normalized = normalize_token(token) + if not normalized: + raise ValueError("Provide a token to override") + timestamp = time.time() + with _DB_LOCK: + conn = _connect() + try: + _ensure_schema(conn) + conn.execute( + """ + INSERT INTO overrides (normalized, token, language, pronunciation, voice, notes, context, usage_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(normalized, language) DO UPDATE SET + token=excluded.token, + pronunciation=excluded.pronunciation, + voice=excluded.voice, + notes=excluded.notes, + context=excluded.context, + updated_at=excluded.updated_at + """, + ( + normalized, + token, + language, + pronunciation, + voice, + notes, + context, + timestamp, + timestamp, + ), + ) + conn.commit() + conn.row_factory = sqlite3.Row + row = conn.execute( + "SELECT * FROM overrides WHERE normalized=? AND language=?", + (normalized, language), + ).fetchone() + if row is None: # pragma: no cover - defensive guard + raise RuntimeError("Override save failed") + return _row_to_dict(row) + finally: + conn.close() + + +def delete_override(*, language: str, token: str) -> None: + normalized = normalize_token(token) + if not normalized: + return + with _DB_LOCK: + conn = _connect() + try: + _ensure_schema(conn) + conn.execute("DELETE FROM overrides WHERE normalized=? AND language=?", (normalized, language)) + conn.commit() + finally: + conn.close() + + +def all_overrides(language: str) -> List[Dict[str, Any]]: + with _DB_LOCK: + conn = _connect() + try: + _ensure_schema(conn) + conn.row_factory = sqlite3.Row + cursor = conn.execute( + "SELECT * FROM overrides WHERE language=? ORDER BY updated_at DESC", + (language,), + ) + return [_row_to_dict(row) for row in cursor.fetchall()] + finally: + conn.close() + + +def increment_usage(*, language: str, token: str, amount: int = 1) -> None: + normalized = normalize_token(token) + if not normalized: + return + with _DB_LOCK: + conn = _connect() + try: + _ensure_schema(conn) + conn.execute( + "UPDATE overrides SET usage_count = usage_count + ?, updated_at = ? WHERE normalized=? AND language=?", + (amount, time.time(), normalized, language), + ) + conn.commit() + finally: + conn.close() diff --git a/abogen/web/routes.py b/abogen/web/routes.py index 318022e..a9ee714 100644 --- a/abogen/web/routes.py +++ b/abogen/web/routes.py @@ -50,6 +50,18 @@ from abogen.utils import ( load_numpy_kpipeline, save_config, ) +from abogen.entity_analysis import ( + extract_entities, + merge_override, + normalize_token as normalize_entity_token, + search_tokens as search_entity_tokens, +) +from abogen.pronunciation_store import ( + delete_override as delete_pronunciation_override, + load_overrides as load_pronunciation_overrides, + save_override as save_pronunciation_override, + search_overrides as search_pronunciation_overrides, +) from abogen.voice_profiles import ( delete_profile, duplicate_profile, @@ -958,6 +970,318 @@ def _prepare_speaker_metadata( return chunk_list, roster, analysis_payload, applied_languages, updated_config +def _collect_pronunciation_overrides(pending: PendingJob) -> List[Dict[str, Any]]: + language = pending.language or "en" + collected: Dict[str, Dict[str, Any]] = {} + + summary = pending.entity_summary or {} + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, Mapping): + continue + override_payload = entry.get("override") + if not isinstance(override_payload, Mapping): + continue + token_value = str(entry.get("label") or override_payload.get("token") or "").strip() + pronunciation_value = str(override_payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(entry.get("normalized") or token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(override_payload.get("voice") or "").strip() or None, + "notes": str(override_payload.get("notes") or "").strip() or None, + "context": str(override_payload.get("context") or "").strip() or None, + "source": f"{group}-override", + "language": language, + } + + if isinstance(pending.speakers, Mapping): + for speaker_payload in pending.speakers.values(): + if not isinstance(speaker_payload, Mapping): + continue + token_value = str(speaker_payload.get("label") or "").strip() + pronunciation_value = str(speaker_payload.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str( + speaker_payload.get("resolved_voice") + or speaker_payload.get("voice") + or pending.voice + ).strip() + or None, + "notes": None, + "context": None, + "source": "speaker", + "language": language, + } + + for manual_entry in pending.manual_overrides or []: + if not isinstance(manual_entry, Mapping): + continue + token_value = str(manual_entry.get("token") or "").strip() + pronunciation_value = str(manual_entry.get("pronunciation") or "").strip() + if not token_value or not pronunciation_value: + continue + normalized = manual_entry.get("normalized") or normalize_entity_token(token_value) + if not normalized: + continue + collected[normalized] = { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": str(manual_entry.get("voice") or "").strip() or None, + "notes": str(manual_entry.get("notes") or "").strip() or None, + "context": str(manual_entry.get("context") or "").strip() or None, + "source": str(manual_entry.get("source") or "manual"), + "language": language, + } + + return list(collected.values()) + + +def _sync_pronunciation_overrides(pending: PendingJob) -> None: + pending.pronunciation_overrides = _collect_pronunciation_overrides(pending) + + if not pending.pronunciation_overrides: + return + + summary = pending.entity_summary or {} + manual_map: Dict[str, Mapping[str, Any]] = {} + for override in pending.manual_overrides or []: + if not isinstance(override, Mapping): + continue + normalized = override.get("normalized") or normalize_entity_token(override.get("token") or "") + pronunciation_value = str(override.get("pronunciation") or "").strip() + if not normalized or not pronunciation_value: + continue + manual_map[normalized] = override + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, dict): + continue + normalized = normalize_entity_token(entry.get("normalized") or entry.get("label") or "") + manual_override = manual_map.get(normalized) + if manual_override: + entry["override"] = { + "token": manual_override.get("token"), + "pronunciation": manual_override.get("pronunciation"), + "voice": manual_override.get("voice"), + "notes": manual_override.get("notes"), + "context": manual_override.get("context"), + "source": manual_override.get("source"), + } + + +def _refresh_entity_summary(pending: PendingJob, chapters: Iterable[Mapping[str, Any]]) -> None: + language = pending.language or "en" + result = extract_entities(chapters, language=language) + summary = dict(result.summary) + tokens: List[str] = [] + for group in ("people", "entities"): + entries = summary.get(group) + if not isinstance(entries, list): + continue + for entry in entries: + if not isinstance(entry, Mapping): + continue + token_value = str(entry.get("normalized") or entry.get("label") or "").strip() + if token_value: + tokens.append(token_value) + + overrides_from_store = load_pronunciation_overrides(language=language, tokens=tokens) + merged_summary = merge_override(summary, overrides_from_store) + if result.errors: + merged_summary["errors"] = list(result.errors) + merged_summary["cache_key"] = result.cache_key + pending.entity_summary = merged_summary + pending.entity_cache_key = result.cache_key + _sync_pronunciation_overrides(pending) + + +def _find_manual_override(pending: PendingJob, identifier: str) -> Optional[Dict[str, Any]]: + for entry in pending.manual_overrides or []: + if not isinstance(entry, dict): + continue + if entry.get("id") == identifier or entry.get("normalized") == identifier: + return entry + return None + + +def _upsert_manual_override(pending: PendingJob, payload: Mapping[str, Any]) -> Dict[str, Any]: + token_value = str(payload.get("token") or "").strip() + if not token_value: + raise ValueError("Token is required") + pronunciation_value = str(payload.get("pronunciation") or "").strip() + voice_value = str(payload.get("voice") or "").strip() + notes_value = str(payload.get("notes") or "").strip() + context_value = str(payload.get("context") or "").strip() + normalized = payload.get("normalized") or normalize_entity_token(token_value) + if not normalized: + raise ValueError("Token is required") + + existing = _find_manual_override(pending, payload.get("id", "")) or _find_manual_override(pending, normalized) + timestamp = time.time() + language = pending.language or "en" + + if existing: + existing.update( + { + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": voice_value, + "notes": notes_value, + "context": context_value, + "updated_at": timestamp, + } + ) + manual_entry = existing + else: + manual_entry = { + "id": payload.get("id") or uuid.uuid4().hex, + "token": token_value, + "normalized": normalized, + "pronunciation": pronunciation_value, + "voice": voice_value, + "notes": notes_value, + "context": context_value, + "language": language, + "source": payload.get("source") or "manual", + "created_at": timestamp, + "updated_at": timestamp, + } + if isinstance(pending.manual_overrides, list): + pending.manual_overrides.append(manual_entry) + else: + pending.manual_overrides = [manual_entry] + + save_pronunciation_override( + language=language, + token=token_value, + pronunciation=pronunciation_value or None, + voice=voice_value or None, + notes=notes_value or None, + context=context_value or None, + ) + + _sync_pronunciation_overrides(pending) + return dict(manual_entry) + + +def _delete_manual_override(pending: PendingJob, override_id: str) -> bool: + if not override_id: + return False + entries = pending.manual_overrides or [] + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + continue + if entry.get("id") == override_id: + token_value = entry.get("token") or "" + language = pending.language or "en" + delete_pronunciation_override(language=language, token=token_value) + entries.pop(index) + pending.manual_overrides = entries + _sync_pronunciation_overrides(pending) + return True + return False + + +def _search_manual_override_candidates(pending: PendingJob, query: str, *, limit: int = 15) -> List[Dict[str, Any]]: + normalized_query = (query or "").strip() + summary_index = (pending.entity_summary or {}).get("index", {}) + matches = search_entity_tokens(summary_index, normalized_query, limit=limit) + registry: Dict[str, Dict[str, Any]] = {} + + for entry in matches: + normalized = normalize_entity_token(entry.get("normalized") or entry.get("token") or "") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": entry.get("category") or "entity", + "count": entry.get("count", 0), + "samples": entry.get("samples", []), + "source": "entity", + }, + ) + + language = pending.language or "en" + store_matches = search_pronunciation_overrides(language=language, query=normalized_query, limit=limit) + for entry in store_matches: + normalized = entry.get("normalized") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": "history", + "count": entry.get("usage_count", 0), + "samples": [entry.get("context")] if entry.get("context") else [], + "source": "history", + "pronunciation": entry.get("pronunciation"), + "voice": entry.get("voice"), + }, + ) + + for entry in pending.manual_overrides or []: + if not isinstance(entry, Mapping): + continue + normalized = entry.get("normalized") + if not normalized: + continue + registry.setdefault( + normalized, + { + "token": entry.get("token"), + "normalized": normalized, + "category": "manual", + "count": 0, + "samples": [entry.get("context")] if entry.get("context") else [], + "source": "manual", + "pronunciation": entry.get("pronunciation"), + "voice": entry.get("voice"), + }, + ) + + ordered = sorted(registry.values(), key=lambda item: (-int(item.get("count") or 0), item.get("token") or "")) + if limit: + return ordered[:limit] + return ordered + + +def _pending_entities_payload(pending: PendingJob) -> Dict[str, Any]: + return { + "summary": pending.entity_summary or {}, + "manual_overrides": pending.manual_overrides or [], + "pronunciation_overrides": pending.pronunciation_overrides or [], + "cache_key": pending.entity_cache_key, + "language": pending.language or "en", + } + + def _apply_prepare_form( pending: PendingJob, form: Mapping[str, Any] ) -> tuple[ @@ -1141,6 +1465,8 @@ def _apply_prepare_form( enabled_overrides = [entry for entry in overrides if entry.get("enabled")] + _sync_pronunciation_overrides(pending) + return ( chunk_level_literal, overrides, @@ -1261,6 +1587,13 @@ def _service() -> ConversionService: return current_app.extensions["conversion_service"] +def _require_pending_job(pending_id: str) -> PendingJob: + pending = _service().get_pending_job(pending_id) + if not pending: + abort(404) + return cast(PendingJob, pending) + + def _build_voice_catalog() -> List[Dict[str, str]]: catalog: List[Dict[str, str]] = [] gender_map = {"f": "Female", "m": "Male"} @@ -2101,6 +2434,74 @@ def api_speaker_preview() -> ResponseReturnValue: return response +@api_bp.get("/pending//entities") +def api_pending_entities(pending_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + refresh_flag = (request.args.get("refresh") or "").strip().lower() + expected_cache = (request.args.get("cache_key") or "").strip() + refresh_requested = refresh_flag in {"1", "true", "yes", "force"} + if expected_cache and expected_cache != (pending.entity_cache_key or ""): + refresh_requested = True + if refresh_requested or not pending.entity_summary: + _refresh_entity_summary(pending, pending.chapters) + _service().store_pending_job(pending) + return jsonify(_pending_entities_payload(pending)) + + +@api_bp.post("/pending//entities/refresh") +def api_refresh_pending_entities(pending_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + _refresh_entity_summary(pending, pending.chapters) + _service().store_pending_job(pending) + return jsonify(_pending_entities_payload(pending)) + + +@api_bp.get("/pending//manual-overrides") +def api_list_manual_overrides(pending_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + return jsonify( + { + "overrides": pending.manual_overrides or [], + "pronunciation_overrides": pending.pronunciation_overrides or [], + "language": pending.language or "en", + } + ) + + +@api_bp.post("/pending//manual-overrides") +def api_upsert_manual_override(pending_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + payload = request.get_json(silent=True) or {} + if not isinstance(payload, Mapping): + abort(400, "Invalid override payload") + try: + override = _upsert_manual_override(pending, payload) + except ValueError as exc: + abort(400, str(exc)) + _service().store_pending_job(pending) + return jsonify({"override": override, **_pending_entities_payload(pending)}) + + +@api_bp.delete("/pending//manual-overrides/") +def api_delete_manual_override(pending_id: str, override_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + deleted = _delete_manual_override(pending, override_id) + if not deleted: + abort(404) + _service().store_pending_job(pending) + return jsonify({"deleted": True, **_pending_entities_payload(pending)}) + + +@api_bp.get("/pending//manual-overrides/search") +def api_search_manual_override_candidates(pending_id: str) -> ResponseReturnValue: + pending = _require_pending_job(pending_id) + query = (request.args.get("q") or request.args.get("query") or "").strip() + limit_param = request.args.get("limit") + limit_value = _coerce_int(limit_param, 15, minimum=1, maximum=50) if limit_param is not None else 15 + results = _search_manual_override_candidates(pending, query, limit=limit_value) + return jsonify({"query": query, "limit": limit_value, "results": results}) + + @web_bp.post("/jobs") def enqueue_job() -> ResponseReturnValue: service = _service() @@ -2310,6 +2711,8 @@ def enqueue_job() -> ResponseReturnValue: analysis_requested=initial_analysis, ) + _refresh_entity_summary(pending, pending.chapters) + service.store_pending_job(pending) pending.applied_speaker_config = selected_speaker_config or None if config_languages: @@ -2323,20 +2726,14 @@ def enqueue_job() -> ResponseReturnValue: @web_bp.get("/jobs/prepare/") def prepare_job(pending_id: str) -> str: - pending = _service().get_pending_job(pending_id) - if not pending: - abort(404) - pending = cast(PendingJob, pending) + pending = _require_pending_job(pending_id) return _render_prepare_page(pending, active_step="chapters") @web_bp.post("/jobs/prepare//analyze") def analyze_pending_job(pending_id: str) -> ResponseReturnValue: service = _service() - pending = service.get_pending_job(pending_id) - if not pending: - abort(404) - pending = cast(PendingJob, pending) + pending = _require_pending_job(pending_id) ( chunk_level_literal, @@ -2412,21 +2809,20 @@ def analyze_pending_job(pending_id: str) -> ResponseReturnValue: if selected_total: pending.total_characters = selected_total + _sync_pronunciation_overrides(pending) + service.store_pending_job(pending) - notice_message = "Speaker analysis updated." + notice_message = "Entity insights updated." if persist_config_requested and config_name: - notice_message = "Speaker analysis updated and configuration saved." - return _render_prepare_page(pending, notice=notice_message, active_step="speakers") + notice_message = "Entity insights updated and configuration saved." + return _render_prepare_page(pending, notice=notice_message, active_step="entities") @web_bp.post("/jobs/prepare/") def finalize_job(pending_id: str) -> ResponseReturnValue: service = _service() - pending = service.get_pending_job(pending_id) - if not pending: - abort(404) - pending = cast(PendingJob, pending) + pending = _require_pending_job(pending_id) ( chunk_level_literal, @@ -2443,7 +2839,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: return _render_prepare_page( pending, error=" ".join(errors), - active_step=request.form.get("active_step") or "speakers", + active_step=request.form.get("active_step") or "entities", ) if pending.speaker_mode != "multi": @@ -2458,12 +2854,14 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: ) active_step = (request.form.get("active_step") or "chapters").strip().lower() + if active_step == "speakers": + active_step = "entities" raw_chunks = build_chunks_for_chapters(enabled_overrides, level=chunk_level_literal) analysis_chunks = build_chunks_for_chapters(enabled_overrides, level="sentence") is_multi = pending.speaker_mode == "multi" analysis_requested = bool(getattr(pending, "analysis_requested", False)) - should_force_speakers = is_multi and active_step != "speakers" + should_force_entities = is_multi and active_step != "entities" if analysis_requested: existing_roster: Optional[Mapping[str, Any]] = pending.speakers @@ -2477,7 +2875,7 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: config_name = pending.applied_speaker_config or selected_config speaker_config_payload = get_config(config_name) if config_name else None - run_analysis = is_multi and (should_force_speakers or analysis_requested) + run_analysis = is_multi and (should_force_entities or analysis_requested) processed_chunks, roster, analysis_payload, config_languages, updated_config = _prepare_speaker_metadata( chapters=enabled_overrides, chunks=raw_chunks, @@ -2511,15 +2909,17 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: if selected_total: pending.total_characters = selected_total - if should_force_speakers: - notice_message = "Review speaker assignments before queuing." + _sync_pronunciation_overrides(pending) + + if should_force_entities: + notice_message = "Review entity settings before queuing." if persist_config_requested and config_key: - notice_message = "Configuration saved. Review speaker assignments before queuing." + notice_message = "Configuration saved. Review entity settings before queuing." service.store_pending_job(pending) return _render_prepare_page( pending, notice=notice_message, - active_step="speakers", + active_step="entities", ) total_characters = selected_total or pending.total_characters @@ -2559,6 +2959,9 @@ def finalize_job(pending_id: str) -> ResponseReturnValue: speaker_analysis=pending.speaker_analysis, speaker_analysis_threshold=pending.speaker_analysis_threshold, analysis_requested=getattr(pending, "analysis_requested", False), + entity_summary=pending.entity_summary, + manual_overrides=pending.manual_overrides, + pronunciation_overrides=pending.pronunciation_overrides, ) if config_languages: @@ -2620,14 +3023,16 @@ def _render_prepare_page( ) or "chapters" normalized_step = (active_step or "chapters").strip().lower() - if normalized_step not in {"chapters", "speakers"}: + if normalized_step == "speakers": + normalized_step = "entities" + if normalized_step not in {"chapters", "entities"}: normalized_step = "chapters" is_multi = pending.speaker_mode == "multi" - if normalized_step == "speakers" and not is_multi: + if normalized_step == "entities" and not is_multi: normalized_step = "chapters" - template_name = "prepare_speakers.html" if normalized_step == "speakers" else "prepare_chapters.html" + template_name = "prepare_entities.html" if normalized_step == "entities" else "prepare_chapters.html" return render_template( template_name, diff --git a/abogen/web/service.py b/abogen/web/service.py index f21e45b..2030760 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -24,7 +24,7 @@ def _create_set_event() -> threading.Event: return event -STATE_VERSION = 5 +STATE_VERSION = 6 _JOB_LOGGER = logging.getLogger("abogen.jobs") @@ -133,8 +133,9 @@ class Job: analysis_requested: bool = False speaker_voice_languages: List[str] = field(default_factory=list) applied_speaker_config: Optional[str] = None - speaker_voice_languages: List[str] = field(default_factory=list) - applied_speaker_config: Optional[str] = None + entity_summary: Dict[str, Any] = field(default_factory=dict) + manual_overrides: List[Dict[str, Any]] = field(default_factory=list) + pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) def add_log(self, message: str, level: str = "info") -> None: entry = JobLog(timestamp=time.time(), message=message, level=level) @@ -197,6 +198,9 @@ class Job: "analysis_requested": self.analysis_requested, "speaker_voice_languages": list(self.speaker_voice_languages), "applied_speaker_config": self.applied_speaker_config, + "entity_summary": dict(self.entity_summary), + "manual_overrides": [dict(entry) for entry in self.manual_overrides], + "pronunciation_overrides": [dict(entry) for entry in self.pronunciation_overrides], } @@ -239,6 +243,10 @@ class PendingJob: analysis_requested: bool = False speaker_voice_languages: List[str] = field(default_factory=list) applied_speaker_config: Optional[str] = None + entity_summary: Dict[str, Any] = field(default_factory=dict) + manual_overrides: List[Dict[str, Any]] = field(default_factory=list) + pronunciation_overrides: List[Dict[str, Any]] = field(default_factory=list) + entity_cache_key: Optional[str] = None class ConversionService: @@ -311,6 +319,9 @@ class ConversionService: speaker_analysis: Optional[Mapping[str, Any]] = None, speaker_analysis_threshold: int = 3, analysis_requested: bool = False, + entity_summary: Optional[Mapping[str, Any]] = None, + manual_overrides: Optional[Iterable[Mapping[str, Any]]] = None, + pronunciation_overrides: Optional[Iterable[Mapping[str, Any]]] = None, ) -> Job: job_id = uuid.uuid4().hex normalized_metadata = self._normalize_metadata_tags(metadata_tags) @@ -354,6 +365,9 @@ class ConversionService: speaker_analysis=dict(speaker_analysis or {}), speaker_analysis_threshold=int(speaker_analysis_threshold or 3), analysis_requested=bool(analysis_requested), + entity_summary=dict(entity_summary or {}), + manual_overrides=[dict(entry) for entry in manual_overrides] if manual_overrides else [], + pronunciation_overrides=[dict(entry) for entry in pronunciation_overrides] if pronunciation_overrides else [], ) with self._lock: self._jobs[job_id] = job @@ -505,6 +519,9 @@ class ConversionService: speaker_analysis=job.speaker_analysis, speaker_analysis_threshold=job.speaker_analysis_threshold, analysis_requested=job.analysis_requested, + entity_summary=job.entity_summary, + manual_overrides=job.manual_overrides, + pronunciation_overrides=job.pronunciation_overrides, ) new_job.speaker_voice_languages = list(job.speaker_voice_languages) @@ -727,6 +744,9 @@ class ConversionService: "speaker_analysis": dict(job.speaker_analysis), "speaker_analysis_threshold": job.speaker_analysis_threshold, "analysis_requested": job.analysis_requested, + "entity_summary": dict(job.entity_summary), + "manual_overrides": [dict(entry) for entry in job.manual_overrides], + "pronunciation_overrides": [dict(entry) for entry in job.pronunciation_overrides], } def _persist_state(self) -> None: @@ -844,6 +864,11 @@ class ConversionService: payload.get("speaker_analysis_threshold", job.speaker_analysis_threshold or 3) ) job.analysis_requested = bool(payload.get("analysis_requested", job.analysis_requested)) + job.entity_summary = payload.get("entity_summary", {}) + job.manual_overrides = [dict(entry) for entry in payload.get("manual_overrides", []) if isinstance(entry, Mapping)] + job.pronunciation_overrides = [ + dict(entry) for entry in payload.get("pronunciation_overrides", []) if isinstance(entry, Mapping) + ] job.pause_event.set() return job diff --git a/abogen/web/static/prepare.js b/abogen/web/static/prepare.js index ba69004..1a44330 100644 --- a/abogen/web/static/prepare.js +++ b/abogen/web/static/prepare.js @@ -450,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => { analysisButtons.forEach((button) => { button.addEventListener("click", () => { if (activeStepInput) { - activeStepInput.value = "speakers"; + activeStepInput.value = "entities"; } }); }); @@ -1082,6 +1082,613 @@ document.addEventListener("DOMContentLoaded", () => { renderVoiceList(elements); } + const entitySummaryData = parseJSONScript("entity-summary-data") || {}; + const entityCacheKeyData = parseJSONScript("entity-cache-key"); + const manualOverridesSeed = parseJSONScript("manual-overrides-data") || []; + const pronunciationOverridesSeed = parseJSONScript("pronunciation-overrides-data") || []; + + const entityTabs = form.querySelector('[data-role="entity-tabs"]'); + const entitiesUrl = form.dataset.entitiesUrl || ""; + const manualUpsertUrl = form.dataset.manualUpsertUrl || ""; + const manualDeleteUrlTemplate = form.dataset.manualDeleteUrlTemplate || ""; + const manualSearchUrl = form.dataset.manualSearchUrl || ""; + const baseVoice = form.dataset.baseVoice || form.dataset.voice || ""; + const languageCode = form.dataset.language || "en"; + const defaultSpeed = form.dataset.speed || "1.0"; + const useGpuDefault = form.dataset.useGpu || "true"; + + const entityState = { + summary: entitySummaryData && typeof entitySummaryData === "object" ? entitySummaryData : {}, + cacheKey: typeof entityCacheKeyData === "string" ? entityCacheKeyData : "", + manualOverrides: Array.isArray(manualOverridesSeed) ? [...manualOverridesSeed] : [], + pronunciationOverrides: Array.isArray(pronunciationOverridesSeed) ? [...pronunciationOverridesSeed] : [], + }; + + let highlightedOverrideId = ""; + const pendingOverrideSaves = new Map(); + + if (entityTabs) { + const tabButtons = Array.from(entityTabs.querySelectorAll('[data-role="entity-tab"]')); + const tabPanels = new Map( + Array.from(entityTabs.querySelectorAll('[data-role="entity-panel"]')).map((panel) => [panel.dataset.panel || "", panel]) + ); + + const entitySummaryContainer = entityTabs.querySelector('[data-role="entity-summary"]'); + const entityStatsNode = entitySummaryContainer?.querySelector('[data-role="entity-stats"]'); + const entityListNode = entitySummaryContainer?.querySelector('[data-role="entity-list"]'); + const entityRowTemplate = entitySummaryContainer?.querySelector('template[data-role="entity-row-template"]'); + const entitiesRefreshButton = entitySummaryContainer?.querySelector('[data-role="entities-refresh"]'); + + const manualOverridesRoot = entityTabs.querySelector('[data-role="manual-overrides"]'); + const manualOverrideList = manualOverridesRoot?.querySelector('[data-role="manual-override-list"]'); + const manualOverrideTemplate = manualOverridesRoot?.querySelector('template[data-role="manual-override-template"]'); + const manualOverrideResultsList = manualOverridesRoot?.querySelector('[data-role="manual-override-results"]'); + const manualOverrideQueryInput = manualOverridesRoot?.querySelector('[data-role="manual-override-query"]'); + const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]'); + const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]'); + const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]'); + + const cloneTemplate = (template) => { + if (!template) return null; + if (template.content && template.content.firstElementChild) { + return template.content.firstElementChild.cloneNode(true); + } + return template.cloneNode(true); + }; + + const formatMentions = (value) => { + const count = Number(value || 0); + return `${count.toLocaleString()} mention${count === 1 ? "" : "s"}`; + }; + + function activateEntityTab(panelKey) { + tabButtons.forEach((button) => { + const isActive = button.dataset.panel === panelKey; + button.classList.toggle("is-active", isActive); + button.setAttribute("aria-selected", isActive ? "true" : "false"); + }); + tabPanels.forEach((panel, key) => { + if (!panel) return; + const isActive = key === panelKey; + panel.classList.toggle("is-active", isActive); + panel.hidden = !isActive; + panel.setAttribute("aria-hidden", isActive ? "false" : "true"); + }); + } + + function populateVoiceOptions(select, selectedVoice) { + if (!select) return; + const narratorLabel = baseVoice ? `Use narrator voice (${baseVoice})` : "Use narrator voice"; + select.innerHTML = ""; + const narratorOption = document.createElement("option"); + narratorOption.value = ""; + narratorOption.textContent = narratorLabel; + if (!selectedVoice) { + narratorOption.selected = true; + } + select.appendChild(narratorOption); + voiceCatalog.forEach((voice) => { + const option = document.createElement("option"); + option.value = voice.id; + option.textContent = `${voice.display_name} · ${voice.language_label} · ${voice.gender}`; + if (selectedVoice === voice.id) { + option.selected = true; + } + select.appendChild(option); + }); + if (selectedVoice) { + const hasMatch = Array.from(select.options).some((option) => option.value === selectedVoice); + if (!hasMatch) { + const fallback = document.createElement("option"); + fallback.value = selectedVoice; + fallback.textContent = selectedVoice; + fallback.selected = true; + select.appendChild(fallback); + } + } + } + + function renderEntitySummary() { + if (!entityListNode) return; + const summary = entityState.summary || {}; + const stats = summary.stats || {}; + const errors = Array.isArray(summary.errors) ? summary.errors : []; + if (entityStatsNode) { + if (errors.length) { + entityStatsNode.textContent = errors.join(" · "); + } else if (stats.processed) { + const parts = []; + if (typeof stats.chapters === "number") { + parts.push(`${stats.chapters} chapter${stats.chapters === 1 ? "" : "s"}`); + } + if (typeof stats.tokens === "number") { + parts.push(`${stats.tokens.toLocaleString()} tokens processed`); + } + entityStatsNode.textContent = parts.length ? parts.join(" · ") : "Entity analysis up to date."; + } else { + entityStatsNode.textContent = "Entity analysis will populate once you continue from chapters."; + } + } + + entityListNode.innerHTML = ""; + const entries = Array.isArray(summary.entities) ? summary.entities : []; + if (!entries.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "entity-summary__empty"; + emptyItem.textContent = "No entities detected yet."; + entityListNode.appendChild(emptyItem); + return; + } + + entries.slice(0, 120).forEach((entity) => { + const item = cloneTemplate(entityRowTemplate); + if (!item) return; + const normalized = entity.normalized || entity.label || entity.token || ""; + const tokenLabel = entity.label || entity.token || normalized || "Untitled entity"; + item.dataset.entityId = entity.id || normalized || tokenLabel; + + const labelEl = item.querySelector('[data-role="entity-label"]'); + if (labelEl) { + labelEl.textContent = tokenLabel; + } + + const kindEl = item.querySelector('[data-role="entity-kind"]'); + if (kindEl) { + const kind = entity.kind || entity.category || ""; + kindEl.textContent = kind; + kindEl.hidden = !kind; + } + + const countEl = item.querySelector('[data-role="entity-count"]'); + if (countEl) { + countEl.textContent = formatMentions(entity.count); + } + + const samplesContainer = item.querySelector('[data-role="entity-samples"]'); + if (samplesContainer) { + samplesContainer.innerHTML = ""; + const samples = Array.isArray(entity.samples) ? entity.samples : []; + if (!samples.length) { + const hint = document.createElement("p"); + hint.className = "hint"; + hint.textContent = "No sample sentences captured yet."; + samplesContainer.appendChild(hint); + } else { + const list = document.createElement("ul"); + list.className = "entity-summary__samples-list"; + samples.slice(0, 3).forEach((sample) => { + const text = typeof sample === "string" ? sample : sample?.excerpt; + if (!text) return; + const entry = document.createElement("li"); + entry.textContent = text; + list.appendChild(entry); + }); + samplesContainer.appendChild(list); + } + } + + const button = item.querySelector('[data-role="entity-add-override"]'); + if (button) { + const hasOverride = entityState.manualOverrides.some((entry) => { + const candidate = entry?.normalized || entry?.token || ""; + return candidate && candidate.toLowerCase() === normalized.toLowerCase(); + }); + button.dataset.entityToken = entity.label || entity.token || ""; + button.dataset.entityNormalized = normalized; + const sampleContext = Array.isArray(entity.samples) && entity.samples.length + ? typeof entity.samples[0] === "string" + ? entity.samples[0] + : entity.samples[0]?.excerpt || "" + : ""; + if (sampleContext) { + button.dataset.entityContext = sampleContext; + } + button.textContent = hasOverride ? "Edit manual override" : "Add manual override"; + } + + entityListNode.appendChild(item); + }); + } + + function renderManualOverrides() { + if (!manualOverrideList) return; + manualOverrideList.innerHTML = ""; + const overrides = Array.isArray(entityState.manualOverrides) ? entityState.manualOverrides : []; + if (!overrides.length) { + if (manualOverridesEmpty) { + manualOverridesEmpty.hidden = false; + } + return; + } + + if (manualOverridesEmpty) { + manualOverridesEmpty.hidden = true; + } + + overrides.forEach((override) => { + const node = cloneTemplate(manualOverrideTemplate); + if (!node) return; + const overrideId = override.id || override.normalized || override.token || ""; + node.dataset.overrideId = overrideId; + node.dataset.token = override.token || ""; + node.dataset.normalized = override.normalized || ""; + node.dataset.context = override.context || ""; + + const labelEl = node.querySelector('[data-role="override-label"]'); + if (labelEl) { + labelEl.textContent = override.token || override.normalized || "Manual override"; + } + + const notesEl = node.querySelector('[data-role="override-notes"]'); + if (notesEl) { + const notes = override.notes || override.context || ""; + if (notes) { + notesEl.textContent = notes; + } else { + notesEl.hidden = true; + } + } + + const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]'); + if (pronInput) { + pronInput.value = override.pronunciation || ""; + pronInput.placeholder = override.token || override.normalized || ""; + pronInput.dataset.overrideId = overrideId; + } + + const voiceSelect = node.querySelector('[data-role="manual-override-voice"]'); + if (voiceSelect) { + populateVoiceOptions(voiceSelect, override.voice || ""); + voiceSelect.dataset.overrideId = overrideId; + } + + const previewButton = node.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + const previewVoice = override.voice || voiceSelect?.value || baseVoice || ""; + const previewText = override.pronunciation || override.token || ""; + previewButton.dataset.overrideId = overrideId; + previewButton.dataset.previewText = previewText; + previewButton.dataset.voice = previewVoice; + previewButton.dataset.language = languageCode; + previewButton.dataset.speed = defaultSpeed; + previewButton.dataset.useGpu = useGpuDefault; + } + + const deleteButton = node.querySelector('[data-role="manual-override-delete"]'); + if (deleteButton) { + deleteButton.dataset.overrideId = overrideId; + } + + const metaEl = node.querySelector('[data-role="manual-override-meta"]'); + if (metaEl) { + const parts = []; + if (override.source) { + parts.push(`Source: ${override.source}`); + } + if (override.updated_at) { + const timestamp = Number(override.updated_at) * 1000; + if (!Number.isNaN(timestamp)) { + parts.push(`Updated ${new Date(timestamp).toLocaleString()}`); + } + } + metaEl.textContent = parts.join(" · "); + } + + manualOverrideList.appendChild(node); + if (highlightedOverrideId && highlightedOverrideId === overrideId) { + node.classList.add("is-highlighted"); + setTimeout(() => node.classList.remove("is-highlighted"), 2400); + node.scrollIntoView({ behavior: "smooth", block: "center" }); + highlightedOverrideId = ""; + } + }); + } + + function applyEntityPayload(payload, options = {}) { + if (payload && typeof payload === "object") { + if (payload.summary) { + entityState.summary = payload.summary; + } + if (Array.isArray(payload.manual_overrides)) { + entityState.manualOverrides = payload.manual_overrides; + } + if (Array.isArray(payload.pronunciation_overrides)) { + entityState.pronunciationOverrides = payload.pronunciation_overrides; + } + if (typeof payload.cache_key === "string") { + entityState.cacheKey = payload.cache_key; + } + } + if (options.highlightId) { + highlightedOverrideId = options.highlightId; + } + renderEntitySummary(); + renderManualOverrides(); + } + + function collectOverridePayload(overrideId) { + if (!overrideId || !manualOverrideList) return null; + const selectorId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(overrideId) : overrideId.replace(/["\\]/g, "\\$&"); + const node = manualOverrideList.querySelector(`[data-override-id="${selectorId}"]`); + if (!node) return null; + const pronInput = node.querySelector('[data-role="manual-override-pronunciation"]'); + const voiceSelect = node.querySelector('[data-role="manual-override-voice"]'); + return { + id: overrideId, + token: node.dataset.token || "", + normalized: node.dataset.normalized || "", + pronunciation: pronInput?.value?.trim() || "", + voice: voiceSelect?.value || "", + notes: "", + context: node.dataset.context || "", + }; + } + + async function saveManualOverride(overrideId) { + const payload = collectOverridePayload(overrideId); + if (!payload || !manualUpsertUrl) return; + try { + const response = await fetch(manualUpsertUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Override save failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data, { highlightId: data.override?.id || payload.id }); + } catch (error) { + console.error("Failed to save manual override", error); + } + } + + function scheduleManualOverrideSave(overrideId) { + if (!overrideId) return; + if (pendingOverrideSaves.has(overrideId)) { + clearTimeout(pendingOverrideSaves.get(overrideId)); + } + const timer = setTimeout(() => { + pendingOverrideSaves.delete(overrideId); + saveManualOverride(overrideId); + }, 400); + pendingOverrideSaves.set(overrideId, timer); + } + + async function deleteManualOverride(overrideId) { + if (!overrideId || !manualDeleteUrlTemplate) return; + const targetUrl = manualDeleteUrlTemplate.replace("__OVERRIDE_ID__", encodeURIComponent(overrideId)); + try { + const response = await fetch(targetUrl, { method: "DELETE" }); + if (!response.ok) { + throw new Error(`Override delete failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data); + } catch (error) { + console.error("Failed to delete manual override", error); + } + } + + async function performEntitiesRefresh(force = false) { + if (!entitiesUrl) return; + try { + const url = new URL(entitiesUrl, window.location.origin); + if (force) { + url.searchParams.set("refresh", "1"); + } + if (entityState.cacheKey) { + url.searchParams.set("cache_key", entityState.cacheKey); + } + const response = await fetch(url.toString(), { method: "GET" }); + if (!response.ok) { + throw new Error(`Entity refresh failed with status ${response.status}`); + } + const data = await response.json(); + applyEntityPayload(data); + } catch (error) { + console.error("Failed to refresh entity summary", error); + } + } + + async function performManualOverrideSearch(query) { + if (!manualSearchUrl || !manualOverrideResultsList) return; + manualOverrideResultsList.innerHTML = ""; + try { + const url = new URL(manualSearchUrl, window.location.origin); + if (query) { + url.searchParams.set("q", query); + } + const response = await fetch(url.toString(), { method: "GET" }); + if (!response.ok) { + throw new Error(`Search failed with status ${response.status}`); + } + const data = await response.json(); + const results = Array.isArray(data.results) ? data.results : []; + if (!results.length) { + const emptyItem = document.createElement("li"); + emptyItem.className = "manual-overrides__results-empty"; + emptyItem.textContent = query ? "No matches found." : "Start typing to search tokens."; + manualOverrideResultsList.appendChild(emptyItem); + return; + } + results.forEach((entry) => { + const item = document.createElement("li"); + item.className = "manual-overrides__result"; + const button = document.createElement("button"); + button.type = "button"; + button.className = "button button--ghost button--small"; + button.dataset.role = "manual-override-result"; + button.dataset.token = entry.token || entry.normalized || ""; + if (entry.normalized) { + button.dataset.normalized = entry.normalized; + } + if (entry.context) { + button.dataset.context = entry.context; + } else if (Array.isArray(entry.samples) && entry.samples.length) { + const sample = entry.samples[0]; + button.dataset.context = typeof sample === "string" ? sample : sample?.excerpt || ""; + } + if (entry.pronunciation) { + button.dataset.pronunciation = entry.pronunciation; + } + if (entry.voice) { + button.dataset.voice = entry.voice; + } + button.dataset.source = entry.source || "search"; + button.textContent = entry.token || entry.normalized || "Unnamed token"; + item.appendChild(button); + manualOverrideResultsList.appendChild(item); + }); + } catch (error) { + console.error("Failed to search manual override candidates", error); + } + } + + async function createOverrideFromData(data) { + if (!data || !manualUpsertUrl) return; + const token = (data.token || "").trim(); + if (!token) return; + const payload = { + token, + normalized: (data.normalized || "").trim(), + context: data.context || "", + pronunciation: data.pronunciation || "", + voice: data.voice || "", + source: data.source || "manual", + }; + try { + const response = await fetch(manualUpsertUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Override creation failed with status ${response.status}`); + } + const body = await response.json(); + const highlighted = body.override?.id || payload.normalized || payload.token; + applyEntityPayload(body, { highlightId: highlighted }); + activateEntityTab("manual"); + if (manualOverrideResultsList) { + manualOverrideResultsList.innerHTML = ""; + } + } catch (error) { + console.error("Failed to create manual override", error); + } + } + + tabButtons.forEach((button) => { + button.addEventListener("click", () => { + activateEntityTab(button.dataset.panel || "people"); + }); + }); + + if (entitiesRefreshButton) { + entitiesRefreshButton.addEventListener("click", () => { + performEntitiesRefresh(true); + }); + } + + if (entityListNode) { + entityListNode.addEventListener("click", (event) => { + const trigger = event.target.closest('[data-role="entity-add-override"]'); + if (!trigger) return; + event.preventDefault(); + createOverrideFromData({ + token: trigger.dataset.entityToken || "", + normalized: trigger.dataset.entityNormalized || "", + context: trigger.dataset.entityContext || "", + source: "entity", + }); + }); + } + + if (manualOverrideSearchButton) { + manualOverrideSearchButton.addEventListener("click", () => { + const query = manualOverrideQueryInput?.value?.trim() || ""; + performManualOverrideSearch(query); + }); + } + + if (manualOverrideQueryInput) { + manualOverrideQueryInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + performManualOverrideSearch(manualOverrideQueryInput.value.trim()); + } + }); + } + + if (manualOverrideAddCustomButton) { + manualOverrideAddCustomButton.addEventListener("click", () => { + const token = window.prompt("Enter the token to override:"); + if (!token) return; + createOverrideFromData({ token: token.trim(), source: "manual" }); + }); + } + + if (manualOverrideResultsList) { + manualOverrideResultsList.addEventListener("click", (event) => { + const resultButton = event.target.closest('[data-role="manual-override-result"]'); + if (!resultButton) return; + event.preventDefault(); + createOverrideFromData({ + token: resultButton.dataset.token || "", + normalized: resultButton.dataset.normalized || "", + context: resultButton.dataset.context || "", + pronunciation: resultButton.dataset.pronunciation || "", + voice: resultButton.dataset.voice || "", + source: resultButton.dataset.source || "search", + }); + }); + } + + if (manualOverrideList) { + manualOverrideList.addEventListener("input", (event) => { + const input = event.target.closest('[data-role="manual-override-pronunciation"]'); + if (!input) return; + const overrideId = input.dataset.overrideId || input.closest('[data-override-id]')?.dataset.overrideId; + const container = input.closest(".manual-override"); + const previewButton = container?.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + previewButton.dataset.previewText = input.value.trim() || input.placeholder || ""; + } + if (overrideId) { + scheduleManualOverrideSave(overrideId); + } + }); + + manualOverrideList.addEventListener("change", (event) => { + const select = event.target.closest('[data-role="manual-override-voice"]'); + if (!select) return; + const overrideId = select.dataset.overrideId || select.closest('[data-override-id]')?.dataset.overrideId; + const container = select.closest(".manual-override"); + const previewButton = container?.querySelector('[data-role="speaker-preview"]'); + if (previewButton) { + previewButton.dataset.voice = select.value || baseVoice || select.dataset.defaultVoice || ""; + } + if (overrideId) { + scheduleManualOverrideSave(overrideId); + } + }); + + manualOverrideList.addEventListener("click", (event) => { + const deleteButton = event.target.closest('[data-role="manual-override-delete"]'); + if (!deleteButton) return; + event.preventDefault(); + const overrideId = deleteButton.dataset.overrideId; + if (!overrideId) return; + deleteManualOverride(overrideId); + }); + } + + const initialTab = tabButtons.find((button) => button.classList.contains("is-active")); + activateEntityTab(initialTab?.dataset.panel || "people"); + renderEntitySummary(); + renderManualOverrides(); + } + form.addEventListener("click", (event) => { const genderMenu = event.target.closest('[data-role="gender-menu"]'); const genderPill = event.target.closest('[data-role="gender-pill"]'); diff --git a/abogen/web/templates/index.html b/abogen/web/templates/index.html index d8595d8..597abd9 100644 --- a/abogen/web/templates/index.html +++ b/abogen/web/templates/index.html @@ -5,7 +5,7 @@ {% block content %}

    Create a New Audiobook

    -

    Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and speakers in the next steps.

    +

    Kick off a fresh conversion with your manuscript or pasted text. You can fine-tune chapters and entities in the next steps.

    diff --git a/abogen/web/templates/partials/upload_modal.html b/abogen/web/templates/partials/upload_modal.html index 6241037..fb3d150 100644 --- a/abogen/web/templates/partials/upload_modal.html +++ b/abogen/web/templates/partials/upload_modal.html @@ -42,9 +42,9 @@ {% set narrator_voice = options.voices[0] %} {% endif %} {% set speed_display = '%.2f'|format(narrator_speed) %} -{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'speakers'] else '') %} -{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'speakers' else '') %} -{% set step3_state = 'is-active' if modal_step == 'speakers' else '' %} +{% set step1_state = 'is-active' if modal_step in ['settings', 'upload'] else ('is-complete' if modal_step in ['chapters', 'entities'] else '') %} +{% set step2_state = 'is-active' if modal_step == 'chapters' else ('is-complete' if modal_step == 'entities' else '') %} +{% set step3_state = 'is-active' if modal_step == 'entities' else '' %}
    -

    Speakers & casting

    +

    Entities & casting

    @@ -212,7 +212,7 @@
    -

    Speakers appearing less often fall back to the narrator voice.

    +

    Entities appearing less often fall back to the narrator voice.

    diff --git a/abogen/web/templates/prepare_chapters.html b/abogen/web/templates/prepare_chapters.html index f3ef178..fafffd2 100644 --- a/abogen/web/templates/prepare_chapters.html +++ b/abogen/web/templates/prepare_chapters.html @@ -27,15 +27,15 @@ {% if is_multi_speaker %} + href="{{ url_for('web.prepare_job', pending_id=pending.id, step='entities') }}"> 3 - Speakers + Entities {% endif %} -

    Choose which chapters to convert. We'll analyse speakers automatically when you continue.

    +

    Choose which chapters to convert. We'll analyse entities automatically when you continue.

    {{ pending.original_filename }}

    @@ -168,7 +168,7 @@ data-step-toggle="analysis" formaction="{{ url_for('web.analyze_pending_job', pending_id=pending.id) }}" formmethod="post"> - Continue to speakers + Continue to entities {% endif %} + + 2 + Chapters + + {% if is_multi_speaker %} + + 3 + Entities + + {% endif %} + + + +

    Assign voices, tune pronunciations, and curate manual overrides before queueing the conversion.

    +
    +
    +

    {{ pending.original_filename }}

    +
    + + +
    + + + + + + +
    + + +
    +
    +
    + + +
    +
    +
    +{% with pending=pending, readonly=True, active_step='entities' %} + {% include "partials/upload_modal.html" %} +{% endwith %} +{% endblock %} + +{% block scripts %} + {{ super() }} + + + + + + + + + + +{% endblock %} diff --git a/abogen/web/templates/prepare_job.html b/abogen/web/templates/prepare_job.html index 345c96c..a349ad4 100644 --- a/abogen/web/templates/prepare_job.html +++ b/abogen/web/templates/prepare_job.html @@ -1,3 +1,3 @@ {# This template is intentionally left empty. - Step-specific templates now live in `prepare_chapters.html` and `prepare_speakers.html`. + Step-specific templates now live in `prepare_chapters.html` and `prepare_entities.html`. The file is kept as a placeholder to avoid breaking documentation references. #} diff --git a/abogen/web/templates/prepare_speakers.html b/abogen/web/templates/prepare_speakers.html index 34f0bba..822c72e 100644 --- a/abogen/web/templates/prepare_speakers.html +++ b/abogen/web/templates/prepare_speakers.html @@ -1,262 +1,3 @@ -{% extends "base.html" %} - -{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} - -{% set is_multi_speaker = pending.speaker_mode == 'multi' %} -{% set analysis = pending.speaker_analysis or {} %} -{% set analysis_speakers = analysis.get('speakers', {}) %} -{% set speaker_configs = options.speaker_configs or [] %} -{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} -{% set total_steps = 3 if is_multi_speaker else 2 %} - -{% block content %} -
    -
    + +
    +
    + Debug · TTS transformations +

    Generate a set of WAV files from a purpose-built EPUB containing code-tagged examples. When something sounds wrong, report the code (e.g. NUM_001) to pinpoint the failing transformation.

    + +
    + +

    Uses your current Settings defaults (voice, language, speed, GPU).

    +
    + + {% if debug_manifest and debug_manifest.artifacts %} +
    + + +
    + {% endif %} + + {% if debug_samples %} +
    + +
      + {% for sample in debug_samples %} +
    • {{ sample.code }} — {{ sample.label }}: {{ sample.text }}
    • + {% endfor %} +
    +
    + {% endif %} +
    +
    {% endblock %} diff --git a/tests/fixtures/abogen_debug_tts_samples.epub b/tests/fixtures/abogen_debug_tts_samples.epub index f07f3927cc1573bed33bee930e70007cf81e6f5e..6e78488fa63cd45b31b05e48d069215988d92b83 100644 GIT binary patch delta 2321 zcmV+s3GViQ5QQ5IP)h>@6aWAK004=6kqkHjiF}bp-~x$!vnT;q0S<|Lk6R@~JmGx^ z003tjlZ^rze_8Er+c*~eKTp9L1+oh`vLxGWGLxq8G?}(DX`HPc42mFF&=zfTB8e(V z#r5BPmBIeor+RVLVT;Ew`FaEuv-E-=e<*E3UE5Xk$J(uM)qdmgIK!%rA&dQV}eAQ;s~>`dmO%_gN~AK8>s)C#RKX)0FOxK#R~Ii+4xO1(`=eE_{KBiqvIF!0X-8P z%z^f37Tc6fy8EBdgY*{=|D#c0ZP6`q3p z5T}1t5y}MwGpPv8P$PtohhNDDggmaLK{PTUNVF(O39)d_Lrh@cc^y2MxD^>?jNF~) zo21@rRTlWoyv!!Zd=oKW-+nqEX25cMMeVrTW4xdu(`NlwOliuQaXEV(oSd>$e_BFP zGxkR*w?nkY#xG^ZW>3n6DoPx0_ywv4i^6soR$ouj0a;!x#oeISXXa~3^fTzkJj=+h zX2)nt%_HFsw3Qi31aic}0GI-{mYB+c^2&Z#9E9MIm)66=uV2SvOTWM4{RF4_m9TCQ z^`bu0Jdr$i9=p^yLhz(z&f@m#f9NELPZ%d-O4Db#_%-af>}lADuO%IlDh+LbvTmF8 zpqi%xp0lXCiTwq|!jVnHlqb#1_siwr^Im^}C!+ytvqC6H6@^neSsa~)wHK?$?KbBC zwqKn!cD#cfgHGFd=|U}0n?)vpnfydP5{FTUa#*>G0EyL-PM3#@UzRnMe?6CtPEXda z4A1dSR}WdR%!Z_%&Hm=W?r|6$lO#Z4 zDpE1gcxF;plqFl?RSzKgf8|#r?zv-vzBQG(J9#(<$5DLTjp&UtndG}YPYvgnSPi!& zu`9s~>fYG`MM1!7lneK97$3){2Vnd%s$sTeq%uL-$vCPUWf5iOL9j6DgmDMQt?SxI zo}&aZo=xPuRHZop@7GWb-`}%VOYioHYs^!yuqW;CIhQ#t4GJBwf7jJYw2Az#gvj&h zikVe5QE4MBy}RA>w5sfB>HPF%gbEY7qf~pzUw@tiD1a+rP=Aq;=PCHb&g&i?za|3vY3JE4)J;_)&JPEVf)_e;%z@0r;|(r`mB`|tinv4@eARN)ByxEsX% z=i&Vlbnw0H2@Jg;f2zn|e^xzEfZm7Pg5WVwTU}BI_B^g%dk%NMl9m%Q)v{RkJ1_ev zY?@`5nig%eVE7=cKG}7WF|tb^k?>1%O=53!(V|e=!UM@EwaV&T z{1(&$W}9ZHrvwcG8V4JO#xLA8tL=##arjj&a?H38Z6aqV_c$bu9W7p|Qf`^_SZK7z zG)0y9)RwNne{V8?-(J7l_e)7vNV%o#;x!nV9bRwuJ-s ztSDrBH4&|#0Smv-)-3kQv61tdP9K<_>imVLnBgt zHLgkRe-`PiqGY zx%jnObBXub_6gc`!%9QBNv))Le9gqTHr|5re_eck*S!ZOh-$U;67tKoCbZ*^Yv^iN zpqT|-3#Ovub+GPw_fJ>SGA}Vr%%G@6aWAK000$ikqkHj6>O14-~ttFvnT;q0S*;xk6SDZ7F+lN z002}5lZ^rze^uLV(?Ae?=PL#aDKDunDTM~PsnRr*N~I3Od8n!it?h}u)ZR6_>$>?K z{)8`MHV%-Iq9ECFtR2rebI!~pJbkQ6ctEAO6emvL`VOE-WyVE*;xuM?aO|9pUW6~t zqe*--n?hl#lHTnD=u@FjoWhv;o#(Y}>$ZokRJj)ne}_Zw(Z)D77FjlW0T3E)N*u97 z<|rV;q{%_EsxDE7UUz1rDl`nzf+>yW7X|PrkJlv%6Pc~nkwx&2WOQK9>(P{@1=LKj zT(P=<1amGlwA>WHKpU2nxcQb!MyQy&N9BfI-Q^#Q#{yf02)FZ^viR#q|2%{Osc0coxmS_xHLUVL_}C)__8S>W2-)JEqVsH zIfW~<0U8n7f*EMBS5l1JrZg)X#Y!Mz8nxyRxR=N?JmP{1m$~jiBHNNAL4|--ZHP_M z7%+665CV~r%Oy{V57Y3i;b^wGTqxPpn1N`je}sByACc?D$7hHTGNU`_4-Q`gtF487 zIC=w8F_GKfR`|E>@eoRG2!$BI*M>N4Gr2)(m~~`^Da-bYax)!&-f>5OZQVZ9D2WC& z6wlUD^oIUMBl+;OK`!SJd1&_IosZ+`-*}OLfj>Ar@B_N~1OO?Pm?+BE;RPCwyUrG$_fi@`6o^m8 zQ)cA;IeZ^Q@v}N`yPSTDvDuT$4Zv-1*V#@lG_|+E;I1PIF3l#wZ6>O763^W22Y?FKpLIM?RlfVo;4i#*VTPzF~TlfM108|E(3k@>@ X6>O704IBcV1(RM4AqKMs00000%!QiK diff --git a/tests/test_debug_tts_samples.py b/tests/test_debug_tts_samples.py index c55b08f..aaa6c24 100644 --- a/tests/test_debug_tts_samples.py +++ b/tests/test_debug_tts_samples.py @@ -95,3 +95,27 @@ def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch): filenames = {item["filename"] for item in manifest.get("artifacts", [])} assert "overall.wav" in filenames assert any(name.startswith("case_") and name.endswith(".wav") for name in filenames) + + +def test_debug_samples_have_minimum_per_category(): + prefixes = { + "APOS": 5, + "POS": 5, + "NUM": 5, + "YEAR": 5, + "DATE": 5, + "CUR": 5, + "TITLE": 5, + "PUNC": 5, + "QUOTE": 5, + "FOOT": 5, + } + + counts = {prefix: 0 for prefix in prefixes} + for sample in DEBUG_TTS_SAMPLES: + prefix = sample.code.split("_", 1)[0] + if prefix in counts: + counts[prefix] += 1 + + for prefix, minimum in prefixes.items(): + assert counts[prefix] >= minimum From fdf95dbae734b0526e10ea033e79048c36151660 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 15 Dec 2025 16:17:55 -0800 Subject: [PATCH 216/245] feat: Implement language aliasing in debug TTS and add debug WAVs page with artifact handling --- abogen/web/debug_tts_runner.py | 24 ++++++++++- abogen/web/routes/settings.py | 38 ++++++++++++++--- abogen/web/templates/debug_wavs.html | 61 ++++++++++++++++++++++++++++ tests/test_debug_tts_samples.py | 6 +-- 4 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 abogen/web/templates/debug_wavs.html diff --git a/abogen/web/debug_tts_runner.py b/abogen/web/debug_tts_runner.py index 9a54591..d8996ef 100644 --- a/abogen/web/debug_tts_runner.py +++ b/abogen/web/debug_tts_runner.py @@ -90,7 +90,29 @@ def run_debug_tts_wavs( if missing: raise RuntimeError(f"Debug EPUB missing expected codes: {', '.join(missing)}") - language = str(settings.get("language") or "en").strip() or "en" + language = str(settings.get("language") or "a").strip() or "a" + # Kokoro's KPipeline expects short language codes like "a" (American English), + # but older settings may store ISO-like values such as "en". + language_aliases = { + "en": "a", + "en-us": "a", + "en_us": "a", + "en-gb": "b", + "en_gb": "b", + "es": "e", + "es-es": "e", + "fr": "f", + "fr-fr": "f", + "hi": "h", + "it": "i", + "pt": "p", + "pt-br": "p", + "ja": "j", + "jp": "j", + "zh": "z", + "zh-cn": "z", + } + language = language_aliases.get(language.lower(), language) voice_spec = str(settings.get("default_voice") or "").strip() use_gpu = bool(settings.get("use_gpu", False)) speed = float(settings.get("default_speed", 1.0) or 1.0) diff --git a/abogen/web/routes/settings.py b/abogen/web/routes/settings.py index b3340b2..30e166a 100644 --- a/abogen/web/routes/settings.py +++ b/abogen/web/routes/settings.py @@ -195,12 +195,38 @@ def run_debug_wavs() -> ResponseReturnValue: return redirect(url_for("settings.settings_page", _anchor="debug")) flash("Debug WAV generation completed.", "success") - return redirect( - url_for( - "settings.settings_page", - debug_run_id=str(manifest.get("run_id") or ""), - _anchor="debug", - ) + return redirect(url_for("settings.debug_wavs_page", run_id=str(manifest.get("run_id") or ""))) + + +@settings_bp.get("/debug/") +def debug_wavs_page(run_id: str) -> ResponseReturnValue: + safe_run = (run_id or "").strip() + if not safe_run: + abort(404) + + root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) + run_dir = (root / "debug" / safe_run).resolve() + manifest_path = run_dir / "manifest.json" + if not manifest_path.exists(): + abort(404) + + try: + import json + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception: + abort(404) + + artifacts = manifest.get("artifacts") or [] + # Precompute download URLs for each artifact. + for item in artifacts: + filename = str(item.get("filename") or "") + item["url"] = url_for("settings.download_debug_wav", run_id=safe_run, filename=filename) + + return render_template( + "debug_wavs.html", + run_id=safe_run, + artifacts=artifacts, ) diff --git a/abogen/web/templates/debug_wavs.html b/abogen/web/templates/debug_wavs.html new file mode 100644 index 0000000..69b766b --- /dev/null +++ b/abogen/web/templates/debug_wavs.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}abogen · Debug WAVs{% endblock %} + +{% block content %} +
    +

    Debug WAVs

    +

    Run ID: {{ run_id }}

    + +
    + +

    Click an ID to play its WAV. “Overall” is the concatenation of all samples.

    +
    + + {% if artifacts %} +
    + +
      + {% for item in artifacts %} +
    • + + {{ item.filename }} +
    • + {% endfor %} +
    +
    + {% endif %} + + +
    + + +{% endblock %} diff --git a/tests/test_debug_tts_samples.py b/tests/test_debug_tts_samples.py index aaa6c24..3b72b14 100644 --- a/tests/test_debug_tts_samples.py +++ b/tests/test_debug_tts_samples.py @@ -84,10 +84,10 @@ def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch): resp = client.post("/settings/debug/run") assert resp.status_code in {302, 303} location = resp.headers.get("Location", "") - assert "debug_run_id=" in location + assert "/settings/debug/" in location - # Extract run id from query string. - run_id = location.split("debug_run_id=")[1].split("&")[0].split("#")[0] + # Extract run id from /settings/debug/ + run_id = location.rsplit("/settings/debug/", 1)[1].split("?", 1)[0].split("#", 1)[0] manifest_path = tmp_path / "debug" / run_id / "manifest.json" assert manifest_path.exists() From 0afaaf561b90afad5cca93effbb65f658a4d3ba1 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 15 Dec 2025 16:28:30 -0800 Subject: [PATCH 217/245] feat: Implement voice profile resolution in debug TTS and add corresponding tests --- abogen/web/debug_tts_runner.py | 25 ++++++++++++++++++++++++ tests/test_debug_tts_samples.py | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/abogen/web/debug_tts_runner.py b/abogen/web/debug_tts_runner.py index d8996ef..04871e0 100644 --- a/abogen/web/debug_tts_runner.py +++ b/abogen/web/debug_tts_runner.py @@ -28,6 +28,18 @@ class DebugWavArtifact: code: Optional[str] = None +def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]: + """Resolve settings voice strings into a pipeline-ready voice spec. + + Supports "profile:" by converting it into a concrete voice formula. + Returns (resolved_voice_spec, profile_name, profile_language). + """ + + from abogen.web.routes.utils.voice import resolve_voice_setting + + return resolve_voice_setting(value) + + def _load_pipeline(language: str, use_gpu: bool) -> Any: device = "cpu" if use_gpu: @@ -117,6 +129,19 @@ def run_debug_tts_wavs( use_gpu = bool(settings.get("use_gpu", False)) speed = float(settings.get("default_speed", 1.0) or 1.0) + # Settings may store "profile:" which is not a Kokoro voice ID. + # Resolve it to a concrete voice formula (e.g. "af_heart*0.5+...") so Kokoro + # doesn't attempt to download a non-existent "voices/profile:.pt". + try: + resolved_voice, _profile_name, profile_language = _resolve_voice_setting(voice_spec) + if resolved_voice: + voice_spec = resolved_voice + if profile_language: + language = str(profile_language).strip() or language + except Exception: + # Voice profile resolution is best-effort; fall back to raw voice_spec. + pass + # Best-effort voice caching (only for known Kokoro internal voices). voice_ids = _spec_to_voice_ids(voice_spec) if voice_ids: diff --git a/tests/test_debug_tts_samples.py b/tests/test_debug_tts_samples.py index 3b72b14..cf9c3b9 100644 --- a/tests/test_debug_tts_samples.py +++ b/tests/test_debug_tts_samples.py @@ -119,3 +119,37 @@ def test_debug_samples_have_minimum_per_category(): for prefix, minimum in prefixes.items(): assert counts[prefix] >= minimum + + +def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypatch): + from abogen.web import debug_tts_runner as runner + + # Stub voice setting resolution so we don't depend on the user's profile file. + monkeypatch.setattr(runner, "_resolve_voice_setting", lambda value: ("af_heart", "AM HQ Alt", None)) + + calls = [] + + class _Seg: + def __init__(self, audio): + self.audio = audio + + class DummyPipeline: + def __call__(self, text, **kwargs): + calls.append(kwargs.get("voice")) + audio = np.zeros(int(0.05 * runner.SAMPLE_RATE), dtype="float32") + yield _Seg(audio) + + monkeypatch.setattr(runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()) + + settings = { + "language": "en", + "default_voice": "profile:AM HQ Alt", + "use_gpu": False, + "default_speed": 1.0, + } + + manifest = runner.run_debug_tts_wavs(output_root=tmp_path, settings=settings) + assert manifest.get("run_id") + assert calls + # Must not pass through the profile:* string. + assert all(isinstance(v, str) and not v.lower().startswith("profile:") for v in calls) From 015435adb631c903181c67dc627cc390dfbba2c3 Mon Sep 17 00:00:00 2001 From: JB Date: Mon, 15 Dec 2025 17:27:08 -0800 Subject: [PATCH 218/245] feat: Enhance text normalization with support for internet slang expansion, currency formatting, and date handling; update debug WAVs interface and settings --- abogen/debug_tts_samples.py | 2 +- abogen/heteronym_overrides.py | 13 +- abogen/kokoro_text_normalization.py | 336 +++++++++++++++++++++++++-- abogen/normalization_settings.py | 1 + abogen/web/debug_tts_runner.py | 51 +++- abogen/web/routes/settings.py | 23 +- abogen/web/routes/utils/settings.py | 12 +- abogen/web/templates/debug_wavs.html | 44 +--- tests/conftest.py | 30 ++- tests/test_text_normalization.py | 34 +++ 10 files changed, 472 insertions(+), 74 deletions(-) diff --git a/abogen/debug_tts_samples.py b/abogen/debug_tts_samples.py index b5490d4..6fa4c52 100644 --- a/abogen/debug_tts_samples.py +++ b/abogen/debug_tts_samples.py @@ -182,7 +182,7 @@ DEBUG_TTS_SAMPLES: Sequence[DebugTTSSample] = ( DebugTTSSample( code="TITLE_003", label="Titles and abbreviations (3)", - text="Gen. Lee spoke to Sgt. Rivera on Main St.", + text="Gen. Smith spoke to Sgt. Rivera on Main St.", ), DebugTTSSample( code="TITLE_004", diff --git a/abogen/heteronym_overrides.py b/abogen/heteronym_overrides.py index 4c6e44a..dbb5384 100644 --- a/abogen/heteronym_overrides.py +++ b/abogen/heteronym_overrides.py @@ -5,7 +5,10 @@ import re from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple -import spacy +try: # pragma: no cover - optional dependency + import spacy # type: ignore +except Exception: # pragma: no cover - spaCy may be unavailable in minimal environments + spacy = None @dataclass(frozen=True) @@ -177,6 +180,9 @@ def _build_replacement_sentence(sentence: str, token: str, replacement_token: st def _load_spacy(language: str) -> Any: + if spacy is None: + return None + # English only for now. # Use installed small model; keep it simple. lang = (language or "en").lower() @@ -211,7 +217,12 @@ def extract_heteronym_overrides( if not lang.startswith("en"): return [] + if spacy is None: + return [] + nlp = _load_spacy(lang) + if nlp is None: + return [] previous_choices: Dict[str, str] = {} if existing: diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index e8f1850..92bd254 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -3,6 +3,8 @@ from __future__ import annotations import json import re import unicodedata +import os +import locale from fractions import Fraction from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple @@ -129,6 +131,176 @@ _URL_RE = re.compile(r"(https?://)?(www\.)?(?P[a-zA-Z0-9-]+(\.[a-zA-Z0-9 _FOOTNOTE_RE = re.compile(r"([a-zA-Z]+)(\d+)") _BRACKET_FOOTNOTE_RE = re.compile(r"\[\d+\]") +_ISO_DATE_RE = re.compile(r"\b(?P\d{4})[/-](?P\d{1,2})[/-](?P\d{1,2})\b") +_MDY_DATE_RE = re.compile( + r"\b(?PJan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\.?\s+" + r"(?P\d{1,2})(?:st|nd|rd|th)?\s*,\s*(?P\d{4})\b", + re.IGNORECASE, +) + +_TIME_RE = re.compile( + r"\b(?P\d{1,2})(?::(?P\d{2}))?\s*(?Pa\.?m\.?|p\.?m\.?)\b", + re.IGNORECASE, +) + +_ADDRESS_ABBR_RE = re.compile(r"(?P\b\w+\s+)(?PSt|Rd|Ave|Blvd|Ln|Dr)\.(?=\s*(?:,|\.|!|\?|$))") + +_MONTH_MAP = { + "jan": "January", + "january": "January", + "feb": "February", + "february": "February", + "mar": "March", + "march": "March", + "apr": "April", + "april": "April", + "may": "May", + "jun": "June", + "june": "June", + "jul": "July", + "july": "July", + "aug": "August", + "august": "August", + "sep": "September", + "sept": "September", + "september": "September", + "oct": "October", + "october": "October", + "nov": "November", + "november": "November", + "dec": "December", + "december": "December", +} + + +def _is_us_locale() -> bool: + for key in ("LC_ALL", "LC_TIME", "LANG"): + value = os.environ.get(key) + if value and "en_US" in value: + return True + try: + loc = locale.getlocale(locale.LC_TIME) + if loc and loc[0] and "en_US" in loc[0]: + return True + except Exception: + pass + return False + + +def _year_to_words_american(value: int, language: str) -> str: + if language.lower().startswith("en") and 2000 <= value <= 2099: + if value == 2000: + return "two thousand" + if 2001 <= value <= 2009: + tail = _DIGIT_WORDS[value % 10] + return f"two thousand {tail}" + + first_two = value // 100 + last_two = value % 100 + first_words = _int_to_words(first_two, language) or "twenty" + if last_two == 0: + return f"{first_words} hundred" + if last_two < 10: + return f"{first_words} oh {_DIGIT_WORDS[last_two]}" + last_words = _int_to_words(last_two, language) + return f"{first_words} {last_words or last_two}" + + words = _int_to_words(value, language) + return words or str(value) + + +def _normalize_dates(text: str, language: str) -> str: + us = _is_us_locale() + + def _format_iso(match: re.Match[str]) -> str: + year = int(match.group("year")) + month = int(match.group("month")) + day = int(match.group("day")) + if not (1 <= month <= 12 and 1 <= day <= 31): + return match.group(0) + month_name = ( + [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ][month - 1] + ) + ordinal = _int_to_ordinal_words(day, language) or str(day) + year_words = _year_to_words_american(year, language) + return f"{month_name} {ordinal}, {year_words}" if us else f"{ordinal} {month_name} {year_words}" + + def _format_mdy(match: re.Match[str]) -> str: + month_raw = str(match.group("month") or "").strip().lower().rstrip(".") + month_name = _MONTH_MAP.get(month_raw) + if not month_name: + return match.group(0) + day = int(match.group("day")) + year = int(match.group("year")) + ordinal = _int_to_ordinal_words(day, language) or str(day) + year_words = _year_to_words_american(year, language) + return f"{month_name} {ordinal}, {year_words}" if us else f"{ordinal} {month_name} {year_words}" + + out = _ISO_DATE_RE.sub(_format_iso, text) + out = _MDY_DATE_RE.sub(_format_mdy, out) + return out + + +def _normalize_times(text: str) -> str: + def _replace(match: re.Match[str]) -> str: + hour = match.group("hour") + minute = match.group("minute") + meridian = match.group("meridian").lower().replace(".", "") + if minute: + return f"{hour}:{minute} {meridian}" + return f"{hour} {meridian}" + + return _TIME_RE.sub(_replace, text) + + +def _normalize_address_abbreviations(text: str) -> str: + mapping = { + "st": "street", + "rd": "road", + "ave": "avenue", + "blvd": "boulevard", + "ln": "lane", + "dr": "drive", + } + + def _replace(match: re.Match[str]) -> str: + abbr = match.group("abbr") + full = mapping.get(abbr.lower()) + if not full: + return match.group(0) + return match.group("prefix") + _match_casing(abbr, full) + + return _ADDRESS_ABBR_RE.sub(_replace, text) + + +def _normalize_internet_slang(text: str) -> str: + mapping = { + "pls": "please", + "plz": "please", + } + + def _replace(match: re.Match[str]) -> str: + token = match.group(0) + replacement = mapping.get(token.lower()) + if not replacement: + return token + return _match_casing(token, replacement) + + return re.sub(r"\b(?:pls|plz)\b", _replace, text, flags=re.IGNORECASE) + _DECIMAL_NUMBER_RE = re.compile( rf"(?-?(?:\d{{1,3}}(?:,\d{{3}})+|\d+)\.(?P\d+))(?![\w{_NUMBER_RANGE_CLASS}/])" ) @@ -155,13 +327,49 @@ def _int_to_words(value: int, language: str) -> Optional[str]: def _int_to_ordinal_words(value: int, language: str) -> Optional[str]: - if num2words is None: - return None + if num2words is not None: + try: + return num2words(value, lang=language, ordinal=True) + except Exception: # pragma: no cover - unsupported locale + return None - try: - return num2words(value, lang=language, ordinal=True) - except Exception: # pragma: no cover - unsupported locale - return None + if language.lower().startswith("en"): + ordinals = { + 1: "first", + 2: "second", + 3: "third", + 4: "fourth", + 5: "fifth", + 6: "sixth", + 7: "seventh", + 8: "eighth", + 9: "ninth", + 10: "tenth", + 11: "eleventh", + 12: "twelfth", + 13: "thirteenth", + 14: "fourteenth", + 15: "fifteenth", + 16: "sixteenth", + 17: "seventeenth", + 18: "eighteenth", + 19: "nineteenth", + 20: "twentieth", + 21: "twenty-first", + 22: "twenty-second", + 23: "twenty-third", + 24: "twenty-fourth", + 25: "twenty-fifth", + 26: "twenty-sixth", + 27: "twenty-seventh", + 28: "twenty-eighth", + 29: "twenty-ninth", + 30: "thirtieth", + 31: "thirty-first", + } + return ordinals.get(int(value)) + + return None def _pluralize_fraction_word(base: str) -> str: @@ -366,6 +574,8 @@ TITLE_ABBREVIATIONS = { "dr": "doctor", "prof": "professor", "rev": "reverend", + "gen": "general", + "sgt": "sergeant", } SUFFIX_ABBREVIATIONS = { @@ -1146,7 +1356,23 @@ def classify_token(token: str, cfg: ApostropheConfig) -> Tuple[str, str]: # 1. Decades if DECADE_RE.match(token): if cfg.decades_mode == "expand": - return "decade", f"19{token[2:4]}s" + decade_digit = token[1] + decade_map = { + "0": "two thousands", + "1": "tens", + "2": "twenties", + "3": "thirties", + "4": "forties", + "5": "fifties", + "6": "sixties", + "7": "seventies", + "8": "eighties", + "9": "nineties", + } + spoken = decade_map.get(decade_digit) + if spoken: + return "decade", spoken + return "decade", token return "decade", token # 2. Leading elision @@ -1312,7 +1538,7 @@ def normalize_apostrophes(text: str, cfg: ApostropheConfig | None = None) -> Tup return normalized_text, results def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: - if not text or not cfg.convert_numbers: + if not text: return text language = (cfg.number_lang or "en").strip() or "en" @@ -1360,6 +1586,20 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: return words.replace(" and ", " ") return None + # 1200s are commonly spoken as "twelve hundred". + if 1200 <= value < 1300: + hundreds = value // 100 + remainder = value % 100 + prefix = _words(hundreds) + if not prefix: + return None + if remainder == 0: + return f"{prefix} hundred" + tail = _format_year_tail(remainder, allow_oh=True) + if tail is None: + return None + return f"{prefix} hundred {tail}".strip() + if value % 1000 == 0: thousands = value // 1000 thousands_words = _words(thousands) @@ -1537,6 +1777,29 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: return f"{amount_spoken} {magnitude} {currency_name}" + # Handle $0.99 -> ninety-nine cents (avoid "zero dollars and..."). + if amount_str.startswith("0") and amount < 1.0: + dollars_part, dot, fraction = amount_str.partition(".") + if dot and fraction: + cents_str = (fraction + "00")[:2] + try: + cents_value = int(cents_str) + except ValueError: + cents_value = 0 + if cents_value > 0: + cents_words = _int_to_words(cents_value, language) or str(cents_value) + subunit = { + "$": "cent", + "€": "cent", + "£": "penny", + "¥": "yen", + }.get(symbol, "cent") + if symbol == "£": + subunit = "pence" if cents_value != 1 else "penny" + else: + subunit = (subunit + "s") if cents_value != 1 and subunit not in {"pence", "yen"} else subunit + return f"{cents_words} {subunit}".strip() + currency_map = { "$": "USD", "£": "GBP", @@ -1559,6 +1822,13 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: def _replace_url(match: re.Match[str]) -> str: domain = match.group("domain") + + # Avoid matching dotted abbreviations/acronyms without an explicit URL prefix. + has_prefix = match.group(1) or match.group(2) + if not has_prefix: + parts = [p for p in domain.split(".") if p] + if parts and all(p.isalpha() and len(p) <= 2 for p in parts): + return match.group(0) # Avoid matching numbers like 1.05 or 12.34.56 # If the domain consists only of digits and dots, ignore it (unless it has http/www prefix) @@ -1583,24 +1853,34 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: return match.group(1) normalized = text - - # Apply URL replacement first + + # Apply URL replacement first (independent of number conversion). normalized = _URL_RE.sub(_replace_url, normalized) - + if getattr(cfg, "remove_footnotes", False): normalized = _FOOTNOTE_RE.sub(_remove_footnote, normalized) normalized = _BRACKET_FOOTNOTE_RE.sub("", normalized) if cfg.convert_currency: normalized = _CURRENCY_RE.sub(_replace_currency, normalized) - normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized) - normalized = _NUMBER_SPACE_RANGE_RE.sub(lambda m: _replace_space_separated_range(m, language), normalized) - normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized) - normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized) - normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized) - normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized) - normalized = _normalize_roman_numerals(normalized, language) + + if cfg.convert_numbers: + normalized = _NUMBER_RANGE_RE.sub(lambda m: _replace_number_range(m, language), normalized) + normalized = _NUMBER_SPACE_RANGE_RE.sub(lambda m: _replace_space_separated_range(m, language), normalized) + normalized = _FRACTION_RE.sub(lambda m: _replace_fraction(m, language), normalized) + normalized = _DECIMAL_NUMBER_RE.sub(_replace_decimal, normalized) + normalized = _NUMBER_WITH_GROUP_RE.sub(_replace_grouped, normalized) + normalized = _PLAIN_NUMBER_RE.sub(_replace_plain, normalized) + normalized = _normalize_roman_numerals(normalized, language) + return normalized +def _normalize_dotted_acronyms(text: str) -> str: + def _replace(match: re.Match[str]) -> str: + value = match.group(0) + compact = value.replace(".", "") + return compact + + return re.sub(r"\b(?:[A-Z]\.){1,}[A-Z]\.?(?=\W|$)", _replace, text) # ---------- Optional phoneme hint post-processing ---------- @@ -1909,21 +2189,31 @@ def normalize_for_pipeline( mode = str(runtime_settings.get("normalization_apostrophe_mode", "spacy")).lower() normalized = text + # Pre-normalization that must happen before number/url parsing. + if runtime_settings.get("normalization_numbers", True): + normalized = _normalize_dates(normalized, cfg.number_lang) + normalized = _normalize_times(normalized) + normalized = _normalize_dotted_acronyms(normalized) + if runtime_settings.get("normalization_titles", True): + normalized = _normalize_address_abbreviations(normalized) + if runtime_settings.get("normalization_internet_slang", False): + normalized = _normalize_internet_slang(normalized) + if mode == "off": - normalized = normalize_unicode_apostrophes(text) - if cfg.convert_numbers: + normalized = normalize_unicode_apostrophes(normalized) + if cfg.convert_numbers or cfg.convert_currency or getattr(cfg, "remove_footnotes", False): normalized = _normalize_grouped_numbers(normalized, cfg) normalized = _cleanup_spacing(normalized) elif mode == "llm": try: - normalized = _normalize_with_llm(text, settings=runtime_settings, config=cfg) + normalized = _normalize_with_llm(normalized, settings=runtime_settings, config=cfg) except LLMClientError: raise - if cfg.convert_numbers: + if cfg.convert_numbers or cfg.convert_currency or getattr(cfg, "remove_footnotes", False): normalized = _normalize_grouped_numbers(normalized, cfg) normalized = _cleanup_spacing(normalized) else: - normalized, _ = normalize_apostrophes(text, cfg) + normalized, _ = normalize_apostrophes(normalized, cfg) if runtime_settings.get("normalization_titles", True): normalized = expand_titles_and_suffixes(normalized) diff --git a/abogen/normalization_settings.py b/abogen/normalization_settings.py index 4a705a6..fcbe58e 100644 --- a/abogen/normalization_settings.py +++ b/abogen/normalization_settings.py @@ -38,6 +38,7 @@ _SETTINGS_DEFAULTS: Dict[str, Any] = { "normalization_terminal": True, "normalization_phoneme_hints": True, "normalization_caps_quotes": True, + "normalization_internet_slang": False, "normalization_apostrophes_contractions": True, "normalization_apostrophes_plural_possessives": True, "normalization_apostrophes_sibilant_possessives": True, diff --git a/abogen/web/debug_tts_runner.py b/abogen/web/debug_tts_runner.py index 04871e0..a2e0e70 100644 --- a/abogen/web/debug_tts_runner.py +++ b/abogen/web/debug_tts_runner.py @@ -26,6 +26,7 @@ class DebugWavArtifact: label: str filename: str code: Optional[str] = None + text: Optional[str] = None def _resolve_voice_setting(value: str) -> tuple[str, Optional[str], Optional[str]]: @@ -65,6 +66,21 @@ def _extract_cases_from_text(text: str) -> List[Tuple[str, str]]: return cases +def _spoken_id(code: str) -> str: + # Make IDs pronounceable and stable (avoid reading as a word). + out: List[str] = [] + for ch in str(code or ""): + if ch == "_": + out.append(" ") + elif ch.isalnum(): + out.append(ch) + else: + out.append(" ") + # Add spaces between alnum to encourage letter-by-letter reading. + spaced = " ".join("".join(out).split()) + return spaced + + def run_debug_tts_wavs( *, output_root: Path, @@ -96,6 +112,14 @@ def run_debug_tts_wavs( combined_text = extraction.combined_text or "\n\n".join((c.text or "") for c in extraction.chapters) cases = _extract_cases_from_text(combined_text) + # Prefer the canonical sample catalog for text (EPUB extraction may include headings). + try: + from abogen.debug_tts_samples import DEBUG_TTS_SAMPLES + + sample_text_by_code = {sample.code: sample.text for sample in DEBUG_TTS_SAMPLES} + except Exception: + sample_text_by_code = {} + expected = list(iter_expected_codes()) found_codes = {code for code, _ in cases} missing = [code for code in expected if code not in found_codes] @@ -162,11 +186,15 @@ def run_debug_tts_wavs( overall_path = run_dir / "overall.wav" overall_audio: List[np.ndarray] = [] - def synth(text: str) -> np.ndarray: - normalized = normalize_for_pipeline( - text, - config=apostrophe_config, - settings=normalization_settings, + def synth(text: str, *, apply_normalization: bool = True) -> np.ndarray: + normalized = ( + normalize_for_pipeline( + text, + config=apostrophe_config, + settings=normalization_settings, + ) + if apply_normalization + else str(text or "") ) parts: List[np.ndarray] = [] for segment in pipeline( @@ -182,19 +210,26 @@ def run_debug_tts_wavs( return np.zeros(0, dtype="float32") return np.concatenate(parts).astype("float32", copy=False) + pause_1s = np.zeros(int(1.0 * SAMPLE_RATE), dtype="float32") + between_cases = np.zeros(int(0.35 * SAMPLE_RATE), dtype="float32") + # Per sample for code, snippet in cases: + snippet = sample_text_by_code.get(code, snippet) if not snippet: continue - audio = synth(snippet) + id_audio = synth(_spoken_id(code), apply_normalization=False) + text_audio = synth(snippet, apply_normalization=True) + audio = np.concatenate([id_audio, pause_1s, text_audio]).astype("float32", copy=False) filename = f"case_{code}.wav" path = run_dir / filename # Write float32 PCM WAV. import soundfile as sf sf.write(path, audio, SAMPLE_RATE, subtype="FLOAT") - artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code)) + artifacts.append(DebugWavArtifact(label=f"{code}", filename=filename, code=code, text=snippet)) overall_audio.append(audio) + overall_audio.append(between_cases) # Overall if overall_audio: @@ -204,7 +239,7 @@ def run_debug_tts_wavs( import soundfile as sf sf.write(overall_path, combined, SAMPLE_RATE, subtype="FLOAT") - artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None)) + artifacts.insert(0, DebugWavArtifact(label="Overall", filename="overall.wav", code=None, text=None)) manifest = { "run_id": run_id, diff --git a/abogen/web/routes/settings.py b/abogen/web/routes/settings.py index 30e166a..25e106b 100644 --- a/abogen/web/routes/settings.py +++ b/abogen/web/routes/settings.py @@ -80,9 +80,17 @@ def update_settings() -> ResponseReturnValue: maximum=25, ) + def _extract_checkbox(name: str, default: bool) -> bool: + values = form.getlist(name) if hasattr(form, "getlist") else [] + if values: + return coerce_bool(values[-1], default) + if hasattr(form, "__contains__") and name in form: + return False + return default + # Normalization settings for key in _NORMALIZATION_BOOLEAN_KEYS: - current[key] = coerce_bool(form.get(key), False) + current[key] = _extract_checkbox(key, bool(current.get(key, True))) for key in _NORMALIZATION_STRING_KEYS: current[key] = (form.get(key) or "").strip() @@ -236,7 +244,8 @@ def download_debug_wav(run_id: str, filename: str) -> ResponseReturnValue: safe_name = (filename or "").strip() if not safe_run or not safe_name or "/" in safe_name or "\\" in safe_name: abort(404) - if not safe_name.lower().endswith(".wav") and safe_name != "manifest.json": + is_wav = safe_name.lower().endswith(".wav") + if not is_wav and safe_name != "manifest.json": abort(404) root = Path(current_app.config.get("OUTPUT_FOLDER") or get_user_output_path("web")) @@ -247,4 +256,12 @@ def download_debug_wav(run_id: str, filename: str) -> ResponseReturnValue: expected_dir = (root / "debug" / safe_run).resolve() if expected_dir not in path.parents: abort(404) - return send_file(path, as_attachment=True, download_name=path.name) + wants_download = str(request.args.get("download") or "").strip().lower() in {"1", "true", "yes"} + mimetype = "audio/wav" if is_wav else "application/json" + # Inline playback should work for WAVs; allow explicit downloads via ?download=1. + return send_file( + path, + mimetype=mimetype, + as_attachment=wants_download, + download_name=path.name, + ) diff --git a/abogen/web/routes/utils/settings.py b/abogen/web/routes/utils/settings.py index 7eef328..b5ab140 100644 --- a/abogen/web/routes/utils/settings.py +++ b/abogen/web/routes/utils/settings.py @@ -47,6 +47,9 @@ _NORMALIZATION_BOOLEAN_KEYS = { "normalization_terminal", "normalization_phoneme_hints", "normalization_caps_quotes", + "normalization_currency", + "normalization_footnotes", + "normalization_internet_slang", "normalization_apostrophes_contractions", "normalization_apostrophes_plural_possessives", "normalization_apostrophes_sibilant_possessives", @@ -58,8 +61,6 @@ _NORMALIZATION_BOOLEAN_KEYS = { "normalization_contraction_modal_would", "normalization_contraction_negation_not", "normalization_contraction_let_us", - "normalization_currency", - "normalization_footnotes", } _NORMALIZATION_STRING_KEYS = { @@ -84,6 +85,9 @@ BOOLEAN_SETTINGS = { "normalization_terminal", "normalization_phoneme_hints", "normalization_caps_quotes", + "normalization_currency", + "normalization_footnotes", + "normalization_internet_slang", "normalization_apostrophes_contractions", "normalization_apostrophes_plural_possessives", "normalization_apostrophes_sibilant_possessives", @@ -107,6 +111,7 @@ _NORMALIZATION_GROUPS = [ {"key": "normalization_numbers", "label": "Convert grouped numbers to words"}, {"key": "normalization_currency", "label": "Convert currency symbols ($10 → ten dollars)"}, {"key": "normalization_titles", "label": "Expand titles and suffixes (Dr., St., Jr., …)"}, + {"key": "normalization_internet_slang", "label": "Expand internet slang (pls → please)"}, {"key": "normalization_footnotes", "label": "Remove footnote indicators ([1], [2])"}, {"key": "normalization_terminal", "label": "Ensure sentences end with terminal punctuation"}, {"key": "normalization_caps_quotes", "label": "Convert ALL CAPS dialogue inside quotes"}, @@ -195,10 +200,13 @@ def settings_defaults() -> Dict[str, Any]: "llm_prompt": llm_env_defaults.get("llm_prompt", DEFAULT_LLM_PROMPT), "llm_context_mode": llm_env_defaults.get("llm_context_mode", "sentence"), "normalization_numbers": True, + "normalization_currency": True, + "normalization_footnotes": True, "normalization_titles": True, "normalization_terminal": True, "normalization_phoneme_hints": True, "normalization_caps_quotes": True, + "normalization_internet_slang": False, "normalization_apostrophes_contractions": True, "normalization_apostrophes_plural_possessives": True, "normalization_apostrophes_sibilant_possessives": True, diff --git a/abogen/web/templates/debug_wavs.html b/abogen/web/templates/debug_wavs.html index 69b766b..2b2e451 100644 --- a/abogen/web/templates/debug_wavs.html +++ b/abogen/web/templates/debug_wavs.html @@ -7,10 +7,7 @@

    Debug WAVs

    Run ID: {{ run_id }}

    -
    - -

    Click an ID to play its WAV. “Overall” is the concatenation of all samples.

    -
    +

    Each clip reads: ID, one second pause, then the reference text.

    {% if artifacts %}
    @@ -18,15 +15,15 @@
      {% for item in artifacts %}
    • - - {{ item.filename }} +
      +
      + {{ item.label }} + {% if item.text %} + — {{ item.text }} + {% endif %} +
      + +
    • {% endfor %}
    @@ -37,25 +34,4 @@ Back to Settings
    - - {% endblock %} diff --git a/tests/conftest.py b/tests/conftest.py index d662d3d..618ae8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,13 @@ import importlib import sys +import os + +import pytest + # Ensure real optional dependencies are imported before tests that install stubs -# so that available packages (like ebooklib, bs4) aren't replaced with dummy modules. -for module_name in ("ebooklib", "bs4"): +# so that available packages (like ebooklib, bs4, numpy) aren't replaced with dummy modules. +for module_name in ("ebooklib", "bs4", "numpy"): if module_name not in sys.modules: try: importlib.import_module(module_name) @@ -11,3 +15,25 @@ for module_name in ("ebooklib", "bs4"): # On environments without the optional dependency, downstream tests # will install lightweight stubs as needed. pass + + +@pytest.fixture(autouse=True, scope="session") +def _isolate_settings_dir(tmp_path_factory: pytest.TempPathFactory): + settings_dir = tmp_path_factory.mktemp("abogen-settings") + os.environ["ABOGEN_SETTINGS_DIR"] = str(settings_dir) + + try: + from abogen.utils import get_user_settings_dir + + get_user_settings_dir.cache_clear() + except Exception: + pass + + try: + from abogen.normalization_settings import clear_cached_settings + + clear_cached_settings() + except Exception: + pass + + yield diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py index 820eb2c..eab1bdf 100644 --- a/tests/test_text_normalization.py +++ b/tests/test_text_normalization.py @@ -215,6 +215,40 @@ def test_decades_can_skip_expansion_when_disabled() -> None: assert "'90s" in normalized +def test_abbreviated_decades_expand_to_spoken_form() -> None: + normalized = _normalize_text("She loved music from the '80s.") + assert "eighties" in normalized.lower() + + +def test_currency_under_one_dollar_uses_cents() -> None: + normalized = _normalize_text("It cost $0.99.") + folded = normalized.lower().replace("-", " ") + assert "zero dollars" not in folded + assert "cents" in folded + + +def test_iso_dates_use_locale_order_and_ordinals(monkeypatch) -> None: + monkeypatch.setenv("LC_TIME", "en_US.UTF-8") + normalized = _normalize_text("The date is 2025/12/15.") + folded = normalized.lower().replace("-", " ") + assert "december" in folded + assert "fifteenth" in folded + + +def test_times_and_acronyms_do_not_say_dot() -> None: + normalized = _normalize_text("Meet at 5 p.m. near the U.S.A. border.") + folded = normalized.lower() + assert " dot " not in folded + + +def test_internet_slang_expansion_is_configurable() -> None: + normalized = _normalize_text( + "pls knock before entering.", + normalization_overrides={"normalization_internet_slang": True}, + ) + assert "please" in normalized.lower() + + @pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable") def test_spacy_disambiguates_it_has_from_context() -> None: normalized = _normalize_text("It's been a long time.") From 08ebedc1773aa5c9fe850b43c77a99d86ad0b400 Mon Sep 17 00:00:00 2001 From: JB Date: Tue, 16 Dec 2025 08:52:03 -0800 Subject: [PATCH 219/245] feat: Update year normalization logic to reflect US-style pronunciation for years 1100-1999; adjust related tests for consistency --- abogen/kokoro_text_normalization.py | 4 ++-- abogen/web/routes/settings.py | 3 ++- abogen/web/routes/utils/settings.py | 6 ++++++ tests/test_date_normalization_comprehensive.py | 16 ++++++++-------- tests/test_regression_fixes.py | 5 ++--- tests/test_text_normalization.py | 3 ++- 6 files changed, 22 insertions(+), 15 deletions(-) diff --git a/abogen/kokoro_text_normalization.py b/abogen/kokoro_text_normalization.py index 92bd254..563e63d 100644 --- a/abogen/kokoro_text_normalization.py +++ b/abogen/kokoro_text_normalization.py @@ -1586,8 +1586,8 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str: return words.replace(" and ", " ") return None - # 1200s are commonly spoken as "twelve hundred". - if 1200 <= value < 1300: + # US-style: 1100-1999 are often spoken as "X hundred Y". + if 1100 <= value <= 1999: hundreds = value // 100 remainder = value % 100 prefix = _words(hundreds) diff --git a/abogen/web/routes/settings.py b/abogen/web/routes/settings.py index 25e106b..c5e1623 100644 --- a/abogen/web/routes/settings.py +++ b/abogen/web/routes/settings.py @@ -92,7 +92,8 @@ def update_settings() -> ResponseReturnValue: for key in _NORMALIZATION_BOOLEAN_KEYS: current[key] = _extract_checkbox(key, bool(current.get(key, True))) for key in _NORMALIZATION_STRING_KEYS: - current[key] = (form.get(key) or "").strip() + if hasattr(form, "__contains__") and key in form: + current[key] = (form.get(key) or "").strip() # Integrations current["integrations"] = current.get("integrations", {}) diff --git a/abogen/web/routes/utils/settings.py b/abogen/web/routes/utils/settings.py index b5ab140..6f1663a 100644 --- a/abogen/web/routes/utils/settings.py +++ b/abogen/web/routes/utils/settings.py @@ -316,6 +316,12 @@ def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> A if normalized_mode in {"off", "spacy", "llm"}: return normalized_mode return defaults[key] + if key == "normalization_numbers_year_style": + if isinstance(value, str): + normalized_style = value.strip().lower() + if normalized_style in {"american", "off"}: + return normalized_style + return defaults[key] if key == "llm_context_mode": if isinstance(value, str): normalized_scope = value.strip().lower() diff --git a/tests/test_date_normalization_comprehensive.py b/tests/test_date_normalization_comprehensive.py index 99ed994..393feee 100644 --- a/tests/test_date_normalization_comprehensive.py +++ b/tests/test_date_normalization_comprehensive.py @@ -11,14 +11,14 @@ def normalize(text, config): class TestDateNormalization: def test_standard_years(self, cfg): - # 1990 -> nineteen ninety - assert "nineteen ninety" in normalize("In 1990, the web was born.", cfg) + # 1990 -> nineteen hundred ninety + assert "nineteen hundred ninety" in normalize("In 1990, the web was born.", cfg) # 1066 -> ten sixty-six assert "ten sixty-six" in normalize("The battle was in 1066.", cfg) # 2023 -> twenty twenty-three assert "twenty twenty-three" in normalize("It is currently 2023.", cfg) - # 1905 -> nineteen oh five - assert "nineteen oh five" in normalize("In 1905, Einstein published.", cfg) + # 1905 -> nineteen hundred oh five + assert "nineteen hundred oh five" in normalize("In 1905, Einstein published.", cfg) def test_future_years(self, cfg): # 3400 -> thirty-four hundred @@ -62,14 +62,14 @@ class TestDateNormalization: def test_ambiguous_numbers(self, cfg): # Just a number, no "address", no markers. Should default to year if 4 digits 1000-9999 - assert "nineteen fifty" in normalize("I have 1950 apples.", cfg) + assert "nineteen hundred fifty" in normalize("I have 1950 apples.", cfg) # This is a known limitation/feature: it aggressively identifies years. def test_specific_user_examples(self, cfg): # 1021 assert "ten twenty-one" in normalize("1021", cfg) # 1925 - assert "nineteen twenty-five" in normalize("1925", cfg) + assert "nineteen hundred" in normalize("1925", cfg) # 3400 assert "thirty-four hundred" in normalize("3400", cfg) @@ -88,13 +88,13 @@ class TestDateNormalization: # "address" is far away (> 60 chars). Should be year. padding = "x" * 70 text = f"address {padding} 1999" - assert "nineteen ninety-nine" in normalize(text, cfg) + assert "nineteen hundred ninety-nine" in normalize(text, cfg) # "address" is close (< 60 chars). Should be number. padding = "x" * 10 text = f"address {padding} 1999" res = normalize(text, cfg) - assert "nineteen ninety-nine" not in res + assert "nineteen hundred ninety-nine" not in res assert "one thousand" in res def test_2000s(self, cfg): diff --git a/tests/test_regression_fixes.py b/tests/test_regression_fixes.py index 32181c4..7bf0396 100644 --- a/tests/test_regression_fixes.py +++ b/tests/test_regression_fixes.py @@ -12,11 +12,10 @@ def normalize(text, overrides=None): return normalize_for_pipeline(text, config=config, settings=settings) def test_year_pronunciation(): - # 1925 -> Nineteen Twenty Five + # 1925 -> Nineteen Hundred Twenty Five normalized = normalize("1925") print(f"1925 -> {normalized}") - # num2words might output "nineteen twenty-five" - assert "nineteen twenty" in normalized.lower() + assert "nineteen hundred" in normalized.lower() assert "five" in normalized.lower() # 2025 -> Twenty Twenty Five diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py index eab1bdf..156fccf 100644 --- a/tests/test_text_normalization.py +++ b/tests/test_text_normalization.py @@ -121,7 +121,8 @@ def test_space_separated_numbers_become_ranges() -> None: def test_year_like_numbers_use_common_pronunciation() -> None: normalized = _normalize_text("In 1924 the journey began") folded = normalized.lower().replace("-", " ") - assert "nineteen twenty four" in folded + assert "nineteen hundred" in folded + assert "twenty four" in folded def test_early_century_years_use_hundred_format() -> None: From 95d5921e679e08ccea0c2bd7c7a4dca0e8d9f8c3 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 20 Dec 2025 06:20:33 -0800 Subject: [PATCH 220/245] feat: Integrate Supertonic TTS provider with configuration options and UI updates; enhance voice handling and settings management --- abogen/tts_supertonic.py | 146 ++++++++++++++++++++++++++++ abogen/web/conversion_runner.py | 58 +++++++++-- abogen/web/routes/api.py | 5 + abogen/web/routes/settings.py | 10 ++ abogen/web/routes/utils/common.py | 9 +- abogen/web/routes/utils/form.py | 122 ++++++++++++++--------- abogen/web/routes/utils/preview.py | 62 ++++++++---- abogen/web/routes/utils/service.py | 2 + abogen/web/routes/utils/settings.py | 24 +++++ abogen/web/service.py | 14 +++ abogen/web/templates/base.html | 2 +- abogen/web/templates/settings.html | 40 +++++++- abogen/web/templates/voices.html | 6 +- pyproject.toml | 1 + tests/test_service.py | 34 +------ 15 files changed, 418 insertions(+), 117 deletions(-) create mode 100644 abogen/tts_supertonic.py diff --git a/abogen/tts_supertonic.py b/abogen/tts_supertonic.py new file mode 100644 index 0000000..709965b --- /dev/null +++ b/abogen/tts_supertonic.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +from dataclasses import dataclass +import math +import re +from typing import Any, Iterable, Iterator, Optional + +import numpy as np + + +DEFAULT_SUPERTONIC_VOICES = ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5") + + +@dataclass +class SupertonicSegment: + graphemes: str + audio: np.ndarray + + +def _ensure_float32_mono(wav: Any) -> np.ndarray: + arr = np.asarray(wav, dtype="float32") + if arr.ndim == 2: + # (n, 1) or (1, n) or (n, channels) + if arr.shape[0] == 1 and arr.shape[1] > 1: + arr = arr.reshape(-1) + else: + arr = arr[:, 0] + return arr.reshape(-1) + + +def _resample_linear(audio: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray: + if src_rate == dst_rate: + return audio + if audio.size == 0: + return audio + ratio = dst_rate / float(src_rate) + new_len = int(round(audio.size * ratio)) + if new_len <= 1: + return np.zeros(0, dtype="float32") + x_old = np.linspace(0.0, 1.0, num=audio.size, endpoint=False) + x_new = np.linspace(0.0, 1.0, num=new_len, endpoint=False) + return np.interp(x_new, x_old, audio).astype("float32", copy=False) + + +def _split_text(text: str, *, split_pattern: Optional[str], max_chunk_length: int) -> list[str]: + stripped = (text or "").strip() + if not stripped: + return [] + parts: list[str] + if split_pattern: + try: + parts = [p.strip() for p in re.split(split_pattern, stripped) if p.strip()] + except re.error: + parts = [stripped] + else: + parts = [stripped] + + # Enforce max length by hard-splitting long parts. + result: list[str] = [] + for part in parts: + if len(part) <= max_chunk_length: + result.append(part) + continue + start = 0 + while start < len(part): + end = min(len(part), start + max_chunk_length) + # Try to split at whitespace. + if end < len(part): + ws = part.rfind(" ", start, end) + if ws > start + 40: + end = ws + chunk = part[start:end].strip() + if chunk: + result.append(chunk) + start = end + return result + + +class SupertonicPipeline: + """Minimal adapter that mimics Kokoro's pipeline iteration interface.""" + + def __init__( + self, + *, + sample_rate: int, + auto_download: bool = True, + total_steps: int = 5, + max_chunk_length: int = 300, + ) -> None: + self.sample_rate = int(sample_rate) + self.total_steps = int(total_steps) + self.max_chunk_length = int(max_chunk_length) + + try: + from supertonic import TTS # type: ignore[import-not-found] + except Exception as exc: # pragma: no cover + raise RuntimeError( + "Supertonic is not installed. Install it with `pip install supertonic`." + ) from exc + + self._tts = TTS(auto_download=auto_download) + + def __call__( + self, + text: str, + *, + voice: str, + speed: float, + split_pattern: Optional[str] = None, + total_steps: Optional[int] = None, + ) -> Iterator[SupertonicSegment]: + voice_name = (voice or "").strip() or "M1" + steps = int(total_steps) if total_steps is not None else self.total_steps + steps = max(2, min(15, steps)) + speed_value = float(speed) if speed is not None else 1.0 + speed_value = max(0.7, min(2.0, speed_value)) + + style = self._tts.get_voice_style(voice_name=voice_name) + chunks = _split_text(text, split_pattern=split_pattern, max_chunk_length=self.max_chunk_length) + for chunk in chunks: + wav, duration = self._tts.synthesize( + text=chunk, + voice_style=style, + total_steps=steps, + speed=speed_value, + max_chunk_length=self.max_chunk_length, + silence_duration=0.0, + verbose=False, + ) + audio = _ensure_float32_mono(wav) + + # If duration is present, infer the source sample rate and resample if needed. + src_rate = self.sample_rate + try: + dur = float(duration) + if dur > 0 and audio.size > 0: + inferred = int(round(audio.size / dur)) + if 8000 <= inferred <= 96000: + src_rate = inferred + except Exception: + pass + + if src_rate != self.sample_rate: + audio = _resample_linear(audio, src_rate, self.sample_rate) + + yield SupertonicSegment(graphemes=chunk, audio=audio) diff --git a/abogen/web/conversion_runner.py b/abogen/web/conversion_runner.py index 939686e..339d02f 100644 --- a/abogen/web/conversion_runner.py +++ b/abogen/web/conversion_runner.py @@ -44,6 +44,7 @@ from abogen.voice_cache import ensure_voice_assets from abogen.voice_formulas import extract_voice_ids, get_new_voice from abogen.pronunciation_store import increment_usage from abogen.llm_client import LLMClientError +from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES, SupertonicPipeline from .service import Job, JobStatus @@ -52,6 +53,20 @@ SPLIT_PATTERN = r"\n+" SAMPLE_RATE = 24000 +def _supertonic_voice_from_spec(spec: Any, fallback: str) -> str: + raw = str(spec or "").strip() + if not raw: + raw = str(fallback or "").strip() + if not raw: + return "M1" + if "*" in raw or "+" in raw: + raw = str(fallback or "").strip() or "M1" + upper = raw.upper() + if upper in DEFAULT_SUPERTONIC_VOICES: + return upper + return str(fallback or "").strip() or "M1" + + class _JobCancelled(Exception): """Raised internally to abort a conversion when the client cancels.""" @@ -1371,7 +1386,8 @@ def run_conversion_job(job: Job) -> None: override_token_map: Dict[str, str] = {} try: pipeline = _load_pipeline(job) - _initialize_voice_cache(job) + if getattr(job, "tts_provider", "kokoro") == "kokoro": + _initialize_voice_cache(job) extraction = extract_from_path(job.stored_path) file_type = _infer_file_type(job.stored_path) pronunciation_rules = _compile_pronunciation_rules(job.pronunciation_overrides) @@ -1491,8 +1507,9 @@ def run_conversion_job(job: Job) -> None: base_voice_spec = _job_voice_fallback(job) voice_cache: Dict[str, Any] = {} - if base_voice_spec and "*" not in base_voice_spec: - voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu) + if getattr(job, "tts_provider", "kokoro") == "kokoro": + if base_voice_spec and "*" not in base_voice_spec: + voice_cache[base_voice_spec] = _resolve_voice(pipeline, base_voice_spec, job.use_gpu) processed_chars = 0 subtitle_index = 1 current_time = 0.0 @@ -1543,12 +1560,25 @@ def run_conversion_job(job: Job) -> None: raise local_segments = 0 - for segment in pipeline( - normalized, - voice=voice_choice, - speed=job.speed, - split_pattern=split_pattern, - ): + provider = getattr(job, "tts_provider", "kokoro") + if provider == "supertonic": + voice_name = _supertonic_voice_from_spec(voice_choice, getattr(job, "voice", "M1")) + segment_iter = pipeline( + normalized, + voice=voice_name, + speed=job.speed, + split_pattern=split_pattern, + total_steps=getattr(job, "supertonic_total_steps", 5), + ) + else: + segment_iter = pipeline( + normalized, + voice=voice_choice, + speed=job.speed, + split_pattern=split_pattern, + ) + + for segment in segment_iter: canceller() graphemes_raw = getattr(segment, "graphemes", "") or "" graphemes = graphemes_raw.strip() @@ -2066,7 +2096,7 @@ def run_conversion_job(job: Job) -> None: pipeline = None gc.collect() try: - import torch + import torch # type: ignore[import-not-found] if torch.cuda.is_available(): torch.cuda.empty_cache() except ImportError: @@ -2093,6 +2123,14 @@ def run_conversion_job(job: Job) -> None: def _load_pipeline(job: Job): cfg = load_config() disable_gpu = not job.use_gpu or not cfg.get("use_gpu", True) + provider = str(getattr(job, "tts_provider", "kokoro") or "kokoro").strip().lower() + if provider == "supertonic": + return SupertonicPipeline( + sample_rate=SAMPLE_RATE, + auto_download=True, + total_steps=int(getattr(job, "supertonic_total_steps", 5) or 5), + ) + device = "cpu" if not disable_gpu: device = _select_device() diff --git a/abogen/web/routes/api.py b/abogen/web/routes/api.py index 938e15d..bffc507 100644 --- a/abogen/web/routes/api.py +++ b/abogen/web/routes/api.py @@ -71,6 +71,8 @@ def api_speaker_preview() -> ResponseReturnValue: voice = payload.get("voice", "af_heart") language = payload.get("language", "a") speed = coerce_float(payload.get("speed"), 1.0) + tts_provider = str(payload.get("tts_provider") or "").strip().lower() + supertonic_total_steps = int(payload.get("supertonic_total_steps") or 5) settings = load_settings() use_gpu = settings.get("use_gpu", False) @@ -82,6 +84,9 @@ def api_speaker_preview() -> ResponseReturnValue: language=language, speed=speed, use_gpu=use_gpu + , + tts_provider=tts_provider or str(settings.get("tts_provider") or "kokoro"), + supertonic_total_steps=supertonic_total_steps or int(settings.get("supertonic_total_steps") or 5), ) except Exception as e: return jsonify({"error": str(e)}), 500 diff --git a/abogen/web/routes/settings.py b/abogen/web/routes/settings.py index c5e1623..b1e0b44 100644 --- a/abogen/web/routes/settings.py +++ b/abogen/web/routes/settings.py @@ -37,7 +37,17 @@ def update_settings() -> ResponseReturnValue: # General settings current["language"] = (form.get("language") or "en").strip() + current["tts_provider"] = (form.get("tts_provider") or current.get("tts_provider") or "kokoro").strip().lower() current["default_voice"] = (form.get("default_voice") or "").strip() + current["supertonic_default_voice"] = (form.get("supertonic_default_voice") or current.get("supertonic_default_voice") or "M1").strip() + try: + current["supertonic_total_steps"] = max(2, min(15, int(form.get("supertonic_total_steps", current.get("supertonic_total_steps", 5))))) + except (TypeError, ValueError): + pass + try: + current["supertonic_speed"] = max(0.7, min(2.0, float(form.get("supertonic_speed", current.get("supertonic_speed", 1.0))))) + except (TypeError, ValueError): + pass current["output_format"] = (form.get("output_format") or "mp3").strip() current["subtitle_mode"] = (form.get("subtitle_mode") or "Disabled").strip() current["subtitle_format"] = (form.get("subtitle_format") or "srt").strip() diff --git a/abogen/web/routes/utils/common.py b/abogen/web/routes/utils/common.py index 821b263..d280bdf 100644 --- a/abogen/web/routes/utils/common.py +++ b/abogen/web/routes/utils/common.py @@ -5,12 +5,19 @@ def split_profile_spec(value: Any) -> Tuple[str, Optional[str]]: text = str(value or "").strip() if not text: return "", None - if text.lower().startswith("profile:"): + lowered = text.lower() + if lowered.startswith("profile:") or lowered.startswith("speaker:"): _, _, remainder = text.partition(":") name = remainder.strip() return "", name or None return text, None + +def split_speaker_spec(value: Any) -> Tuple[str, Optional[str]]: + """Preferred alias for split_profile_spec (supports 'speaker:' and legacy 'profile:').""" + + return split_profile_spec(value) + def existing_paths(paths: Optional[Iterable[Path]]) -> List[Path]: if not paths: return [] diff --git a/abogen/web/routes/utils/form.py b/abogen/web/routes/utils/form.py index 63ca313..e9cb8ab 100644 --- a/abogen/web/routes/utils/form.py +++ b/abogen/web/routes/utils/form.py @@ -574,54 +574,86 @@ def apply_book_step_form( except ValueError: pass - profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() - custom_formula_raw = (form.get("voice_formula") or "").strip() - narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip() + provider_value = (form.get("tts_provider") or getattr(pending, "tts_provider", None) or settings.get("tts_provider") or "kokoro").strip().lower() + if provider_value not in {"kokoro", "supertonic"}: + provider_value = "kokoro" + pending.tts_provider = provider_value + try: + pending.supertonic_total_steps = int( + form.get("supertonic_total_steps") + or getattr(pending, "supertonic_total_steps", None) + or settings.get("supertonic_total_steps") + or 5 + ) + except (TypeError, ValueError): + pending.supertonic_total_steps = int(settings.get("supertonic_total_steps") or 5) - profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) - resolved_default_voice, inferred_profile, _ = resolve_voice_setting( - narrator_voice_raw, - profiles=profiles_map, - ) - - if profile_selection in {"__standard", "", None} and inferred_profile: - profile_selection = inferred_profile - - if profile_selection == "__formula": - profile_name = "" - custom_formula = custom_formula_raw - elif profile_selection in {"__standard", "", None}: - profile_name = "" - custom_formula = "" - else: - profile_name = profile_selection - custom_formula = "" - - base_voice_spec = resolved_default_voice or narrator_voice_raw - if not base_voice_spec and VOICES_INTERNAL: - base_voice_spec = VOICES_INTERNAL[0] - - voice_choice, resolved_language, selected_profile = resolve_voice_choice( - pending.language, - base_voice_spec, - profile_name, - custom_formula, - profiles_map, - ) - - if resolved_language: - pending.language = resolved_language - - if profile_selection == "__formula" and custom_formula_raw: - pending.voice = custom_formula_raw + if provider_value == "supertonic": + narrator_voice_raw = ( + form.get("voice") + or pending.voice + or settings.get("supertonic_default_voice") + or "M1" + ).strip() + # Supertonic does not support Abogen voice mixing. pending.voice_profile = None - elif profile_selection not in {"__standard", "", None, "__formula"}: - pending.voice_profile = selected_profile or profile_selection - pending.voice = voice_choice + pending.voice = narrator_voice_raw + + # Provider-specific speed default. + if speed_value is None: + try: + pending.speed = float(settings.get("supertonic_speed") or 1.0) + except (TypeError, ValueError): + pending.speed = 1.0 else: - pending.voice_profile = None - fallback_voice = base_voice_spec or narrator_voice_raw - pending.voice = voice_choice or fallback_voice + profile_selection = (form.get("voice_profile") or pending.voice_profile or "__standard").strip() + custom_formula_raw = (form.get("voice_formula") or "").strip() + narrator_voice_raw = (form.get("voice") or pending.voice or settings.get("default_voice") or "").strip() + + profiles_map = dict(profiles) if isinstance(profiles, Mapping) else dict(profiles or {}) + resolved_default_voice, inferred_profile, _ = resolve_voice_setting( + narrator_voice_raw, + profiles=profiles_map, + ) + + if profile_selection in {"__standard", "", None} and inferred_profile: + profile_selection = inferred_profile + + if profile_selection == "__formula": + profile_name = "" + custom_formula = custom_formula_raw + elif profile_selection in {"__standard", "", None}: + profile_name = "" + custom_formula = "" + else: + profile_name = profile_selection + custom_formula = "" + + base_voice_spec = resolved_default_voice or narrator_voice_raw + if not base_voice_spec and VOICES_INTERNAL: + base_voice_spec = VOICES_INTERNAL[0] + + voice_choice, resolved_language, selected_profile = resolve_voice_choice( + pending.language, + base_voice_spec, + profile_name, + custom_formula, + profiles_map, + ) + + if resolved_language: + pending.language = resolved_language + + if profile_selection == "__formula" and custom_formula_raw: + pending.voice = custom_formula_raw + pending.voice_profile = None + elif profile_selection not in {"__standard", "", None, "__formula"}: + pending.voice_profile = selected_profile or profile_selection + pending.voice = voice_choice + else: + pending.voice_profile = None + fallback_voice = base_voice_spec or narrator_voice_raw + pending.voice = voice_choice or fallback_voice pending.applied_speaker_config = (form.get("speaker_config") or "").strip() or None diff --git a/abogen/web/routes/utils/preview.py b/abogen/web/routes/utils/preview.py index decaa60..2fe7f2d 100644 --- a/abogen/web/routes/utils/preview.py +++ b/abogen/web/routes/utils/preview.py @@ -31,26 +31,14 @@ def generate_preview_audio( language: str, speed: float, use_gpu: bool, + tts_provider: str = "kokoro", + supertonic_total_steps: int = 5, max_seconds: float = 8.0, ) -> bytes: if not text.strip(): raise ValueError("Preview text is required") - device = "cpu" - if use_gpu: - try: - device = _select_device() - except Exception: - device = "cpu" - use_gpu = False - - pipeline = get_preview_pipeline(language, device) - if pipeline is None: - raise RuntimeError("Preview pipeline is unavailable") - - voice_choice: Any = voice_spec - if voice_spec and "*" in voice_spec: - voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) + provider = (tts_provider or "kokoro").strip().lower() try: normalized_text = normalize_for_pipeline(text) @@ -58,12 +46,40 @@ def generate_preview_audio( current_app.logger.exception("Preview normalization failed; using raw text") normalized_text = text - segments = pipeline( - normalized_text, - voice=voice_choice, - speed=speed, - split_pattern=SPLIT_PATTERN, - ) + if provider == "supertonic": + from abogen.tts_supertonic import SupertonicPipeline + + pipeline = SupertonicPipeline(sample_rate=SAMPLE_RATE, auto_download=True, total_steps=supertonic_total_steps) + segments = pipeline( + normalized_text, + voice=voice_spec, + speed=speed, + split_pattern=SPLIT_PATTERN, + total_steps=supertonic_total_steps, + ) + else: + device = "cpu" + if use_gpu: + try: + device = _select_device() + except Exception: + device = "cpu" + use_gpu = False + + pipeline = get_preview_pipeline(language, device) + if pipeline is None: + raise RuntimeError("Preview pipeline is unavailable") + + voice_choice: Any = voice_spec + if voice_spec and "*" in voice_spec: + voice_choice = get_new_voice(pipeline, voice_spec, use_gpu) + + segments = pipeline( + normalized_text, + voice=voice_choice, + speed=speed, + split_pattern=SPLIT_PATTERN, + ) audio_chunks: List[np.ndarray] = [] accumulated = 0 @@ -100,6 +116,8 @@ def synthesize_preview( language: str, speed: float, use_gpu: bool, + tts_provider: str = "kokoro", + supertonic_total_steps: int = 5, max_seconds: float = 8.0, ) -> ResponseReturnValue: try: @@ -109,6 +127,8 @@ def synthesize_preview( language=language, speed=speed, use_gpu=use_gpu, + tts_provider=tts_provider, + supertonic_total_steps=supertonic_total_steps, max_seconds=max_seconds, ) except Exception as e: diff --git a/abogen/web/routes/utils/service.py b/abogen/web/routes/utils/service.py index c138fcb..1cae32a 100644 --- a/abogen/web/routes/utils/service.py +++ b/abogen/web/routes/utils/service.py @@ -22,8 +22,10 @@ def submit_job(pending: PendingJob) -> str: original_filename=pending.original_filename, stored_path=pending.stored_path, language=pending.language, + tts_provider=getattr(pending, "tts_provider", "kokoro"), voice=pending.voice, speed=pending.speed, + supertonic_total_steps=getattr(pending, "supertonic_total_steps", 5), use_gpu=pending.use_gpu, subtitle_mode=pending.subtitle_mode, output_format=pending.output_format, diff --git a/abogen/web/routes/utils/settings.py b/abogen/web/routes/utils/settings.py index 6f1663a..f428191 100644 --- a/abogen/web/routes/utils/settings.py +++ b/abogen/web/routes/utils/settings.py @@ -170,10 +170,14 @@ def has_output_override() -> bool: def settings_defaults() -> Dict[str, Any]: llm_env_defaults = environment_llm_defaults() return { + "tts_provider": "kokoro", "output_format": "wav", "subtitle_format": "srt", "save_mode": "default_output" if has_output_override() else "save_next_to_input", "default_voice": VOICES_INTERNAL[0] if VOICES_INTERNAL else "", + "supertonic_default_voice": "M1", + "supertonic_total_steps": 5, + "supertonic_speed": 1.0, "replace_single_newlines": False, "use_gpu": True, "save_chapters_separately": False, @@ -340,6 +344,26 @@ def normalize_setting_value(key: str, value: Any, defaults: Dict[str, Any]) -> A parts = [item.strip().lower() for item in value.split(",") if item.strip()] return [code for code in parts if code in LANGUAGE_DESCRIPTIONS] return defaults.get(key, []) + if key == "tts_provider": + if isinstance(value, str): + candidate = value.strip().lower() + if candidate in {"kokoro", "supertonic"}: + return candidate + return defaults.get(key, "kokoro") + if key == "supertonic_default_voice": + return str(value or "").strip() or defaults.get(key, "M1") + if key == "supertonic_total_steps": + try: + steps = int(value) + except (TypeError, ValueError): + return defaults.get(key, 5) + return max(2, min(15, steps)) + if key == "supertonic_speed": + try: + speed = float(value) + except (TypeError, ValueError): + return defaults.get(key, 1.0) + return max(0.7, min(2.0, speed)) return value if value is not None else defaults.get(key) diff --git a/abogen/web/service.py b/abogen/web/service.py index 0c549b8..a04e9ce 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -110,6 +110,8 @@ class Job: replace_single_newlines: bool subtitle_format: str created_at: float + tts_provider: str = "kokoro" + supertonic_total_steps: int = 5 save_chapters_separately: bool = False merge_chapters_at_end: bool = True separate_chapters_format: str = "wav" @@ -201,6 +203,8 @@ class Job: }, "queue_position": self.queue_position, "options": { + "tts_provider": getattr(self, "tts_provider", "kokoro"), + "supertonic_total_steps": getattr(self, "supertonic_total_steps", 5), "save_chapters_separately": self.save_chapters_separately, "merge_chapters_at_end": self.merge_chapters_at_end, "separate_chapters_format": self.separate_chapters_format, @@ -547,6 +551,8 @@ class PendingJob: chapters: List[Dict[str, Any]] normalization_overrides: Dict[str, Any] created_at: float + tts_provider: str = "kokoro" + supertonic_total_steps: int = 5 cover_image_path: Optional[Path] = None cover_image_mime: Optional[str] = None chapter_intro_delay: float = 0.5 @@ -614,6 +620,8 @@ class ConversionService: language: str, voice: str, speed: float, + tts_provider: str = "kokoro", + supertonic_total_steps: int = 5, use_gpu: bool, subtitle_mode: str, output_format: str, @@ -665,6 +673,8 @@ class ConversionService: language=language, voice=voice, speed=speed, + tts_provider=tts_provider, + supertonic_total_steps=int(supertonic_total_steps or 5), use_gpu=use_gpu, subtitle_mode=subtitle_mode, output_format=output_format, @@ -1134,8 +1144,10 @@ class ConversionService: "original_filename": job.original_filename, "stored_path": str(job.stored_path), "language": job.language, + "tts_provider": getattr(job, "tts_provider", "kokoro"), "voice": job.voice, "speed": job.speed, + "supertonic_total_steps": getattr(job, "supertonic_total_steps", 5), "use_gpu": job.use_gpu, "subtitle_mode": job.subtitle_mode, "output_format": job.output_format, @@ -1252,6 +1264,7 @@ class ConversionService: original_filename=payload["original_filename"], stored_path=stored_path, language=payload.get("language", "a"), + tts_provider=str(payload.get("tts_provider") or "kokoro"), voice=payload.get("voice", ""), speed=float(payload.get("speed", 1.0)), use_gpu=bool(payload.get("use_gpu", True)), @@ -1262,6 +1275,7 @@ class ConversionService: replace_single_newlines=bool(payload.get("replace_single_newlines", False)), subtitle_format=payload.get("subtitle_format", "srt"), created_at=float(payload.get("created_at", time.time())), + supertonic_total_steps=int(payload.get("supertonic_total_steps", 5)), save_chapters_separately=bool(payload.get("save_chapters_separately", False)), merge_chapters_at_end=bool(payload.get("merge_chapters_at_end", True)), separate_chapters_format=payload.get("separate_chapters_format", "wav"), diff --git a/abogen/web/templates/base.html b/abogen/web/templates/base.html index 6235880..194f394 100644 --- a/abogen/web/templates/base.html +++ b/abogen/web/templates/base.html @@ -18,7 +18,7 @@