diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..341d28b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,44 @@
+# 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 (or another writable host path) so non-root users can
+# 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.
+# Find your current values with:
+# id -u # UID
+# id -g # GID
+ABOGEN_UID=1000
+ABOGEN_GID=1000
+
+# Network mode for the Docker container. Options:
+# bridge (default) - Isolated container network, uses port mapping
+# host - Container uses host's network directly, required for
+# accessing LAN resources like Calibre OPDS servers
+# ABOGEN_NETWORK_MODE=host
+
+# Optional: Seed the web UI with working defaults for the LLM-powered
+# text normalization features. Leave these blank to configure everything
+# from the Settings page.
+ABOGEN_LLM_BASE_URL=http://localhost:11434 # Supply the server root; /v1 is added automatically.
+ABOGEN_LLM_API_KEY=ollama
+ABOGEN_LLM_MODEL=llama3.1:8b
+ABOGEN_LLM_TIMEOUT=45
+ABOGEN_LLM_CONTEXT_MODE=sentence
+# For custom prompts, keep the text on a single line or escape newlines.
+#ABOGEN_LLM_PROMPT=Provide regex replacements for any apostrophes in {{sentence}} using apply_regex_replacements.
diff --git a/.gitignore b/.gitignore
index c99cf9f..bd660ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ __pycache__/
env/
venv/
.env/
+.env
.venv/
test/
@@ -30,6 +31,10 @@ python_embedded/
# abogen
*config.json
+config/
+storage/
build/
dist/
.old/
+test_assets/
+dev_notes/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90311a9..2e78e11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,15 @@
+# Unreleased
+
+# 1.3.0
+- Added an EPUB 3 packaging pipeline that builds media-overlay EPUBs from generated audio and chunk metadata.
+- Persisted chunk timing metadata in job artifacts and exercised the exporter with automated tests.
+- Added Flask-based Web UI (`abogen-web`) for Docker and headless server deployments.
+- Reorganized codebase to support both PyQt6 desktop GUI and Web UI from a shared core.
+- Added Supertonic TTS engine support with GPU acceleration.
+- Added entity analysis and pronunciation override system for proper nouns.
+- Added speaker/role assignment for multi-voice "theatrical" audiobooks.
+- Added Calibre OPDS and Audiobookshelf integration.
+
# 1.2.5
- Added new option: `Override item settings with current selection` in the queue manager. When enabled, all items in the queue will be processed using the current global settings selected in the main GUI, overriding their individual settings. When disabled, each item will retain its own specific settings.
- Fixed `Error "Could not load the Qt platform plugin "xcb"` error that occurred in some Linux distributions due to missing `libxcb-cursor0` library by conditionally loading the bundled library when the system version is unavailable, issue mentioned by @bmcgonag in #101.
@@ -53,7 +65,7 @@
- Fixed `/` and `\` path display by normalizing paths.
- Fixed book reprocessing issue where books were being processed every time the chapters window was opened, improving performance when reopening the same book.
- Fixed taskbar icon not appearing correctly in Windows.
-- Fixed “Go to folder” button not opening the chapter output directory when only separate chapters were generated.
+- Fixed "Go to folder" button not opening the chapter output directory when only separate chapters were generated.
- Improvements in code and documentation.
# 1.1.9
@@ -182,7 +194,7 @@
- Improved invalid profile handling in the voice mixer.
# v1.0.3
-- Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5.
+- Added voice mixing, allowing multiple voices to be combined into a single "Mixed Voice", a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5.
- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles.
- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience.
- Added icons for flags and genders in the GUI, making it easier to identify different options.
diff --git a/README.md b/README.md
index 3f1a810..ddb3f79 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Abogen is a powerful text-to-speech conversion tool that makes it easy to turn e
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).
+> 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?`
@@ -166,13 +166,30 @@ pip3 install --pre torch torchvision torchaudio --index-url https://download.pyt
> 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.
+
+## Interfaces
+
+Abogen offers **two interfaces**, but currently they have different feature sets. The **Web UI** contains newer features that are still being integrated into the desktop application.
+
+| Command | Interface | Features |
+|---------|-----------|----------|
+| `abogen` | PyQt6 Desktop GUI | Stable core features |
+| `abogen-web` | Flask Web UI | Core features + **Supertonic TTS**, **LLM Normalization**, **Audiobookshelf Integration** and more! |
+
+> **Note:** The Web UI is under active development. We are working to integrate these new features into the PyQt desktop app. until then, the Web UI provides the most feature-rich experience.
+
+> Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for making this possible! I was honestly surprised by his [massive contribution](https://github.com/denizsafak/abogen/pull/120) (>55,000 lines!) that brought the entire Web UI to life.
+
+# 🖥️ Desktop Application (PyQt)
+
## `How to run?`
-You can simply run this command to start Abogen:
+You can simply run this command to start Abogen Desktop GUI:
```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.
@@ -189,7 +206,7 @@ abogen
## `In action`
-Here’s Abogen in action: in this demo, it processes ∼3,000 characters of text in just 11 seconds and turns it into 3 minutes and 28 seconds of audio, and I have a low-end **RTX 2060 Mobile laptop GPU**. Your results may vary depending on your hardware.
+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`
@@ -262,6 +279,172 @@ Abogen will process each item in the queue automatically, saving outputs as conf
> Special thanks to [@jborza](https://github.com/jborza) for adding queue mode in PR [#35](https://github.com/denizsafak/abogen/pull/35)
+---
+# 🌐 Web Application (WebUI)
+
+## `How to run?`
+
+Run this command to start the Web UI:
+
+```bash
+abogen-web
+```
+Then open http://localhost:8808 and drag in your documents. Jobs run in the background worker and the browser updates automatically.
+
+
+
+## `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.
+
+## `Container image`
+You can build a lightweight container image directly from the repository root:
+
+```bash
+docker build -t abogen .
+mkdir -p ~/abogen-data/uploads ~/abogen-data/outputs
+docker run --rm \
+ -p 8808:8808 \
+ -v ~/abogen-data:/data \
+ --name abogen \
+ abogen
+```
+
+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` | `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 (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 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) |
+| `ABOGEN_LLM_BASE_URL` | `""` | OpenAI-compatible endpoint used to seed the Settings → LLM panel |
+| `ABOGEN_LLM_API_KEY` | `""` | API key passed to the endpoint above |
+| `ABOGEN_LLM_MODEL` | `""` | Default model selected when you refresh the model list |
+| `ABOGEN_LLM_TIMEOUT` | `30` | Timeout (seconds) for server-side LLM requests |
+| `ABOGEN_LLM_CONTEXT_MODE` | `sentence` | Default prompt context window (`sentence`, `paragraph`, `document`) |
+| `ABOGEN_LLM_PROMPT` | `""` | Custom normalization prompt template seeded into the UI |
+
+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.
+
+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. 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:
+
+```bash
+docker compose up -d --build
+```
+
+Key build/runtime knobs:
+
+- `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`).
+
+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 .
+docker run --rm \
+ --gpus all \
+ -p 8808:8808 \
+ -v ~/abogen-data:/data \
+ abogen-gpu
+```
+
+## `LLM-assisted text normalization`
+Abogen can hand tricky apostrophes and contractions to an OpenAI-compatible large language model. Configure it from **Settings → LLM**:
+
+1. Enter the base URL for your endpoint (Ollama, OpenAI proxy, etc.) and an API key if required. Use the server root (for Ollama: `http://localhost:11434`)—Abogen appends `/v1/...` automatically, but it also accepts inputs that already end in `/v1`.
+2. Click **Refresh models** to load the catalog, pick a default model, and adjust the timeout or prompt template.
+3. Use the preview box to test the prompt, then save the settings. The Normalization panel can synthesize a short audio preview with the current configuration.
+
+When you are running inside Docker or a CI pipeline, seed the form automatically with `ABOGEN_LLM_*` variables in your `.env` file. The `.env.example` file includes sample values for a local Ollama server.
+
+## `Audiobookshelf integration`
+Abogen can push finished audiobooks directly into Audiobookshelf. Configure this under **Settings → Integrations → Audiobookshelf** by providing:
+
+- **Base URL** – the HTTPS origin (and optional path prefix) where your Audiobookshelf server is reachable, for example `https://abs.example.com` or `https://media.example.com/abs`. Do **not** append `/api`.
+- **Library ID** – the identifier of the target Audiobookshelf library (copy it from the library’s settings page in ABS).
+- **Folder (name or ID)** – the destination folder inside that library. Enter the folder name exactly as it appears in Audiobookshelf (Abogen resolves it to the correct ID automatically), paste the raw `folderId`, or click **Browse folders** to fetch the available folders and populate the field.
+- **API token** – a personal access token generated in Audiobookshelf under *Account → API tokens*.
+
+You can enable automatic uploads for future jobs or trigger individual uploads from the queue once the connection succeeds.
+
+### Reverse proxy checklist (Nginx Proxy Manager)
+When Audiobookshelf sits behind Nginx Proxy Manager (NPM), make sure the API paths and headers reach the backend untouched:
+
+1. Create a **Proxy Host** that points to your ABS container or host (default forward port `13378`).
+2. Under the **SSL** tab, enable your certificate and tick **Force SSL** if you want HTTPS only.
+3. In the **Advanced** tab, append the snippet below so bearer tokens, client IPs, and large uploads survive the proxy hop:
+ ```nginx
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port $server_port;
+ proxy_set_header Authorization $http_authorization;
+ client_max_body_size 5g;
+ proxy_read_timeout 300s;
+ proxy_connect_timeout 300s;
+ ```
+4. Disable **Block Common Exploits** (it strips Authorization headers in some NPM builds).
+5. Enable **Websockets Support** on the main proxy screen (Audiobookshelf uses it for the web UI, and it keeps the reverse proxy configuration consistent).
+6. If you publish Audiobookshelf under a path prefix (for example `/abs`), add a **Custom Location** with `Location: /abs/` and set the **Forward Path** to `/`. That rewrite strips the `/abs` prefix before traffic reaches Audiobookshelf so `/abs/api/...` on the internet becomes `/api/...` on the backend. Use the same prefixed URL in Abogen’s “Base URL” field.
+
+After saving the proxy host, test the API from the machine running Abogen:
+
+```bash
+curl -i "https://abs.example.com/api/libraries" \
+ -H "Authorization: Bearer YOUR_API_TOKEN"
+```
+
+If you still receive `Cannot GET /api/...`, the proxy is rewriting paths. Double-check the **Custom Locations** table (the `Forward Path` column should be empty for `/abs/`) and review the NPM access/error logs while issuing the curl request to confirm the backend sees the full `/api/libraries` URL.
+
+A JSON response confirming the libraries list means the proxy is routing API calls correctly. You can then use **Browse folders** to confirm the library contents, run **Test connection** in Abogen’s settings (it verifies the library and resolves the folder), and use the “Send to Audiobookshelf” button on completed jobs.
+
+## `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.
+
+---
+# Core Features (Available in Both)
+
## `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:
@@ -335,6 +518,9 @@ For a complete list of supported languages and voices, refer to Kokoro's [VOICES
> See [How to fix Japanese audio not working?](#japanese-audio-not-working)
+---
+# Guides & Troubleshooting
+
## `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`:
```
@@ -353,54 +539,6 @@ 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 terminal in that directory and run the following commands:
-
-```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.
-```
-
-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.
-
-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).
-
-> 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/).
-
-## `🌐 Web Application`
-
-A web-based version of Abogen has been developed by [@jeremiahsb](https://github.com/jeremiahsb).
-
-**Access the repository here:** [jeremiahsb/abogen](https://github.com/jeremiahsb/abogen)
-
-> [!NOTE]
-> I intend to merge this implementation into the main repository in the future once existing conflicts are resolved. Until then, please be aware that the web version is maintained independently and may not always be in sync with the latest updates in this repository.
-
-> Special thanks to [@jeremiahsb](https://github.com/jeremiahsb) for implementing the web app!
-
## `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)**
@@ -570,6 +708,7 @@ abogen # Opens the GUI
Feel free to explore the code and make any changes you like.
## `Credits`
+- Web UI implementation by [@jeremiahsb](https://github.com/jeremiahsb)
- 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 the [spaCy](https://spacy.io/) project for its sentence-segmentation tools, which help Abogen produce cleaner, more natural sentence segmentation.
- 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.
diff --git a/WINDOWS_INSTALL.bat b/WINDOWS_INSTALL.bat
index 81a1f02..d414e87 100644
--- a/WINDOWS_INSTALL.bat
+++ b/WINDOWS_INSTALL.bat
@@ -310,7 +310,7 @@ for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from abogen.is_nvidia import check; pr
echo.
echo Checking CUDA availability...
if /I "%IS_NVIDIA%"=="true" (
- for /f %%i in ('%PYTHON_CONSOLE_PATH% -c "from torch.cuda import is_available; print(is_available())"') do set cuda_available=%%i
+ for /f %%i in ('%PYTHON_CONSOLE_PATH% %PROJECTFOLDER%\check_cuda.py') do set cuda_available=%%i
if "%cuda_available%"=="False" (
echo "Installing PyTorch with CUDA (12.8) support..."
diff --git a/abogen/Dockerfile b/abogen/Dockerfile
deleted file mode 100644
index e24a50b..0000000
--- a/abogen/Dockerfile
+++ /dev/null
@@ -1,44 +0,0 @@
-# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
-# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
-
-# 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
-
-# Load stuff needed by abogen
-RUN apt-get update \
- && apt-get install -y \
- python3 \
- python3-venv \
- python3-pip \
- python3-pyqt6 \
- espeak-ng \
- libxcb-cursor0 \
- libgl1 \
- && 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 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
diff --git a/abogen/VERSION b/abogen/VERSION
index 3a1f10e..589268e 100644
--- a/abogen/VERSION
+++ b/abogen/VERSION
@@ -1 +1 @@
-1.2.5
\ No newline at end of file
+1.3.0
\ No newline at end of file
diff --git a/abogen/book_parser.py b/abogen/book_parser.py
new file mode 100644
index 0000000..a5da16a
--- /dev/null
+++ b/abogen/book_parser.py
@@ -0,0 +1,1066 @@
+import os
+import re
+import logging
+import textwrap
+import urllib.parse
+from abc import ABC, abstractmethod
+
+import ebooklib
+from ebooklib import epub
+from bs4 import BeautifulSoup, NavigableString
+import fitz # PyMuPDF
+import markdown
+
+from abogen.utils import detect_encoding
+from abogen.subtitle_utils import clean_text, calculate_text_length
+
+# Pre-compile frequently used regex patterns
+_BRACKETED_NUMBERS_PATTERN = re.compile(r"\[\s*\d+\s*\]")
+_STANDALONE_PAGE_NUMBERS_PATTERN = re.compile(r"^\s*\d+\s*$", re.MULTILINE)
+_PAGE_NUMBERS_AT_END_PATTERN = re.compile(r"\s+\d+\s*$", re.MULTILINE)
+_PAGE_NUMBERS_WITH_DASH_PATTERN = re.compile(
+ r"\s+[-–—]\s*\d+\s*[-–—]?\s*$", re.MULTILINE
+)
+
+
+class BaseBookParser(ABC):
+ """
+ Abstract base class for parsing different book formats.
+ """
+
+ def __init__(self, book_path):
+ self.book_path = os.path.normpath(os.path.abspath(book_path))
+ self.content_texts = {}
+ self.content_lengths = {}
+ self.book_metadata = {}
+ # Unified structure for navigation: list of dicts
+ # { 'title': str, 'src': str, 'children': [], 'has_content': bool }
+ self.processed_nav_structure = []
+ self.load()
+
+ @abstractmethod
+ def load(self):
+ """Load the book file."""
+ pass
+
+ def close(self):
+ """Close any open file handles."""
+ pass
+
+ def __enter__(self):
+ # Already loaded in __init__, or lazily.
+ # Just ensure we have resources if needed, or do nothing.
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+
+ @abstractmethod
+ def process_content(self, replace_single_newlines=True):
+ """Process the book content to extract text and structure."""
+ pass
+
+ @property
+ @abstractmethod
+ def file_type(self):
+ """Return the type of the file (pdf, epub, markdown)."""
+ pass
+
+ def get_chapters(self):
+ """Return a list of chapter IDs and Names."""
+ chapters = []
+ if self.processed_nav_structure:
+
+ def flatten_nav(nodes):
+ for node in nodes:
+ if node.get("has_content"):
+ chapters.append((node["src"], node["title"]))
+ if node.get("children"):
+ flatten_nav(node["children"])
+
+ flatten_nav(self.processed_nav_structure)
+ else:
+ # Fallback for simple content without nav structure
+ for ch_id, content in self.content_texts.items():
+ # This could be improved, but serves as a generic fallback
+ chapters.append((ch_id, ch_id))
+ return chapters
+
+ def get_formatted_text(self):
+ """
+ Returns the full text of the book formatted with chapter markers.
+ """
+ chapters = self.get_chapters()
+ full_text = []
+
+ for chapter_id, chapter_name in chapters:
+ text = self.content_texts.get(chapter_id, "")
+ if text:
+ full_text.append(f"\n<>\n")
+ full_text.append(text)
+
+ return "\n".join(full_text)
+
+ def get_metadata(self):
+ """Return extracted metadata."""
+ return self.book_metadata
+
+
+class PdfParser(BaseBookParser):
+ def __init__(self, book_path):
+ self.pdf_doc = None
+ super().__init__(book_path)
+
+ @property
+ def file_type(self):
+ return "pdf"
+
+ def load(self):
+ try:
+ self.pdf_doc = fitz.open(self.book_path)
+ except Exception as e:
+ logging.error(f"Error loading PDF {self.book_path}: {e}")
+ raise
+
+ def close(self):
+ if self.pdf_doc:
+ self.pdf_doc.close()
+ self.pdf_doc = None
+
+ def _extract_book_metadata(self):
+ # PDF metadata extraction can be added here if needed
+ # For now, base class metadata is empty dict
+ pass
+
+ def process_content(self, replace_single_newlines=True):
+ if not self.pdf_doc:
+ self.load()
+
+ # 1. Extract text from all pages first
+ for page_num in range(len(self.pdf_doc)):
+ text = clean_text(self.pdf_doc[page_num].get_text())
+
+ # Clean up common PDF artifacts:
+ text = _BRACKETED_NUMBERS_PATTERN.sub("", text)
+ text = _STANDALONE_PAGE_NUMBERS_PATTERN.sub("", text)
+ text = _PAGE_NUMBERS_AT_END_PATTERN.sub("", text)
+ text = _PAGE_NUMBERS_WITH_DASH_PATTERN.sub("", text)
+
+ page_id = f"page_{page_num + 1}"
+ self.content_texts[page_id] = text
+ self.content_lengths[page_id] = calculate_text_length(text)
+
+ # 2. Build Navigation Structure
+ toc = self.pdf_doc.get_toc()
+
+ if not toc:
+ # Fallback: Flat list of pages if no TOC
+ self.processed_nav_structure = []
+ pages_node = {
+ "title": "Pages",
+ "src": None,
+ "children": [],
+ "has_content": False
+ }
+ # Add all pages as children
+ for page_num in range(len(self.pdf_doc)):
+ page_id = f"page_{page_num + 1}"
+ title = self._get_page_title(page_num, self.content_texts.get(page_id, ""))
+ pages_node["children"].append({
+ "title": title,
+ "src": page_id,
+ "children": [],
+ "has_content": True
+ })
+ self.processed_nav_structure.append(pages_node)
+ else:
+ self.processed_nav_structure = self._build_structure_from_toc(toc)
+
+ return self.content_texts, self.content_lengths
+
+ def _get_page_title(self, page_num, text):
+ title = f"Page {page_num + 1}"
+ if text:
+ first_line = text.split("\n", 1)[0].strip()
+ if first_line and len(first_line) < 100:
+ title += f" - {first_line}"
+ return title
+
+ def _build_structure_from_toc(self, toc):
+ # 1. Flatten TOC to easier list (page_num, title, level)
+ # fitz TOC is [[lvl, title, page, dest], ...]
+
+ bookmarks = []
+ for entry in toc:
+ lvl, title, page = entry[:3]
+ if isinstance(page, int):
+ page_idx = page - 1
+ else:
+ # Handle potential complex destinations if necessary, but usually simple int
+ # PyMuPDF docs say int.
+ page_idx = -1
+
+ if page_idx >= 0:
+ bookmarks.append({"level": lvl, "title": title, "page": page_idx})
+
+
+ root_children = []
+ stack = [] # Stack of (level, list_to_append_to)
+ stack.append((0, root_children))
+
+ # Step 1: Build the Skeleton Tree from TOC
+ # And keep a flat list of these nodes to associate with pages.
+
+ processed_nodes = [] # List of (page_idx, node_dict)
+
+ for entry in bookmarks:
+ node = {
+ "title": entry["title"],
+ "src": f"page_{entry['page'] + 1}",
+ "children": [],
+ "has_content": True
+ }
+
+ # Find parent
+ level = entry["level"]
+
+ # Adjust stack
+ while stack and stack[-1][0] >= level:
+ stack.pop()
+
+ parent_list = stack[-1][1]
+ parent_list.append(node)
+
+ stack.append((level, node["children"]))
+ processed_nodes.append((entry["page"], node))
+
+ # Step 3: Add gap pages.
+ # Sort processed_nodes by page index to find ranges.
+ sorted_bookmarks = sorted(processed_nodes, key=lambda x: x[0])
+
+ # Set of pages that are "bookmarks"
+ bookmarked_pages = set(p for p, n in sorted_bookmarks)
+
+ current_node = None
+ # We need a way to look up bookmarks starting at p
+ bookmarks_by_page = {}
+ for p, node in processed_nodes:
+ if p not in bookmarks_by_page:
+ bookmarks_by_page[p] = []
+ bookmarks_by_page[p].append(node)
+
+
+ # Let's iterate.
+ for page_num in range(len(self.pdf_doc)):
+ page_id = f"page_{page_num + 1}"
+
+ # Check if this page STARTS bookmarks
+ if page_num in bookmarks_by_page:
+
+ starts = bookmarks_by_page[page_num]
+ current_node = starts[-1]
+
+ continue
+
+ # If page is NOT a bookmark, it's a "gap page".
+ # Add as child to current_node
+ title = self._get_page_title(page_num, self.content_texts.get(page_id, ""))
+ page_node = {
+ "title": title,
+ "src": page_id,
+ "children": [],
+ "has_content": True
+ }
+
+ if current_node:
+ current_node["children"].append(page_node)
+ else:
+ # No preceding bookmark. Add to root.
+ root_children.append(page_node)
+
+ return root_children
+
+
+class MarkdownParser(BaseBookParser):
+ def __init__(self, book_path):
+ self.markdown_text = None
+ super().__init__(book_path)
+
+ @property
+ def file_type(self):
+ return "markdown"
+
+ def load(self):
+ try:
+ encoding = detect_encoding(self.book_path)
+ with open(self.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 = ""
+
+ def process_content(self, replace_single_newlines=True):
+ if self.markdown_text is None:
+ self.load()
+
+ self._process_markdown_content()
+ return self.content_texts, self.content_lengths
+
+ def _convert_markdown_toc_to_nav(self, toc_tokens):
+ nav_nodes = []
+ for token in toc_tokens:
+ node = {
+ "title": token["name"],
+ "src": token["id"],
+ "children": self._convert_markdown_toc_to_nav(
+ token.get("children", [])
+ ),
+ "has_content": True,
+ }
+ nav_nodes.append(node)
+ return nav_nodes
+
+ def _process_markdown_content(self):
+ if not self.markdown_text:
+ return
+
+ original_text = textwrap.dedent(self.markdown_text)
+ md = markdown.Markdown(extensions=["toc", "fenced_code"])
+ html = md.convert(original_text)
+ markdown_toc = md.toc_tokens
+
+ # Convert markdown TOC tokens to our unified navigation structure
+ self.processed_nav_structure = self._convert_markdown_toc_to_nav(markdown_toc)
+
+ cleaned_full_text = clean_text(original_text)
+
+ # If no TOC found, treat as single chapter
+ if not self.processed_nav_structure:
+ chapter_id = "markdown_content"
+ self.content_texts[chapter_id] = cleaned_full_text
+ self.content_lengths[chapter_id] = calculate_text_length(cleaned_full_text)
+ return
+
+ soup = BeautifulSoup(html, "html.parser")
+
+ all_headers = []
+
+ def flatten_nav_internal(nodes):
+ for node in nodes:
+ all_headers.append(node)
+ if node.get("children"):
+ flatten_nav_internal(node["children"])
+
+ flatten_nav_internal(self.processed_nav_structure)
+
+ header_positions = []
+ for node in all_headers:
+ header_id = node["src"]
+ 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": node["title"]}
+ )
+ 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()
+
+ 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 get_chapters(self):
+ chapters = super().get_chapters()
+ if not chapters and "markdown_content" in self.content_texts:
+ chapters.append(("markdown_content", "Content"))
+ return chapters
+
+
+class EpubParser(BaseBookParser):
+ def __init__(self, book_path):
+ self.book = None
+ self.doc_content = {}
+ super().__init__(book_path)
+
+ @property
+ def file_type(self):
+ return "epub"
+
+ def load(self):
+ try:
+ self.book = epub.read_epub(self.book_path)
+ except KeyError as e:
+ # TODO: should we just patch the ebooklib pre-emptively to avoid the need to catch this exception?
+ logging.warning(f"EPUB missing referenced file: {e}. Attempting to patch.")
+ # Patch ebooklib to skip missing files
+ import types
+ 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
+ try:
+ self.book = epub.read_epub(self.book_path)
+ finally:
+ reader_class.read_file = orig_read_file
+
+ def process_content(self, replace_single_newlines=True):
+ if not self.book:
+ self.load()
+
+ self.book_metadata = self._extract_book_metadata()
+ try:
+ nav_item, nav_type = self._identify_nav_item()
+ self._execute_nav_parsing_logic(nav_item, nav_type)
+ except Exception as e:
+ logging.warning(f"EPUB nav processing failed: {e}. Falling back to spine.")
+ self._process_epub_content_spine_fallback()
+
+ return self.content_texts, self.content_lengths
+
+ def _extract_book_metadata(self):
+ metadata = {}
+ if not self.book:
+ return metadata
+
+ try:
+ metadata["title"] = self.book.get_metadata("DC", "title")[0][0]
+ except Exception:
+ metadata["title"] = os.path.splitext(os.path.basename(self.book_path))[0]
+
+ try:
+ metadata["author"] = self.book.get_metadata("DC", "creator")[0][0]
+ except Exception:
+ metadata["author"] = "Unknown Author"
+
+ try:
+ metadata["language"] = self.book.get_metadata("DC", "language")[0][0]
+ except Exception:
+ metadata["language"] = "en"
+
+ return metadata
+
+ def _find_doc_key(self, base_href, doc_order, doc_order_decoded):
+ candidates = [
+ base_href,
+ urllib.parse.unquote(base_href),
+ ]
+ base_name = os.path.basename(base_href).lower()
+ for k in list(doc_order.keys()) + list(doc_order_decoded.keys()):
+ if os.path.basename(k).lower() == base_name:
+ candidates.append(k)
+ for candidate in candidates:
+ if candidate in doc_order:
+ return candidate, doc_order[candidate]
+ elif candidate in doc_order_decoded:
+ return candidate, doc_order_decoded[candidate]
+ return None, None
+
+ def _find_position_robust(self, doc_href, fragment_id):
+ if doc_href not in self.doc_content:
+ logging.warning(f"Document '{doc_href}' not found in cached content.")
+ 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 as e:
+ logging.warning(f"BeautifulSoup failed to find id='{fragment_id}': {e}")
+
+ 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_match_str = f'id="{fragment_id}"'
+ name_match_str = f'name="{fragment_id}"'
+ id_pos = html_content.find(id_match_str)
+ name_pos = html_content.find(name_match_str)
+
+ pos = -1
+ if id_pos != -1 and name_pos != -1:
+ pos = min(id_pos, name_pos)
+ elif id_pos != -1:
+ pos = id_pos
+ elif name_pos != -1:
+ pos = name_pos
+
+ if pos != -1:
+ tag_start_pos = html_content.rfind("<", 0, pos)
+ final_pos = tag_start_pos if tag_start_pos != -1 else 0
+ return final_pos
+
+ logging.warning(
+ f"Anchor '{fragment_id}' not found in {doc_href}. Defaulting to position 0."
+ )
+ return 0
+
+ def _parse_ncx_navpoint(
+ self,
+ nav_point,
+ ordered_entries,
+ doc_order,
+ doc_order_decoded,
+ tree_structure_list,
+ find_position_func,
+ ):
+ """
+ Recursive parsing of NCX navigation nodes.
+
+ Logic tested by: tests/test_epub_ncx_parsing.py
+ """
+ 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["src"] if content and "src" in content.attrs else None
+
+ current_entry_node = {"title": title, "src": src, "children": []}
+
+ 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 not doc_key:
+ current_entry_node["has_content"] = False
+ else:
+ position = find_position_func(doc_key, fragment)
+ entry_data = {
+ "src": src,
+ "title": title,
+ "doc_href": doc_key,
+ "position": position,
+ "doc_order": doc_idx,
+ }
+ ordered_entries.append(entry_data)
+ current_entry_node["has_content"] = True
+ else:
+ current_entry_node["has_content"] = False
+
+ child_navpoints = nav_point.find_all("navPoint", recursive=False)
+ if child_navpoints:
+ for child_np in child_navpoints:
+ self._parse_ncx_navpoint(
+ child_np,
+ ordered_entries,
+ doc_order,
+ doc_order_decoded,
+ current_entry_node["children"],
+ find_position_func,
+ )
+
+ if title and (
+ current_entry_node.get("has_content", False)
+ or current_entry_node["children"]
+ ):
+ tree_structure_list.append(current_entry_node)
+
+ def _extract_nav_li_title(self, li_element, link_element=None, span_element=None):
+ """Helper to extract title from a nav
element, handling various structures."""
+ title = "Untitled Section"
+
+ if link_element:
+ title = link_element.get_text(strip=True) or title
+ elif span_element:
+ title = span_element.get_text(strip=True) or title
+
+ # Fallback to direct text if title is empty or default
+ # If we used link/span but got empty string, we try fallback.
+ # If we didn't use link/span, we try fallback.
+ if not title.strip() or title == "Untitled Section":
+ li_text = "".join(
+ t for t in li_element.contents if isinstance(t, NavigableString)
+ ).strip()
+ if li_text:
+ title = li_text
+
+ # Second fallback: if we have a span but title is still empty, try span text again
+ # (covered by logic above mostly, but mirroring original logic's intense fallback)
+ if (not title.strip() or title == "Untitled Section") and span_element:
+ title = span_element.get_text(strip=True) or title
+
+ return title
+
+ def _parse_html_nav_li(
+ self,
+ li_element,
+ ordered_entries,
+ doc_order,
+ doc_order_decoded,
+ tree_structure_list,
+ find_position_func,
+ ):
+ """
+ Recursive parsing of HTML5 Navigation (li) nodes.
+
+ Logic tested by: tests/test_epub_html_nav_parsing.py
+ """
+ link = li_element.find("a", recursive=False)
+ span_text = li_element.find("span", recursive=False)
+ src = None
+ current_entry_node = {"children": []}
+
+ if link and "href" in link.attrs:
+ src = link["href"]
+
+ title = self._extract_nav_li_title(li_element, link, span_text)
+
+ current_entry_node["title"] = title
+ current_entry_node["src"] = src
+
+ doc_key = None
+ doc_idx = None
+ position = 0
+ fragment = 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:
+ position = find_position_func(doc_key, fragment)
+ entry_data = {
+ "src": src,
+ "title": title,
+ "doc_href": doc_key,
+ "position": position,
+ "doc_order": doc_idx,
+ }
+ ordered_entries.append(entry_data)
+ current_entry_node["has_content"] = True
+ else:
+ current_entry_node["has_content"] = False
+ else:
+ current_entry_node["has_content"] = False
+
+ 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,
+ current_entry_node["children"],
+ find_position_func,
+ )
+ tree_structure_list.append(current_entry_node)
+
+ def _identify_nav_item(self):
+ """Identify the navigation item (HTML Nav or NCX) and its type."""
+ nav_item = None
+ nav_type = None
+
+ # 1. Check ITEM_NAVIGATION
+ nav_items = list(self.book.get_items_of_type(ebooklib.ITEM_NAVIGATION))
+
+ # 1.1 Support for EPUB 3 EpubNav which might be ITEM_DOCUMENT (9) but with properties=['nav']
+ if not nav_items:
+ # Look in ITEM_DOCUMENT for items with 'nav' property
+ for item in self.book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
+ if (
+ hasattr(item, "get_type")
+ and item.get_type() == ebooklib.ITEM_DOCUMENT
+ ):
+ # Check properties - ebooklib stores opf properties in list
+ # Some versions use item.properties, some need checking
+ props = getattr(item, "properties", [])
+ if "nav" in props:
+ nav_items.append(item)
+
+ if nav_items:
+ nav_item = next(
+ (
+ item
+ for item in nav_items
+ if "nav" in item.get_name().lower()
+ and item.get_name().lower().endswith((".xhtml", ".html"))
+ ),
+ None,
+ ) or next(
+ (
+ item
+ for item in nav_items
+ if item.get_name().lower().endswith((".xhtml", ".html"))
+ ),
+ None,
+ )
+ if nav_item:
+ nav_type = "html"
+
+ # 2. NCX in NAV
+ 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"
+
+ # 3. ITEM_NCX or Fallback
+ # If no explicit navigation item found, try to find a standard NCX file
+ 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"
+
+ # 4. Heuristic Search
+ # Scan documents for something that looks like a TOC if standard methods fail
+ 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")
+ if "
+ {% endfor %}
+
+
+ {% else %}
+
No additional speakers detected yet. The narrator voice will be used for all dialogue.
Create and manage speakers for Kokoro and Supertonic.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Speaker Studio
+
Choose a TTS provider
+
+
+
+
Select which text-to-speech provider this speaker will use. You can create separate speakers per provider.
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+
+
+{% endblock %}
diff --git a/build_pypi.py b/build_pypi.py
index 076e538..9203498 100644
--- a/build_pypi.py
+++ b/build_pypi.py
@@ -27,10 +27,11 @@ def main():
version = None
if version:
print(f"🔖 Package version: {version}")
-
+
# Check if build module is installed, install if not
# Temporarily remove script_dir from sys.path to avoid importing local build.py
import sys
+
original_path = sys.path[:]
try:
sys.path = [p for p in sys.path if os.path.abspath(p) != script_dir]
diff --git a/demo/abogen-webui.png b/demo/abogen-webui.png
new file mode 100644
index 0000000..42f32c4
Binary files /dev/null and b/demo/abogen-webui.png differ
diff --git a/docker-compose.webui.yml b/docker-compose.webui.yml
new file mode 100644
index 0000000..fa35865
--- /dev/null
+++ b/docker-compose.webui.yml
@@ -0,0 +1,58 @@
+# Docker Compose for Abogen Web UI (Flask-based interface)
+#
+# This configuration runs the web-based Flask UI for Abogen.
+# For the Qt desktop UI, see the upstream project's docker configuration.
+#
+# Usage:
+# docker compose -f docker-compose.webui.yml up --build
+#
+# Or set as default:
+# docker compose up --build
+#
+services:
+ abogen-webui:
+ build:
+ context: .
+ dockerfile: abogen/webui/Dockerfile
+ args:
+ TORCH_INDEX_URL: ${TORCH_INDEX_URL:-https://download.pytorch.org/whl/cu124}
+ TORCH_VERSION: ${TORCH_VERSION:-}
+ image: abogen-webui:latest
+ user: "${ABOGEN_UID:-1000}:${ABOGEN_GID:-1000}"
+ ports:
+ - "${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_DIR: "/data/outputs"
+ ABOGEN_OUTPUT_ROOT: "/data/outputs"
+ ABOGEN_TEMP_DIR: "/data/cache"
+ ABOGEN_VOICE_CACHE_DIR: "/data/voice-cache"
+ HF_HOME: "/data/huggingface"
+ HUGGINGFACE_HUB_CACHE: "/data/huggingface/hub"
+ 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
+ # below if you are deploying to a CPU-only host.
+ deploy:
+ resources:
+ limits:
+ cpus: '4.0'
+ memory: 8G
+ reservations:
+ devices:
+ - 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
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644
index 0000000..1c92069
--- /dev/null
+++ b/docker-compose.yaml
@@ -0,0 +1,64 @@
+# Docker Compose for Abogen
+#
+# This configuration runs the Flask-based Web UI for Abogen.
+# The Web UI provides a browser-based interface for audiobook generation.
+#
+# Usage:
+# docker compose up --build
+#
+# Access the web interface at http://localhost:8808
+#
+# Network modes:
+# - Set ABOGEN_NETWORK_MODE=host in .env to use host networking
+# (required for accessing LAN resources like Calibre OPDS)
+# - Leave unset or use "bridge" for isolated container networking
+#
+services:
+ abogen:
+ build:
+ context: .
+ dockerfile: abogen/webui/Dockerfile
+ args:
+ TORCH_INDEX_URL: ${TORCH_INDEX_URL:-https://download.pytorch.org/whl/cu126}
+ TORCH_VERSION: ${TORCH_VERSION:-}
+ USE_GPU: ${USE_GPU:-true}
+ image: abogen:latest
+ user: "${ABOGEN_UID:-1000}:${ABOGEN_GID:-1000}"
+ network_mode: ${ABOGEN_NETWORK_MODE:-bridge}
+ ports:
+ - "${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_DIR: "/data/outputs"
+ ABOGEN_OUTPUT_ROOT: "/data/outputs"
+ ABOGEN_TEMP_DIR: "/data/cache"
+ ABOGEN_VOICE_CACHE_DIR: "/data/voice-cache"
+ HF_HOME: "/data/huggingface"
+ HUGGINGFACE_HUB_CACHE: "/data/huggingface/hub"
+ 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
+ # below if you are deploying to a CPU-only host.
+ deploy:
+ resources:
+ limits:
+ cpus: '4.0'
+ memory: 8G
+ reservations:
+ devices:
+ - 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
diff --git a/docs/entities_step_overhaul_plan.md b/docs/entities_step_overhaul_plan.md
new file mode 100644
index 0000000..c001d7e
--- /dev/null
+++ b/docs/entities_step_overhaul_plan.md
@@ -0,0 +1,152 @@
+# Entities Step Overhaul Plan
+
+## Requirements Recap
+- Integrate part-of-speech (POS) tagging to detect proper nouns with better precision.
+- Rename Step 3 of the wizard from **Speakers** to **Entities** everywhere (routes, templates, copy, JS).
+- Introduce a sub-navigation immediately below the step indicators with three tabs: **People**, **Entities**, **Manual Overrides**.
+- Populate tabs with appropriate data:
+ - **People**: characters with dialogue/speech evidence.
+ - **Entities**: non-person proper nouns (organizations, places, artefacts, etc.).
+ - **Manual Overrides**: user-added entries with search-driven selection, pronunciation editing, and voice assignment tools.
+- Allow manual overrides to:
+ - Search for tokens present in the uploaded manuscript/EPUB.
+ - Configure pronunciations and pick a voice (defaulting to narrator voice).
+ - Trigger previews using the same audio preview logic as other steps.
+- Provide voice selection dropdowns (with auto-generate, browse, clear, etc.) for People and Manual Override rows.
+- Tighten extraction logic so only proper nouns surface (no "The", "That", etc.).
+- Normalise detected names by removing titles ("Mr.", "Dr.") and possessives ("Bob's" -> "Bob").
+- Retain expandable sample paragraphs for context ("Preview full text" pattern) in the People tab and wherever excerpts appear.
+- Persist pronunciation overrides in a shared store so recurring entities automatically preload past settings.
+- Apply pronunciation overrides to every preview request and final conversion so TTS always respects user inputs.
+- Add a help page documenting phonetic spelling techniques (inspired by the CMU guide) and surface it via a contextual tooltip/icon inside Step 3.
+
+## Additional Considerations & Assumptions
+- POS tagging scope is English-only for the initial release; spaCy will process the manuscript once and cache results so repeated visits to Step 3 reuse the parsed doc.
+- spaCy core is MIT-licensed while the bundled `en_core_web_sm` model is CC BY-SA 3.0; we must include attribution and ensure redistribution remains compliant with the share-alike terms when packaging the model.
+- spaCy may surface unusual proper nouns (e.g., fantasy names); users can leave them unchanged or override as desired.
+- Manual overrides should persist with the pending job so that they can influence subsequent steps and final conversion. We likely need to extend pending job JSON storage and final job payloads.
+- People tab currently depends on `pending.speakers` generated in `speaker_analysis.py`. Re-architecting should avoid breaking existing downstream behaviour (e.g., queueing with selected voices).
+- Entities tab is new; we need to decide what metadata to display (count, first occurrence, sample sentences) and how it affects conversion (e.g., optional pronunciations, tags?). For now, assume read-only insights with optional pronunciation overrides similar to People.
+- Voice preview/generation flows already live in `prepare.js`; ensure refactors keep a single source of truth to avoid duplication.
+
+## Linguistic & Data Strategy
+1. **POS Tagging Research & Adoption**
+ - Leverage **spaCy** (>=3.5) for tokenisation, POS tagging, and named entity recognition (NER). It offers:
+ - Accurate POS tags for proper nouns (`PROPN`).
+ - Entity type labels (`PERSON`, `ORG`, `GPE`, etc.) that can help route to People vs Entities.
+ - Add `spacy` to dependencies and document model installation (`en_core_web_sm` minimum). Provide fallbacks:
+ - If model missing, prompt friendly error and skip advanced detection rather than failing job.
+ - Future extension: allow language-specific models per job language (English default, warn otherwise).
+
+2. **Proper Noun Filtering Logic**
+ - Process each chapter/chunk through spaCy pipeline.
+ - For each token / entity:
+ - Keep tokens tagged `PROPN` or NER labelled as proper nouns.
+ - Discard stopwords and determiners even if mislabelled (helps avoid "The", "That").
+ - Normalise by removing leading titles (`Mr.`, `Dr.`, `Lady`, etc.) and trailing possessives (`'s`, `’s`).
+ - Merge contiguous proper nouns into multi-word names (spaCy entity spans help).
+ - Build frequency map; attach contextual snippets (e.g., surrounding sentence) for each.
+ - Classify as Person vs Entity:
+ - If entity label `PERSON` or strongly associated with dialogue attribution (existing heuristics), treat as **Person**.
+ - Otherwise, map to **Entity**; optionally infer subtypes (Org, Place) for later enhancements.
+
+3. **Integration with Existing Speaker Analysis**
+ - Reuse dialogue-based detection (`speaker_analysis.py`) for People to keep gender heuristics and sample quotes.
+ - Align IDs: ensure People tab entries map to existing speaker IDs so voice selections propagate to final job.
+ - Entities tab can draw from new data structure, decoupled from `speaker_analysis` but referencing chapter/chunk indices.
+
+4. **Manual Overrides Workflow**
+ - Backend:
+ - Maintain `pending.manual_overrides` list containing `token`, `normalised_label`, `pronunciation`, `voice`, `notes`, `context`, while syncing to a persistent overrides table (e.g., SQLite) keyed by normalised token + language so history is reused across projects. Manual entries do not require spaCy detection—users can add arbitrary tokens.
+ - On load, hydrate the pending list with any matching historical overrides before rendering Step 3.
+ - Provide API endpoints:
+ 1. `GET` suggestions for a search query (scan processed tokens + raw text indexes).
+ 2. `POST` create/update override entries.
+ 3. `DELETE` override.
+ - Frontend:
+ - Search input with debounced calls to suggestion endpoint; results list to choose target word/phrase.
+ - Once selected, show pronunciation input, voice picker (reusing component from People), preview buttons.
+ - Allow manual entry of custom tokens when no suggestion matches (spaCy not required).
+ - Persist changes via AJAX (same pattern as existing speaker updates if possible) or within form submission when continuing.
+
+## Implementation Plan
+1. **Backend Enhancements**
+ - Add spaCy dependency and lazy-load model in `speaker_analysis.py` or a new `entity_analysis.py` module.
+ - Cache parsed spaCy documents per pending job (disk-backed or memoized) so repeated analysis reuses existing results without reprocessing the manuscript.
+ - Implement `extract_entities(chapters, language, config)` returning structure:
+ ```python
+ {
+ "people": [
+ {"id": "speaker_1", "label": "Bob", "count": 12, "samples": [...], ...}
+ ],
+ "entities": [
+ {"id": "entity_1", "label": "Starfleet", "kind": "ORG", "count": 5, "snippets": [...]}
+ ],
+ "index": {...} # for search/autocomplete
+ }
+ ```
+ - Enhance normalisation function to strip titles/possessives and collapse whitespace/diacritics consistently.
+ - Integrate entity output into pending job serialization so Step 3 view can render tabs without recomputation.
+ - Update job finalisation logic to include manual overrides and entity-derived metadata (for future TTS improvements).
+ - Introduce a persistent pronunciation overrides repository (SQLite via SQLAlchemy layer) shared across jobs/instances, with migrations and CRUD helpers.
+ - Apply pronunciation overrides to preview/conversion pipelines by substituting text prior to TTS synthesis (covering narrator defaults, People tab assignments, Entities tab items, and manual overrides on every TTS run).
+
+2. **Template & UI Updates**
+ - Rename Step 3 to **Entities** in all templates (`prepare_speakers.html`, upload modal partial, step indicator macros).
+ - Refactor `prepare_speakers.html` to:
+ - Wrap content in tabbed interface (likely `
` + panels).
+ - Tab panels:
+ 1. **People**: existing speaker list; adjust headings and copy.
+ 2. **Entities**: new list/grid showing non-person entities with counts and sample context; include optional pronunciation/voice controls if relevant.
+ 3. **Manual Overrides**: search box, selected override editing form, table of current overrides.
+ - Ensure sample paragraphs remain behind a collapsible disclosure control (link + `` as today).
+ - Place a help icon near pronunciation inputs; focusing/hovering reveals tooltip text summarising phonetic spelling tips and links to the full guide.
+ - Update CSS to style tabs consistent with modal aesthetic, including tooltip styling for the help icon.
+ - Add a dedicated phonetic spelling help page (e.g., `phonetic-pronunciation.html`) sourced from the CMU reference with attribution, linked from the tooltip and main help menu.
+
+3. **Frontend Logic (`prepare.js`)**
+ - Introduce tab controller managing focus and ARIA attributes.
+ - Wire People tab voice dropdowns to existing preview logic; extend to manual overrides entries.
+ - Implement search suggestions for manual overrides (debounce, fetch, render list, handle selection).
+ - Ensure previews use existing `data-role="speaker-preview"` pipeline; extend dataset attributes as needed.
+ - Persist override edits either via hidden inputs or asynchronous saves; align with form submission semantics.
+
+4. **APIs & Routing**
+ - Add Flask routes under `routes.py` or `web/service.py` for:
+ - `/pending//entities` (fetch processed entity data if not already included in template context).
+ - `/pending//overrides` (CRUD operations for manual overrides).
+ - Ensure permissions and CSRF tokens align with existing patterns.
+
+5. **Data Persistence**
+ - Expand pending job model (likely stored in `queue_manager_gui.py` / `queued_item.py`) to keep:
+ - `entity_summary` snapshot (people/entities lists).
+ - `manual_overrides` list with user edits.
+ - Cached spaCy doc metadata (hash of source + serialized parse) to avoid reprocessing unchanged texts.
+ - Introduce persistent `pronunciation_overrides` table (SQLite) keyed by normalised token + language, storing pronunciation, preferred voice, notes, and usage metadata for reuse across projects.
+ - On finalise, merge overrides into job metadata so downstream conversion can honour pronunciations/voices and sync any changes back to the shared table.
+
+6. **Testing Strategy**
+ - Unit tests for new normalisation and POS filtering functions (ensure "The", "That" excluded; "Bob's" normalised).
+ - Integration tests to confirm People tab still flows, manual overrides persist, Entities tab populates expected data.
+ - Add regression tests ensuring Step 3 rename does not break existing forms (e.g., `test_prepare_form.py`).
+ - Consider snapshot tests for API JSON structures.
+ - Add automated checks that pronunciation overrides apply to preview playback and conversion payloads for People and Entities entries alike.
+
+7. **Documentation & Ops**
+ - Update README / docs with new Step 3 name and manual override instructions.
+ - Provide guidance for installing spaCy model (e.g., `python -m spacy download en_core_web_sm`).
+ - Document spaCy/model licensing obligations (MIT for core, CC BY-SA for small model) and add attribution in app credits/help page.
+ - Publish phonetic spelling help page content and link it from the tooltip/icon in Step 3 and support docs.
+
+## Open Questions / Follow-Ups
+- Should Entities tab allow voice assignments that influence TTS, or is it informational only? Yes, it should include voice assignments that influence TTS.
+- Manual override search scope: entire text vs detected proper nouns? Current plan searches raw text and entity index.
+- Performance: confirm caching strategy (e.g., store spaCy Doc pickles vs. rebuilding from serialized spans) to balance speed and storage.
+
+## Next Steps
+1. Validate spaCy dependency choice and licensing obligations (MIT core, CC BY-SA model) with stakeholders.
+2. Finalise data contracts for entities, overrides, and the persistent pronunciation history schema.
+3. Implement backend entity extraction, cached spaCy parsing, override hydration, and the TTS substitution pipeline.
+4. Refactor frontend Step 3 UI with tabs, help icon/tooltip, and updated voice controls.
+5. Build manual override search/edit UX wired to the shared overrides store and preview flow.
+6. Update documentation (including phonetic guide) and expand automated tests.
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/pyproject.toml b/pyproject.toml
index a8f27ac..8c33e78 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,20 +14,29 @@ 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 = [
"pip",
- "PyQt6>=6.10.0",
"kokoro>=0.9.4",
"misaki[zh]>=0.9.4",
+ "supertonic>=0.1.0",
"ebooklib>=0.19",
"beautifulsoup4>=4.13.4",
+ "spacy>=3.8.7,<4.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",
+ "mutagen>=1.47.0",
"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",
- "spacy>=3.8.7"
+ "Flask>=3.0.3",
+ "numpy>=1.24.0",
+ "gpustat>=1.1.1",
+ "num2words>=0.5.13",
+ "httpx>=0.27.0",
+ "PyQt6>=6.5.0"
]
classifiers = [
@@ -50,11 +59,17 @@ 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.main:main"
+abogen = "abogen.pyqt.main:main"
[project.scripts]
-abogen-cli = "abogen.main:main"
+abogen-cli = "abogen.webui.app:main"
+abogen-web = "abogen.webui.app:main"
+abogen-pyqt = "abogen.pyqt.main:main"
[tool.hatch.build.targets.sdist]
exclude = [
@@ -68,10 +83,22 @@ exclude = [
[tool.hatch.build.targets.wheel]
packages = ["abogen"]
+[tool.hatch.build]
+include = [
+ "abogen/webui/templates/**",
+ "abogen/webui/static/**",
+]
+
[tool.hatch.version]
path = "abogen/VERSION"
pattern = "^(?P.+)$"
+[tool.pytest.ini_options]
+filterwarnings = [
+ "ignore:builtin type .* has no __module__ attribute:DeprecationWarning",
+ "ignore:Importing 'parser.split_arg_string' is deprecated:DeprecationWarning"
+]
+
# --- OPTIONAL DEPENDENCIES ---
[project.optional-dependencies]
@@ -84,7 +111,7 @@ cuda130 = ["torch"]
# AMD GPU (Linux) (ROCm 6.4) # uv tool install abogen[rocm]
rocm = ["torch", "pytorch-triton-rocm"]
# Development dependencies # uv tool install abogen[dev]
-dev = ["build"]
+dev = ["build", "pytest"]
# --- KOKORO CONFIGURATION (for macOS) ---
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..eb66ce9
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,51 @@
+"""Test package initialization.
+
+Provides lightweight fallbacks for optional dependencies so unit tests can run
+without the full runtime stack.
+"""
+
+from __future__ import annotations
+
+import sys
+from types import ModuleType
+
+
+def _soundfile_write_stub(
+ file_obj, data, samplerate, format="WAV", **_kwargs
+): # pragma: no cover - stub
+ """Minimal stand-in for soundfile.write used in tests.
+
+ The real library streams waveform data to disk. Our tests don't exercise
+ audio synthesis, so it's safe to accept the call and write nothing.
+ """
+
+ if hasattr(file_obj, "write"):
+ try:
+ file_obj.write(b"")
+ except Exception:
+ # Ignore errors from exotic buffers; the real implementation would
+ # write binary samples, so a no-op keeps behavior predictable.
+ pass
+
+
+try:
+ import soundfile
+except ImportError:
+ if "soundfile" not in sys.modules: # pragma: no cover - import guard
+ stub = ModuleType("soundfile")
+ stub.write = _soundfile_write_stub # type: ignore[attr-defined]
+ sys.modules["soundfile"] = stub
+
+
+def _static_ffmpeg_add_paths_stub(*_args, **_kwargs) -> None: # pragma: no cover - stub
+ """Placeholder for static_ffmpeg.add_paths used in tests."""
+
+
+if "static_ffmpeg" not in sys.modules: # pragma: no cover - import guard
+ ffmpeg_module = ModuleType("static_ffmpeg")
+ ffmpeg_module.add_paths = _static_ffmpeg_add_paths_stub # type: ignore[attr-defined]
+ ffmpeg_run = ModuleType("static_ffmpeg.run")
+ ffmpeg_run.LOCK_FILE = "" # type: ignore[attr-defined]
+ ffmpeg_module.run = ffmpeg_run # type: ignore[attr-defined]
+ sys.modules["static_ffmpeg"] = ffmpeg_module
+ sys.modules["static_ffmpeg.run"] = ffmpeg_run
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..618ae8d
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,39 @@
+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, 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)
+ except Exception:
+ # 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/fixtures/abogen_debug_tts_samples.epub b/tests/fixtures/abogen_debug_tts_samples.epub
new file mode 100644
index 0000000..6e78488
Binary files /dev/null and b/tests/fixtures/abogen_debug_tts_samples.epub differ
diff --git a/tests/test_audiobookshelf_client.py b/tests/test_audiobookshelf_client.py
new file mode 100644
index 0000000..12678e4
--- /dev/null
+++ b/tests/test_audiobookshelf_client.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+import json
+
+from abogen.integrations.audiobookshelf import (
+ AudiobookshelfClient,
+ AudiobookshelfConfig,
+)
+
+
+def test_upload_fields_include_series_sequence(tmp_path):
+ audio_path = tmp_path / "book.mp3"
+ audio_path.write_bytes(b"audio")
+
+ config = AudiobookshelfConfig(
+ base_url="https://example.test",
+ api_token="token",
+ library_id="library-id",
+ folder_id="folder-id",
+ )
+ client = AudiobookshelfClient(config)
+
+ client._folder_cache = ("folder-id", "Folder", "Library")
+
+ metadata = {
+ "title": "Example Title",
+ "seriesName": "Example Saga",
+ "seriesSequence": "7",
+ }
+
+ fields = client._build_upload_fields(audio_path, metadata, chapters=None)
+
+ assert fields["series"] == "Example Saga"
+ assert fields["seriesSequence"] == "7"
+
+ assert "metadata" in fields
+ payload = json.loads(fields["metadata"])
+ assert payload["seriesSequence"] == "7"
+
+
+def test_upload_fields_normalize_alternate_sequence_keys(tmp_path):
+ audio_path = tmp_path / "book.mp3"
+ audio_path.write_bytes(b"audio")
+
+ config = AudiobookshelfConfig(
+ base_url="https://example.test",
+ api_token="token",
+ library_id="library-id",
+ folder_id="folder-id",
+ )
+ client = AudiobookshelfClient(config)
+ client._folder_cache = ("folder-id", "Folder", "Library")
+
+ metadata = {
+ "title": "Example Title",
+ "seriesName": "Example Saga",
+ "series_index": "Book 3",
+ }
+
+ fields = client._build_upload_fields(audio_path, metadata, chapters=None)
+
+ assert fields["series"] == "Example Saga"
+ assert fields["seriesSequence"] == "3"
+
+
+def test_upload_fields_preserve_decimal_sequence(tmp_path):
+ audio_path = tmp_path / "book.mp3"
+ audio_path.write_bytes(b"audio")
+
+ config = AudiobookshelfConfig(
+ base_url="https://example.test",
+ api_token="token",
+ library_id="library-id",
+ folder_id="folder-id",
+ )
+ client = AudiobookshelfClient(config)
+ client._folder_cache = ("folder-id", "Folder", "Library")
+
+ metadata = {
+ "title": "Example Title",
+ "seriesName": "Example Saga",
+ "seriesSequence": "0.5",
+ }
+
+ fields = client._build_upload_fields(audio_path, metadata, chapters=None)
+
+ assert fields["seriesSequence"] == "0.5"
diff --git a/tests/test_book_handler_regression.py b/tests/test_book_handler_regression.py
new file mode 100644
index 0000000..eccfdc6
--- /dev/null
+++ b/tests/test_book_handler_regression.py
@@ -0,0 +1,89 @@
+import unittest
+import os
+import sys
+import shutil
+import time
+from PyQt6.QtWidgets import QApplication
+
+# Ensure we can import the module
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.pyqt.book_handler import HandlerDialog
+from ebooklib import epub
+
+# We need a QApplication instance for QWriter/QDialog
+app = QApplication(sys.argv)
+
+
+class TestBookHandlerRegression(unittest.TestCase):
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_handler"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.sample_epub_path = os.path.join(self.test_dir, "test_book.epub")
+ self._create_sample_epub()
+
+ def tearDown(self):
+ HandlerDialog.clear_content_cache()
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def _create_sample_epub(self):
+ book = epub.EpubBook()
+ book.set_identifier("id123456")
+ book.set_title("Sample Book")
+ book.set_language("en")
+
+ c1 = epub.EpubHtml(title="Intro", file_name="intro.xhtml", lang="en")
+ c1.content = "
Introduction
Welcome to the book.
"
+ book.add_item(c1)
+ book.spine = ["nav", c1]
+ book.add_item(epub.EpubNcx())
+ book.add_item(epub.EpubNav())
+ epub.write_epub(self.sample_epub_path, book)
+
+ def test_handler_initialization(self):
+ """Test that HandlerDialog processes the book correctly."""
+ # HandlerDialog starts processing in a background thread in __init__
+ # We assume headless environment, so we won't show it.
+ # But we need to wait for the thread to finish.
+
+ dialog = HandlerDialog(self.sample_epub_path)
+
+ # Wait for thread to finish
+ # The dialog emits no signal publicly, but we can check internal state or thread
+
+ start_time = time.time()
+ while time.time() - start_time < 5:
+ # HandlerDialog logic:
+ # _loader_thread.finished connect to _on_load_finished
+ # _on_load_finished populates content_texts and content_lengths
+
+ # We can check if content_texts is populated
+ if dialog.content_texts:
+ break
+ app.processEvents() # Process Qt events to let thread signals propagate
+ time.sleep(0.1)
+
+ self.assertTrue(
+ len(dialog.content_texts) > 0,
+ "HandlerDialog failed to process content in time",
+ )
+
+ # Validate content similar to what we expect
+ # intro.xhtml should be there
+ found_intro = False
+ for key, text in dialog.content_texts.items():
+ if "Welcome to the book" in text:
+ found_intro = True
+ break
+ self.assertTrue(found_intro)
+
+ # Cleanup
+ dialog.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_book_parser.py b/tests/test_book_parser.py
new file mode 100644
index 0000000..f4b67c1
--- /dev/null
+++ b/tests/test_book_parser.py
@@ -0,0 +1,219 @@
+import unittest
+import os
+import sys
+import shutil
+import fitz # PyMuPDF
+from ebooklib import epub
+
+# Ensure we can import the module
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.book_parser import get_book_parser, PdfParser, EpubParser, MarkdownParser
+
+
+class TestBookParser(unittest.TestCase):
+
+ def setUp(self):
+ self.test_dir = "tests/test_data"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+
+ self.sample_pdf_path = os.path.join(self.test_dir, "test_book.pdf")
+ self.sample_epub_path = os.path.join(self.test_dir, "test_book.epub")
+ self.sample_md_path = os.path.join(self.test_dir, "test_book.md")
+
+ self._create_sample_pdf()
+ self._create_sample_epub()
+ self._create_sample_md()
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def _create_sample_pdf(self):
+ doc = fitz.open()
+
+ # Page 1
+ page1 = doc.new_page()
+ page1.insert_text((50, 50), "Page 1 content")
+ # Add pattern to be cleaned
+ page1.insert_text((50, 100), "[12]")
+ page1.insert_text((50, 200), "1") # Page number at bottom
+
+ # Page 2
+ page2 = doc.new_page()
+ page2.insert_text((50, 50), "Page 2 content")
+
+ doc.save(self.sample_pdf_path)
+ doc.close()
+
+ def _create_sample_epub(self):
+ book = epub.EpubBook()
+ book.set_identifier("id123456")
+ book.set_title("Sample Book")
+ book.set_language("en")
+ book.add_author("Test Author")
+
+ c1 = epub.EpubHtml(title="Intro", file_name="intro.xhtml", lang="en")
+ c1.content = "
'
+ parser.doc_content["dummy.html"] = html
+
+ # Test finding ID
+ pos = parser._find_position_robust("dummy.html", "target")
+ self.assertGreater(pos, 0)
+ self.assertTrue(html[pos:].startswith('
>", text)
+ self.assertIn("Some text", text)
+
+ def test_file_type_property(self):
+ """Test that file_type property returns correct string for each parser."""
+ pdf_parser = PdfParser(self.sample_pdf_path)
+ self.assertEqual(pdf_parser.file_type, "pdf")
+
+ epub_parser = EpubParser(self.sample_epub_path)
+ self.assertEqual(epub_parser.file_type, "epub")
+
+ md_parser = MarkdownParser(self.sample_md_path)
+ self.assertEqual(md_parser.file_type, "markdown")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_book_step_speaker_provider.py b/tests/test_book_step_speaker_provider.py
new file mode 100644
index 0000000..6591c81
--- /dev/null
+++ b/tests/test_book_step_speaker_provider.py
@@ -0,0 +1,83 @@
+from pathlib import Path
+
+from werkzeug.datastructures import MultiDict
+
+from abogen.webui.routes.utils.form import apply_book_step_form
+from abogen.webui.service import PendingJob
+
+
+def _make_pending_job() -> PendingJob:
+ pending = PendingJob(
+ id="pending",
+ original_filename="example.epub",
+ stored_path=Path("example.epub"),
+ language="a",
+ voice="af_nova",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="none",
+ output_format="mp3",
+ save_mode="save_next_to_input",
+ output_folder=None,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ total_characters=0,
+ save_chapters_separately=False,
+ merge_chapters_at_end=True,
+ separate_chapters_format="wav",
+ silence_between_chapters=2.0,
+ save_as_project=False,
+ voice_profile=None,
+ max_subtitle_words=50,
+ metadata_tags={},
+ chapters=[],
+ normalization_overrides={},
+ created_at=0.0,
+ read_title_intro=False,
+ normalize_chapter_opening_caps=True,
+ )
+ pending.tts_provider = "kokoro"
+ return pending
+
+
+def test_book_step_supertonic_profile_becomes_speaker_reference() -> None:
+ pending = _make_pending_job()
+
+ settings = {
+ "language": "a",
+ "chunk_level": "paragraph",
+ "speaker_analysis_threshold": 3,
+ "default_voice": "af_nova",
+ "default_speaker": "",
+ "default_speed": 1.0,
+ "read_title_intro": False,
+ "read_closing_outro": True,
+ "normalize_chapter_opening_caps": True,
+ }
+
+ profiles = {
+ "Female HQ": {
+ "provider": "supertonic",
+ "voice": "F3",
+ "speed": 1.0,
+ "total_steps": 5,
+ "language": "a",
+ }
+ }
+
+ form = MultiDict(
+ {
+ "language": "a",
+ "voice_profile": "Female HQ",
+ "speed": "1.0",
+ }
+ )
+
+ apply_book_step_form(pending, form, settings=settings, profiles=profiles)
+
+ # Voice is stored as a speaker reference so provider can be resolved per-speaker.
+ assert pending.voice == "speaker:Female HQ"
+ assert pending.voice_profile == "Female HQ"
+
+ # Book-level provider should not be overridden by narrator defaults.
+ assert pending.tts_provider == "kokoro"
diff --git a/tests/test_calibre_opds.py b/tests/test_calibre_opds.py
new file mode 100644
index 0000000..e19bf07
--- /dev/null
+++ b/tests/test_calibre_opds.py
@@ -0,0 +1,648 @@
+from abogen.integrations.calibre_opds import (
+ CalibreOPDSClient,
+ OPDSEntry,
+ OPDSFeed,
+ OPDSLink,
+ feed_to_dict,
+)
+
+
+def test_calibre_opds_feed_exposes_series_metadata() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ book-1
+ Sample Book
+ The Expanse
+ 4
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+ assert feed.entries, "Expected at least one entry in parsed feed"
+ entry = feed.entries[0]
+
+ assert entry.series == "The Expanse"
+ assert entry.series_index == 4.0
+
+ feed_dict = feed_to_dict(feed)
+ assert feed_dict["entries"][0]["series"] == "The Expanse"
+ assert feed_dict["entries"][0]["series_index"] == 4.0
+
+
+def test_calibre_opds_feed_exposes_subtitle_metadata() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ book-1
+ Sample Book
+ A Novel
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+ assert feed.entries
+ assert feed.entries[0].subtitle == "A Novel"
+
+ feed_dict = feed_to_dict(feed)
+ assert feed_dict["entries"][0]["subtitle"] == "A Novel"
+
+
+def test_calibre_opds_feed_extracts_series_from_categories() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ book-2
+ Network Effect
+
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+ assert feed.entries, "Expected at least one entry in parsed feed"
+ entry = feed.entries[0]
+
+ assert entry.series == "The Murderbot Diaries"
+ assert entry.series_index == 5.0
+
+
+def test_calibre_opds_does_not_map_author_into_series_from_categories() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ book-author-series-bug
+ Sample Book
+
+ Alexandre Dumas
+
+
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+ assert feed.entries
+ entry = feed.entries[0]
+
+ assert entry.authors == ["Alexandre Dumas"]
+ assert entry.series is None
+ assert entry.series_index is None
+
+
+def test_calibre_opds_extracts_tags_and_rating_from_summary() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ book-3
+ Summary Sample
+ 2024-01-15T00:00:00+00:00
+ RATING: ★★★½
+TAGS: Science Fiction; Adventure
+SERIES: Saga [3]
+This is the detailed summary text.
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+ entry = feed.entries[0]
+
+ assert entry.series == "Saga"
+ assert entry.series_index == 3.0
+ assert entry.tags == ["Science Fiction", "Adventure"]
+ assert entry.rating == 3.5
+ assert entry.rating_max == 5.0
+ assert entry.summary == "This is the detailed summary text."
+ assert entry.published == "2024-01-15T00:00:00+00:00"
+
+
+def test_calibre_opds_relative_urls_keep_catalog_prefix() -> None:
+ client = CalibreOPDSClient("http://example.com/opds/")
+
+ assert client._make_url("search") == "http://example.com/opds/search"
+ assert (
+ client._make_url("books/sample.epub")
+ == "http://example.com/opds/books/sample.epub"
+ )
+ assert client._make_url("/cover/1") == "http://example.com/cover/1"
+ assert client._make_url("?page=2") == "http://example.com/opds?page=2"
+
+
+def test_calibre_opds_base_url_without_trailing_slash() -> None:
+ """Ensure the client works with base URLs that don't have trailing slashes."""
+ client = CalibreOPDSClient("http://example.com/api/v1/opds")
+
+ # Base URL should be stored without trailing slash
+ assert client._base_url == "http://example.com/api/v1/opds"
+ # Relative paths should resolve as siblings to the base URL
+ assert client._make_url("catalog") == "http://example.com/api/v1/opds/catalog"
+ assert (
+ client._make_url("search?q=test")
+ == "http://example.com/api/v1/opds/search?q=test"
+ )
+ assert (
+ client._make_url("/api/v1/opds/books") == "http://example.com/api/v1/opds/books"
+ )
+ assert client._make_url("?page=2") == "http://example.com/api/v1/opds?page=2"
+
+
+def test_calibre_opds_filters_out_unsupported_formats() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ audio-book
+ Unsupported Audio
+
+
+
+ pdf-book
+ Allowed PDF
+
+
+
+ epub-book
+ Allowed EPUB
+
+
+
+ nav-author
+ Authors (A)
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+
+ identifiers = {entry.id for entry in feed.entries}
+ assert identifiers == {"pdf-book", "epub-book", "nav-author"}
+ for entry in feed.entries:
+ if entry.id.startswith("nav-"):
+ assert entry.download is None
+ assert entry.links, "Expected navigation entry to preserve links"
+ else:
+ assert entry.download is not None
+ assert entry.download.href.endswith((".pdf", ".epub"))
+
+
+def test_calibre_opds_navigation_entries_without_download_are_preserved() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ xml_payload = """
+
+ catalog
+ Example Catalog
+
+ nav-series
+ Series
+
+
+
+ """
+
+ feed = client._parse_feed(xml_payload, base_url="http://example.com/catalog")
+
+ assert [entry.id for entry in feed.entries] == ["nav-series"]
+ entry = feed.entries[0]
+ assert entry.download is None
+ assert any(link.href.endswith("/opds/series") for link in entry.links)
+
+
+def test_calibre_opds_search_filters_by_title_and_author() -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ feed = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[
+ OPDSEntry(id="1", title="The Long Journey", authors=["Alice Smith"]),
+ OPDSEntry(id="2", title="Hidden Worlds", authors=["Bob Johnson"]),
+ OPDSEntry(
+ id="3",
+ title="Side Stories",
+ authors=["Cara Nguyen"],
+ series="Journey Tales",
+ ),
+ ],
+ )
+
+ filtered = client._filter_feed_entries(feed, "journey alice")
+ assert [entry.id for entry in filtered.entries] == ["1"]
+
+ filtered = client._filter_feed_entries(feed, "bob")
+ assert [entry.id for entry in filtered.entries] == ["2"]
+
+ filtered = client._filter_feed_entries(feed, "journey tales")
+ assert [entry.id for entry in filtered.entries] == ["3"]
+
+ filtered = client._filter_feed_entries(feed, "missing")
+ assert filtered.entries == []
+
+
+def test_calibre_opds_local_search_follows_next(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ page_one = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
+ links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
+ )
+ page_two = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[
+ OPDSEntry(id="2", title="The Journey Continues", authors=["Bob Johnson"])
+ ],
+ links={},
+ )
+
+ def fake_fetch(href=None, params=None):
+ if href == "http://example.com/catalog?page=2":
+ return page_two
+ return page_one
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client._local_search("journey", seed_feed=page_one)
+ assert [entry.id for entry in result.entries] == ["2"]
+
+
+def test_calibre_opds_local_search_traverses_navigation(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ root_feed = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[
+ OPDSEntry(
+ id="nav-authors",
+ title="Browse Authors",
+ links=[
+ OPDSLink(
+ href="http://example.com/catalog/authors",
+ rel="http://opds-spec.org/navigation",
+ type="application/atom+xml;profile=opds-catalog",
+ )
+ ],
+ )
+ ],
+ links={},
+ )
+ authors_feed = OPDSFeed(
+ id="authors",
+ title="Authors",
+ entries=[
+ OPDSEntry(
+ id="book-42",
+ title="The Count of Monte Cristo",
+ authors=["Alexandre Dumas"],
+ )
+ ],
+ links={},
+ )
+
+ def fake_fetch(href=None, params=None):
+ if href == "http://example.com/catalog/authors":
+ return authors_feed
+ return root_feed
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client._local_search("monte cristo", seed_feed=root_feed)
+ assert [entry.id for entry in result.entries] == ["book-42"]
+
+
+def test_calibre_opds_search_falls_back_to_local_search(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ search_page = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[OPDSEntry(id="1", title="Unrelated", authors=["Alice Smith"])],
+ links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
+ )
+ next_page = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[OPDSEntry(id="2", title="Journey in Space", authors=["Cara Nguyen"])],
+ links={},
+ )
+
+ def fake_fetch(path=None, params=None):
+ if path == "search":
+ return search_page
+ if path == "http://example.com/catalog?page=2":
+ return next_page
+ return search_page
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.search("journey")
+ assert [entry.id for entry in result.entries] == ["2"]
+
+
+def test_calibre_opds_search_collects_next_page_results(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ first_page = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[OPDSEntry(id="1", title="Ryan's Adventure")],
+ links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
+ )
+ second_page = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[OPDSEntry(id="2", title="Return of Ryan")],
+ links={},
+ )
+
+ def fake_fetch(path=None, params=None):
+ if path == "search":
+ return first_page
+ if path == "http://example.com/catalog?page=2":
+ return second_page
+ if path is None and params is None:
+ return first_page
+ return first_page
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.search("ryan")
+ assert [entry.id for entry in result.entries] == ["1", "2"]
+
+
+def test_calibre_opds_search_supplements_with_local_navigation(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ search_feed = OPDSFeed(
+ id="catalog",
+ title="Catalog",
+ entries=[
+ OPDSEntry(id="book-1", title="Ryan's First Mission"),
+ OPDSEntry(
+ id="nav-authors",
+ title="Browse Authors",
+ links=[
+ OPDSLink(
+ href="http://example.com/catalog/authors",
+ rel="http://opds-spec.org/navigation",
+ type="application/atom+xml;profile=opds-catalog",
+ )
+ ],
+ ),
+ ],
+ links={},
+ )
+ authors_feed = OPDSFeed(
+ id="authors",
+ title="Authors",
+ entries=[OPDSEntry(id="book-2", title="Chronicles of Ryan")],
+ links={},
+ )
+
+ def fake_fetch(path=None, params=None):
+ if path == "search":
+ return search_feed
+ if path == "http://example.com/catalog/authors":
+ return authors_feed
+ if path is None and params is None:
+ return search_feed
+ return search_feed
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.search("ryan")
+ assert [entry.id for entry in result.entries] == ["book-1", "book-2"]
+
+
+def test_calibre_opds_browse_letter_traverses_next(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ root_feed = OPDSFeed(
+ id="catalog",
+ title="Browse Authors",
+ entries=[
+ OPDSEntry(
+ id="nav-a",
+ title="A",
+ links=[
+ OPDSLink(
+ href="http://example.com/catalog/authors/a",
+ rel="http://opds-spec.org/navigation",
+ type="application/atom+xml;profile=opds-catalog",
+ )
+ ],
+ )
+ ],
+ links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
+ )
+ page_two = OPDSFeed(
+ id="catalog",
+ title="Browse Authors",
+ entries=[
+ OPDSEntry(
+ id="nav-c",
+ title="C",
+ links=[
+ OPDSLink(
+ href="http://example.com/catalog/authors/c",
+ rel="http://opds-spec.org/navigation",
+ type="application/atom+xml;profile=opds-catalog",
+ )
+ ],
+ )
+ ],
+ links={},
+ )
+ letter_feed = OPDSFeed(
+ id="authors-c",
+ title="Authors starting with C",
+ entries=[OPDSEntry(id="author-1", title="Clarke, Arthur C.")],
+ links={},
+ )
+
+ def fake_fetch(href=None, params=None):
+ if not href:
+ return root_feed
+ if href == "http://example.com/catalog?page=2":
+ return page_two
+ if href == "http://example.com/catalog/authors/c":
+ return letter_feed
+ return root_feed
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.browse_letter("C")
+ assert [entry.id for entry in result.entries] == ["author-1"]
+
+
+def test_calibre_opds_browse_letter_filters_when_missing_navigation(
+ monkeypatch,
+) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ titles_feed = OPDSFeed(
+ id="catalog",
+ title="Browse Titles",
+ entries=[
+ OPDSEntry(id="book-1", title="The Moon is a Harsh Mistress"),
+ OPDSEntry(id="book-2", title="Another Story"),
+ ],
+ links={},
+ )
+
+ def fake_fetch(href=None, params=None):
+ return titles_feed
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.browse_letter("M")
+ assert [entry.id for entry in result.entries] == ["book-1"]
+
+
+def test_calibre_opds_browse_letter_collects_paginated_entries(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ first_page = OPDSFeed(
+ id="catalog",
+ title="Browse Titles",
+ entries=[
+ OPDSEntry(id="book-1", title="Ryan's First Adventure"),
+ OPDSEntry(id="book-2", title="Another Tale"),
+ ],
+ links={"next": OPDSLink(href="http://example.com/catalog?page=2", rel="next")},
+ )
+ second_page = OPDSFeed(
+ id="catalog",
+ title="Browse Titles",
+ entries=[OPDSEntry(id="book-3", title="Return of Ryan")],
+ links={},
+ )
+
+ def fake_fetch(href=None, params=None):
+ if not href:
+ return first_page
+ if href == "http://example.com/catalog?page=2":
+ return second_page
+ return first_page
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.browse_letter("R")
+ assert [entry.id for entry in result.entries] == ["book-1", "book-3"]
+
+
+def test_calibre_opds_browse_letter_collects_paginated_navigation(monkeypatch) -> None:
+ client = CalibreOPDSClient("http://example.com/catalog")
+ root_feed = OPDSFeed(
+ id="catalog",
+ title="Browse Authors",
+ entries=[
+ OPDSEntry(
+ id="nav-a",
+ title="A",
+ links=[
+ OPDSLink(
+ href="http://example.com/catalog/authors/a",
+ rel="http://opds-spec.org/navigation",
+ type="application/atom+xml;profile=opds-catalog",
+ )
+ ],
+ ),
+ OPDSEntry(
+ id="nav-r",
+ title="R",
+ links=[
+ OPDSLink(
+ href="http://example.com/catalog/authors/r",
+ rel="http://opds-spec.org/navigation",
+ type="application/atom+xml;profile=opds-catalog",
+ )
+ ],
+ ),
+ ],
+ links={},
+ )
+ letter_feed = OPDSFeed(
+ id="authors-r",
+ title="Authors — R",
+ entries=[
+ OPDSEntry(id="author-1", title="Ryan, Alice"),
+ ],
+ links={
+ "next": OPDSLink(
+ href="http://example.com/catalog/authors/r?page=2", rel="next"
+ )
+ },
+ )
+ letter_page_two = OPDSFeed(
+ id="authors-r",
+ title="Authors — R",
+ entries=[OPDSEntry(id="author-2", title="Ryan, Bob")],
+ links={},
+ )
+
+ def fake_fetch(href=None, params=None):
+ if not href:
+ return root_feed
+ if href == "http://example.com/catalog/authors/r":
+ return letter_feed
+ if href == "http://example.com/catalog/authors/r?page=2":
+ return letter_page_two
+ return root_feed
+
+ monkeypatch.setattr(client, "fetch_feed", fake_fetch)
+
+ result = client.browse_letter("R")
+ assert [entry.id for entry in result.entries] == ["author-1", "author-2"]
diff --git a/tests/test_chapter_overrides.py b/tests/test_chapter_overrides.py
new file mode 100644
index 0000000..7ffbe1a
--- /dev/null
+++ b/tests/test_chapter_overrides.py
@@ -0,0 +1,195 @@
+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.webui.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
diff --git a/tests/test_chunk_helpers.py b/tests/test_chunk_helpers.py
new file mode 100644
index 0000000..ee9da79
--- /dev/null
+++ b/tests/test_chunk_helpers.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from abogen.chunking import chunk_text
+from abogen.webui.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"
+
+
+def test_chunk_text_merges_title_abbreviations() -> None:
+ text = "Dr. Watson met Mr. Holmes at 5 p.m."
+
+ chunks = chunk_text(
+ chapter_index=0,
+ chapter_title="Chapter 1",
+ text=text,
+ level="sentence",
+ )
+
+ assert len(chunks) == 1
+ chunk = chunks[0]
+ text_value = str(chunk["text"])
+ normalized_value = str(chunk.get("normalized_text") or "")
+ assert normalized_value
+ assert text_value.startswith("Dr.")
+ assert "Doctor" in normalized_value
+ display_value = str(chunk.get("display_text") or "")
+ assert display_value.startswith("Dr.")
+ original_value = str(chunk.get("original_text") or "")
+ assert original_value.startswith("Dr.")
+
+
+def test_chunk_text_display_preserves_whitespace() -> None:
+ text = "Line one with double spaces.\nSecond line\n\nThird paragraph."
+
+ chunks = chunk_text(
+ chapter_index=0,
+ chapter_title="Chapter 1",
+ text=text,
+ level="paragraph",
+ )
+
+ assert len(chunks) == 2
+ first_display = str(chunks[0].get("display_text") or "")
+ assert " with " in first_display
+ assert first_display.endswith("\n\n")
+ second_display = str(chunks[1].get("display_text") or "")
+ assert second_display == "Third paragraph."
+ first_original = str(chunks[0].get("original_text") or "")
+ assert first_original.endswith("\n\n")
diff --git a/tests/test_chunk_text_for_tts_prefers_raw.py b/tests/test_chunk_text_for_tts_prefers_raw.py
new file mode 100644
index 0000000..faf3c67
--- /dev/null
+++ b/tests/test_chunk_text_for_tts_prefers_raw.py
@@ -0,0 +1,25 @@
+from abogen.webui.conversion_runner import _chunk_text_for_tts
+
+
+def test_chunk_text_for_tts_prefers_text_over_normalized_text():
+ entry = {
+ # Simulate a pre-normalized chunk that lost the asterisk.
+ "normalized_text": "Unfuk",
+ # Raw chunk should preserve censored token for manual overrides.
+ "text": "Unfu*k",
+ }
+
+ assert _chunk_text_for_tts(entry) == "Unfu*k"
+
+
+def test_chunk_text_for_tts_falls_back_to_original_text_then_normalized_text():
+ entry = {
+ "original_text": "Hello * world",
+ "normalized_text": "Hello world",
+ }
+ assert _chunk_text_for_tts(entry) == "Hello * world"
+
+ entry2 = {
+ "normalized_text": "Only normalized",
+ }
+ assert _chunk_text_for_tts(entry2) == "Only normalized"
diff --git a/tests/test_conversion_chapter_titles.py b/tests/test_conversion_chapter_titles.py
new file mode 100644
index 0000000..5fe6a4d
--- /dev/null
+++ b/tests/test_conversion_chapter_titles.py
@@ -0,0 +1,126 @@
+import sys
+import types
+
+if "soundfile" not in sys.modules:
+ soundfile_stub = types.ModuleType("soundfile")
+
+ class _SoundFileStub: # pragma: no cover - placeholder to satisfy imports
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ raise RuntimeError("soundfile is not installed in the test environment")
+
+ soundfile_stub.SoundFile = _SoundFileStub # type: ignore[attr-defined]
+ sys.modules["soundfile"] = soundfile_stub
+
+if "static_ffmpeg" not in sys.modules:
+ sys.modules["static_ffmpeg"] = types.ModuleType("static_ffmpeg")
+
+if "ebooklib" not in sys.modules:
+ ebooklib_stub = types.ModuleType("ebooklib")
+ ebooklib_epub_stub = types.ModuleType("ebooklib.epub")
+ ebooklib_stub.epub = ebooklib_epub_stub # type: ignore[attr-defined]
+ sys.modules["ebooklib"] = ebooklib_stub
+ sys.modules["ebooklib.epub"] = ebooklib_epub_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 _MarkdownStub:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ self.toc_tokens = []
+
+ def convert(self, text: str) -> str:
+ return text
+
+ markdown_stub.Markdown = _MarkdownStub # type: ignore[attr-defined]
+ sys.modules["markdown"] = markdown_stub
+
+if "bs4" not in sys.modules:
+ bs4_stub = types.ModuleType("bs4")
+
+ class _BeautifulSoupStub:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ self._text = ""
+
+ def find(self, *args: object, **kwargs: object) -> None:
+ return None
+
+ def get_text(self) -> str:
+ return self._text
+
+ def decompose(self) -> None: # pragma: no cover - compatibility shim
+ return None
+
+ class _NavigableStringStub(str):
+ pass
+
+ bs4_stub.BeautifulSoup = _BeautifulSoupStub # type: ignore[attr-defined]
+ bs4_stub.NavigableString = _NavigableStringStub # type: ignore[attr-defined]
+ sys.modules["bs4"] = bs4_stub
+
+
+from abogen.webui.conversion_runner import (
+ _format_spoken_chapter_title,
+ _headings_equivalent,
+ _normalize_chapter_opening_caps,
+ _strip_duplicate_heading_line,
+)
+
+
+def test_format_spoken_chapter_title_adds_prefix() -> None:
+ assert _format_spoken_chapter_title("1: A Tale", 1, True) == "Chapter 1. A Tale"
+
+
+def test_format_spoken_chapter_title_respects_existing_prefix() -> None:
+ assert (
+ _format_spoken_chapter_title("Chapter 2: Story", 2, True) == "Chapter 2: Story"
+ )
+
+
+def test_format_spoken_chapter_title_handles_empty_title() -> None:
+ assert _format_spoken_chapter_title("", 4, True) == "Chapter 4"
+
+
+def test_format_spoken_chapter_title_trims_delimiters() -> None:
+ assert (
+ _format_spoken_chapter_title("7 - Into the Wild", 7, True)
+ == "Chapter 7. Into the Wild"
+ )
+
+
+def test_headings_equivalent_ignores_case_and_prefix() -> None:
+ assert _headings_equivalent("1: The House", "Chapter 1: The House")
+
+
+def test_strip_duplicate_heading_line_removes_first_match() -> None:
+ text, removed = _strip_duplicate_heading_line(
+ "Chapter 3: Intro\nBody text", "Chapter 3: Intro"
+ )
+ assert removed is True
+ assert text.strip() == "Body text"
+
+
+def test_normalize_chapter_opening_caps_basic_title() -> None:
+ normalized, changed = _normalize_chapter_opening_caps("ALL CAPS TITLE")
+ assert normalized == "All Caps Title"
+ assert changed is True
+
+
+def test_normalize_chapter_opening_caps_respects_acronyms() -> None:
+ normalized, changed = _normalize_chapter_opening_caps("NASA MISSION LOG")
+ assert normalized == "NASA Mission Log"
+ assert changed is True
+
+
+def test_normalize_chapter_opening_caps_handles_roman_numerals() -> None:
+ normalized, changed = _normalize_chapter_opening_caps("IV. THE RETURN")
+ assert normalized == "IV. The Return"
+ assert changed is True
+
+
+def test_normalize_chapter_opening_caps_keeps_mixed_case() -> None:
+ normalized, changed = _normalize_chapter_opening_caps("Already Mixed Case")
+ assert normalized == "Already Mixed Case"
+ assert changed is False
diff --git a/tests/test_conversion_series.py b/tests/test_conversion_series.py
new file mode 100644
index 0000000..9bb8def
--- /dev/null
+++ b/tests/test_conversion_series.py
@@ -0,0 +1,120 @@
+import sys
+import types
+
+if "soundfile" not in sys.modules:
+ soundfile_stub = types.ModuleType("soundfile")
+
+ class _SoundFileStub: # pragma: no cover - placeholder to satisfy imports
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ raise RuntimeError("soundfile is not installed in the test environment")
+
+ soundfile_stub.SoundFile = _SoundFileStub # type: ignore[attr-defined]
+ sys.modules["soundfile"] = soundfile_stub
+
+if "static_ffmpeg" not in sys.modules:
+ sys.modules["static_ffmpeg"] = types.ModuleType("static_ffmpeg")
+
+if "ebooklib" not in sys.modules:
+ ebooklib_stub = types.ModuleType("ebooklib")
+ ebooklib_epub_stub = types.ModuleType("ebooklib.epub")
+ ebooklib_stub.epub = ebooklib_epub_stub # type: ignore[attr-defined]
+ sys.modules["ebooklib"] = ebooklib_stub
+ sys.modules["ebooklib.epub"] = ebooklib_epub_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 _MarkdownStub:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ self.toc_tokens = []
+
+ def convert(self, text: str) -> str:
+ return text
+
+ markdown_stub.Markdown = _MarkdownStub # type: ignore[attr-defined]
+ sys.modules["markdown"] = markdown_stub
+
+if "bs4" not in sys.modules:
+ bs4_stub = types.ModuleType("bs4")
+
+ class _BeautifulSoupStub:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ self._text = ""
+
+ def find(self, *args: object, **kwargs: object) -> None:
+ return None
+
+ def get_text(self) -> str:
+ return self._text
+
+ def decompose(self) -> None: # pragma: no cover - compatibility shim
+ return None
+
+ class _NavigableStringStub(str):
+ pass
+
+ bs4_stub.BeautifulSoup = _BeautifulSoupStub # type: ignore[attr-defined]
+ bs4_stub.NavigableString = _NavigableStringStub # type: ignore[attr-defined]
+ sys.modules["bs4"] = bs4_stub
+
+
+from abogen.webui.conversion_runner import _build_outro_text, _build_title_intro_text
+
+
+def test_title_intro_includes_series_sentence() -> None:
+ metadata = {
+ "title": "Galactic Chronicles",
+ "author": "Jane Doe",
+ "series": "Chronicles",
+ "series_index": "2",
+ }
+
+ intro_text = _build_title_intro_text(metadata, "chronicles.mp3")
+
+ assert intro_text.startswith("Book 2 of the Chronicles.")
+ assert "Galactic Chronicles." in intro_text
+ assert "By Jane Doe." in intro_text
+
+
+def test_series_sentence_skips_duplicate_article() -> None:
+ metadata = {
+ "title": "Iron Council",
+ "authors": "China Miéville",
+ "series": "The Bas-Lag",
+ "series_index": "3",
+ }
+
+ intro_text = _build_title_intro_text(metadata, "iron_council.mp3")
+
+ assert "Book 3 of The Bas-Lag." in intro_text
+ assert "of the The" not in intro_text
+
+
+def test_outro_appends_series_information() -> None:
+ metadata = {
+ "title": "Abaddon's Gate",
+ "authors": "James S. A. Corey",
+ "series": "The Expanse",
+ "series_index": "3",
+ }
+
+ outro_text = _build_outro_text(metadata, "abaddon.mp3")
+
+ assert outro_text.startswith("The end of Abaddon's Gate from James S. A. Corey.")
+ assert outro_text.endswith("Book 3 of The Expanse.")
+
+
+def test_series_number_preserves_decimal_positions() -> None:
+ metadata = {
+ "title": "Interlude",
+ "author": "Alex Writer",
+ "series": "Chronicles",
+ "series_index": "2.5",
+ }
+
+ intro_text = _build_title_intro_text(metadata, "interlude.mp3")
+
+ assert "Book 2.5 of the Chronicles." in intro_text
diff --git a/tests/test_conversion_voice_resolution.py b/tests/test_conversion_voice_resolution.py
new file mode 100644
index 0000000..e791306
--- /dev/null
+++ b/tests/test_conversion_voice_resolution.py
@@ -0,0 +1,52 @@
+from types import SimpleNamespace
+from typing import cast
+
+from abogen.constants import VOICES_INTERNAL
+from abogen.webui.conversion_runner import (
+ _chapter_voice_spec,
+ _chunk_voice_spec,
+ _collect_required_voice_ids,
+)
+from abogen.webui.service import Job
+
+
+def _sample_job(formula: str) -> Job:
+ return cast(
+ Job,
+ SimpleNamespace(
+ voice="__custom_mix",
+ speakers={
+ "narrator": {
+ "resolved_voice": formula,
+ }
+ },
+ chapters=[],
+ chunks=[{}],
+ ),
+ )
+
+
+def test_chapter_voice_spec_uses_resolved_formula():
+ formula = "af_nova*0.7+am_liam*0.3"
+ job = _sample_job(formula)
+
+ assert _chapter_voice_spec(job, None) == formula
+
+
+def test_chunk_voice_fallback_uses_resolved_formula():
+ formula = "af_nova*0.7+am_liam*0.3"
+ job = _sample_job(formula)
+
+ result = _chunk_voice_spec(job, {}, "")
+
+ assert result == formula
+
+
+def test_voice_collection_includes_formula_components():
+ formula = "af_nova*0.7+am_liam*0.3"
+ job = _sample_job(formula)
+
+ voices = _collect_required_voice_ids(job)
+
+ assert {"af_nova", "am_liam"}.issubset(voices)
+ assert voices.issuperset(VOICES_INTERNAL)
diff --git a/tests/test_date_normalization_comprehensive.py b/tests/test_date_normalization_comprehensive.py
new file mode 100644
index 0000000..1bc04a0
--- /dev/null
+++ b/tests/test_date_normalization_comprehensive.py
@@ -0,0 +1,123 @@
+import pytest
+from abogen.kokoro_text_normalization import (
+ _normalize_grouped_numbers,
+ ApostropheConfig,
+)
+
+
+@pytest.fixture
+def cfg():
+ return ApostropheConfig(convert_numbers=True, year_pronunciation_mode="american")
+
+
+def normalize(text, config):
+ return _normalize_grouped_numbers(text, config)
+
+
+class TestDateNormalization:
+
+ def test_standard_years(self, 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 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
+ assert "thirty-four hundred" in normalize("In the year 3400, we fly.", cfg)
+ # 2500 -> twenty-five hundred
+ assert "twenty-five hundred" in normalize("The year 2500 is far off.", cfg)
+
+ def test_years_with_markers(self, cfg):
+ # 1021 BC -> ten twenty-one
+ assert "ten twenty-one" in normalize("It happened in 1021 BC.", cfg)
+ # 4000 BCE -> forty hundred (or four thousand?)
+ # _format_year_like logic:
+ # if value % 1000 == 0: return "X thousand"
+ # 4000 -> four thousand.
+ # Let's check 4001 -> forty oh one
+ assert "forty oh one" in normalize("Ancient times 4001 BCE.", cfg)
+
+ def test_addresses_explicit(self, cfg):
+ # "address" keyword present -> should NOT be year
+ # 1925 -> one thousand nine hundred twenty-five (default num2words)
+ # or "one nine two five" if num2words isn't doing year stuff.
+ # num2words(1925) -> "one thousand, nine hundred and twenty-five"
+ res = normalize("My address is 1925 Main St.", cfg)
+ assert "nineteen twenty-five" not in res
+ assert "one thousand" in res or "nineteen hundred" in res
+
+ res = normalize("Please send it to the address: 3400 North Blvd.", cfg)
+ assert "thirty-four hundred" not in res # Should not be year style
+ assert "three thousand" in res or "thirty-four hundred" in res
+ # Wait, "thirty-four hundred" IS how you say 3400 in num2words sometimes?
+ # num2words(3400) -> "three thousand, four hundred" usually.
+ # Let's verify what "thirty-four hundred" implies.
+ # If it's a year: "thirty-four hundred".
+ # If it's a number: "three thousand four hundred".
+ assert "three thousand" in res
+
+ def test_address_with_year_marker_edge_case(self, cfg):
+ # "address" is present, BUT "BC" is also present. Should be year.
+ res = normalize("The address was found in 1021 BC ruins.", cfg)
+ assert "ten twenty-one" in res
+
+ def test_ambiguous_numbers(self, cfg):
+ # Just a number, no "address", no markers. Should default to year if 4 digits 1000-9999
+ 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 hundred" in normalize("1925", cfg)
+ # 3400
+ assert "thirty-four hundred" in normalize("3400", cfg)
+
+ def test_martin_ford_jobless_future_context(self, cfg):
+ # Simulating a title or sentence from the book
+ # "The Rise of the Robots: Technology and the Threat of a Jobless Future"
+ # Maybe it mentions a year like 2015 (pub date) or a future date.
+
+ # "In 2015, Martin Ford wrote..."
+ assert "twenty fifteen" in normalize("In 2015, Martin Ford wrote...", cfg)
+
+ # "By 2100, robots will..."
+ assert "twenty-one hundred" in normalize("By 2100, robots will...", cfg)
+
+ def test_address_context_window(self, cfg):
+ # "address" is far away (> 60 chars). Should be year.
+ padding = "x" * 70
+ text = f"address {padding} 1999"
+ 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 hundred ninety-nine" not in res
+ assert "one thousand" in res
+
+ def test_2000s(self, cfg):
+ # 2000-2009 are usually "two thousand X"
+ assert "two thousand one" in normalize("2001", cfg)
+ assert "two thousand nine" in normalize("2009", cfg)
+ # 2010 -> twenty ten
+ assert "twenty ten" in normalize("2010", cfg)
+
+ def test_addresses_plural(self, cfg):
+ # "addresses" plural -> should also trigger non-year mode?
+ # Currently the code only looks for "address".
+ # "The addresses are 1925 and 1926."
+ # If it fails to detect "addresses", it will say "nineteen twenty-five".
+ # If we want it to be "one thousand...", we need to update the regex.
+ res = normalize("The addresses are 1925 and 1926.", cfg)
+ # Expectation: should probably be numbers, not years.
+ assert "nineteen twenty-five" not in res
diff --git a/tests/test_debug_tts_samples.py b/tests/test_debug_tts_samples.py
new file mode 100644
index 0000000..a936fb2
--- /dev/null
+++ b/tests/test_debug_tts_samples.py
@@ -0,0 +1,176 @@
+import json
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+from abogen.debug_tts_samples import (
+ DEBUG_TTS_SAMPLES,
+ MARKER_PREFIX,
+ MARKER_SUFFIX,
+ iter_expected_codes,
+)
+from abogen.kokoro_text_normalization import HAS_NUM2WORDS, normalize_for_pipeline
+from abogen.normalization_settings import build_apostrophe_config
+from abogen.text_extractor import extract_from_path
+from abogen.webui.app import create_app
+
+
+def test_debug_epub_contains_all_codes():
+ epub_path = Path("tests/fixtures/abogen_debug_tts_samples.epub")
+ assert epub_path.exists()
+
+ extraction = extract_from_path(epub_path)
+ combined = extraction.combined_text or "\n\n".join(
+ (c.text or "") for c in extraction.chapters
+ )
+
+ for code in iter_expected_codes():
+ marker = f"{MARKER_PREFIX}{code}{MARKER_SUFFIX}"
+ assert marker in combined
+
+
+def test_debug_samples_normalize_smoke():
+ # Use the same defaults as the web UI.
+ from abogen.webui.routes.utils.settings import settings_defaults
+
+ settings = settings_defaults()
+ apostrophe = build_apostrophe_config(settings=settings)
+ runtime = dict(settings)
+
+ normalized = {
+ sample.code: normalize_for_pipeline(
+ sample.text, config=apostrophe, settings=runtime
+ )
+ for sample in DEBUG_TTS_SAMPLES
+ }
+
+ # Contractions should expand under defaults.
+ assert "it is" in normalized["APOS_001"].lower()
+
+ # Titles should expand.
+ assert "doctor" in normalized["TITLE_001"].lower()
+
+ # Footnotes should be removed.
+ assert "[1]" not in normalized["FOOT_001"]
+
+ # Terminal punctuation should be added.
+ assert normalized["PUNC_001"].strip()[-1] in {".", "!", "?"}
+
+ if HAS_NUM2WORDS:
+ # Currency and numbers should expand to words when num2words is available.
+ assert "dollar" in normalized["CUR_001"].lower()
+ assert "thousand" in normalized["NUM_001"].lower()
+
+
+def test_settings_debug_route_writes_manifest(tmp_path, monkeypatch):
+ # Avoid pulling Kokoro models in tests: stub the pipeline.
+ from abogen.webui import debug_tts_runner as runner
+
+ class _Seg:
+ def __init__(self, audio):
+ self.audio = audio
+
+ class DummyPipeline:
+ def __call__(self, text, **kwargs):
+ # 100ms of audio per call, deterministic.
+ audio = np.zeros(int(0.1 * runner.SAMPLE_RATE), dtype="float32")
+ audio[::100] = 0.1
+ yield _Seg(audio)
+
+ monkeypatch.setattr(
+ runner, "_load_pipeline", lambda language, use_gpu: DummyPipeline()
+ )
+
+ app = create_app(
+ {
+ "TESTING": True,
+ "SECRET_KEY": "test",
+ "OUTPUT_FOLDER": str(tmp_path),
+ "UPLOAD_FOLDER": str(tmp_path / "uploads"),
+ }
+ )
+
+ with app.test_client() as client:
+ resp = client.post("/settings/debug/run")
+ assert resp.status_code in {302, 303}
+ location = resp.headers.get("Location", "")
+ assert "/settings/debug/" in location
+
+ # 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()
+
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ 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
+
+
+def test_debug_runner_resolves_profile_voice_before_pipeline(tmp_path, monkeypatch):
+ from abogen.webui 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
+ )
diff --git a/tests/test_epub_content_slicing.py b/tests/test_epub_content_slicing.py
new file mode 100644
index 0000000..829e233
--- /dev/null
+++ b/tests/test_epub_content_slicing.py
@@ -0,0 +1,203 @@
+import unittest
+import os
+import shutil
+import sys
+from ebooklib import epub
+
+# Ensure import path
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.book_parser import get_book_parser
+
+
+class TestEpubContentSlicing(unittest.TestCase):
+ """
+ Tests for the complex content slicing logic in _execute_nav_parsing_logic.
+ This covers scenarios where multiple chapters/sections are contained within
+ a single physical HTML file, separated by anchors (fragments).
+ """
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_slicing"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.epub_path = os.path.join(self.test_dir, "slicing_test.epub")
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def test_single_file_multiple_chapters(self):
+ """
+ Test splitting one XHTML file into two chapters using an anchor.
+ """
+ book = epub.EpubBook()
+ book.set_identifier("slice123")
+ book.set_title("Slicing Test Book")
+
+ # Create a single content file with two sections
+ content_html = """
+
+
+
Chapter 1
+
Text for chapter 1.
+
+
Chapter 2
+
Text for chapter 2.
+
+
+ """
+ c1 = epub.EpubHtml(title="Full Content", file_name="content.xhtml", lang="en")
+ c1.content = content_html
+ book.add_item(c1)
+
+ # Create Nav that points to anchors in the SAME file
+ # We use EpubHtml for Nav to control content exactly without ebooklib interference
+ nav_html = """
+
+
+
", first_paragraph_start)
+ first_paragraph = chapter_doc[first_paragraph_start:first_paragraph_end]
+ assert "First sentence." in first_paragraph
+ assert "Second sentence in same paragraph." in first_paragraph
diff --git a/tests/test_epub_heuristic_nav.py b/tests/test_epub_heuristic_nav.py
new file mode 100644
index 0000000..eb5a418
--- /dev/null
+++ b/tests/test_epub_heuristic_nav.py
@@ -0,0 +1,123 @@
+import unittest
+import os
+import shutil
+import sys
+from ebooklib import epub
+
+# Ensure import path
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.book_parser import get_book_parser
+
+
+class TestEpubHeuristicNav(unittest.TestCase):
+ """
+ Tests for the heuristic fallback in _identify_nav_item (Step 4),
+ where the parser scans ITEM_DOCUMENTs for
+ when no explicit ITEM_NAVIGATION is found.
+ """
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_heuristic"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.epub_path = os.path.join(self.test_dir, "heuristic_test.epub")
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def test_heuristic_nav_discovery(self):
+ book = epub.EpubBook()
+ book.set_identifier("heuristic123")
+ book.set_title("Heuristic Test Book")
+
+ # 1. Add Content
+ c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
+ c1.content = "
Chapter 1
Text
"
+ book.add_item(c1)
+
+ # 2. Add a Nav file BUT as a regular EpubHtml (ITEM_DOCUMENT)
+ # We do NOT use EpubNav. We do NOT look like a standard nav file if possible,
+ # but content must contain the magical signature.
+ nav_content = """
+
+
+
+
+ """
+ self._create_epub_with_custom_nav(nav_html)
+ parser = get_book_parser(self.epub_path)
+ # Note: _identify_nav_item relies on self.book being loaded
+ # The parser constructor or process_content handles load()
+ # But here we can call load directly if needed, or rely on normal flow up until navigation
+ parser.load()
+ nav_item, nav_type = parser._identify_nav_item()
+
+ self.assertEqual(nav_type, "html")
+ self.assertIsNotNone(nav_item)
+ self.assertTrue("nav.xhtml" in nav_item.get_name())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_epub_missing_file_error_handling.py b/tests/test_epub_missing_file_error_handling.py
new file mode 100644
index 0000000..18c69ca
--- /dev/null
+++ b/tests/test_epub_missing_file_error_handling.py
@@ -0,0 +1,102 @@
+import unittest
+import os
+import shutil
+import zipfile
+import sys
+import logging
+from ebooklib import epub
+
+# Ensure import path
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.book_parser import get_book_parser
+
+
+class TestEpubMissingFileErrorHandling(unittest.TestCase):
+ """
+ Tests for robust error handling and recovery in the book parser.
+ """
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_errors"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.broken_epub_path = os.path.join(self.test_dir, "missing_file.epub")
+
+ # Suppress logging during tests to keep output clean,
+ # or capture it if we want to assert on warnings.
+ # For now, we just let it be or set level to ERROR.
+ logging.getLogger().setLevel(logging.ERROR)
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def _create_broken_epub(self):
+ """
+ Creates an EPUB where a file listed in the manifest is missing from the ZIP archive.
+ """
+ book = epub.EpubBook()
+ book.set_identifier("broken123")
+ book.set_title("Broken Book")
+
+ # 1. Add a valid chapter
+ c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
+ c1.content = "
Chapter 1
Survivable content.
"
+ book.add_item(c1)
+
+ # 2. Add a 'ghost' chapter that we will delete later
+ c2 = epub.EpubHtml(title="Ghost Chapter", file_name="ghost.xhtml", lang="en")
+ c2.content = "
Ghost
I will disappear.
"
+ book.add_item(c2)
+
+ book.spine = ["nav", c1, c2]
+ book.add_item(epub.EpubNcx())
+ book.add_item(epub.EpubNav())
+
+ temp_path = os.path.join(self.test_dir, "temp.epub")
+ epub.write_epub(temp_path, book)
+
+ # 3. Physically remove 'ghost.xhtml' from the ZIP
+ with zipfile.ZipFile(temp_path, "r") as zin:
+ with zipfile.ZipFile(self.broken_epub_path, "w") as zout:
+ for item in zin.infolist():
+ # Copy everything EXCEPT the ghost file
+ # Note: ebooklib might put files in OEPS/ or EPUB/ folders depending on version,
+ # so checking "ghost.xhtml" presence in filename is safer.
+ if "ghost.xhtml" not in item.filename:
+ zout.writestr(item, zin.read(item.filename))
+
+ def test_missing_file_recovery(self):
+ """
+ Verify that the parser recovers gracefully when a referenced file is missing.
+ Should log a warning instead of raising KeyError.
+ """
+ self._create_broken_epub()
+
+ try:
+ parser = get_book_parser(self.broken_epub_path)
+ parser.process_content()
+
+ # 1. Ensure process didn't crash
+ self.assertTrue(True, "Parser should not crash on missing file")
+
+ # 2. Ensure valid content was extracted
+ # Identify the ID for chap1.xhtml (usually file path based)
+ # Since IDs can vary, we check if ANY content contains our known string
+ chap1_found = False
+ for text in parser.content_texts.values():
+ if "Survivable content" in text:
+ chap1_found = True
+ break
+ self.assertTrue(chap1_found, "The valid chapter should still be processed")
+
+ except KeyError:
+ self.fail("Parser raised KeyError instead of handling the missing file!")
+ except Exception as e:
+ self.fail(f"Parser raised unexpected exception: {e}")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_epub_ncx_parsing.py b/tests/test_epub_ncx_parsing.py
new file mode 100644
index 0000000..313d785
--- /dev/null
+++ b/tests/test_epub_ncx_parsing.py
@@ -0,0 +1,140 @@
+import unittest
+import os
+import shutil
+import sys
+from ebooklib import epub
+
+# Ensure we can import the module
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.book_parser import get_book_parser, EpubParser
+
+
+class TestEpubNcxParsing(unittest.TestCase):
+ """
+ Focused tests for NCX navigation scenarios, ensuring legacy/compatibility
+ modes work when HTML5 Navigation is missing.
+ """
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_ncx"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.ncx_only_epub_path = os.path.join(self.test_dir, "ncx_only.epub")
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def _create_ncx_only_epub(self, chapters):
+ """
+ Helper to create an EPUB with ONLY NCX table of contents (no HTML nav).
+ """
+ book = epub.EpubBook()
+ book.set_identifier("ncx_test_123")
+ book.set_title("NCX Only Book")
+ book.set_language("en")
+
+ epub_chapters = []
+ for i, (title, content) in enumerate(chapters):
+ filename = f"chap{i+1}.xhtml"
+ c = epub.EpubHtml(title=title, file_name=filename, lang="en")
+ # Ensure content is substantial enough to not be skipped
+ c.content = f"
{title}
{content}
"
+ book.add_item(c)
+ epub_chapters.append(c)
+
+ # Define Table of Contents
+ book.toc = tuple(epub_chapters)
+
+ # Add default NCX and generic spine
+ book.add_item(epub.EpubNcx())
+ # IMPORTANT: Do NOT add EpubNav() here, that's what we are testing!
+
+ book.spine = ["nav"] + epub_chapters
+
+ epub.write_epub(self.ncx_only_epub_path, book)
+
+ def test_ncx_only_parsing(self):
+ """
+ Verify that an EPUB with only an NCX file (no HTML nav) is parsed correctly.
+ Logic tested: _process_epub_content_nav (NCX branch), _parse_ncx_navpoint
+ """
+ # 1. Setup Data
+ chapters_data = [
+ ("Chapter 1", "This is the first chapter."),
+ ("Chapter 2", "This is the second chapter."),
+ ]
+ self._create_ncx_only_epub(chapters_data)
+
+ # 2. Run Parser
+ parser = get_book_parser(self.ncx_only_epub_path)
+ parser.process_content()
+
+ # 3. Verify Breakdown
+ # We expect detailed breakdown based on NCX
+ chapters = parser.get_chapters()
+
+ # Should find exactly 2 chapters based on the Toc
+ self.assertEqual(len(chapters), 2, "Should have 2 chapters extracted from NCX")
+
+ # Check Titles and Sequence
+ self.assertEqual(chapters[0][1], "Chapter 1")
+ self.assertEqual(chapters[1][1], "Chapter 2")
+
+ # Verify content was extracted
+ # Note: 'src' in chapters usually points to file_name if no fragments
+ id_1 = chapters[0][0]
+ self.assertIn("This is the first chapter", parser.content_texts[id_1])
+
+ def test_nested_ncx_parsing(self):
+ """
+ Verify parsing of nested NCX structures (Chapters with Subchapters).
+ """
+ book = epub.EpubBook()
+ book.set_identifier("nested_ncx")
+ book.set_title("Nested NCX")
+
+ # Create one big file with sections
+ c1 = epub.EpubHtml(title="Main Chapter", file_name="main.xhtml", lang="en")
+ c1.content = """
+
Introduction
+
Intro text.
+
Section 1
+
Section 1 text.
+ """
+ book.add_item(c1)
+
+ # Manually construct nested TOC because ebooklib's default helpers are simple
+ # EbookLib automatically builds NCX from book.toc
+ # Nested tuple structure: (Section, (Subsection, Sub-subsection))
+
+ # We need to link to Fragments for this to really test nested NCX pointing to same file
+ # EbookLib Link object: epub.Link(href, title, uid)
+
+ link_root = epub.Link("main.xhtml#intro", "Introduction", "intro")
+ link_sect = epub.Link("main.xhtml#sect1", "Section 1", "sect1")
+
+ # Structure: Intro -> Section 1 (as child)
+ book.toc = ((link_root, (link_sect,)),)
+
+ book.add_item(epub.EpubNcx())
+ book.spine = ["nav", c1]
+
+ epub.write_epub(self.ncx_only_epub_path, book)
+
+ # Parse
+ parser = get_book_parser(self.ncx_only_epub_path)
+ parser.process_content()
+
+ chapters = parser.get_chapters()
+
+ # Depending on how the parser flattens, we should see both entries
+ titles = [node[1] for node in chapters]
+ self.assertIn("Introduction", titles)
+ self.assertIn("Section 1", titles)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_epub_standard_nav.py b/tests/test_epub_standard_nav.py
new file mode 100644
index 0000000..b4aaf39
--- /dev/null
+++ b/tests/test_epub_standard_nav.py
@@ -0,0 +1,133 @@
+import unittest
+import os
+import shutil
+import sys
+from ebooklib import epub
+import ebooklib
+from unittest.mock import MagicMock
+
+# Ensure import path
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
+
+from abogen.book_parser import get_book_parser
+
+
+class TestEpubStandardNav(unittest.TestCase):
+ """
+ Tests for the standard ITEM_NAVIGATION discovery in _identify_nav_item.
+ Refactored to explicitly test different discovery paths defined in the parser.
+ """
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_standard_nav"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.epub_path = os.path.join(self.test_dir, "standard_nav_test.epub")
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def _create_and_load_epub(self):
+ """Helper to create a basic EPUB and return a loaded parser."""
+ book = epub.EpubBook()
+ book.set_identifier("stdnav123")
+ book.set_title("Standard Nav Test")
+
+ c1 = epub.EpubHtml(title="Chapter 1", file_name="chap1.xhtml", lang="en")
+ c1.content = "
Chapter 1
Text 1
"
+ book.add_item(c1)
+
+ # Use Standard EpubNav
+ nav = epub.EpubNav()
+ book.add_item(nav)
+ book.spine = [nav, c1]
+
+ epub.write_epub(self.epub_path, book)
+
+ # "Zip Surgery" Patch:
+ # ebooklib unconditionally adds `toc="ncx"` to the spine, even for EPUB 3 files that purely use HTML Nav.
+ # This creates a dangling reference to a non-existent "ncx" item, causing ebooklib to crash on read.
+ # We manually remove this attribute to ensure the test EPUB is valid and readable.
+ # TODO - find real world examples of EPUB 3 files that use HTML Nav
+ import zipfile
+
+ patched = False
+ with zipfile.ZipFile(self.epub_path, "r") as zin:
+ opf_content = zin.read("EPUB/content.opf").decode("utf-8")
+ if 'toc="ncx"' in opf_content:
+ opf_content = opf_content.replace('toc="ncx"', "")
+ patched = True
+ TEMP_EPUB = self.epub_path + ".temp"
+ with zipfile.ZipFile(TEMP_EPUB, "w") as zout:
+ for item in zin.infolist():
+ if item.filename == "EPUB/content.opf":
+ zout.writestr(item, opf_content)
+ else:
+ zout.writestr(item, zin.read(item.filename))
+
+ if patched:
+ shutil.move(TEMP_EPUB, self.epub_path)
+
+ parser = get_book_parser(self.epub_path)
+ parser.load()
+ return parser
+
+ def test_discovery_by_item_navigation_type(self):
+ """
+ Scenario 1: The item is explicitly identified as ITEM_NAVIGATION (4).
+ This exercises the first branch of _identify_nav_item.
+ """
+ parser = self._create_and_load_epub()
+
+ # Inject an item that mocks the ITEM_NAVIGATION type behavior
+ # (This simulates a library/parser that correctly types the item as 4)
+ mock_nav = MagicMock()
+ mock_nav.get_name.return_value = "nav.xhtml"
+ mock_nav.get_type.return_value = ebooklib.ITEM_NAVIGATION
+
+ # We append this mock to the book items to ensure get_items_of_type(ITEM_NAVIGATION) finds it
+ parser.book.items.append(mock_nav)
+
+ nav_item, nav_type = parser._identify_nav_item()
+
+ self.assertEqual(nav_type, "html")
+ self.assertEqual(nav_item.get_name(), "nav.xhtml")
+ # Verify we are getting the object we expect (implied by success)
+
+ def test_discovery_by_nav_property(self):
+ """
+ Scenario 2: The item is ITEM_DOCUMENT (9) but has properties=['nav'].
+ This is the standard EPUB 3 behavior and exercises the fallback branch.
+ """
+ parser = self._create_and_load_epub()
+
+ # Locate the generic 'nav' item loaded by ebooklib
+ original_nav = parser.book.get_item_with_id("nav")
+ self.assertIsNotNone(original_nav)
+
+ # "Fix" the object to match what we expect from a correct EPUB 3 read:
+ # It should have properties=['nav'].
+ # We use a real EpubNav object to ensure structural correctness.
+ proper_nav = epub.EpubNav(uid=original_nav.id, file_name=original_nav.file_name)
+ proper_nav.content = original_nav.content
+ proper_nav.properties = ["nav"]
+
+ # Swap it into the book items list
+ try:
+ idx = parser.book.items.index(original_nav)
+ parser.book.items[idx] = proper_nav
+ except ValueError:
+ self.fail("Could not find original nav item to swap")
+
+ nav_item, nav_type = parser._identify_nav_item()
+
+ self.assertEqual(nav_type, "html")
+ self.assertEqual(nav_item.get_name(), "nav.xhtml")
+ # Check that we actually found the one with properties
+ self.assertEqual(getattr(nav_item, "properties", []), ["nav"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_ffmetadata.py b/tests/test_ffmetadata.py
new file mode 100644
index 0000000..cc58cc8
--- /dev/null
+++ b/tests/test_ffmetadata.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from abogen.webui.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()
diff --git a/tests/test_manual_overrides_applied_first.py b/tests/test_manual_overrides_applied_first.py
new file mode 100644
index 0000000..d21de5e
--- /dev/null
+++ b/tests/test_manual_overrides_applied_first.py
@@ -0,0 +1,51 @@
+from abogen.webui import conversion_runner
+
+
+class DummyJob:
+ def __init__(self):
+ self.language = "en"
+ self.voice = "M1"
+ self.speakers = None
+ self.manual_overrides = []
+ self.pronunciation_overrides = []
+
+
+def _apply(text: str, job: DummyJob) -> str:
+ merged = conversion_runner._merge_pronunciation_overrides(job)
+ rules = conversion_runner._compile_pronunciation_rules(merged)
+ return conversion_runner._apply_pronunciation_rules(text, rules)
+
+
+def test_manual_override_is_applied_even_if_pronunciation_overrides_stale():
+ job = DummyJob()
+ job.manual_overrides = [
+ {
+ "token": "Unfu*k",
+ "pronunciation": "Unfuck",
+ }
+ ]
+
+ out = _apply("He said Unfu*k loudly.", job)
+ assert "Unfuck" in out
+ assert "Unfu*k" not in out
+
+
+def test_manual_override_takes_precedence_over_existing_pronunciation_override():
+ job = DummyJob()
+ job.pronunciation_overrides = [
+ {
+ "token": "Unfu*k",
+ "normalized": "unfu*k",
+ "pronunciation": "WRONG",
+ }
+ ]
+ job.manual_overrides = [
+ {
+ "token": "Unfu*k",
+ "pronunciation": "RIGHT",
+ }
+ ]
+
+ out = _apply("Unfu*k.", job)
+ assert "RIGHT" in out
+ assert "WRONG" not in out
diff --git a/tests/test_opds_import_metadata_mapping.py b/tests/test_opds_import_metadata_mapping.py
new file mode 100644
index 0000000..1e7db36
--- /dev/null
+++ b/tests/test_opds_import_metadata_mapping.py
@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from abogen.webui.routes.api import _opds_metadata_overrides
+
+
+def test_opds_metadata_overrides_maps_author_and_subtitle() -> None:
+ overrides = _opds_metadata_overrides(
+ {
+ "authors": ["Alexandre Dumas"],
+ "subtitle": "Unabridged",
+ "series": "Example",
+ "series_index": 2,
+ "tags": ["Fiction", "Classic"],
+ "summary": "Summary text",
+ }
+ )
+
+ assert overrides["authors"] == "Alexandre Dumas"
+ assert overrides["author"] == "Alexandre Dumas"
+ assert overrides["subtitle"] == "Unabridged"
+
+ # Existing behavior still present
+ assert overrides["series"] == "Example"
+ assert overrides["series_index"] == "2"
+ assert overrides["tags"] == "Fiction, Classic"
+ assert overrides["description"] == "Summary text"
+
+
+def test_opds_metadata_overrides_accepts_author_string() -> None:
+ overrides = _opds_metadata_overrides({"author": "Mary Shelley"})
+ assert overrides["authors"] == "Mary Shelley"
+ assert overrides["author"] == "Mary Shelley"
diff --git a/tests/test_output_paths.py b/tests/test_output_paths.py
new file mode 100644
index 0000000..77e8992
--- /dev/null
+++ b/tests/test_output_paths.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+import time
+from pathlib import Path
+
+import pytest
+
+from abogen.webui.conversion_runner import _build_output_path, _prepare_project_layout
+from abogen.webui.service import Job
+
+
+def _sample_job(tmp_path: Path) -> Job:
+ source = tmp_path / "sample.txt"
+ source.write_text("example", encoding="utf-8")
+ return Job(
+ id="job-1",
+ original_filename="Sample Title.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Use default save location",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ )
+
+
+def test_prepare_project_layout_uses_timestamped_folder(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ job = _sample_job(tmp_path)
+ monkeypatch.setattr(
+ "abogen.webui.conversion_runner._output_timestamp_token",
+ lambda: "20250101-120000",
+ )
+
+ project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
+ job, tmp_path
+ )
+
+ assert project_root.name.startswith(
+ "20250101-120000_Sample_Title"
+ ), project_root.name
+ assert audio_dir == project_root
+ assert subtitle_dir == project_root
+ assert metadata_dir is None
+
+ output_path = _build_output_path(audio_dir, job.original_filename, "mp3")
+ assert output_path == project_root / "Sample_Title.mp3"
+
+
+def test_prepare_project_layout_creates_project_subdirs(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ job = _sample_job(tmp_path)
+ job.save_as_project = True
+ monkeypatch.setattr(
+ "abogen.webui.conversion_runner._output_timestamp_token",
+ lambda: "20250101-120500",
+ )
+
+ project_root, audio_dir, subtitle_dir, metadata_dir = _prepare_project_layout(
+ job, tmp_path
+ )
+
+ assert audio_dir == project_root / "audio"
+ assert subtitle_dir == project_root / "subtitles"
+ assert metadata_dir == project_root / "metadata"
+ assert audio_dir.is_dir()
+ assert subtitle_dir.is_dir()
+ assert metadata_dir is not None and metadata_dir.is_dir()
+
+ output_path = _build_output_path(audio_dir, job.original_filename, "wav")
+ assert output_path == audio_dir / "Sample_Title.wav"
diff --git a/tests/test_pdf_structure.py b/tests/test_pdf_structure.py
new file mode 100644
index 0000000..5c62043
--- /dev/null
+++ b/tests/test_pdf_structure.py
@@ -0,0 +1,117 @@
+import unittest
+import os
+import shutil
+import fitz # PyMuPDF
+from abogen.book_parser import PdfParser
+
+class TestPdfStructure(unittest.TestCase):
+
+ def setUp(self):
+ self.test_dir = "tests/test_data_pdf"
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ os.makedirs(self.test_dir)
+ self.pdf_path = os.path.join(self.test_dir, "structure_test.pdf")
+
+ def tearDown(self):
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def test_pdf_structure_with_toc(self):
+ # Create PDF
+ doc = fitz.open()
+ p1 = doc.new_page()
+ p1.insert_text((50,50), "Page 1 Content")
+ p2 = doc.new_page()
+ p2.insert_text((50,50), "Page 2 Content")
+ p3 = doc.new_page() # Chapter 2 start
+ p3.insert_text((50,50), "Page 3 Content")
+ p4 = doc.new_page()
+ p4.insert_text((50,50), "Page 4 Content")
+
+ # Add TOC:
+ # 1. "Chap 1" -> Page 1
+ # 2. "Chap 2" -> Page 3
+ doc.set_toc([[1, "Chap 1", 1], [1, "Chap 2", 3]])
+ doc.save(self.pdf_path)
+ doc.close()
+
+ with PdfParser(self.pdf_path) as parser:
+ parser.process_content()
+
+ nav = parser.processed_nav_structure
+
+ # Expect 2 top level items
+ self.assertEqual(len(nav), 2)
+ self.assertEqual(nav[0]['title'], "Chap 1")
+ self.assertEqual(nav[0]['src'], "page_1")
+ self.assertEqual(nav[1]['title'], "Chap 2")
+ self.assertEqual(nav[1]['src'], "page_3")
+
+ # Check children of Chap 1 (Page 2 should be there)
+ children_c1 = nav[0]['children']
+ self.assertEqual(len(children_c1), 1)
+ # The child should likely be titled "Page 2 - Page 2 Content" or similar
+ self.assertIn("Page 2", children_c1[0]['title'])
+ self.assertEqual(children_c1[0]['src'], "page_2")
+
+ # Check children of Chap 2 (Page 4 should be there)
+ children_c2 = nav[1]['children']
+ self.assertEqual(len(children_c2), 1)
+ self.assertIn("Page 4", children_c2[0]['title'])
+ self.assertEqual(children_c2[0]['src'], "page_4")
+
+ def test_pdf_structure_without_toc(self):
+ # Create PDF without TOC
+ doc = fitz.open()
+ p1 = doc.new_page()
+ p1.insert_text((50,50), "Start")
+ p2 = doc.new_page()
+ p2.insert_text((50,50), "End")
+ doc.save(self.pdf_path)
+ doc.close()
+
+ with PdfParser(self.pdf_path) as parser:
+ parser.process_content()
+
+ nav = parser.processed_nav_structure
+
+ # Expect 1 top level item (Pages)
+ self.assertEqual(len(nav), 1)
+ self.assertEqual(nav[0]['title'], "Pages")
+
+ # Check children (all pages)
+ children = nav[0]['children']
+ self.assertEqual(len(children), 2)
+ self.assertIn("Page 1", children[0]['title'])
+ self.assertIn("Page 2", children[1]['title'])
+
+ def test_pdf_structure_nested_toc(self):
+ # Create PDF
+ doc = fitz.open()
+ p1 = doc.new_page() # Chap 1
+ p2 = doc.new_page() # Sec 1.1
+ p3 = doc.new_page() # Chap 2
+
+ doc.set_toc([
+ [1, "Chap 1", 1],
+ [2, "Sec 1.1", 2],
+ [1, "Chap 2", 3]
+ ])
+ doc.save(self.pdf_path)
+ doc.close()
+
+ with PdfParser(self.pdf_path) as parser:
+ parser.process_content()
+ nav = parser.processed_nav_structure
+
+ self.assertEqual(len(nav), 2) # Chap 1, Chap 2
+ self.assertEqual(nav[0]['title'], "Chap 1")
+
+ # Chap 1 should have child Sec 1.1
+ self.assertEqual(len(nav[0]['children']), 1)
+ self.assertEqual(nav[0]['children'][0]['title'], "Sec 1.1")
+ self.assertEqual(nav[0]['children'][0]['src'], "page_2")
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_prepare_form.py b/tests/test_prepare_form.py
new file mode 100644
index 0000000..9456d0f
--- /dev/null
+++ b/tests/test_prepare_form.py
@@ -0,0 +1,125 @@
+from pathlib import Path
+
+from werkzeug.datastructures import MultiDict
+
+from abogen.webui.routes.utils.form import apply_prepare_form
+from abogen.webui.routes.utils.voice import resolve_voice_setting
+from abogen.webui.service import PendingJob
+
+
+def _make_pending_job() -> PendingJob:
+ return PendingJob(
+ id="pending",
+ original_filename="example.epub",
+ stored_path=Path("example.epub"),
+ language="a",
+ voice="af_nova",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="none",
+ output_format="mp3",
+ save_mode="save_next_to_input",
+ output_folder=None,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ total_characters=0,
+ save_chapters_separately=False,
+ merge_chapters_at_end=True,
+ separate_chapters_format="wav",
+ silence_between_chapters=2.0,
+ save_as_project=False,
+ voice_profile=None,
+ max_subtitle_words=50,
+ metadata_tags={},
+ chapters=[],
+ normalization_overrides={},
+ created_at=0.0,
+ read_title_intro=False,
+ normalize_chapter_opening_caps=True,
+ )
+
+
+def test_apply_prepare_form_handles_custom_mix_for_speakers():
+ pending = _make_pending_job()
+ pending.speakers = {
+ "hero": {
+ "id": "hero",
+ "label": "Hero",
+ }
+ }
+
+ form = MultiDict(
+ {
+ "chapter_intro_delay": "0.5",
+ "speaker-hero-voice": "__custom_mix",
+ "speaker-hero-formula": "af_nova*0.6+am_liam*0.4",
+ }
+ )
+
+ _, _, _, errors, *_ = apply_prepare_form(pending, form)
+
+ assert not errors
+ hero = pending.speakers["hero"]
+ assert hero["voice_formula"] == "af_nova*0.6+am_liam*0.4"
+ assert hero["resolved_voice"] == "af_nova*0.6+am_liam*0.4"
+ assert "voice" not in hero or hero["voice"] != "__custom_mix"
+
+
+def test_apply_prepare_form_accepts_saved_speaker_reference_for_voice():
+ pending = _make_pending_job()
+ pending.speakers = {
+ "hero": {
+ "id": "hero",
+ "label": "Hero",
+ }
+ }
+
+ form = MultiDict(
+ {
+ "chapter_intro_delay": "0.5",
+ "speaker-hero-voice": "speaker:Female HQ",
+ "speaker-hero-formula": "",
+ }
+ )
+
+ _, _, _, errors, *_ = apply_prepare_form(pending, form)
+
+ assert not errors
+ hero = pending.speakers["hero"]
+ assert hero["voice"] == "speaker:Female HQ"
+ assert hero["resolved_voice"] == "speaker:Female HQ"
+ assert "voice_formula" not in hero
+
+
+def test_resolve_voice_setting_handles_profile_reference():
+ profiles = {
+ "Blend": {
+ "language": "b",
+ "voices": [
+ ("af_nova", 1.0),
+ ("am_liam", 1.0),
+ ],
+ }
+ }
+
+ voice, profile_name, language = resolve_voice_setting(
+ "profile:Blend", profiles=profiles
+ )
+
+ assert voice == "af_nova*0.5+am_liam*0.5"
+ assert profile_name == "Blend"
+ assert language == "b"
+
+
+def test_apply_prepare_form_updates_closing_outro_flag():
+ pending = _make_pending_job()
+ pending.read_closing_outro = True
+ form = MultiDict(
+ {
+ "read_closing_outro": "false",
+ }
+ )
+
+ apply_prepare_form(pending, form)
+
+ assert pending.read_closing_outro is False
diff --git a/tests/test_preview_applies_manual_overrides.py b/tests/test_preview_applies_manual_overrides.py
new file mode 100644
index 0000000..0f08512
--- /dev/null
+++ b/tests/test_preview_applies_manual_overrides.py
@@ -0,0 +1,55 @@
+from abogen.webui.routes.utils import preview
+
+
+def test_preview_applies_manual_override_before_normalization(monkeypatch):
+ # Don't run real TTS/normalization; just exercise the override stage by
+ # forcing provider=kokoro and then stubbing normalize_for_pipeline.
+
+ monkeypatch.setattr(preview, "get_preview_pipeline", lambda language, device: None)
+
+ # Stub normalize_for_pipeline to be identity; we only care that overrides run.
+ class _Norm:
+ @staticmethod
+ def normalize_for_pipeline(text):
+ return text
+
+ monkeypatch.setitem(
+ __import__("sys").modules, "abogen.kokoro_text_normalization", _Norm
+ )
+
+ # And stub the kokoro pipeline path so generate_preview_audio won't proceed.
+ # We'll instead validate by calling the override logic through generate_preview_audio
+ # with provider=supertonic and stub SupertonicPipeline to capture input.
+ captured = {}
+
+ class DummyPipeline:
+ def __init__(self, **kwargs):
+ pass
+
+ def __call__(self, text, **kwargs):
+ captured["text"] = text
+ return iter(())
+
+ monkeypatch.setitem(
+ __import__("sys").modules,
+ "abogen.tts_supertonic",
+ type("M", (), {"SupertonicPipeline": DummyPipeline}),
+ )
+
+ try:
+ preview.generate_preview_audio(
+ text="He said Unfu*k loudly.",
+ voice_spec="M1",
+ language="en",
+ speed=1.0,
+ use_gpu=False,
+ tts_provider="supertonic",
+ manual_overrides=[{"token": "Unfu*k", "pronunciation": "Unfuck"}],
+ )
+ except Exception:
+ # generate_preview_audio will raise because no audio chunks; that's fine.
+ pass
+
+ assert "text" in captured
+ assert "Unfuck" in captured["text"]
+ assert "Unfu*k" not in captured["text"]
diff --git a/tests/test_regression_fixes.py b/tests/test_regression_fixes.py
new file mode 100644
index 0000000..babc2f8
--- /dev/null
+++ b/tests/test_regression_fixes.py
@@ -0,0 +1,92 @@
+import pytest
+from unittest.mock import patch
+from abogen.kokoro_text_normalization import (
+ normalize_for_pipeline,
+ DEFAULT_APOSTROPHE_CONFIG,
+)
+from abogen.normalization_settings import build_apostrophe_config, _SETTINGS_DEFAULTS
+
+
+def normalize(text, overrides=None):
+ settings = dict(_SETTINGS_DEFAULTS)
+ if overrides:
+ settings.update(overrides)
+
+ config = build_apostrophe_config(settings=settings, base=DEFAULT_APOSTROPHE_CONFIG)
+ return normalize_for_pipeline(text, config=config, settings=settings)
+
+
+def test_year_pronunciation():
+ # 1925 -> Nineteen Hundred Twenty Five
+ normalized = normalize("1925")
+ print(f"1925 -> {normalized}")
+ assert "nineteen hundred" in normalized.lower()
+ assert "five" in normalized.lower()
+
+ # 2025 -> Twenty Twenty Five
+ normalized = normalize("2025")
+ print(f"2025 -> {normalized}")
+ assert "twenty twenty" in normalized.lower()
+ assert "five" in normalized.lower()
+
+
+def test_currency_pronunciation():
+ # $1.00 -> One dollar (no zero cents)
+ normalized = normalize("$1.00")
+ print(f"$1.00 -> {normalized}")
+ assert "one dollar" in normalized.lower()
+ assert "zero cents" not in normalized.lower()
+
+ # $1.05 -> One dollar and five cents (or comma)
+ normalized = normalize("$1.05")
+ print(f"$1.05 -> {normalized}")
+ assert "one dollar" in normalized.lower()
+ assert "five cents" in normalized.lower()
+
+
+def test_url_pronunciation():
+ # https://www.amazon.com -> amazon dot com
+ normalized = normalize("https://www.amazon.com")
+ print(f"https://www.amazon.com -> {normalized}")
+ assert "amazon dot com" in normalized.lower()
+ assert "http" not in normalized.lower()
+ assert "www" not in normalized.lower()
+
+ # www.google.com -> google dot com
+ normalized = normalize("www.google.com")
+ print(f"www.google.com -> {normalized}")
+ assert "google dot com" in normalized.lower()
+
+
+def test_roman_numerals_world_war():
+ # World War I -> World War One
+ normalized = normalize("World War I")
+ print(f"World War I -> {normalized}")
+ assert "world war one" in normalized.lower()
+
+ # World War II -> World War Two
+ normalized = normalize("World War II")
+ print(f"World War II -> {normalized}")
+ assert "world war two" in normalized.lower()
+
+
+def test_footnote_removal():
+ # Bob is awesome1. -> Bob is awesome.
+ normalized = normalize("Bob is awesome1.")
+ print(f"Bob is awesome1. -> {normalized}")
+ assert "bob is awesome." in normalized.lower()
+ assert "1" not in normalized
+
+ # Citation needed[1]. -> Citation needed.
+ normalized = normalize("Citation needed[1].")
+ print(f"Citation needed[1]. -> {normalized}")
+ assert "citation needed." in normalized.lower()
+ assert "[1]" not in normalized
+
+
+def test_manual_override_normalization():
+ from abogen.entity_analysis import normalize_manual_override_token
+
+ assert normalize_manual_override_token("The") == "the"
+ assert normalize_manual_override_token(" A ") == "a"
+ assert normalize_manual_override_token("word") == "word"
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..afa56c5
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,300 @@
+from __future__ import annotations
+
+import io
+import time
+from abogen.webui.service import (
+ Job,
+ JobStatus,
+ build_service,
+ _JOB_LOGGER,
+ build_audiobookshelf_metadata,
+)
+
+
+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"
+ assert job.chunk_level == "paragraph"
+ assert job.speaker_mode == "single"
+ assert job.chunks == []
+ assert not job.generate_epub3
+
+
+def test_job_add_log_emits_to_stream(tmp_path):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("payload", encoding="utf-8")
+
+ job = Job(
+ id="job-test",
+ original_filename="sample.txt",
+ stored_path=sample,
+ 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=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ )
+
+ captured_buffers = []
+ for handler in list(_JOB_LOGGER.handlers):
+ if not hasattr(handler, "setStream"):
+ continue
+ buffer = io.StringIO()
+ original_stream = getattr(handler, "stream", None)
+ handler.setStream(buffer) # type: ignore[attr-defined]
+ captured_buffers.append((handler, original_stream, buffer))
+
+ assert captured_buffers, "Expected job logger to have stream handlers"
+
+ try:
+ job.add_log("Test log line", level="error")
+ outputs = [buffer.getvalue() for _, _, buffer in captured_buffers]
+ finally:
+ for handler, original_stream, _ in captured_buffers:
+ if hasattr(handler, "setStream"):
+ handler.setStream(original_stream) # type: ignore[attr-defined]
+
+ assert any("Test log line" in output for output in outputs)
+ assert job.logs[-1].message == "Test log line"
+
+
+def test_job_add_log_handles_exception(tmp_path, capsys):
+ sample = tmp_path / "sample.txt"
+ sample.write_text("payload", encoding="utf-8")
+
+ job = Job(
+ id="job-fail-test",
+ original_filename="sample.txt",
+ stored_path=sample,
+ 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=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ )
+
+ # Mock the logger to raise an exception
+ original_log = _JOB_LOGGER.log
+
+ def side_effect(*args, **kwargs):
+ raise RuntimeError("Logger exploded")
+
+ _JOB_LOGGER.log = side_effect
+
+ try:
+ job.add_log("This should trigger fallback", level="info")
+ finally:
+ _JOB_LOGGER.log = original_log
+
+ captured = capsys.readouterr()
+ assert "Logging failed for job job-fail-test" in captured.err
+ assert "Logger exploded" in captured.err
+
+
+def test_retry_removes_failed_job(tmp_path):
+ uploads = tmp_path / "uploads"
+ outputs = tmp_path / "outputs"
+ uploads.mkdir()
+ outputs.mkdir()
+
+ source = uploads / "sample.txt"
+ source.write_text("hello", encoding="utf-8")
+
+ def failing_runner(job):
+ job.add_log("runner failing", level="error")
+ raise RuntimeError("boom")
+
+ service = build_service(
+ runner=failing_runner,
+ output_root=outputs,
+ uploads_root=uploads,
+ )
+
+ try:
+ 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("hello"),
+ )
+
+ deadline = time.time() + 5
+ while time.time() < deadline and job.status is not JobStatus.FAILED:
+ time.sleep(0.05)
+
+ assert job.status is JobStatus.FAILED
+
+ new_job = service.retry(job.id)
+ assert new_job is not None
+ assert new_job.id != job.id
+
+ job_ids = {entry.id for entry in service.list_jobs()}
+ assert job.id not in job_ids
+ assert new_job.id in job_ids
+ finally:
+ service.shutdown()
+
+
+def test_audiobookshelf_metadata_uses_book_number(tmp_path):
+ source = tmp_path / "book.txt"
+ source.write_text("content", encoding="utf-8")
+
+ job = Job(
+ id="job-abs",
+ original_filename="book.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ metadata_tags={
+ "series": "Example Saga",
+ "book_number": "7",
+ },
+ )
+
+ metadata = build_audiobookshelf_metadata(job)
+
+ assert metadata["seriesName"] == "Example Saga"
+ assert metadata["seriesSequence"] == "7"
+
+
+def test_audiobookshelf_metadata_normalizes_sequence_value(tmp_path):
+ source = tmp_path / "book.txt"
+ source.write_text("content", encoding="utf-8")
+
+ job = Job(
+ id="job-abs-normalize",
+ original_filename="book.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ metadata_tags={
+ "series": "Example Saga",
+ "series_index": "Book 7 of the Series",
+ },
+ )
+
+ metadata = build_audiobookshelf_metadata(job)
+
+ assert metadata["seriesName"] == "Example Saga"
+ assert metadata["seriesSequence"] == "7"
+
+
+def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path):
+ source = tmp_path / "book.txt"
+ source.write_text("content", encoding="utf-8")
+
+ job = Job(
+ id="job-abs-decimal",
+ original_filename="book.txt",
+ stored_path=source,
+ language="en",
+ voice="af_alloy",
+ speed=1.0,
+ use_gpu=False,
+ subtitle_mode="Sentence",
+ output_format="mp3",
+ save_mode="Save next to input file",
+ output_folder=tmp_path,
+ replace_single_newlines=False,
+ subtitle_format="srt",
+ created_at=time.time(),
+ metadata_tags={
+ "series": "Example Saga",
+ "series_number": "Book 4.5",
+ },
+ )
+
+ metadata = build_audiobookshelf_metadata(job)
+
+ assert metadata["seriesSequence"] == "4.5"
diff --git a/tests/test_settings_integrations_secrets.py b/tests/test_settings_integrations_secrets.py
new file mode 100644
index 0000000..96896fa
--- /dev/null
+++ b/tests/test_settings_integrations_secrets.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from abogen.utils import load_config, save_config
+from abogen.webui.app import create_app
+
+
+def test_settings_update_preserves_abs_api_token_when_blank(tmp_path):
+ # Seed config with stored integration secret.
+ save_config(
+ {
+ "language": "en",
+ "integrations": {
+ "audiobookshelf": {
+ "enabled": True,
+ "base_url": "https://abs.example",
+ "api_token": "SECRET_TOKEN",
+ "library_id": "lib1",
+ "folder_id": "fold1",
+ "verify_ssl": True,
+ },
+ "calibre_opds": {
+ "enabled": True,
+ "base_url": "https://opds.example",
+ "username": "user",
+ "password": "SECRET_PASS",
+ "verify_ssl": True,
+ },
+ },
+ }
+ )
+
+ app = create_app(
+ {
+ "TESTING": True,
+ "SECRET_KEY": "test",
+ "OUTPUT_FOLDER": str(tmp_path),
+ "UPLOAD_FOLDER": str(tmp_path / "uploads"),
+ }
+ )
+
+ with app.test_client() as client:
+ # Emulate saving settings where integrations are present but secrets are blank
+ # (typical of masked password/token inputs).
+ resp = client.post(
+ "/settings/update",
+ data={
+ "language": "en",
+ "output_format": "mp3",
+ # ABS integration fields (token blank)
+ "audiobookshelf_enabled": "on",
+ "audiobookshelf_base_url": "https://abs.example",
+ "audiobookshelf_api_token": "",
+ "audiobookshelf_library_id": "lib1",
+ "audiobookshelf_folder_id": "fold1",
+ "audiobookshelf_verify_ssl": "on",
+ # Calibre OPDS integration fields (password blank)
+ "calibre_opds_enabled": "on",
+ "calibre_opds_base_url": "https://opds.example",
+ "calibre_opds_username": "user",
+ "calibre_opds_password": "",
+ "calibre_opds_verify_ssl": "on",
+ },
+ follow_redirects=False,
+ )
+ assert resp.status_code in {302, 303}
+
+ cfg = load_config() or {}
+ integrations = cfg.get("integrations") or {}
+
+ assert integrations["audiobookshelf"]["api_token"] == "SECRET_TOKEN"
+ assert integrations["calibre_opds"]["password"] == "SECRET_PASS"
+
+
+def test_settings_update_preserves_secrets_when_fields_missing(tmp_path):
+ save_config(
+ {
+ "language": "en",
+ "integrations": {
+ "audiobookshelf": {"api_token": "SECRET_TOKEN"},
+ "calibre_opds": {"password": "SECRET_PASS"},
+ },
+ }
+ )
+
+ app = create_app(
+ {
+ "TESTING": True,
+ "SECRET_KEY": "test",
+ "OUTPUT_FOLDER": str(tmp_path),
+ "UPLOAD_FOLDER": str(tmp_path / "uploads"),
+ }
+ )
+
+ with app.test_client() as client:
+ # Post unrelated changes; omit integration fields completely.
+ resp = client.post(
+ "/settings/update",
+ data={
+ "language": "en",
+ "output_format": "wav",
+ },
+ follow_redirects=False,
+ )
+ assert resp.status_code in {302, 303}
+
+ cfg = load_config() or {}
+ integrations = cfg.get("integrations") or {}
+ assert integrations["audiobookshelf"]["api_token"] == "SECRET_TOKEN"
+ assert integrations["calibre_opds"]["password"] == "SECRET_PASS"
diff --git a/tests/test_speaker_analysis.py b/tests/test_speaker_analysis.py
new file mode 100644
index 0000000..2b46085
--- /dev/null
+++ b/tests/test_speaker_analysis.py
@@ -0,0 +1,96 @@
+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"
+
+
+def test_analyze_speakers_ignores_leading_stopwords():
+ chunks = [
+ _chunk('But Volescu said, "We march at dawn."', 0),
+ _chunk('Then Blue Leader shouted, "Hold the perimeter."', 1),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
+
+ speakers = analysis.speakers
+ assert "volescu" in speakers
+ assert speakers["volescu"].label == "Volescu"
+ assert "blue_leader" in speakers
+ assert speakers["blue_leader"].label == "Blue Leader"
+ assert "but_volescu" not in speakers
+ assert "then_blue_leader" not in speakers
+
+
+def test_analyze_speakers_applies_threshold_suppression():
+ chunks = [
+ _chunk('"Hello there," said Narrator.', 0),
+ _chunk('"It is lying," said Green.', 1),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=3, max_speakers=0)
+
+ green = analysis.speakers.get("green")
+ assert green is not None
+ assert green.suppressed is True
+ assert "green" in analysis.suppressed
+
+
+def test_sample_excerpt_includes_context_paragraphs():
+ chunks = [
+ _chunk("The hallway was quiet as footsteps approached.", 0),
+ _chunk('"Open the door," said John as he reached for the handle.', 1),
+ _chunk("Mary watched him closely, unsure of his intent.", 2),
+ ]
+
+ analysis = analyze_speakers(_chapters(), chunks, threshold=1, max_speakers=0)
+
+ john = analysis.speakers.get("john")
+ assert john is not None
+ assert john.sample_quotes, "Expected John to have at least one sample quote"
+ excerpt = john.sample_quotes[0]["excerpt"]
+ assert "The hallway was quiet" in excerpt
+ assert '"Open the door," said John' in excerpt
+ assert "Mary watched him closely" in excerpt
diff --git a/tests/test_text_extractor.py b/tests/test_text_extractor.py
new file mode 100644
index 0000000..fe41aff
--- /dev/null
+++ b/tests/test_text_extractor.py
@@ -0,0 +1,76 @@
+from unittest.mock import patch
+
+import pytest
+from pathlib import Path
+
+from ebooklib import epub
+
+from abogen.text_extractor import extract_from_path
+from abogen.utils import calculate_text_length
+
+
+@pytest.fixture
+def sample_epub_path():
+ return Path(__file__).parent / "fixtures" / "abogen_debug_tts_samples.epub"
+
+
+def test_epub_character_counts_align_with_calculated_total(sample_epub_path):
+ result = extract_from_path(sample_epub_path)
+
+ 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(sample_epub_path):
+ result = extract_from_path(sample_epub_path)
+
+ 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"
+
+
+def test_epub_series_metadata_extracted_from_opf_meta(tmp_path):
+ book = epub.EpubBook()
+ book.set_identifier("id")
+ book.set_title("Example Title")
+ book.set_language("en")
+ book.add_author("Example Author")
+
+ # Calibre-style series metadata
+ # ebooklib stores this in memory correctly, but may not round-trip via disk in read_epub
+ book.add_metadata(
+ "OPF", "meta", None, {"name": "calibre:series", "content": "Example Saga"}
+ )
+ book.add_metadata(
+ "OPF", "meta", None, {"name": "calibre:series_index", "content": "2"}
+ )
+
+ chapter = epub.EpubHtml(title="Chapter 1", file_name="chap_01.xhtml", lang="en")
+ chapter.content = "
Chapter 1
Hello
"
+ chapter.id = "chap_01"
+ book.add_item(chapter)
+
+ # We manually set the spine to match what ebooklib.read_epub produces (list of tuples),
+ # since we are bypassing the serialization round-trip that normally converts it.
+ # The 'nav' item is usually handled separately or implicitly, but for this test
+ # we just need the chapter to be navigable via spine.
+ book.spine = [("nav", "yes"), ("chap_01", "yes")]
+
+ book.add_item(epub.EpubNcx())
+ book.add_item(epub.EpubNav())
+
+ path = tmp_path / "example.epub"
+ epub.write_epub(str(path), book)
+
+ # We mock read_epub to avoid serialization issues with custom metadata in ebooklib
+ with patch("abogen.text_extractor.epub.read_epub", return_value=book):
+ result = extract_from_path(path)
+
+ assert result.metadata.get("series") == "Example Saga"
+ assert result.metadata.get("series_index") == "2"
+
diff --git a/tests/test_text_normalization.py b/tests/test_text_normalization.py
new file mode 100644
index 0000000..4680017
--- /dev/null
+++ b/tests/test_text_normalization.py
@@ -0,0 +1,361 @@
+from __future__ import annotations
+
+import pytest
+from unittest.mock import patch
+
+from abogen.kokoro_text_normalization import (
+ DEFAULT_APOSTROPHE_CONFIG,
+ normalize_for_pipeline,
+ normalize_roman_numeral_titles,
+)
+from abogen.normalization_settings import (
+ apply_overrides as apply_normalization_overrides,
+ build_apostrophe_config,
+ get_runtime_settings,
+)
+from abogen.spacy_contraction_resolver import resolve_ambiguous_contractions
+
+
+SPACY_RESOLVER_AVAILABLE = bool(
+ resolve_ambiguous_contractions("It's been a long time.")
+)
+
+
+def _normalize_text(
+ text: str, *, normalization_overrides: dict[str, object] | None = None
+) -> str:
+ runtime_settings = get_runtime_settings()
+ if normalization_overrides:
+ runtime_settings = apply_normalization_overrides(
+ runtime_settings, normalization_overrides
+ )
+ config = build_apostrophe_config(
+ settings=runtime_settings, base=DEFAULT_APOSTROPHE_CONFIG
+ )
+ return normalize_for_pipeline(text, config=config, settings=runtime_settings)
+
+
+def test_title_abbreviations_are_expanded():
+ text = "Dr. Watson met Mr. Holmes and Ms. Hudson."
+ normalized = _normalize_text(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_text(text)
+ assert "John Doe Junior" in normalized
+ assert "JANE DOE SENIOR" in normalized
+
+
+def test_missing_terminal_punctuation_is_added():
+ normalized = _normalize_text("Chapter 1")
+ assert normalized.endswith(".")
+
+
+def test_terminal_punctuation_respects_closing_quotes():
+ normalized = _normalize_text('"Chapter 1"')
+ compact = normalized.replace(" ", "")
+ assert compact.endswith('."')
+
+
+def test_normalization_preserves_spacing_around_quotes_and_hyphen():
+ sample = "“Still,” said Château-Renaud, “Dr. d’Avrigny, who attends my mother, declares he is in despair about it."
+ normalized = _normalize_text(sample)
+
+ assert normalized.startswith(
+ "“Still,” said Château-Renaud, “Doctor d'Avrigny, who attends my mother, declares he is in despair about it."
+ )
+ assert " " not in normalized
+ assert "Château-Renaud" in normalized
+ assert "Doctor d'Avrigny" in normalized
+
+
+def test_normalize_roman_titles_converts_when_majority() -> None:
+ titles = ["I: Opening", "II: Rising Action", "III: Climax"]
+ normalized = normalize_roman_numeral_titles(titles)
+
+ assert normalized == ["1: Opening", "2: Rising Action", "3: Climax"]
+
+
+def test_normalize_roman_titles_skips_when_not_majority() -> None:
+ titles = ["Preface", "I: Opening", "Acknowledgements"]
+ normalized = normalize_roman_numeral_titles(titles)
+
+ assert normalized == titles
+
+
+def test_normalize_roman_titles_preserves_separators() -> None:
+ titles = [" IV. The Trial", "V - The Verdict", "VI\nAftermath"]
+ normalized = normalize_roman_numeral_titles(titles)
+
+ 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_text("The vault holds 35,000 credits")
+ assert "thirty-five thousand" in normalized.lower()
+
+
+def test_numeric_ranges_are_spoken_with_to() -> None:
+ normalized = _normalize_text("Chapters 1-3")
+ assert "one to three" in normalized.lower()
+
+
+def test_simple_fractions_are_spoken() -> None:
+ normalized = _normalize_text("Add 1/2 cup of sugar")
+ assert "one half" in normalized.lower()
+
+
+def test_plain_numbers_are_spelled_out() -> None:
+ normalized = _normalize_text("He rolled a 42.")
+ assert "forty-two" in normalized.lower()
+
+
+def test_decimal_numbers_include_point() -> None:
+ normalized = _normalize_text("Book 4.5 of the series.")
+ assert "four point five" in normalized.lower()
+
+
+def test_space_separated_numbers_become_ranges() -> None:
+ normalized = _normalize_text("Read pages 12 14 tonight.")
+ assert "pages twelve to fourteen" in normalized.lower()
+
+
+def test_year_like_numbers_use_common_pronunciation() -> None:
+ normalized = _normalize_text("In 1924 the journey began")
+ folded = normalized.lower().replace("-", " ")
+ assert "nineteen hundred" in folded
+ assert "twenty four" in folded
+
+
+def test_early_century_years_use_hundred_format() -> None:
+ normalized = _normalize_text("In 1204 the city fell")
+ assert "twelve hundred" in normalized.lower()
+ assert "oh four" in normalized.lower()
+
+
+def test_roman_numerals_in_titles_are_converted() -> None:
+ normalized = _normalize_text("Chapter IV begins now")
+ assert "chapter four" in normalized.lower()
+
+
+def test_roman_numeral_suffixes_use_ordinals() -> None:
+ normalized = _normalize_text("Bob Smith II arrived late")
+ assert "bob smith the second" in normalized.lower()
+
+
+def test_lowercase_roman_after_part_converts_to_cardinal() -> None:
+ normalized = _normalize_text("We studied part iii of the manuscript.")
+ assert "part three" in normalized.lower()
+
+
+def test_hyphenated_phase_with_roman_is_converted() -> None:
+ normalized = _normalize_text("They executed phase-IV without delay.")
+ assert "phase four" in normalized.lower()
+
+
+def test_all_caps_quotes_are_sentence_cased() -> None:
+ normalized = _normalize_text('"THIS IS A TEST."')
+ cleaned = normalized.replace('" ', '"')
+ assert '"This is a test."' in cleaned
+
+
+def test_caps_quote_preserves_acronyms() -> None:
+ normalized = _normalize_text("“THE NASA TEAM ARRIVED.”")
+ assert "“The NASA team arrived.”" in normalized
+
+
+def test_caps_quote_normalization_respects_override() -> None:
+ normalized = _normalize_text(
+ '"KEEP SHOUTING."',
+ normalization_overrides={"normalization_caps_quotes": False},
+ )
+ cleaned = normalized.replace('" ', '"')
+ assert '"KEEP SHOUTING."' in cleaned
+
+
+def test_recent_years_split_twenty_style() -> None:
+ normalized = _normalize_text("In 2025 we planned ahead")
+ folded = normalized.lower().replace("-", " ")
+ assert "twenty twenty five" in folded
+
+
+def test_two_thousands_use_two_thousand_prefix() -> None:
+ normalized = _normalize_text("In 2005 we celebrated")
+ assert "two thousand five" in normalized.lower()
+
+
+def test_year_style_can_be_disabled() -> None:
+ normalized = _normalize_text(
+ "In 2025 we planned ahead",
+ normalization_overrides={"normalization_numbers_year_style": "off"},
+ )
+ folded = normalized.lower().replace("-", " ")
+ assert "twenty twenty five" not in folded
+
+
+def test_contractions_can_be_kept_when_override_disabled() -> None:
+ normalized = _normalize_text(
+ "It's a good day.",
+ normalization_overrides={"normalization_apostrophes_contractions": False},
+ )
+ assert "It's" in normalized
+
+
+def test_sibilant_possessives_remain_when_marking_disabled() -> None:
+ normalized = _normalize_text(
+ "The boss's chair wobbled.",
+ normalization_overrides={
+ "normalization_apostrophes_sibilant_possessives": False
+ },
+ )
+ assert "boss's" in normalized
+ assert "boss iz" not in normalized.lower()
+
+
+def test_decades_can_skip_expansion_when_disabled() -> None:
+ normalized = _normalize_text(
+ "Classic hits from the '90s filled the hall.",
+ normalization_overrides={"normalization_apostrophes_decades": False},
+ )
+ 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.")
+ assert "It has been a long time." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_it_is_from_context() -> None:
+ normalized = _normalize_text("It's cold outside.")
+ assert "It is cold outside." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_she_had() -> None:
+ normalized = _normalize_text("She'd left before dawn.")
+ assert "She had left before dawn." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_spacy_disambiguates_she_would() -> None:
+ normalized = _normalize_text("She'd go if invited.")
+ assert "She would go if invited." == normalized
+
+
+@pytest.mark.skipif(not SPACY_RESOLVER_AVAILABLE, reason="spaCy model unavailable")
+def test_sample_sentence_handles_complex_contractions() -> None:
+ sample = (
+ "I've heard the captain'll arrive by dusk, but they'd said the same yesterday."
+ )
+ normalized = _normalize_text(sample)
+ assert (
+ "I have heard the captain will arrive by dusk, but they had said the same yesterday."
+ == normalized
+ )
+
+
+def test_modal_will_contractions_can_be_disabled() -> None:
+ sample = "The captain'll arrive at dawn."
+ normalized = _normalize_text(
+ sample,
+ normalization_overrides={"normalization_contraction_modal_will": False},
+ )
+ assert "captain'll" in normalized
+
+
+@pytest.fixture(autouse=True)
+def mock_settings():
+ defaults = {
+ "normalization_numbers": True,
+ "normalization_titles": True,
+ "normalization_terminal": True,
+ "normalization_phoneme_hints": True,
+ "normalization_caps_quotes": True,
+ "normalization_apostrophes_contractions": True,
+ "normalization_apostrophes_plural_possessives": True,
+ "normalization_apostrophes_sibilant_possessives": True,
+ "normalization_apostrophes_decades": True,
+ "normalization_apostrophes_leading_elisions": True,
+ "normalization_apostrophe_mode": "spacy",
+ "normalization_contraction_aux_be": True,
+ "normalization_contraction_aux_have": True,
+ "normalization_contraction_modal_will": True,
+ "normalization_contraction_modal_would": True,
+ "normalization_contraction_negation_not": True,
+ "normalization_contraction_let_us": True,
+ "normalization_currency": True,
+ "normalization_footnotes": True,
+ "normalization_numbers_year_style": "american",
+ }
+ with patch(
+ "tests.test_text_normalization.get_runtime_settings", return_value=defaults
+ ):
+ yield
+
+
+def test_currency_magnitude():
+ cases = [
+ ("$2 million", "two million dollars"),
+ ("$2.5 million", "two point five million dollars"),
+ ("$100 billion", "one hundred billion dollars"),
+ ("$1.2 trillion", "one point two trillion dollars"),
+ ("$2.55 million", "two point five five million dollars"),
+ ("$1 million", "one million dollars"),
+ ("$0.5 million", "zero point five million dollars"),
+ ("$2.50", "two dollars, fifty cents"),
+ ("$100", "one hundred dollars"),
+ ]
+
+ settings = {
+ "normalization_numbers": True,
+ "normalization_currency": True,
+ "normalization_apostrophe_mode": "spacy",
+ }
+
+ for input_text, expected in cases:
+ normalized = _normalize_text(input_text, normalization_overrides=settings)
+ assert (
+ expected.lower() in normalized.lower()
+ ), f"Failed for {input_text}: got '{normalized}'"
diff --git a/tests/test_tts_supertonic_unsupported_chars.py b/tests/test_tts_supertonic_unsupported_chars.py
new file mode 100644
index 0000000..c08ca2c
--- /dev/null
+++ b/tests/test_tts_supertonic_unsupported_chars.py
@@ -0,0 +1,53 @@
+import numpy as np
+
+from abogen.tts_supertonic import SupertonicPipeline
+
+
+class _DummyTTS:
+ def get_voice_style(self, voice_name: str):
+ return {"voice": voice_name}
+
+ def synthesize(
+ self,
+ *,
+ text: str,
+ voice_style,
+ total_steps: int,
+ speed: float,
+ max_chunk_length: int,
+ silence_duration: float,
+ verbose: bool,
+ ):
+ if "•" in text:
+ raise ValueError("Found 1 unsupported character(s): ['•']")
+ # Return 50ms of audio at 24kHz.
+ sr = 24000
+ audio = np.zeros(int(0.05 * sr), dtype="float32")
+ return audio, 0.05
+
+
+def test_supertonic_pipeline_strips_unsupported_characters_and_retries():
+ # Avoid importing/initializing real supertonic by manually constructing the pipeline.
+ pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
+ pipeline.sample_rate = 24000
+ pipeline.total_steps = 5
+ pipeline.max_chunk_length = 1000
+ pipeline._tts = _DummyTTS()
+
+ segs = list(pipeline("Hello • world", voice="M1", speed=1.0))
+ assert len(segs) == 1
+ assert segs[0].graphemes == "Hello world" or segs[0].graphemes == "Hello world"
+ assert isinstance(segs[0].audio, np.ndarray)
+ assert segs[0].audio.dtype == np.float32
+ assert segs[0].audio.size > 0
+
+
+def test_supertonic_pipeline_drops_chunk_if_only_unsupported_characters():
+ pipeline = SupertonicPipeline.__new__(SupertonicPipeline)
+ pipeline.sample_rate = 24000
+ pipeline.total_steps = 5
+ pipeline.max_chunk_length = 1000
+ pipeline._tts = _DummyTTS()
+
+ segs = list(pipeline("•", voice="M1", speed=1.0))
+ assert segs == []
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
diff --git a/tests/test_voice_cache.py b/tests/test_voice_cache.py
new file mode 100644
index 0000000..c428b3a
--- /dev/null
+++ b/tests/test_voice_cache.py
@@ -0,0 +1,69 @@
+from types import SimpleNamespace
+from typing import cast
+
+import pytest
+
+from abogen.constants import VOICES_INTERNAL
+from abogen.voice_cache import (
+ LocalEntryNotFoundError,
+ _CACHED_VOICES,
+ ensure_voice_assets,
+)
+from abogen.webui.conversion_runner import _collect_required_voice_ids
+from abogen.webui.service import Job
+
+
+@pytest.fixture(autouse=True)
+def clear_voice_cache():
+ _CACHED_VOICES.clear()
+ yield
+ _CACHED_VOICES.clear()
+
+
+def test_ensure_voice_assets_downloads_missing(monkeypatch):
+ recorded = []
+
+ cached = set()
+
+ def fake_download(**kwargs):
+ filename = kwargs["filename"]
+ if kwargs.get("local_files_only"):
+ if filename in cached:
+ return f"/tmp/{filename}"
+ raise LocalEntryNotFoundError(f"{filename} missing")
+
+ recorded.append(filename)
+ cached.add(filename)
+ return f"/tmp/{filename}"
+
+ monkeypatch.setattr("abogen.voice_cache.hf_hub_download", fake_download)
+
+ downloaded, errors = ensure_voice_assets(["af_nova", "am_liam"])
+
+ assert downloaded == {"af_nova", "am_liam"}
+ assert errors == {}
+ assert set(recorded) == {"voices/af_nova.pt", "voices/am_liam.pt"}
+
+ recorded.clear()
+ downloaded_again, errors_again = ensure_voice_assets(["af_nova"])
+
+ assert downloaded_again == set()
+ assert errors_again == {}
+ assert recorded == []
+
+
+def test_collect_required_voice_ids_includes_all():
+ job = SimpleNamespace(
+ voice="af_nova",
+ chapters=[{"voice_formula": "af_nova*0.7+am_liam*0.3"}],
+ chunks=[{"voice": "am_michael"}],
+ speakers={
+ "hero": {"voice_formula": "af_nova*0.6+am_liam*0.4"},
+ "narrator": {"voice": "af_nova"},
+ },
+ )
+
+ voices = _collect_required_voice_ids(cast(Job, job))
+
+ assert {"af_nova", "am_liam", "am_michael"}.issubset(voices)
+ assert voices.issuperset(VOICES_INTERNAL)
diff --git a/tests/test_voice_formula_resolution.py b/tests/test_voice_formula_resolution.py
new file mode 100644
index 0000000..2bc3430
--- /dev/null
+++ b/tests/test_voice_formula_resolution.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from abogen.webui.conversion_runner import _resolve_voice, _supertonic_voice_from_spec
+from abogen.tts_supertonic import DEFAULT_SUPERTONIC_VOICES
+
+
+def test_resolve_voice_formula_without_pipeline_does_not_crash() -> None:
+ # This can happen when a previously-saved Kokoro mix formula is present
+ # but the active provider is SuperTonic (no Kokoro pipeline object).
+ formula = "af_heart*0.5+af_sky*0.5"
+ resolved = _resolve_voice(None, formula, use_gpu=False)
+ assert resolved == formula
+
+
+def test_supertonic_voice_from_formula_falls_back_to_valid_voice() -> None:
+ # When a stale Kokoro mix formula is present, SuperTonic should not receive it.
+ chosen = _supertonic_voice_from_spec("af_heart*0.5+af_sky*0.5", "af_heart*1.0")
+ assert chosen in DEFAULT_SUPERTONIC_VOICES